API Documentation

Integrate card issuing directly into your own product. Verify your business, fund a pooled wallet, onboard customers, issue and manage cards, and receive real-time webhooks — all server-to-server.

Getting Started

Apply for access from the landing page. Once onboarded, your team gets a merchant dashboard login. From there:

  1. Submit KYB (Know-Your-Business) from the dashboard and wait for approval.
  2. Create an API key under Account → API keys. The secret is shown exactly once.
  3. Set your webhook URL under Webhooks, and reveal the signing secret to verify deliveries.
  4. Fund your wallet by sending USDT to one of its deposit addresses.
This API is server-to-server only. Call it from your backend — your API secret must never reach a browser or mobile app.

Base URL

Every endpoint is shown relative to $API_BASE_URL. All partner API routes are prefixed with /api/v1. Requests and responses are plain JSON.

Response envelope

Every JSON response, success or failure, has the same shape:

Envelope
{
  "status": true,
  "message": "Successful",
  "data": { },
  "request_id": "req_01a10673-a078-7641-a76f-94b9ab90cbf0",
  "correlation_id": "req_01a10673-a078-7641-a76f-94b9ab90cbf0",
  "timestamp": "2026-10-04T10:26:43.453Z"
}
FieldTypeDescription
statusbooleantrue on success, false on failure. Always check this, not just the HTTP code.
messagestringHuman-readable. Safe to show to your users on failure.
dataanyThe payload. On failure, usually { "status": false } or null.
codestringFailures only. A stable, machine-readable reason (see Errors). Branch on this, not on the message text.
extensions.field_violations{ field, description }[]Validation failures that concern specific fields.
request_idstringUnique id of this one request. Also sent as the X-Request-Id response header.
correlation_idstringId of the whole operation this request starts, carried onto the webhooks it causes. On an API response it equals request_id. Also sent as the X-Correlation-Id response header.
timestampISO dateWhen the response was produced.

Request ids

  • Log it. Store request_id with your own logs, and quote it when you contact support: we can find every log line for that request with it.
  • Both ids are ours. Read them from the response; don't generate them. That keeps them unique and trustworthy: nobody can choose or reuse one. An X-Request-Id or X-Correlation-Id you send isn't used as ours; it's only recorded in our logs as your own reference.
  • Match webhooks to calls. Keep the correlation_id from a call's response: the webhooks that call causes arrive with the same value. For example, freeze a card and store the response's correlation_id; the card.frozen webhook carries it.

Data conventions

  • IDs are UUID strings.
  • Money is US dollars. Values read from storage (wallet balance, ledger rows, withdrawals) are decimal strings like "970.00"; computed values (cards, card transactions) are numbers. Parse with Number(value).
  • Timestamps are ISO-8601 UTC strings.
  • Pagination: ?page= (1-based) and ?limit= (default 20, max 100). Most lists return { page, limit, total, totalPages, <items> }; exceptions are noted.

Authentication

Two steps: exchange your API key for a 1-hour access token, then send that token on every call, with each request signed. From your API key you need three values: clientId, clientSecret (the part of secret after sk_<clientId>_) and uniqueKey.

  • The secret isn't sent with API calls. It goes only to the token endpoint, about once an hour.
  • The secret still signs every request, so a stolen token alone can't make calls.

1. Get an access token

POST /api/v1/auth/token with HTTP Basic auth (clientId:clientSecret). Form bodies (grant_type=client_credentials&scope=CardAPIs) work too. scope is optional: leave it out and the token gets every scope.

Request
POST /api/v1/auth/token
Authorization: Basic <base64("clientId:clientSecret")>
Content-Type: application/json

{ "grant_type": "client_credentials", "scope": "CardAPIs CustomerAPIs" }
Response
{
  "status": true,
  "message": "Access token issued",
  "data": { "access_token": "at_3f9c…", "expires_in": 3600, "token_type": "Bearer", "scope": "CardAPIs CustomerAPIs" },
  "access_token": "at_3f9c…",
  "expires_in": 3600,
  "token_type": "Bearer",
  "scope": "CardAPIs CustomerAPIs"
}

The token fields appear both in data and at the top level, so standard OAuth2 client-credentials libraries can read them directly.

  • Reuse it. Keep using the same token until shortly before expires_in (1 hour) runs out. Don't request one per call: the endpoint allows 30 requests per minute per IP.
  • It can stop working early. Regenerating or revoking the API key ends all of its tokens immediately. Get a new token with the current secret.
  • Same checks as API calls: the key's IP allowlist and your business being active. KYB approval isn't needed, so you can get a token to call the KYB endpoints.

Scopes

A token can only call the endpoints its scopes cover. Request just what each integration needs.

FieldTypeDescription
KybAPIsscope/kyb, /kyb/verification-session, /kyb/verification-status
WalletAPIsscope/wallet, /wallet/transactions, /wallet/withdrawals…, /pricing
CustomerAPIsscopePOST /customers, GET /customers/:id, /customers/:id/kyc…
CardAPIsscope/customers/:id/cards (issue, list), GET /cards/:cardId (masked), /cards/:cardId/transactions, /topup, /withdraw, /freeze, /unfreeze, /terminate
CardDetailsAPIsscopeGET /cards/:cardId/pan (full card number and CVV). Separate, so most tokens can be issued without it.
WebhookAPIsscope/webhooks, /webhooks/:id, /webhooks/:id/retry
  • Separate scopes with spaces (commas also work). Matching ignores case; the response gives the canonical names.
  • An unknown scope fails the request (Unknown scope: …) and no token is issued.
  • Calling an endpoint without its scope fails with HTTP 400 and code: "FORBIDDEN"; data.requiredScope names the scope needed.
  • A token's scopes are fixed when it's issued. To widen them, request a new token.

2. Send these headers on every API call

FieldTypeDescription
AuthorizationrequiredheaderBearer <access_token>, from step 1. The API key itself is not accepted here.
ReferencerequiredheaderAny string, unique per request. Reusing one within 15 minutes is rejected.
UniqueKeyrequiredheaderShown on the key in your dashboard. Folded into the signature, so a wrong value fails verification.
SignaturerequiredheaderComputed as below.

Computing the signature

signature = base64( sha1hex( METHOD & encodeURIComponent(pathWithQuery) & Reference & clientId & clientSecret & uniqueKey ) )

  • METHOD — uppercase (GET, POST).
  • pathWithQuery — the path and query string, e.g. /api/v1/wallet/transactions?page=2&limit=50 — never the full URL.
  • Join with &, take the SHA-1 hex string, then base64-encode that hex string (not the raw bytes).
  • clientSecret is part of the signature but is not sent.
Node.js
const crypto = require("crypto");

// creds come from an API key created in your dashboard (Developers → API keys):
//   secret    = "sk_<clientId>_<clientSecret>"  — shown once, at creation
//   uniqueKey = shown on the key, safe to display
// Keep clientSecret on your server only.

// Token cache: one token, reused until a minute before it expires.
let cached = { token: null, expiresAt: 0 };
async function accessToken({ clientId, clientSecret }) {
  if (cached.token && Date.now() < cached.expiresAt - 60_000) return cached.token;
  const res = await fetch(`${API_BASE_URL}/api/v1/auth/token`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Authorization: "Basic " + Buffer.from(`${clientId}:${clientSecret}`).toString("base64"),
    },
    body: JSON.stringify({ grant_type: "client_credentials" }), // add scope: "CardAPIs …" to narrow it
  });
  const body = await res.json();
  if (!body.status) throw new Error(`${body.code}: ${body.message} (request ${body.request_id})`);
  cached = { token: body.access_token, expiresAt: Date.now() + body.expires_in * 1000 };
  return cached.token;
}

async function signedHeaders(method, pathWithQuery, creds) {
  const { clientId, clientSecret, uniqueKey } = creds;
  const reference = crypto.randomUUID(); // unique per request
  const base = [
    method.toUpperCase(),
    encodeURIComponent(pathWithQuery), // path AND query string, never the full URL
    reference,
    clientId,
    clientSecret, // signs the request, but is never sent
    uniqueKey,
  ].join("&");
  // SHA-1 hex string, then base64 of that hex string (not of the raw bytes)
  const sha1Hex = crypto.createHash("sha1").update(base, "utf8").digest("hex");
  const signature = Buffer.from(sha1Hex).toString("base64");

  return {
    "Content-Type": "application/json",
    Authorization: `Bearer ${await accessToken(creds)}`,
    Reference: reference,
    UniqueKey: uniqueKey,
    Signature: signature,
  };
}

// Usage
const path = "/api/v1/wallet/transactions?page=2&limit=50";
const res = await fetch(`${API_BASE_URL}${path}`, { headers: await signedHeaders("GET", path, creds) });
cURL
# 1. Get an access token (about once an hour)
curl -X POST "$API_BASE_URL/api/v1/auth/token" \
  -u "54d1a7bc6b61:<clientSecret>" \
  -H "Content-Type: application/json" \
  -d '{ "grant_type": "client_credentials", "scope": "WalletAPIs" }'

# 2. Call the API with it
curl -X GET "$API_BASE_URL/api/v1/wallet" \
  -H "Authorization: Bearer at_3f9c…" \
  -H "Reference: 3f2c9a1e-6b7d-4f0a-9c1e-2b5d8e7f1a4c" \
  -H "UniqueKey: 7aa0fe0a…" \
  -H "Signature: <computed-signature>" \
  -H "Content-Type: application/json"

Optional IP allowlist

Each API key can be restricted to a list of exact IPv4/IPv6 addresses (no CIDR ranges) from your dashboard. An empty list allows any IP.

Token errors

message
FieldTypeDescription
Send Authorization: Basic base64(clientId:clientSecret)400Missing or wrong Authorization header on /auth/token.
Invalid client credentials400Wrong clientId or clientSecret, or the key is revoked.
Unknown scope: X. Valid scopes: …400scope contains a name not in the table above.
grant_type must be client_credentials400

Authentication errors

All return HTTP 400 with status: false and code: "UNAUTHORIZED".

message
FieldTypeDescription
Missing access token — get one from POST /api/v1/auth/token400No Authorization: Bearer … header.
Access token is invalid or expired — get a new one from POST /api/v1/auth/token400Expired (after 1 hour), made up, or the key was regenerated or revoked since it was issued.
Send an access token from POST /api/v1/auth/token400Something other than an access token was sent as the bearer, e.g. the API key itself.
Missing Reference/Signature/UniqueKey headers400One or more headers not sent.
Invalid UniqueKey400Doesn't match the key's value.
Invalid request signature400Check the path includes the query string, the method is uppercase, and you base64-encoded the hex string.
This Reference has already been used400References can't repeat within 15 minutes.
This IP is not whitelisted for this API key400The caller's IP isn't on the key's allowlist.
This account is not active400Your business has been suspended.
This business has not completed KYB verification400Every route except /kyb needs KYB APPROVED.

Errors & Rate Limits

Almost every failure — validation, a business rule, not found — returns HTTP 400 with status: false, a readable message and a machine-readable code.

Validation failure
{
  "status": false,
  "message": "amount must be a positive number",
  "data": { "status": false, "errors": { "amount": "amount must be a positive number" } },
  "code": "VALIDATION_ERROR",
  "extensions": {
    "field_violations": [
      { "field": "amount", "description": "amount must be a positive number" }
    ]
  },
  "request_id": "req_01a10673-a0aa-75b8-8dba-c6eb75f3d42f",
  "correlation_id": "req_01a10673-a0aa-75b8-8dba-c6eb75f3d42f",
  "timestamp": "2026-10-04T10:26:43.499Z"
}
Error codes
FieldTypeDescription
VALIDATION_ERROR400A field is missing or invalid. See extensions.field_violations when present.
BAD_REQUEST400The request can't be done as asked (a business rule). Read message.
UNAUTHORIZED400 / 401Missing or invalid credentials: access token, client credentials, request signature, or an IP that isn't allow-listed.
FORBIDDEN400The access token doesn't include the scope this endpoint needs; data.requiredScope names it.
NOT_FOUND400No such resource, or it belongs to another business.
CONFLICT400Already done or already exists (e.g. a clientReference already used).
INSUFFICIENT_FUNDS400The wallet or card balance can't cover it.
SERVICE_DISABLED400Switched off: card service, withdrawals, or an account that isn't active.
PAYLOAD_TOO_LARGE413Body over 25 MB.
RATE_LIMITED429Too many requests: wait and retry.
INTERNAL_ERROR500Unexpected server error: retry, and quote the request_id if it persists.

New codes may be added later; treat an unknown code like BAD_REQUEST.

Not found is 400, not 404.A customer or card that belongs to another business also comes back as "not found", so the API never confirms it exists.
Your API key only ever sees your business's data.Every customer, card, transaction, KYC record, webhook delivery and withdrawal is scoped to your business: lists and searches never include another business's records, and actions on another business's card or customer (top-up, withdraw, freeze, terminate, KYC, card creation) are refused before anything happens.

Rate limits

FieldTypeDescription
General120 req/minPer business.
GET /cards/:cardId/pan20 req/minA separate, tighter limit on full card details.
Card issuance1 per 30 sPer customer. See Issue a card.
POST /auth/token30 req/minPer IP. Reuse tokens rather than requesting one per call.
Response 429
HTTP/1.1 429 Too Many Requests
{
  "status": false,
  "message": "Too many requests, try again later.",
  "data": null,
  "code": "RATE_LIMITED",
  "request_id": "req_…",
  "correlation_id": "req_…",
  "timestamp": "2026-10-04T10:26:43.453Z"
}

Retries & Idempotency

Card creation, card top-ups, card withdrawals and wallet withdrawals accept a clientReference. Send a unique one per operation (e.g. your own order id).

  • Same reference again: you're never charged twice. A completed operation answers This clientReference was already used…; a wallet withdrawal returns the original request.
  • Failed and refunded: That card request failed and was refunded. Send a new clientReference to try again.
  • Outcome couldn't be confirmed (a network problem with the card network): the error's data.clientReference holds the reference to retry with — generated for you if you sent none. Retry with exactly the same body and reference; it completes at most once, with no new charge. If you don't retry, it resolves on its own within about 30 minutes: the card appears, or the charge is refunded.
  • Changed request: reusing a reference with a different amount or card is refused.
Unconfirmed outcome
{
  "status": false,
  "message": "…",
  "data": { "status": false, "clientReference": "order-8812" }
}

KYB

Know-Your-Business verification. Your first submission normally goes through the dashboard, since you can't create an API key until KYB is approved. These endpoints work before approval and mainly serve resubmission after a rejection.

POST/api/v1/kyb

Submit or resubmit your KYB. The business goes back to PENDING.

Request body
FieldTypeDescription
legalNamerequiredstring
registrationNumberrequiredstring
countryOfIncorporationrequiredstring
contactEmailrequiredstringReceives the review outcome and wallet deposit emails.
certificateOfIncorporationrequiredstringBase64 data URI or URL.
shareholdersrequiredobject[]At least one. Each needs fullName; optional nationality, percentageOwnership, idType, idNumber, idDocument (data URI or URL).
line1, city, state, zipCodestringRegistered address.
contactPhone, websitestring
proofOfAddress, ownerIdDocument, memart, cacDocumentstringBase64 data URI or URL.
additionalDocuments{ label, data }[]Any further documents.
Request body
{
  "legalName": "Acme Payments Ltd",
  "registrationNumber": "RC1234567",
  "countryOfIncorporation": "NG",
  "contactEmail": "compliance@acme.com",
  "certificateOfIncorporation": "data:application/pdf;base64,JVBERi0xLjQK...",
  "shareholders": [
    { "fullName": "Ada Lovelace", "nationality": "NG", "percentageOwnership": 60 }
  ],
  "line1": "12 Marina Road",
  "city": "Lagos",
  "state": "Lagos",
  "zipCode": "100001",
  "contactPhone": "+2348012345678",
  "website": "https://acme.com"
}
Response 200
{
  "status": true,
  "message": "KYB submitted for review",
  "data": {
    "id": "5b2e…",
    "legalName": "Acme Payments Ltd",
    "registrationNumber": "RC1234567",
    "countryOfIncorporation": "NG",
    "contactEmail": "compliance@acme.com",
    "shareholders": [
      { "fullName": "Ada Lovelace", "nationality": "NG", "percentageOwnership": 60 }
    ],
    "status": "PENDING",
    "rejectionReason": null,
    "reviewedAt": null,
    "createdAt": "2026-10-01T10:00:00.000Z"
  }
}
GET/api/v1/kyb

Current status (NOT_SUBMITTED, PENDING, APPROVED, REJECTED) and the latest submission. Uploaded documents are never returned.

Response 200
{
  "status": true,
  "message": "Successful",
  "data": {
    "kybStatus": "APPROVED",
    "latestSubmission": {
      "id": "5b2e…",
      "legalName": "Acme Payments Ltd",
      "status": "APPROVED",
      "rejectionReason": null,
      "reviewedAt": "2026-10-01T09:12:00.000Z",
      "createdAt": "2026-09-30T10:00:00.000Z"
    }
  }
}

Hosted verification with Didit

Instead of the form above, your business can be verified on Didit's hosted page, which checks the company against the business registry. Once finished it's submitted for our team's review, exactly like a form submission; Didit never approves on its own.

POST/api/v1/kyb/verification-session

Starts a verification, or returns the unfinished one (resumed: true). Open url for the person completing it.

Request body
FieldTypeDescription
contactEmailrequiredstringReceives the review outcome and wallet deposit emails.
Response 200
{
  "status": true,
  "message": "Business verification started",
  "data": {
    "kybId": "5b2e…",
    "sessionId": "…",
    "url": "https://…",
    "status": "Not Started",
    "resumed": false
  }
}
GET/api/v1/kyb/verification-status

Cheap to poll every few seconds while verification is open; stop when done is true. state is not_started, in_progress, submitted (pending review), approved, rejected, verification_failed or expired (start again), with a ready-to-show message.

Response 200
{
  "status": true,
  "message": "Successful",
  "data": {
    "kybStatus": "PENDING",
    "state": "submitted",
    "message": "Your KYB has been submitted and is pending review. We'll email you once it's been reviewed.",
    "done": true,
    "verificationStatus": "Approved",
    "verificationUrl": null
  }
}

Wallet

One pooled USD wallet per business pays for everything: card creation, top-ups and fees. Fund it by sending USDT to one of its deposit addresses; deposits are credited automatically and your KYB contact gets an email.

GET/api/v1/wallet

Balance and deposit addresses. depositAddresses lists each address with its chain (currently ethereum and solana); always show the network next to the address, because funds sent on another network can't be recovered. Addresses are created on first call, once KYB is approved; until then depositAddressError says why they're missing. address and addressBep20 are older aliases for the first and second address (the name is historical).

Response 200
{
  "status": true,
  "message": "Successful",
  "data": {
    "balance": "970.00",
    "depositAddresses": [
      { "chain": "ethereum", "address": "0x8f2c…e41a" },
      { "chain": "solana", "address": "7Xq3…kP9d" }
    ],
    "depositAddressError": null,
    "address": "0x8f2c…e41a",
    "addressBep20": "7Xq3…kP9d"
  }
}
GET/api/v1/wallet/transactions

The wallet ledger, newest first. Query: page, limit.

Response 200
{
  "status": true,
  "message": "Successful",
  "data": {
    "page": 1,
    "limit": 20,
    "transactions": [
      {
        "id": "9c31…",
        "businessId": "1a2b…",
        "businessWalletId": "aa11…",
        "type": "DEBIT",
        "service": "CARD_CREATION",
        "title": "Card Creation",
        "description": "New card loaded with $50.00",
        "amount": "50.00",
        "fee": "2.00",
        "balanceBefore": "1022.00",
        "balanceAfter": "970.00",
        "ref": "card-create:order-8812",
        "customerId": "02328859-…",
        "cardId": "c4c09003-…",
        "createdAt": "2026-10-01T10:00:00.000Z",
        "updatedAt": "2026-10-01T10:00:00.000Z"
      }
    ]
  }
}
This list has no total/totalPages — request the next page until you get fewer than limit rows. Ignore any field not shown here.
service
FieldTypeDescription
DEPOSITCREDITWallet funded.
CARD_CREATIONDEBIT / CREDITCard issued (amount + fees) / issuance refunded.
CARD_TOPUPDEBIT / CREDITCard top-up / top-up reversed.
CARD_WITHDRAWALCREDITMoved from a card to the wallet.
CARD_TERMINATION_REFUNDCREDITRemaining card balance returned on termination.
CARD_TERMINATION_FEEDEBITTermination fee, or a charge after termination.
CARD_LATE_REFUNDCREDITA refund that landed after the card was terminated.
CARD_DECLINE_FEEDEBIT / CREDITDecline-rule fee / refund.
CARD_CROSSBORDER_FEEDEBIT / CREDITCross-border fee / reversal.
CARD_CONTACTLESS_FEEDEBITContactless fee.
WITHDRAWALDEBIT / CREDITWithdrawal requested / refunded.
ADJUSTMENTeitherManual adjustment.
Creating or topping up a card is refused if the wallet can't cover it. A fee that comes due while the wallet is empty is recorded as owed and collected from your next deposit.
GET/api/v1/pricing

The fees your business pays. Scope WalletAPIs.

Response 200
{
  "status": true,
  "message": "Successful",
  "data": {
    "currency": "USD",
    "cardCreationFee": 2,
    "fundCardFeeValue": 1,
    "fundFeePercent": 1.5,
    "terminationFee": 1,
    "declineFee": 0.75,
    "fundFeePercentThreshold": 99
  }
}
FieldTypeDescription
cardCreationFeenumberFlat fee per new card.
fundCardFeeValuenumberFlat funding fee for a top-up (or a card's initial funding) below fundFeePercentThreshold.
fundFeePercentnumberPercentage funding fee at or above fundFeePercentThreshold (1.5 = 1.5% of the amount).
fundFeePercentThresholdnumberThe funding amount ($99) where the flat fee switches to the percentage.
terminationFeenumberCharged when a card is terminated.
declineFeenumberCharged on the 2nd and 3rd repeated decline of the same payment.

Fees are numbers in US dollars. Show them to your team before issuing or topping up a card.

Withdrawals

A request, not a self-serve payout: the amount is reserved from your wallet immediately, our team sends the funds manually, then marks it completed or rejected. A rejection refunds your wallet. You get a wallet.withdrawal.completed or wallet.withdrawal.rejected webhook either way.

POST/api/v1/wallet/withdrawals

Request a withdrawal from your wallet.

Request body
FieldTypeDescription
amountrequirednumberGreater than 0, at most your balance.
destinationAddressrequiredstring
destinationChainstringThe network to send on, e.g. "ethereum", "solana".
notestring
clientReferencestringIdempotency key. The same value again returns the original request ("Withdrawal already requested").
Request body
{
  "amount": 50,
  "destinationAddress": "TXyz…tron",
  "destinationChain": "TRON",
  "note": "Monthly settlement",
  "clientReference": "wd-2026-10-001"
}
Response 200
{
  "status": true,
  "message": "Withdrawal requested",
  "data": {
    "id": "f0a1…",
    "amount": "50.00",
    "destinationAddress": "TXyz…tron",
    "destinationChain": "TRON",
    "note": "Monthly settlement",
    "status": "PENDING",
    "rejectionReason": null,
    "externalReference": null,
    "reviewedAt": null,
    "createdAt": "2026-10-01T10:00:00.000Z"
  }
}
Errors
FieldTypeDescription
Insufficient business wallet balance400
Withdrawals are currently disabled for this account. Please contact support.400Switched off for your business.
This clientReference was already used for a different withdrawal…400Send a new clientReference.
GET/api/v1/wallet/withdrawals

History. Query: page, limit. Returns { page, limit, total, totalPages, withdrawals }.

GET/api/v1/wallet/withdrawals/:id

One request. COMPLETED shows externalReference (e.g. the tx hash); REJECTED shows rejectionReason.

Customers

A customer is one of your cardholders. Create one before KYC or cards. Its id is the customerId used everywhere, including webhooks.

POST/api/v1/customers

Create a customer. Returns the customer object ("Customer created").

Request body
FieldTypeDescription
emailrequiredstringUnique within your business.
firstName, lastNamerequiredstring
phonestring
externalRefstringYour own id for this person — echoed in webhooks.
Request body
{
  "email": "ada@example.com",
  "firstName": "Ada",
  "lastName": "Lovelace",
  "phone": "+2348012345678",
  "externalRef": "user-123"
}
GET/api/v1/customers/:id

The customer, plus kycStatus (NOT_SUBMITTED, PENDING, APPROVED, REJECTED, NOT_STARTED, MAX_RETRIES_REACHED, MISSING_KYC_DATA) and hasSubmittedKyc.

Response 200
{
  "status": true,
  "message": "Successful",
  "data": {
    "id": "02328859-aab9-4fb5-8f58-b1fb8a1eff65",
    "email": "ada@example.com",
    "firstName": "Ada",
    "lastName": "Lovelace",
    "phone": "+2348012345678",
    "externalRef": "user-123",
    "status": "ACTIVE",
    "kycStatus": "APPROVED",
    "hasSubmittedKyc": true,
    "createdAt": "2026-10-01T10:00:00.000Z"
  }
}
There's no "list customers" endpoint on the API; the merchant dashboard has one.

Customer KYC

A card can only be issued once the customer's KYC is approved. Either send the cardholder to a hosted verification page (recommended), or submit identity data you've already verified yourself. KYC endpoints return status as approved, pending, rejected or unknown.

Option A — hosted verification

Option B — direct submission

POST/api/v1/customers/:id/kyc

Submit identity data you've already verified yourself. Same fields as Option A, plus:

Additional fields
FieldTypeDescription
idFrontImagestringRequired unless idType is BVN/NIN. data:image/jpeg;base64,… or an image URL, max 2 MB.
clientReferencestringYour own reference for this submission.
Response 200
{
  "status": true,
  "message": "KYC submitted",
  "data": {
    "status": "pending",
    "customerId": "02328859-aab9-4fb5-8f58-b1fb8a1eff65",
    "clientReference": "kyc-user-123"
  }
}
POST/api/v1/customers/:id/kyc/retry

Resubmit after a rejection. Same response shape.

Request body
FieldTypeDescription
idType, idNumber, line1, city, state, country, zipCoderequiredstring
dateOfBirth, idFrontImage, firstName, lastNamestringOptional.
GET/api/v1/customers/:id/kyc

Live status: { status, customerId, clientReference }, or { status: "unknown", hasSubmittedKyc: false } before any submission.

GET/api/v1/customers/:id/kyc/documents

The ID images captured during hosted verification, available once KYC is approved. A customer approved through direct submission has none; this endpoint then returns status: false with a message saying so.

Response 200
{
  "status": true,
  "message": "Successful",
  "data": {
    "customerId": "02328859-…",
    "documentId": "…",
    "documents": [
      {
        "idDocType": "PASSPORT",
        "country": "NGA",
        "images": [{ "fileName": "front", "url": "https://…" }]
      }
    ]
  }
}

Cards

Every cost is drawn from your wallet. Card status is ACTIVE, FROZEN, INACTIVE or TERMINATED.

POST/api/v1/customers/:id/cards

Issue a card. Needs the customer's KYC approved. Returns the card object.

Request body
FieldTypeDescription
amountrequirednumberInitial funding, minimum $2.
cardBrand"Visa" | "Mastercard"Default "Mastercard".
cardCurrency / walletCurrencystringDefault "USD".
addressType"default" | "kyc"Billing address: the company's US address, or the cardholder's KYC address.
cardLimitsobjectSpending limits.
whiteListedMccs / blackListedMccsstring[]Merchant category codes.
clientReferencestringRecommended — see Retries.
Request body
{
  "amount": 50,
  "cardBrand": "Mastercard",
  "cardCurrency": "USD",
  "addressType": "default",
  "clientReference": "order-8812"
}
Response 200
{
  "status": true,
  "message": "Successful",
  "data": {
    "id": "c4c09003-c996-49b3-bc47-5f32f5a2cc1b",
    "customerId": "02328859-aab9-4fb5-8f58-b1fb8a1eff65",
    "brand": "Mastercard",
    "currency": "USD",
    "maskedNumber": "517746******4775",
    "firstSix": "517746",
    "lastFour": "4775",
    "status": "ACTIVE",
    "balance": 10,
    "isPhysical": false,
    "createdAt": "2026-10-01T23:46:56.000Z",
    "updatedAt": "2026-10-01T23:46:56.000Z"
  }
}
Charged to your wallet: amount + card creation fee + funding fee (see your fees). At most 5 active or frozen cards per customer. Contactless is always on.

One card per customer every 30 seconds

  • The refusal: after a card is issued to a customer, another issue request for that customer within 30 seconds gets HTTP 429, code: "RATE_LIMITED", a Retry-After header and data.retryAfterSeconds. A request sent while one is still being issued for the same customer gets the same refusal.
  • Unaffected: your other customers.
  • Nothing is charged on a refused request.
  • Failures don't start the wait. A failed issue, or one whose outcome couldn't be confirmed, starts no wait, so a retry with the same clientReference goes through at once.
Response 429
HTTP/1.1 429 Too Many Requests
Retry-After: 24
{
  "status": false,
  "message": "A card was just issued to this customer. Please wait 24 seconds before issuing another card to them.",
  "data": { "retryAfterSeconds": 24 },
  "code": "RATE_LIMITED",
  "request_id": "req_…",
  "correlation_id": "req_…",
  "timestamp": "2026-10-11T10:26:43.453Z"
}
GET/api/v1/customers/:id/cards

The customer's non-terminated cards. balance is the last known value — fetch a card for the live one.

GET/api/v1/cards/:cardId

The card with a live balance, plus minimumBalance (always 1 — $1 must stay on the card) and withdrawableBalance.

GET/api/v1/cards/:cardId/pan

The card object plus full card number, CVV, expiry, name and billing address.

PCI-sensitive: fetched live, never stored by us, rate-limited to 20/min. Never log it, and never show it without an explicit user action.
Response 200
{
  "status": true,
  "message": "Successful",
  "data": {
    "id": "c4c09003-…",
    "maskedNumber": "517746******4775",
    "status": "ACTIVE",
    "cardNumber": "5177461234564775",
    "cvv": "123",
    "expiry": "2029-10",
    "nameOnCard": "ADA LOVELACE",
    "billingAddress": {
      "line1": "…", "line2": null, "city": "…", "state": "…", "postalCode": "…", "country": "US"
    },
    "balance": 10
  }
}
GET/api/v1/cards/:cardId/transactions

Query: pageNumber (or cursor, default 1), limit (default 20), and optional type, status, startDate, endDate, transactionId.

Response 200
{
  "status": true,
  "message": "Successful",
  "data": {
    "transactions": [
      {
        "id": "9004",
        "type": "Authorization",
        "status": "Approved",
        "amount": 12.5,
        "currency": "USD",
        "merchantName": "APPLE.COM/BILL",
        "merchantCategoryCode": "5735",
        "reference": "ord-…",
        "description": null,
        "time": "2026-10-02T11:00:00"
      }
    ],
    "page": 1,
    "pageSize": 20,
    "total": 1,
    "totalPages": 1
  }
}
merchantName is "" for funding movements made by the card platform itself.
POST/api/v1/cards/:cardId/topup

Add funds from your wallet. Minimum $2; charged amount + funding fee.

Request body
{ "amount": 20, "clientReference": "topup-0001" }
Response 200
{ "status": true, "message": "…", "data": { "cardId": "c4c09003-…", "amount": 20 } }
POST/api/v1/cards/:cardId/withdraw

Move money from the card to your wallet. $1 must stay on the card. Your wallet is credited once the card network confirms.

Request body
{ "amount": 5, "reason": "Customer request", "clientReference": "wd-0001" }
POST/api/v1/cards/:cardId/freeze

Block payments. Returns the card (FROZEN).

POST/api/v1/cards/:cardId/unfreeze

Returns the card (ACTIVE).

POST/api/v1/cards/:cardId/terminate

Irreversible. The remaining balance is refunded to your wallet; a termination fee may apply. Returns the card (TERMINATED).

Common card errors

message
FieldTypeDescription
Card not found400Wrong id, or not your business's card.
Card service is currently disabled for this account. Please contact support.400Create, top-up, withdraw and terminate are switched off for your business.
Card creation is currently disabled…400Issuance is switched off platform-wide or for your business.
Please complete your KYC verification before creating a card.400The customer's KYC isn't approved.
Insufficient business wallet balance400Fund your wallet.

The decline rule

For repeated declines of the same purchase within one hour (insufficient funds, a frozen card, or an authorisation never completed):

  • 1st decline: free.
  • 2nd and 3rd: a decline fee each, taken from the card, or from your wallet if the card can't cover it.
  • On the 3rd: the card is terminated.

Receiving Webhooks

We POSTevents to the webhook URL you set in your dashboard (Webhooks → Settings). Your endpoint must answer 2xx within 10 seconds; redirects aren't followed.

Headers
FieldTypeDescription
X-Webhook-IdheaderThe event's id — the same value as id in the body, and the id in the delivery log.
X-Webhook-EventheaderThe eventType.
X-Webhook-SignatureheaderHex HMAC-SHA256 of the raw body, keyed by your signing secret (reveal it in the dashboard).
X-Correlation-IdheaderWhen the event was caused by an API call: the same as correlation_id in the body, and the correlation_id returned in that call's response. For card-network events (transactions, issuance) it identifies the incoming network event on our side; quote it to support.
Node.js — verify
const crypto = require("crypto");

// rawBody = the exact bytes received — don't re-serialise parsed JSON.
function isGenuine(rawBody, signatureHeader, signingSecret) {
  const expected = crypto.createHmac("sha256", signingSecret).update(rawBody).digest("hex");
  return signatureHeader?.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(signatureHeader), Buffer.from(expected));
}

Delivery guarantees

FieldTypeDescription
RetriesautomaticA failed delivery is resent 5 times, after 1, 2, 4, 8 and 16 minutes (6 attempts over about 31 minutes), then marked FAILED. Resend it from the delivery log.
Duplicatesdedupe on idEvery event has a unique id that stays the same on every retry or resend of it. A slow response can make the same event arrive more than once, so record each id you've processed and skip repeats.
Ordernot guaranteedUse eventTime.
Sensitive datanever sentNo card numbers, CVVs or expiry dates.

Event Reference

Every event, of every class, carries an id: unique per event, unchanged across retries, and equal to the X-Webhook-Id header. Use it to deduplicate.

Card events (eventClass: "card")

Every card event carries id, eventClass, eventType, eventTime, sourceEventId, customerId, externalRef and cardId, plus:

eventType
FieldTypeDescription
card.issuedactivated, availableBalance, lastFour
card.transaction.preauthorizedtransaction fields
card.transaction.authorizedtransaction fields
card.transaction.declinedtransaction fields; description is the decline reason
card.transaction.settledtransaction fields
card.transaction.expiredtransaction fields — an authorisation the merchant never completed
card.transaction.reversedtransaction fields
card.transaction.refund_authorizedtransaction fields
card.transaction.refund_settledtransaction fields
card.fee.cross_bordertransaction fields; status: "Pending" means the card couldn't cover it yet
card.fee.cross_border_reversedtransaction fields
card.fee.cross_border_reversal_pendingtransaction fields
card.fee.contactlesstransaction fields
card.fee.declineamount, balanceAfter — a decline-rule fee taken from the card
card.topup.completedamount, balanceBefore, balanceAfter, reference
card.withdrawal.completedamount, balanceBefore, balanceAfter, reference
card.frozenstatus (FROZEN) — the card was frozen through the API; sent once the freeze is confirmed
card.unfrozenstatus (ACTIVE) — the card was unfrozen through the API
card.blockedreason, status — the card network froze the card itself (e.g. suspected fraud after a declined purchase). Unfreeze it to restore it, which then sends card.unfrozen
card.terminatedtrigger (terminated | repeated_declines | expired), balanceBeforeTermination, balanceRefunded, reason
card.terminated.balance_regularizedtransaction fields — a charge that settled after termination
card.terminated.refund_settledamount, refunded
card.expiringexpiresInDays
card.verifiedmerchantName

Transaction fields: transactionId, reference, amount, currency, merchantName, merchantCategoryCode, merchantCountry, status, description, availableBalance, time. Any may be null.

Example
{
  "id": "5f3c2a91-7d4e-4b8a-9c61-2e0f8b7d3a14",
  "eventClass": "card",
  "eventType": "card.transaction.declined",
  "eventTime": "2026-10-02T12:00:01.000Z",
  "sourceEventId": "…",
  "customerId": "02328859-…",
  "externalRef": "user-123",
  "cardId": "c4c09003-…",
  "transactionId": "…",
  "reference": "…",
  "amount": 12.5,
  "currency": "USD",
  "merchantName": "APPLE.COM/BILL",
  "merchantCategoryCode": "5735",
  "merchantCountry": "US",
  "status": "Declined",
  "description": "No sufficient funds",
  "availableBalance": 3.1,
  "time": "2026-10-02T12:00:00"
}

Customer KYC events (eventClass: "customerkyc")

customer.kyc.approved · customer.kyc.rejected · customer.kyc.pending · customer.kyc.unknown.

documentId is set when an approval comes with captured ID documents (KYC completed through the hosted verification link); fetch the images with KYC documents. It's null otherwise, for example on an approval of a direct submission like the one below, and there are no documents to fetch for that customer.

Example
{
  "id": "f0f1723c-6486-4938-9f85-e9fced5f8b46",
  "status": "approved",
  "eventTime": "2026-10-02T13:57:19.759Z",
  "eventType": "customer.kyc.approved",
  "customerId": "e79038e4-289f-4ca7-b003-abee58d822a2",
  "documentId": null,
  "eventClass": "customerkyc",
  "externalRef": "your-internal-user-id-123",
  "sourceEventId": "586f7fcb-aeff-41ac-b33d-e797a1a6db65",
  "clientReference": "optional-your-own-reference"
}

Wallet events

walletdebit and walletcredit events carry id, eventClass, eventType, eventId, eventTime, businessId, amount, currency, reason, cardId, customerId, balanceAfter and reference.

eventType
FieldTypeDescription
wallet.debited.decline_feewalletdebitA decline fee the card couldn’t cover.
wallet.debited.crossborder_feewalletdebitA cross-border fee the card couldn’t cover.
wallet.debited.contactless_feewalletdebitA contactless fee the card couldn’t cover.
wallet.debited.terminated_card_chargewalletdebitA charge that settled after the card was terminated.
wallet.credited.decline_fee_refundwalletcreditA decline fee refunded to your wallet.
wallet.withdrawal.completedwalletwithdrawalid, eventClass, eventType, eventTime, withdrawalId, amount, destinationAddress, externalReference.
wallet.withdrawal.rejectedwalletwithdrawalid, eventClass, eventType, eventTime, withdrawalId, amount, reason — the amount is back in your wallet.

Examples: one card's lifecycle

The events below are what your endpoint receives as a card is issued, topped up, partly withdrawn, frozen and unfrozen — in the order they arrive. Field order inside a payload isn't fixed, so read fields by name.

card.issued
{
  "id": "9a7d1c3e-58b2-4f60-a1d4-3e8c7b2f6a90",
  "cardId": "0755f40b-89ad-40cb-bd8d-052e63f8c0e3",
  "lastFour": "9488",
  "activated": true,
  "eventTime": "2026-10-02T13:25:59.353Z",
  "eventType": "card.issued",
  "customerId": "e79038e4-289f-4ca7-b003-abee58d822a2",
  "eventClass": "card",
  "externalRef": "your-internal-user-id-123",
  "sourceEventId": "0755f40b-89ad-40cb-bd8d-052e63f8c0e3",
  "availableBalance": 2
}
card.topup.completed
{
  "id": "2c6e8f41-0b9a-4d73-8f25-6a1d3e9c7b02",
  "amount": 20,
  "cardId": "0755f40b-89ad-40cb-bd8d-052e63f8c0e3",
  "eventTime": "2026-10-02T13:27:03.116Z",
  "eventType": "card.topup.completed",
  "reference": "B4711A8F8B1AA87",
  "customerId": "e79038e4-289f-4ca7-b003-abee58d822a2",
  "eventClass": "card",
  "externalRef": "your-internal-user-id-123",
  "balanceAfter": 22,
  "balanceBefore": 2,
  "sourceEventId": "26200444"
}
card.withdrawal.completed
{
  "id": "7e04b9d2-6f31-4c85-b7a9-1d2e8f5c0a36",
  "amount": 5,
  "cardId": "0755f40b-89ad-40cb-bd8d-052e63f8c0e3",
  "eventTime": "2026-10-02T13:28:31.173Z",
  "eventType": "card.withdrawal.completed",
  "reference": "B3352CDE70B3099",
  "customerId": "e79038e4-289f-4ca7-b003-abee58d822a2",
  "eventClass": "card",
  "externalRef": "your-internal-user-id-123",
  "balanceAfter": 17,
  "balanceBefore": 22,
  "sourceEventId": "26200445"
}
card.frozen
{
  "id": "422c7aba-164b-46eb-8f24-eb0b07ad3b91",
  "cardId": "0755f40b-89ad-40cb-bd8d-052e63f8c0e3",
  "status": "FROZEN",
  "eventTime": "2026-10-02T13:36:04.246Z",
  "eventType": "card.frozen",
  "customerId": "e79038e4-289f-4ca7-b003-abee58d822a2",
  "eventClass": "card",
  "externalRef": "your-internal-user-id-123",
  "sourceEventId": null
}
card.unfrozen
{
  "id": "fe8f7884-d059-4010-a81b-04b40e4be758",
  "cardId": "0755f40b-89ad-40cb-bd8d-052e63f8c0e3",
  "status": "ACTIVE",
  "eventTime": "2026-10-02T13:37:25.537Z",
  "eventType": "card.unfrozen",
  "customerId": "e79038e4-289f-4ca7-b003-abee58d822a2",
  "eventClass": "card",
  "externalRef": "your-internal-user-id-123",
  "sourceEventId": null
}

Delivery Log

GET/api/v1/webhooks

Deliveries, paginated. Query: page, limit. The payload itself isn't returned; responseStatus/responseBody are what your server answered.

Response 200
{
  "status": true,
  "message": "Successful",
  "data": {
    "page": 1, "limit": 20, "total": 1, "totalPages": 1,
    "deliveries": [
      {
        "id": "d4e5…",
        "eventId": "…",
        "eventType": "card.transaction.authorized",
        "eventClass": "card",
        "status": "DELIVERED",
        "attempts": 1,
        "lastAttemptAt": "2026-10-02T10:00:05.000Z",
        "deliveredAt": "2026-10-02T10:00:05.000Z",
        "responseStatus": 200,
        "responseBody": "ok",
        "createdAt": "2026-10-02T10:00:00.000Z"
      }
    ]
  }
}
GET/api/v1/webhooks/:id

One delivery.

POST/api/v1/webhooks/:id/retry

Re-queue a FAILED delivery with a fresh set of attempts. Any other status is refused.