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.
/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.
Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...
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
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
| client_id | string | required | Your API client ID, issued by MojaCard |
| client_secret | string | required | Your API client secret. Keep server-side only |
{
"client_id": "kwz_live_cid_xxxxxxxxxxxxxxxx",
"client_secret": "kwz_live_sec_xxxxxxxxxxxxxxxxxxxxxxxx"
}
{
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJrd3pfbGl2ZV9...",
"token_type": "Bearer",
"expires_in": 86400,
"issued_at": "2026-06-15T10:00:00Z"
}
{
"error": "unauthorized",
"message": "Invalid client_id or client_secret",
"request_id": "req_01HZ8KPA4BFMXCQ3J7N"
}
Create Customer
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
| 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 |
| string | optional | Customer email for receipts and notifications |
{
"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"
}
{
"customer_id": "cust_01J2HMPK3DFXQ6YW8V",
"name": "Ana Paula Ferreira",
"msisdn": "+244923456789",
"bi_number": "005432891LA042",
"kyc_status": "pending",
"created_at": "2026-06-15T10:02:14Z"
}
{
"error": "validation_failed",
"message": "Invalid BI number format",
"field": "bi_number"
}
Submit KYC
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
| Parameter | Type | Description |
|---|---|---|
| customer_id | string | Customer ID returned from POST /customers |
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
| 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) |
{
"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"
}
{
"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
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
| Field | Type | Required | Description |
|---|---|---|---|
| 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 |
{
"customer_id": "cust_01J2HMPK3DFXQ6YW8V",
"currency": "USD",
"card_type": "virtual",
"funding_rail": "multicaixa_express"
}
{
"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"
}
{
"error": "kyc_required",
"message": "Customer KYC status is 'pending'. Cards can only be issued to approved customers.",
"kyc_status":"pending"
}
Get Card Details
Path Parameters
| Parameter | Type | Description |
|---|---|---|
| card_id | string | Unique card identifier |
{
"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
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
| Parameter | Type | Required | Description |
|---|---|---|---|
| confirmation_code | string | required | 6-digit OTP delivered to the cardholder's MSISDN |
{
"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
}
{
"error": "invalid_otp",
"message": "OTP is invalid or has expired. Request a new code."
}
Top Up Card
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
| Field | Type | Required | Description |
|---|---|---|---|
| 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 |
{
"amount_aoa": 50000,
"rail": "multicaixa_express",
"msisdn": "+244923456789",
"fx_rate_locked": "rate_01J2HQRSTUVWX2",
"idempotency_key": "550e8400-e29b-41d4-a716-446655440000"
}
{
"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."
}
{
"error": "insufficient_funds",
"message": "Insufficient funds in your payment account.",
"amount_aoa": 50000,
"rail_balance": 12300
}
List Transactions
Query Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| 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 |
{
"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
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.
{
"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
No request body required. The card is reactivated immediately and will accept new transactions.
{
"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
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.
{
"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
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
| Field | Type | Required | Description |
|---|---|---|---|
| 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 |
{
"url": "https://app.yourservice.ao/webhooks/mojacard",
"events": [
"card.topup",
"card.transaction",
"kyc.approved"
],
"description": "Production webhook for card events"
}
{
"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.
X-Moja-Signature header using HMAC-SHA256 with your signing_secret before trusting any payload. Reject requests that fail verification.{
"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"
}
}
{
"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"
}
}
{
"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": "error_code_slug",
"message": "Human-readable description",
"request_id":"req_01HZ8KPA4BFMXCQ3J7N",
"details": {} // optional: field-level errors for 400/422
}
details for field-level errors.POST /auth/token.kyc_status must be approved to issue cards or increase limits.Retry-After header for the number of seconds to wait before retrying.request_id.Rate Limits
Rate limits are applied per API key on a rolling 60-second window.
| Endpoint Group | Limit | Window |
|---|---|---|
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 |
X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset (Unix timestamp).