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:
- Submit KYB (Know-Your-Business) from the dashboard and wait for approval.
- Create an API key under Account → API keys. The secret is shown exactly once.
- Set your webhook URL under Webhooks, and reveal the signing secret to verify deliveries.
- Fund your wallet by sending USDT to one of its deposit addresses.
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:
{
"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"
}| Field | Type | Description |
|---|---|---|
status | boolean | true on success, false on failure. Always check this, not just the HTTP code. |
message | string | Human-readable. Safe to show to your users on failure. |
data | any | The payload. On failure, usually { "status": false } or null. |
code | string | Failures 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_id | string | Unique id of this one request. Also sent as the X-Request-Id response header. |
correlation_id | string | Id 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. |
timestamp | ISO date | When the response was produced. |
Request ids
- Log it. Store
request_idwith 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-IdorX-Correlation-Idyou send isn't used as ours; it's only recorded in our logs as your own reference. - Match webhooks to calls. Keep the
correlation_idfrom a call's response: the webhooks that call causes arrive with the same value. For example, freeze a card and store the response'scorrelation_id; thecard.frozenwebhook 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 withNumber(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.
POST /api/v1/auth/token
Authorization: Basic <base64("clientId:clientSecret")>
Content-Type: application/json
{ "grant_type": "client_credentials", "scope": "CardAPIs CustomerAPIs" }{
"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.
| Field | Type | Description |
|---|---|---|
KybAPIs | scope | /kyb, /kyb/verification-session, /kyb/verification-status |
WalletAPIs | scope | /wallet, /wallet/transactions, /wallet/withdrawals…, /pricing |
CustomerAPIs | scope | POST /customers, GET /customers/:id, /customers/:id/kyc… |
CardAPIs | scope | /customers/:id/cards (issue, list), GET /cards/:cardId (masked), /cards/:cardId/transactions, /topup, /withdraw, /freeze, /unfreeze, /terminate |
CardDetailsAPIs | scope | GET /cards/:cardId/pan (full card number and CVV). Separate, so most tokens can be issued without it. |
WebhookAPIs | scope | /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.requiredScopenames 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
| Field | Type | Description |
|---|---|---|
Authorizationrequired | header | Bearer <access_token>, from step 1. The API key itself is not accepted here. |
Referencerequired | header | Any string, unique per request. Reusing one within 15 minutes is rejected. |
UniqueKeyrequired | header | Shown on the key in your dashboard. Folded into the signature, so a wrong value fails verification. |
Signaturerequired | header | Computed 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). clientSecretis part of the signature but is not sent.
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) });# 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
| Field | Type | Description |
|---|---|---|
Send Authorization: Basic base64(clientId:clientSecret) | 400 | Missing or wrong Authorization header on /auth/token. |
Invalid client credentials | 400 | Wrong clientId or clientSecret, or the key is revoked. |
Unknown scope: X. Valid scopes: … | 400 | scope contains a name not in the table above. |
grant_type must be client_credentials | 400 |
Authentication errors
All return HTTP 400 with status: false and code: "UNAUTHORIZED".
| Field | Type | Description |
|---|---|---|
Missing access token — get one from POST /api/v1/auth/token | 400 | No Authorization: Bearer … header. |
Access token is invalid or expired — get a new one from POST /api/v1/auth/token | 400 | Expired (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/token | 400 | Something other than an access token was sent as the bearer, e.g. the API key itself. |
Missing Reference/Signature/UniqueKey headers | 400 | One or more headers not sent. |
Invalid UniqueKey | 400 | Doesn't match the key's value. |
Invalid request signature | 400 | Check the path includes the query string, the method is uppercase, and you base64-encoded the hex string. |
This Reference has already been used | 400 | References can't repeat within 15 minutes. |
This IP is not whitelisted for this API key | 400 | The caller's IP isn't on the key's allowlist. |
This account is not active | 400 | Your business has been suspended. |
This business has not completed KYB verification | 400 | Every 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.
{
"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"
}| Field | Type | Description |
|---|---|---|
VALIDATION_ERROR | 400 | A field is missing or invalid. See extensions.field_violations when present. |
BAD_REQUEST | 400 | The request can't be done as asked (a business rule). Read message. |
UNAUTHORIZED | 400 / 401 | Missing or invalid credentials: access token, client credentials, request signature, or an IP that isn't allow-listed. |
FORBIDDEN | 400 | The access token doesn't include the scope this endpoint needs; data.requiredScope names it. |
NOT_FOUND | 400 | No such resource, or it belongs to another business. |
CONFLICT | 400 | Already done or already exists (e.g. a clientReference already used). |
INSUFFICIENT_FUNDS | 400 | The wallet or card balance can't cover it. |
SERVICE_DISABLED | 400 | Switched off: card service, withdrawals, or an account that isn't active. |
PAYLOAD_TOO_LARGE | 413 | Body over 25 MB. |
RATE_LIMITED | 429 | Too many requests: wait and retry. |
INTERNAL_ERROR | 500 | Unexpected server error: retry, and quote the request_id if it persists. |
New codes may be added later; treat an unknown code like BAD_REQUEST.
Rate limits
| Field | Type | Description |
|---|---|---|
General | 120 req/min | Per business. |
GET /cards/:cardId/pan | 20 req/min | A separate, tighter limit on full card details. |
Card issuance | 1 per 30 s | Per customer. See Issue a card. |
POST /auth/token | 30 req/min | Per IP. Reuse tokens rather than requesting one per call. |
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.clientReferenceholds 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.
{
"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.
/api/v1/kybSubmit or resubmit your KYB. The business goes back to PENDING.
| Field | Type | Description |
|---|---|---|
legalNamerequired | string | |
registrationNumberrequired | string | |
countryOfIncorporationrequired | string | |
contactEmailrequired | string | Receives the review outcome and wallet deposit emails. |
certificateOfIncorporationrequired | string | Base64 data URI or URL. |
shareholdersrequired | object[] | At least one. Each needs fullName; optional nationality, percentageOwnership, idType, idNumber, idDocument (data URI or URL). |
line1, city, state, zipCode | string | Registered address. |
contactPhone, website | string | |
proofOfAddress, ownerIdDocument, memart, cacDocument | string | Base64 data URI or URL. |
additionalDocuments | { label, data }[] | Any further documents. |
{
"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"
}{
"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"
}
}/api/v1/kybCurrent status (NOT_SUBMITTED, PENDING, APPROVED, REJECTED) and the latest submission. Uploaded documents are never returned.
{
"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.
/api/v1/kyb/verification-sessionStarts a verification, or returns the unfinished one (resumed: true). Open url for the person completing it.
| Field | Type | Description |
|---|---|---|
contactEmailrequired | string | Receives the review outcome and wallet deposit emails. |
{
"status": true,
"message": "Business verification started",
"data": {
"kybId": "5b2e…",
"sessionId": "…",
"url": "https://…",
"status": "Not Started",
"resumed": false
}
}/api/v1/kyb/verification-statusCheap 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.
{
"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.
/api/v1/walletBalance 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).
{
"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"
}
}/api/v1/wallet/transactionsThe wallet ledger, newest first. Query: page, limit.
{
"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"
}
]
}
}total/totalPages — request the next page until you get fewer than limit rows. Ignore any field not shown here.| Field | Type | Description |
|---|---|---|
DEPOSIT | CREDIT | Wallet funded. |
CARD_CREATION | DEBIT / CREDIT | Card issued (amount + fees) / issuance refunded. |
CARD_TOPUP | DEBIT / CREDIT | Card top-up / top-up reversed. |
CARD_WITHDRAWAL | CREDIT | Moved from a card to the wallet. |
CARD_TERMINATION_REFUND | CREDIT | Remaining card balance returned on termination. |
CARD_TERMINATION_FEE | DEBIT | Termination fee, or a charge after termination. |
CARD_LATE_REFUND | CREDIT | A refund that landed after the card was terminated. |
CARD_DECLINE_FEE | DEBIT / CREDIT | Decline-rule fee / refund. |
CARD_CROSSBORDER_FEE | DEBIT / CREDIT | Cross-border fee / reversal. |
CARD_CONTACTLESS_FEE | DEBIT | Contactless fee. |
WITHDRAWAL | DEBIT / CREDIT | Withdrawal requested / refunded. |
ADJUSTMENT | either | Manual adjustment. |
/api/v1/pricingThe fees your business pays. Scope WalletAPIs.
{
"status": true,
"message": "Successful",
"data": {
"currency": "USD",
"cardCreationFee": 2,
"fundCardFeeValue": 1,
"fundFeePercent": 1.5,
"terminationFee": 1,
"declineFee": 0.75,
"fundFeePercentThreshold": 99
}
}| Field | Type | Description |
|---|---|---|
cardCreationFee | number | Flat fee per new card. |
fundCardFeeValue | number | Flat funding fee for a top-up (or a card's initial funding) below fundFeePercentThreshold. |
fundFeePercent | number | Percentage funding fee at or above fundFeePercentThreshold (1.5 = 1.5% of the amount). |
fundFeePercentThreshold | number | The funding amount ($99) where the flat fee switches to the percentage. |
terminationFee | number | Charged when a card is terminated. |
declineFee | number | Charged 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.
/api/v1/wallet/withdrawalsRequest a withdrawal from your wallet.
| Field | Type | Description |
|---|---|---|
amountrequired | number | Greater than 0, at most your balance. |
destinationAddressrequired | string | |
destinationChain | string | The network to send on, e.g. "ethereum", "solana". |
note | string | |
clientReference | string | Idempotency key. The same value again returns the original request ("Withdrawal already requested"). |
{
"amount": 50,
"destinationAddress": "TXyz…tron",
"destinationChain": "TRON",
"note": "Monthly settlement",
"clientReference": "wd-2026-10-001"
}{
"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"
}
}| Field | Type | Description |
|---|---|---|
Insufficient business wallet balance | 400 | |
Withdrawals are currently disabled for this account. Please contact support. | 400 | Switched off for your business. |
This clientReference was already used for a different withdrawal… | 400 | Send a new clientReference. |
/api/v1/wallet/withdrawalsHistory. Query: page, limit. Returns { page, limit, total, totalPages, withdrawals }.
/api/v1/wallet/withdrawals/:idOne 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.
/api/v1/customersCreate a customer. Returns the customer object ("Customer created").
| Field | Type | Description |
|---|---|---|
emailrequired | string | Unique within your business. |
firstName, lastNamerequired | string | |
phone | string | |
externalRef | string | Your own id for this person — echoed in webhooks. |
{
"email": "ada@example.com",
"firstName": "Ada",
"lastName": "Lovelace",
"phone": "+2348012345678",
"externalRef": "user-123"
}/api/v1/customers/:idThe customer, plus kycStatus (NOT_SUBMITTED, PENDING, APPROVED, REJECTED, NOT_STARTED, MAX_RETRIES_REACHED, MISSING_KYC_DATA) and hasSubmittedKyc.
{
"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"
}
}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
/api/v1/customers/:id/kyc/verification-linkCreate a link where the cardholder completes ID capture. Open it for them (redirect, new tab or webview).
| Field | Type | Description |
|---|---|---|
idTyperequired | string | PASSPORT, NATIONAL_ID, DRIVERS_LICENSE, NIN, BVN, … |
idNumberrequired | string | |
phoneNumberrequired | string | |
dateOfBirthrequired | string | YYYY-MM-DD. |
line1, city, state, zipCode, countryrequired | string | country: NG, NGA or Nigeria. |
houseNumber | string |
{
"idType": "PASSPORT",
"idNumber": "A12345678",
"phoneNumber": "+2348012345678",
"dateOfBirth": "1990-01-01",
"line1": "12 Marina Road",
"city": "Lagos",
"state": "Lagos",
"zipCode": "100001",
"country": "NG"
}{
"status": true,
"message": "KYC verification link created",
"data": {
"status": "pending",
"verificationLink": "https://…",
"linkExpiresAt": "2026-10-03T12:00:00Z",
"clientReference": "…"
}
}customer.kyc.approved / customer.kyc.rejected), or poll the status endpoint below.Option B — direct submission
/api/v1/customers/:id/kycSubmit identity data you've already verified yourself. Same fields as Option A, plus:
| Field | Type | Description |
|---|---|---|
idFrontImage | string | Required unless idType is BVN/NIN. data:image/jpeg;base64,… or an image URL, max 2 MB. |
clientReference | string | Your own reference for this submission. |
{
"status": true,
"message": "KYC submitted",
"data": {
"status": "pending",
"customerId": "02328859-aab9-4fb5-8f58-b1fb8a1eff65",
"clientReference": "kyc-user-123"
}
}/api/v1/customers/:id/kyc/retryResubmit after a rejection. Same response shape.
| Field | Type | Description |
|---|---|---|
idType, idNumber, line1, city, state, country, zipCoderequired | string | |
dateOfBirth, idFrontImage, firstName, lastName | string | Optional. |
/api/v1/customers/:id/kycLive status: { status, customerId, clientReference }, or { status: "unknown", hasSubmittedKyc: false } before any submission.
/api/v1/customers/:id/kyc/documentsThe 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.
{
"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.
/api/v1/customers/:id/cardsIssue a card. Needs the customer's KYC approved. Returns the card object.
| Field | Type | Description |
|---|---|---|
amountrequired | number | Initial funding, minimum $2. |
cardBrand | "Visa" | "Mastercard" | Default "Mastercard". |
cardCurrency / walletCurrency | string | Default "USD". |
addressType | "default" | "kyc" | Billing address: the company's US address, or the cardholder's KYC address. |
cardLimits | object | Spending limits. |
whiteListedMccs / blackListedMccs | string[] | Merchant category codes. |
clientReference | string | Recommended — see Retries. |
{
"amount": 50,
"cardBrand": "Mastercard",
"cardCurrency": "USD",
"addressType": "default",
"clientReference": "order-8812"
}{
"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"
}
}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", aRetry-Afterheader anddata.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
clientReferencegoes through at once.
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"
}/api/v1/customers/:id/cardsThe customer's non-terminated cards. balance is the last known value — fetch a card for the live one.
/api/v1/cards/:cardIdThe card with a live balance, plus minimumBalance (always 1 — $1 must stay on the card) and withdrawableBalance.
/api/v1/cards/:cardId/panThe card object plus full card number, CVV, expiry, name and billing address.
{
"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
}
}/api/v1/cards/:cardId/transactionsQuery: pageNumber (or cursor, default 1), limit (default 20), and optional type, status, startDate, endDate, transactionId.
{
"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./api/v1/cards/:cardId/topupAdd funds from your wallet. Minimum $2; charged amount + funding fee.
{ "amount": 20, "clientReference": "topup-0001" }{ "status": true, "message": "…", "data": { "cardId": "c4c09003-…", "amount": 20 } }/api/v1/cards/:cardId/withdrawMove money from the card to your wallet. $1 must stay on the card. Your wallet is credited once the card network confirms.
{ "amount": 5, "reason": "Customer request", "clientReference": "wd-0001" }/api/v1/cards/:cardId/freezeBlock payments. Returns the card (FROZEN).
/api/v1/cards/:cardId/unfreezeReturns the card (ACTIVE).
/api/v1/cards/:cardId/terminateIrreversible. The remaining balance is refunded to your wallet; a termination fee may apply. Returns the card (TERMINATED).
Common card errors
| Field | Type | Description |
|---|---|---|
Card not found | 400 | Wrong id, or not your business's card. |
Card service is currently disabled for this account. Please contact support. | 400 | Create, top-up, withdraw and terminate are switched off for your business. |
Card creation is currently disabled… | 400 | Issuance is switched off platform-wide or for your business. |
Please complete your KYC verification before creating a card. | 400 | The customer's KYC isn't approved. |
Insufficient business wallet balance | 400 | Fund 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.
| Field | Type | Description |
|---|---|---|
X-Webhook-Id | header | The event's id — the same value as id in the body, and the id in the delivery log. |
X-Webhook-Event | header | The eventType. |
X-Webhook-Signature | header | Hex HMAC-SHA256 of the raw body, keyed by your signing secret (reveal it in the dashboard). |
X-Correlation-Id | header | When 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. |
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
| Field | Type | Description |
|---|---|---|
Retries | automatic | A 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. |
Duplicates | dedupe on id | Every 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. |
Order | not guaranteed | Use eventTime. |
Sensitive data | never sent | No 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:
| Field | Type | Description |
|---|---|---|
card.issued | activated, availableBalance, lastFour | |
card.transaction.preauthorized | transaction fields | |
card.transaction.authorized | transaction fields | |
card.transaction.declined | transaction fields; description is the decline reason | |
card.transaction.settled | transaction fields | |
card.transaction.expired | transaction fields — an authorisation the merchant never completed | |
card.transaction.reversed | transaction fields | |
card.transaction.refund_authorized | transaction fields | |
card.transaction.refund_settled | transaction fields | |
card.fee.cross_border | transaction fields; status: "Pending" means the card couldn't cover it yet | |
card.fee.cross_border_reversed | transaction fields | |
card.fee.cross_border_reversal_pending | transaction fields | |
card.fee.contactless | transaction fields | |
card.fee.decline | amount, balanceAfter — a decline-rule fee taken from the card | |
card.topup.completed | amount, balanceBefore, balanceAfter, reference | |
card.withdrawal.completed | amount, balanceBefore, balanceAfter, reference | |
card.frozen | status (FROZEN) — the card was frozen through the API; sent once the freeze is confirmed | |
card.unfrozen | status (ACTIVE) — the card was unfrozen through the API | |
card.blocked | reason, 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.terminated | trigger (terminated | repeated_declines | expired), balanceBeforeTermination, balanceRefunded, reason | |
card.terminated.balance_regularized | transaction fields — a charge that settled after termination | |
card.terminated.refund_settled | amount, refunded | |
card.expiring | expiresInDays | |
card.verified | merchantName |
Transaction fields: transactionId, reference, amount, currency, merchantName, merchantCategoryCode, merchantCountry, status, description, availableBalance, time. Any may be null.
{
"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.
{
"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.
| Field | Type | Description |
|---|---|---|
wallet.debited.decline_fee | walletdebit | A decline fee the card couldn’t cover. |
wallet.debited.crossborder_fee | walletdebit | A cross-border fee the card couldn’t cover. |
wallet.debited.contactless_fee | walletdebit | A contactless fee the card couldn’t cover. |
wallet.debited.terminated_card_charge | walletdebit | A charge that settled after the card was terminated. |
wallet.credited.decline_fee_refund | walletcredit | A decline fee refunded to your wallet. |
wallet.withdrawal.completed | walletwithdrawal | id, eventClass, eventType, eventTime, withdrawalId, amount, destinationAddress, externalReference. |
wallet.withdrawal.rejected | walletwithdrawal | id, 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.
{
"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
}{
"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"
}{
"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"
}{
"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
}{
"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
/api/v1/webhooksDeliveries, paginated. Query: page, limit. The payload itself isn't returned; responseStatus/responseBody are what your server answered.
{
"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"
}
]
}
}/api/v1/webhooks/:idOne delivery.
/api/v1/webhooks/:id/retryRe-queue a FAILED delivery with a fresh set of attempts. Any other status is refused.