v1.0
api.mojacard.ao/v1

MojaCard API Reference

MojaCard is a prepaid USD virtual card service for unbanked Africans and their diaspora. The API enables you to create customers, run KYC, issue virtual Visa cards, and process top-ups via diaspora payment rails or Angola in-country mobile money — all in a single integration.

Production Base URL
https://api.mojacard.ao/v1
Sandbox Base URL
https://sandbox.mojacard.ao/v1
Card Issuer
Nium (UK-licensed)
Supported Load Rails
Debit Card · Wise · Bank Transfer (BACS/SEPA) · Multicaixa · Unitel Money · Africell Money
Diaspora top-ups are denominated in the sender's currency (GBP, EUR, or USD). Angola in-country top-ups use AOA (Angolan Kwanza). All card balances are in USD. FX conversion is applied at the locked rate returned by /rates/current.

Authentication

All API requests (except POST /auth/token) must include a Bearer token in the Authorization header. Tokens expire after 24 hours and must be refreshed.

HTTP Header
Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...
Never expose your client_secret in client-side code. Always exchange credentials server-side and pass only the resulting Bearer token to your frontend.

Sandbox vs Production

Use the sandbox environment for all development and testing. No real funds or cards are created in sandbox.

Feature Sandbox Production
Base URL sandbox.mojacard.ao/v1 api.mojacard.ao/v1
Real KYC checks No — auto-approve Yes — Smile ID
Real card issuance No — mock PAN Yes — Nium network
Real mobile money No — use test MSISDN Yes
Test MSISDN +244900000001 Customer's real number

Get API Token

POST /auth/token Exchange credentials for Bearer token

Request Body

FieldTypeRequiredDescription
client_id string required Your API client ID, issued by MojaCard
client_secret string required Your API client secret. Keep server-side only
JSON
{
  "client_id":     "kwz_live_cid_xxxxxxxxxxxxxxxx",
  "client_secret": "kwz_live_sec_xxxxxxxxxxxxxxxxxxxxxxxx"
}
JSON
{
  "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJrd3pfbGl2ZV9...",
  "token_type":   "Bearer",
  "expires_in":   86400,
  "issued_at":    "2026-06-15T10:00:00Z"
}
JSON
{
  "error":      "unauthorized",
  "message":    "Invalid client_id or client_secret",
  "request_id": "req_01HZ8KPA4BFMXCQ3J7N"
}

Create Customer

POST /customers Register a new cardholder

Request Body

FieldTypeRequiredDescription
name string required Full legal name as on identity document
msisdn string required E.164 mobile number, e.g. +244923456789
bi_number string required National ID document number (passport, national ID card)
bi_doc_url string (url) required Pre-signed URL to BI document image (JPEG/PNG, max 5MB)
date_of_birth string (date) optional ISO 8601 format: YYYY-MM-DD
email string optional Customer email for receipts and notifications
JSON
{
  "name":         "Ana Paula Ferreira",
  "msisdn":       "+244923456789",
  "bi_number":    "005432891LA042",
  "bi_doc_url":   "https://uploads.mojacard.ao/docs/bi_5432891_front.jpg?token=sig123",
  "date_of_birth": "1992-07-15",
  "email":        "ana.ferreira@exemplo.ao"
}
JSON
{
  "customer_id": "cust_01J2HMPK3DFXQ6YW8V",
  "name":        "Ana Paula Ferreira",
  "msisdn":      "+244923456789",
  "bi_number":   "005432891LA042",
  "kyc_status":  "pending",
  "created_at":  "2026-06-15T10:02:14Z"
}
JSON
{
  "error":   "validation_failed",
  "message": "Invalid BI number format",
  "field":   "bi_number"
}

Submit KYC

POST /customers/{customer_id}/kyc Upload KYC documents for identity verification
KYC verification is powered by Smile ID. The kyc_status will return as pending immediately. You will receive a kyc.approved or kyc.rejected webhook when the check completes (typically under 60 seconds).

Path Parameters

ParameterTypeDescription
customer_id string Customer ID returned from POST /customers

Request Body

FieldTypeRequiredDescription
bi_front_url string (url) required Pre-signed URL to BI card front image
selfie_url string (url) required Pre-signed URL to live selfie photo
bi_back_url string (url) optional Pre-signed URL to BI card back (increases approval rate)
JSON
{
  "bi_front_url": "https://uploads.mojacard.ao/docs/bi_5432891_front.jpg?token=abc",
  "selfie_url":   "https://uploads.mojacard.ao/docs/selfie_5432891.jpg?token=xyz",
  "bi_back_url":  "https://uploads.mojacard.ao/docs/bi_5432891_back.jpg?token=def"
}
JSON
{
  "kyc_ref":    "kyc_01J2HN3PQRST4UV5WX",
  "customer_id":"cust_01J2HMPK3DFXQ6YW8V",
  "kyc_status": "pending",
  "message":    "KYC submitted. You will receive a webhook when verification completes.",
  "submitted_at":"2026-06-15T10:05:00Z"
}

Issue Virtual Card

POST /cards/issue Issue a USD prepaid virtual card to an approved customer
The customer's kyc_status must be approved before a card can be issued. Attempting to issue a card for a pending or rejected customer will return 422 KYC Required.

Request Body

FieldTypeRequiredDescription
customer_id string required ID of the KYC-approved customer
currency string required Must be "USD"
card_type string required Must be "virtual"
funding_rail string optional Preferred default load rail: multicaixa_express, unitel_money, or africell_money
JSON
{
  "customer_id":  "cust_01J2HMPK3DFXQ6YW8V",
  "currency":     "USD",
  "card_type":    "virtual",
  "funding_rail": "multicaixa_express"
}
JSON
{
  "card_id":       "card_01J2HP7QRSTUVWXY1",
  "customer_id":   "cust_01J2HMPK3DFXQ6YW8V",
  "pan_last4":     "4271",
  "network":       "Visa",
  "card_type":     "virtual",
  "currency":      "USD",
  "balance_usd":   0.00,
  "status":        "active",
  "expiry":        "06/29",
  "issued_at":     "2026-06-15T10:07:00Z"
}
JSON
{
  "error":      "kyc_required",
  "message":   "Customer KYC status is 'pending'. Cards can only be issued to approved customers.",
  "kyc_status":"pending"
}

Get Card Details

GET /cards/{card_id} Retrieve card metadata and current balance

Path Parameters

ParameterTypeDescription
card_id string Unique card identifier
JSON
{
  "card_id":      "card_01J2HP7QRSTUVWXY1",
  "customer_id":  "cust_01J2HMPK3DFXQ6YW8V",
  "pan_last4":    "4271",
  "network":      "Visa",
  "card_type":    "virtual",
  "currency":     "USD",
  "balance_usd":  47.82,
  "status":       "active",
  "expiry":       "06/29",
  "funding_rail": "multicaixa_express",
  "issued_at":    "2026-06-15T10:07:00Z",
  "last_used_at": "2026-06-15T09:31:00Z"
}

Reveal Full PAN

GET /cards/{card_id}/pan Retrieve full card number, CVV and expiry (requires 2FA)
This endpoint requires a one-time confirmation_code sent to the cardholder's registered MSISDN via SMS. The code expires in 5 minutes. PCI-DSS compliance requires you to display the PAN in a secure iframe and not store it in your database.

Query Parameters

ParameterTypeRequiredDescription
confirmation_code string required 6-digit OTP delivered to the cardholder's MSISDN
JSON
{
  "card_id": "card_01J2HP7QRSTUVWXY1",
  "pan":     "4532 8811 0012 4271",
  "cvv":     "837",
  "expiry":  "06/29",
  "expires_at": "2026-06-15T10:10:00Z"  // PAN response TTL — do not cache past this
}
JSON
{
  "error":   "invalid_otp",
  "message": "OTP is invalid or has expired. Request a new code."
}

Top Up Card

POST /cards/{card_id}/topup Fund a card via Debit Card, Wise or Bank Transfer (diaspora) or Multicaixa Express, Unitel Money, Africell Money (Angola Phase 1)
Always fetch GET /rates/current immediately before a top-up to lock the FX rate. Pass the returned rate_id as fx_rate_locked. Rate locks are valid for 90 seconds.

Request Body

FieldTypeRequiredDescription
amount_aoa number required Amount in sender's currency (GBP/EUR/USD for diaspora; AOA for Angola in-country), min £5 / $5 / 500 AOA
rail string required Load rail: debit_card, wise, bank_transfer (diaspora) or multicaixa_express, unitel_money, africell_money (Angola Phase 1)
msisdn string required Mobile number registered with the rail operator (E.164)
fx_rate_locked string required Rate ID from GET /rates/current — locks the exchange rate
idempotency_key string optional UUID to prevent duplicate top-ups on retry
JSON
{
  "amount_aoa":      50000,
  "rail":            "multicaixa_express",
  "msisdn":          "+244923456789",
  "fx_rate_locked":  "rate_01J2HQRSTUVWX2",
  "idempotency_key": "550e8400-e29b-41d4-a716-446655440000"
}
JSON
{
  "topup_id":      "top_01J2HRST3UVWX4YZ",
  "card_id":       "card_01J2HP7QRSTUVWXY1",
  "status":        "processing",
  "amount_aoa":    50000,
  "usd_estimated": 27.40,
  "rail":          "multicaixa_express",
  "msisdn":        "+244923456789",
  "fx_rate":       1824.50,
  "spread_pct":    2.5,
  "initiated_at":  "2026-06-15T10:09:00Z",
  "message":       "Top-up initiated. Customer will receive an USSD prompt to confirm payment."
}
JSON
{
  "error":        "insufficient_funds",
  "message":      "Insufficient funds in your payment account.",
  "amount_aoa":   50000,
  "rail_balance": 12300
}

List Transactions

GET /cards/{card_id}/transactions Paginated transaction history for a card

Query Parameters

ParameterTypeRequiredDescription
limit integer optional Number of records to return (default: 20, max: 100)
offset integer optional Number of records to skip for pagination (default: 0)
from_date string (ISO 8601) optional Filter from date e.g. 2026-06-01T00:00:00Z
to_date string (ISO 8601) optional Filter to date e.g. 2026-06-15T23:59:59Z
JSON
{
  "data": [
    {
      "tx_id":         "tx_01J2HS9ABCDEFGH01",
      "card_id":       "card_01J2HP7QRSTUVWXY1",
      "merchant_name": "Netflix International",
      "merchant_mcc":  "7812",
      "category":      "Entertainment",
      "amount_usd":    15.99,
      "status":        "settled",
      "settled_at":    "2026-06-14T22:10:00Z"
    },
    {
      "tx_id":         "tx_01J2HSDEFGHIJK02",
      "card_id":       "card_01J2HP7QRSTUVWXY1",
      "merchant_name": "Spotify AB",
      "merchant_mcc":  "7929",
      "category":      "Music",
      "amount_usd":    9.99,
      "status":        "settled",
      "settled_at":    "2026-06-13T08:00:00Z"
    }
  ],
  "pagination": {
    "total":  47,
    "limit":  20,
    "offset": 0,
    "has_more": true
  }
}

Freeze Card

POST /cards/{card_id}/freeze Temporarily block all transactions on a card

No request body required. The card is frozen immediately. All in-flight authorisations will be declined. The card can be unfrozen at any time via POST /cards/{card_id}/unfreeze.

JSON
{
  "card_id":    "card_01J2HP7QRSTUVWXY1",
  "status":     "frozen",
  "frozen_at":  "2026-06-15T10:15:00Z",
  "message":    "Card has been frozen. No transactions will be processed."
}

Unfreeze Card

POST /cards/{card_id}/unfreeze Re-enable a previously frozen card

No request body required. The card is reactivated immediately and will accept new transactions.

JSON
{
  "card_id":      "card_01J2HP7QRSTUVWXY1",
  "status":       "active",
  "unfrozen_at":  "2026-06-15T10:22:00Z",
  "message":      "Card is now active and ready to transact."
}

Get Current FX Rate

GET /rates/current Retrieve live FX rate (GBP/USD for diaspora; AOA/USD for Angola in-country) with spread and validity window

Returns the current exchange rate used for top-ups. The rate_id must be passed as fx_rate_locked in the top-up request. This locks the rate for the customer regardless of market movements during the USSD confirmation flow.

JSON
{
  "rate_id":         "rate_01J2HQRSTUVWX2",
  "pair":            "GBP/USD",
  "rate":            1.27,        // 1 GBP = 1.27 USD (live mid-market)
  "spread_pct":      2.5,
  "effective_rate":  1.239,       // rate after spread (what customer gets per £)
  "valid_until":     "2026-06-15T10:11:30Z",
  "fetched_at":      "2026-06-15T10:10:00Z"
}

Register Webhook

POST /webhooks/register Subscribe your endpoint to MojaCard events
MojaCard signs every webhook payload with an HMAC-SHA256 signature in the X-Moja-Signature header. Verify this before processing any event. Webhooks are retried up to 5 times with exponential backoff on non-2xx responses.

Request Body

FieldTypeRequiredDescription
url string (url) required HTTPS endpoint to receive events
events string[] required Array of event types: card.topup, card.transaction, kyc.approved, kyc.rejected
description string optional Human-readable label for this webhook
JSON
{
  "url":         "https://app.yourservice.ao/webhooks/mojacard",
  "events": [
    "card.topup",
    "card.transaction",
    "kyc.approved"
  ],
  "description": "Production webhook for card events"
}
JSON
{
  "webhook_id":     "wh_01J2HTUVWXYZ34AB",
  "url":            "https://app.yourservice.ao/webhooks/mojacard",
  "events": ["card.topup", "card.transaction", "kyc.approved"],
  "signing_secret": "whsec_XyZ9kLmN2pQrStUvWxYz...",
  "status":         "active",
  "registered_at":  "2026-06-15T10:12:00Z"
}

Payload Examples

All webhook deliveries share the same envelope structure. The data object varies by event type.

Verify the X-Moja-Signature header using HMAC-SHA256 with your signing_secret before trusting any payload. Reject requests that fail verification.
card.topup
JSON
{
  "event":      "card.topup",
  "webhook_id": "wh_01J2HTUVWXYZ34AB",
  "delivered_at":"2026-06-15T10:09:45Z",
  "data": {
    "topup_id":    "top_01J2HRST3UVWX4YZ",
    "card_id":     "card_01J2HP7QRSTUVWXY1",
    "aoa_deducted":50000,
    "usd_credited":26.73,
    "rail":        "multicaixa_express",
    "settled_at":  "2026-06-15T10:09:38Z"
  }
}
card.transaction
JSON
{
  "event":      "card.transaction",
  "webhook_id": "wh_01J2HTUVWXYZ34AB",
  "delivered_at":"2026-06-15T11:22:05Z",
  "data": {
    "tx_id":         "tx_01J2HS9ABCDEFGH01",
    "card_id":       "card_01J2HP7QRSTUVWXY1",
    "merchant_name": "Amazon.com",
    "amount_usd":    34.99,
    "category":      "Shopping",
    "status":        "authorised"
  }
}
kyc.approved
JSON
{
  "event":      "kyc.approved",
  "webhook_id": "wh_01J2HTUVWXYZ34AB",
  "delivered_at":"2026-06-15T10:06:12Z",
  "data": {
    "customer_id":      "cust_01J2HMPK3DFXQ6YW8V",
    "kyc_ref":          "kyc_01J2HN3PQRST4UV5WX",
    "tier":             "standard",
    "daily_limit_usd":  500
  }
}

Error Codes

All errors follow a consistent structure. The request_id field can be shared with support for faster diagnosis.

Error Envelope
{
  "error":      "error_code_slug",
  "message":   "Human-readable description",
  "request_id":"req_01HZ8KPA4BFMXCQ3J7N",
  "details":   {}  // optional: field-level errors for 400/422
}
400
Bad Request
Request body is malformed or missing required fields. Check details for field-level errors.
401
Unauthorized
Bearer token is missing, expired, or invalid. Re-authenticate via POST /auth/token.
402
Insufficient Funds
The mobile money wallet does not have enough balance for the requested top-up amount.
404
Not Found
The requested resource (customer, card, transaction) does not exist or does not belong to your account.
409
Conflict
Duplicate request detected. An identical idempotency key was used for a different payload.
422
KYC Required
Action requires an approved KYC. The customer's kyc_status must be approved to issue cards or increase limits.
429
Rate Limited
Too many requests. See the Retry-After header for the number of seconds to wait before retrying.
500
Internal Error
Unexpected server error. Retry with exponential backoff. If persistent, contact support with your request_id.

Rate Limits

Rate limits are applied per API key on a rolling 60-second window.

Endpoint GroupLimitWindow
POST /auth/token 10 requests per minute
POST /customers 60 requests per minute
POST /cards/issue 30 requests per minute
POST /cards/{id}/topup 20 requests per minute
All other GET endpoints 200 requests per minute
Rate limit headers are included on every response: X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset (Unix timestamp).