Sections
Get Started
Integrate the Nexus Gateway API — buy, top up and reveal cards, all funded from your prepaid pool.
Overview
Get started
The Gateway lets you issue and manage cards funded by a prepaid pool. You hold a USD balance with us; every card purchase and top-up debits that pool. You never talk to the underlying card issuer — everything is normalized (ids, errors, statuses) behind one clean REST API.
Take these docs with you
Export the full partner integration guide as Markdown, or copy the LLM-oriented summary (llms.txt) to paste into your AI coding assistant.
Base URL
https://api.nexuscard.ioJSON over HTTPS · USD only · amounts as numbers (e.g. 50 or 49.99).
Keys
pk_live_ — production, moves real money
pk_test_ — authenticates, but money endpoints return 501 for now
Create and revoke keys yourself on the API Keys screen.
Getting started — five steps to your first card
Everything below is self-service in this console. Follow the links to the full reference for each step.
- 1Get your deposit address & fund the pool. Copy your deposit address from Pool & money (the Pool & Funding screen) and send stablecoin to it — the pool credits automatically once the transfer confirms on-chain.
- 2Mint a
pk_live_key. Create it on the API Keys screen with the scopes you need (cards:write,cards:read,pool:read; addcards:revealonly if you show PANs). Keep it server-side — see Authentication. - 3Create a cardholder, then wait until it is approved.
POST /cardholderswith the identity, then pollGET /cardholders/:id(or thekyc.approvedwebhook) untilapproved. One approved holder serves every SKU on its BIN — see Issue a card. (NEEDLESS SKUs skip this.) - 4Issue the card. Virtual = one call —
POST /cardsmints itACTIVE. Physical = two calls —POST /cardsassigns the printed card (settles toASSIGNED), thenPOST /cards/:id/activateactivates it and captures the pool hold (UQ-family sends the 6-digitpinat activate). You can also do both from the Cards screen (Issue → Activate). - 5Subscribe to webhooks & watch your pool. Configure your receiver on the Webhooks screen for
kyc.*,card.*,transaction.*andpool.*events (see Webhooks), and queryGET /pool/activityor the Pool & Funding screen for the funding / capture ledger.
Security
Authentication
Every request carries your key as a bearer token. The tenant (your agency, your pool) is derived from the key — never send an agency_id in the body. Keys are server-side secrets: never call the API from a browser or ship a key in an app build. Money endpoints are rate-limited (60 requests/minute per endpoint bucket — a 429 means back off and retry with the same Idempotency-Key).
curl https://api.nexuscard.io/v1/gateway/pool/balance \
-H "Authorization: Bearer pk_live_..."
# -> { "currency": "USD", "available": 1250.00, "held": 40.00 }| Scope | Grants |
|---|---|
| cards:write | Create cardholders, buy cards, activate physical cards, and top them up (POST /cardholders, /cards, /cards/:id/activate, /cards/:id/topup). |
| cards:manage | Freeze / unfreeze a card (POST /cards/:id/freeze, /unfreeze). A DESTRUCTIVE lifecycle scope with its OWN grant — NOT implied by cards:write and NOT in the default minted set. (Cancel is registered under it but parked: returns 501 ERR-GW-UNSUPPORTED.) |
| cards:read | Read cardholder status, card status, top-up history, live balance, transactions and products. |
| cards:reveal | Reveal full PAN / expiry / CVV. A SEPARATE scope from cards:read — a read-only key cannot reveal card secrets. |
| cards:pin | Change the PIN on a physical card (POST /cards/:id/pin) — also the forgotten-PIN flow, since the old PIN is never required. Its OWN grant: NOT implied by cards:write and NOT in the default minted set. |
| pool:read | Read your pool balance and statement. |
Keys are minted with the scopes you pick on the API Keys screen. A key without a scope is refused (403) for the calls that need it.
IP allowlist (optional)
Each key can be pinned to the source IPs your backend calls from — a defense in depth against a leaked key. Set it when you create a key, or later via Restrict IPs on the API Keys screen (one IP per line). It is optional: leave it empty and the key works from any address, exactly as before. When set, a request from an address that is not on the list is rejected with a 401 — the same status as a bad key, so a mismatch never reveals that the key is otherwise valid. Send from stable, static server IPs. Entries can be exact IPv4/IPv6 addresses or IPv4 CIDR ranges (e.g. 203.0.113.0/24); IPv6 CIDR ranges are not supported yet.
Money
Pool & money semantics
Your pool is a prepaid USD balance with two figures: available (spendable) and held (reserved for in-flight operations). Fund it by sending stablecoin (USDT/USDC) on a supported chain to your deposit address — shown on Pool & Funding. Once the transfer confirms on-chain, the pool is credited automatically (a pool.credited webhook fires with the new available balance). Contact the platform for the list of supported chains.
What debits the pool
- • Card purchase — your assigned price for the SKU, plus the optional
deposit_amount(initial load). The amount is held first and only captured on success; a failed issue releases the hold, so a failure never costs you. - • Top-up — the
amountyou send lands on the card; the platform fee is charged on top, so your pool is debitedamount + fee(charged = loaded + fee— the/topupsendpoint shows all three).
The pool endpoints — GET /pool/balance, GET /pool/statement, GET /pool/activity and GET /pool/deposit-address — are documented with typed fields and examples in the API reference → Pool.
Issuing
Issue a card (two-call flow)
To issue a card you must FIRST create a cardholder and wait until it is APPROVED, then issue the card against that cardholder_id. No card exists without an approved cardholder (except NEEDLESS SKUs).
KYC lives on the cardholder, never inside card issuance. An approved cardholder is reusable across products — it serves every SKU sharing the same BIN (card program), regardless of tier, so one cardholder_id issues many cards across same-BIN SKUs. A SKU on a different BIN → 409 ERR-GW-CARDHOLDER-BIN.
- 1.
POST /cardholderswith the identity →{ cardholder_id, status }. - 2. Wait for approval — poll
GET /cardholders/:idor thekyc.approved/kyc.rejectedwebhook. - 3.
POST /cardswith{ cardholder_id, sku_id, … }.
A SKU is either virtual or physical (the card_type from GET /products), and the two behave differently:
- • Virtual —
POST /cardsmints a brand-new card synchronously (the holder is pre-approved, so there is no KYC wait); the responsestatusis a live status (e.g.ACTIVE), usable immediately after reveal. An optionaldeposit_amountpre-loads it. - • Physical — a TWO-call flow that mirrors the card network (one gateway write = one Nexus write).
POST /cardsperforms the assign only — it binds a printed card your user already holds (the "gift" model) and needscard_number(RP-family SKUs also sendpin;activation_codeper the SKU). The responsestatusisprocessing, resolving toASSIGNED(pollGET /v1/gateway/cards/:idor thecard.assignedwebhook). Then callPOST /v1/gateway/cards/:id/activate(UQ-family SKUs send thepinhere) — this runs the Nexus activate and is where the card is billed (the pool hold is captured). The pool is held at assign, captured at activate. An assigned card left un-activated is held up to 24h then released (cardFAILED).
# STEP 1 — create the cardholder (KYC). Idempotent on external_id + sku_id.
curl -X POST https://api.nexuscard.io/v1/gateway/cardholders \
-H "Authorization: Bearer pk_live_..." \
-H "Content-Type: application/json" \
-d '{
"external_id": "your-user-42",
"sku_id": 123,
"identity": { "email": "ada@example.com", "first_name": "Ada", "last_name": "Lovelace" }
}'
# 201 -> { "cardholder_id": "...", "status": "pending" }
# STEP 2 — poll until approved (or use the kyc.approved / kyc.rejected webhook).
curl https://api.nexuscard.io/v1/gateway/cardholders/{cardholder_id} \
-H "Authorization: Bearer pk_live_..."
# -> { "cardholder_id": "...", "status": "approved" }# STEP 3 — issue the card against the APPROVED cardholder (no identity here).
curl -X POST https://api.nexuscard.io/v1/gateway/cards \
-H "Authorization: Bearer pk_live_..." \
-H "Idempotency-Key: 1e4f...-your-uuid" \
-H "Content-Type: application/json" \
-d '{
"external_id": "your-user-42",
"sku_id": 123,
"cardholder_id": "...",
"deposit_amount": 10
}'
# 201 -> { "card_id": "...", "status": "ACTIVE", "masked_number": "****1234" }
# (replaying the same Idempotency-Key + body returns 200 with the same card)
# a not-yet-approved holder -> 409 ERR-GW-CARDHOLDER-PENDING (wait, don't retry-spam)
# --- PHYSICAL is a TWO-call flow instead (mirror the card network) ---
# STEP 3 (assign): POST /cards with card_number (+ pin for RP-family) -> status "processing",
# resolving to ASSIGNED. Poll GET /cards/:id (or the card.assigned webhook) until ASSIGNED.
# curl ... -d '{ "external_id":"...","sku_id":140,"cardholder_id":"...","card_number":"5200..." }'
# STEP 4 (activate): once ASSIGNED, activate it and capture the hold:
curl -X POST https://api.nexuscard.io/v1/gateway/cards/{card_id}/activate \
-H "Authorization: Bearer pk_live_..." \
-H "Content-Type: application/json" \
-d '{ "pin": "123456" }' # UQ-family (assign-then-activate) only; omit for RP-family
# 200 -> { "card_id":"...","status":"ACTIVE","masked_number":"****1234" }
# 202 -> still settling (poll GET /cards/:id) ; 409 ERR-GW-CARD-STATUS-TRANSITION -> retry shortlyEvery operation used here — POST /cardholders, GET /cardholders/:id, POST /cards, POST /cards/:id/activate, GET /cards/:id and POST /cards/:id/reveal — is documented with typed request/response fields and examples in the API reference.
Issuing
Top up a card
Add value to an ACTIVE card. The amount you send lands on the card; the platform fee is charged on top, so your pool is debited amount + fee up front (held). A 200 means it settled synchronously; a 202 means it's processing — poll /topups. A top-up that can't settle is auto-released back to your pool (worst-case within the 6-hour deadline), so held funds are never stranded.
curl -X POST https://api.nexuscard.io/v1/gateway/cards/{card_id}/topup \
-H "Authorization: Bearer pk_live_..." \
-H "Idempotency-Key: 3d9e...-your-uuid" \
-H "Content-Type: application/json" \
-d '{ "amount": 50 }'
# 200 -> { "topup_id": "...", "status": "completed" }
# 202 -> { "topup_id": "...", "status": "processing" } (poll /topups)Typed fields and examples for POST /cards/:id/topup, GET /cards/:id/topups and GET /cards/:id/balance are in the API reference → Top-up.
Flows
Lifecycle / Flows
The end-to-end path a card order and a top-up take through the Gateway — every state, branch and billing/deadline rule. Colour marks each node's money state: an amber hold, a green capture (billed / funds landed), a red release/failure, and a blue poll/processing step.
Issuing
Card Order — end to end
Money
Top-up — end to end
Money
Pool accounting — when you are charged
The one rule
Your available balance is deducted the instant you call POST /cards or POST /cards/:id/topup — synchronously, before any card-network call, by moving money available → held. On confirmed success the held amount is simply consumed; on failure it is released 1:1 back to available. So the request is the deduction point — success vs failure only decides whether the held money is kept or refunded to you. A failed operation never costs you.
Debited only after activation: the hold is captured (the money is permanently consumed, and pool.debited fires) strictly after the card is confirmed ACTIVE — for both virtual and physical cards. A virtual card that Nexus returns as still processing, and every physical card, are captured on the confirmed activation (synchronously, or on the card-network activation event), never before. A card that never activates has its hold released 1:1.
| Event | When | Effect on your pool |
|---|---|---|
| Deposit (fund) | On-chain USDT confirms into your treasury | available + amount |
| Card issue — reserve | POST /cards (virtual mint / physical assign), at request time | available − price → held + price |
| Card issued — consumed | Virtual mint succeeds, or physical POST /cards/:id/activate succeeds | held − price (available unchanged) |
| Card issue — release | Terminal assign/activate failure or the 24h physical deadline | held − price → available + price (refund) |
| Top-up — reserve | POST /cards/:id/topup, at request time | available − (amount+fee) → held + (amount+fee) |
| Top-up — consumed | Settlement confirmed | held − (amount+fee) (available unchanged) |
| Top-up — release | Declined, or never landed past 6h | held − (amount+fee) → available + (amount+fee) (refund) |
Top-up fee is ON-TOP
amount is what lands on the card; the fee is charged on top. Example — a $100 top-up at a 2% rate: the card receives $100, the fee is $2, and your pool is charged $102 (charged = loaded + fee). At request time available drops $102 into held; on settlement it is consumed, and on failure the full $102 returns to available.
Only your own custody figures (available, held, your SKU price, the top-up fee and loaded/charged) ever appear in the API or on the pool statement. Full event-by-event detail is in the Agency Integration Guide (docs/gateway-api/AGENCY-INTEGRATION-GUIDE.md).
Reference
API reference
Every Gateway operation, with typed request and response fields and a realistic example request + response. All amounts and balances are USD, 2 decimal places; all ids are opaque tokens. Base URL https://api.nexuscard.io, base path /v1/gateway.
Products
Your sellable SKU catalog.
/v1/gateway/productscards:readList exactly the SKUs assigned to your agency with a resolved price > 0 — everything issuable as-is.
Response fields
| Field | Type | Description |
|---|---|---|
| products | array | Your sellable SKUs. |
| products[].sku_id | integer | SKU id to pass to POST /cards and POST /cardholders. |
| products[].name | string | Display name (your override, else the catalog name). |
| products[].card_type | string | "virtual" or "physical". |
| products[].price | number | What a purchase debits from your pool (your price for the SKU). |
| products[].topup_fee_pct | number | Fractional top-up fee rate (e.g. 0.02 = 2%). |
| products[].currency | string | Always "USD". |
| products[].holder_requirement | string | KYC tier — which identity fields to send: NEEDLESS / CONTACT / MOCK / PROVIDED / ACTUAL. |
| products[].min_topup / max_topup | number | null | Allowed per-top-up range (null = unset). |
| products[].min_deposit / max_deposit | number | null | Allowed opening-deposit range at issue (null = unset). |
| products[].physical | object | Physical SKUs only — which assign fields are required: { card_number, pin, activation_code, card_number_last4 } (booleans). |
curl https://api.nexuscard.io/v1/gateway/products \
-H "Authorization: Bearer pk_live_xxxxxxxxxxxxxxxx"{
"products": [
{
"sku_id": 42,
"name": "USD Virtual (Standard)",
"card_type": "virtual",
"price": 3.00,
"topup_fee_pct": 0.02,
"currency": "USD",
"holder_requirement": "PROVIDED",
"min_topup": 5,
"max_topup": 5000,
"min_deposit": 0,
"max_deposit": 1000
}
]
}Cardholders
KYC identities — create and track approval before issuing.
/v1/gateway/cardholderscards:writeSTEP 1 — create a cardholder (the KYC identity) as exactly one card-network create. Issue cards against it once approved.
Idempotency · Idempotent on your external_id + sku_id. An Idempotency-Key header is accepted but optional.
Request fields
| Field | Type | Required | Description |
|---|---|---|---|
| external_id | string | Yes | Your id for the end-user (opaque to us; the idempotency key). |
| sku_id | integer | Yes | A SKU from GET /products — scopes the cardholder to that product's KYC tier. |
| identity | object | Yes | KYC pass-through (fields below). |
| identity.email | string | Yes | End-user email. |
| identity.first_name | string | Yes | Given name. |
| identity.last_name | string | Yes | Family name. |
| identity.dial | string | — | International calling code, with the leading + — e.g. +1, +234, +65. A bare code without the + (e.g. 1) is also accepted (the gateway normalizes it), but the + form is recommended. |
| identity.mobile | string | — | Phone (national part). |
| identity.birthday | string (ISO date) | — | e.g. "1990-01-15". |
| identity.gender | "MALE" | "FEMALE" | — | Gender. |
| identity.address | object | — | { country, region?, city, postcode?, line }. |
| identity.document | object | — | { type, country, code, issue?, expiry?, front_image?, back_image?, selfie_image? } (images base64). ACTUAL-tier requires issue + expiry when a document is supplied. |
Response fields
| Field | Type | Description |
|---|---|---|
| cardholder_id | string | Opaque cardholder id — pass to POST /cards. |
| status | string | approved (issue now) · pending (wait for approval) · rejected. |
| reason_code | string | null | Normalized ERR-KYC-… when rejected. |
| reason_message | string | null | Normalized, partner-safe message when rejected. |
curl -X POST https://api.nexuscard.io/v1/gateway/cardholders \
-H "Authorization: Bearer pk_live_xxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"external_id": "user_1001",
"sku_id": 42,
"identity": {
"email": "ada@example.com",
"first_name": "Ada",
"last_name": "Lovelace"
}
}'{
"cardholder_id": "9b1e2f7a-3c5d-4e8a-b1f0-2a9c7d6e4b10",
"status": "pending"
}- • 201 for a new cardholder, 200 for an idempotent replay.
- • Calling this for a NEEDLESS SKU → 400 ERR-GW-CARDHOLDER-NEEDLESS (that product has no cardholder — issue the card directly).
/v1/gateway/cardholders/:idcards:readSTEP 2 poll — refresh the cardholder's status live and persist any pending → approved/rejected transition.
Response fields
| Field | Type | Description |
|---|---|---|
| cardholder_id | string | The cardholder id. |
| status | string | approved / pending / rejected. |
| reason_code | string | null | Normalized ERR-KYC-… when rejected. |
| reason_message | string | null | Normalized, partner-safe message when rejected. |
curl https://api.nexuscard.io/v1/gateway/cardholders/9b1e2f7a-3c5d-4e8a-b1f0-2a9c7d6e4b10 \
-H "Authorization: Bearer pk_live_xxxxxxxxxxxxxxxx"{
"cardholder_id": "9b1e2f7a-3c5d-4e8a-b1f0-2a9c7d6e4b10",
"status": "approved"
}- • Poll until approved (a transition also fires the kyc.approved / kyc.rejected webhook).
- • `:id` is the cardholder_id from POST /cardholders or from any row of GET /cardholders. The `ch_…` form shown on the Cardholders screen is accepted here too, so an id copied off that table works as-is.
/v1/gateway/cardholderscards:readKYC visibility for your end-users, and the way to look up a cardholder you created in this console rather than over the API — every row carries its cardholder_id. Normalized reason only — never the raw upstream reason.
Query parameters
| Field | Type | Required | Description |
|---|---|---|---|
| external_id | string | — | Filter by your exact end-user id. |
| sku_id | integer | — | Filter by SKU. |
Response fields
| Field | Type | Description |
|---|---|---|
| cardholders[].cardholder_id | string | The cardholder id — pass it to GET /cardholders/:id and POST /cards. Present on every row, so a cardholder created here in the console (which never called POST /cardholders) is addressable over the API too. |
| cardholders[].external_id | string | Your id. |
| cardholders[].sku_id | integer | null | SKU the cardholder was created against. |
| cardholders[].status | string | approved / rejected / pending. |
| cardholders[].reason_code | string | null | Normalized ERR-KYC-… when rejected. |
| cardholders[].reason_message | string | null | Normalized, partner-safe message. |
| cardholders[].created_at | string (ISO) | Creation timestamp. |
curl "https://api.nexuscard.io/v1/gateway/cardholders?external_id=user_1001" \
-H "Authorization: Bearer pk_live_xxxxxxxxxxxxxxxx"{
"cardholders": [
{
"cardholder_id": "9b1e2f7a-3c5d-4e8a-b1f0-2a9c7d6e4b10",
"external_id": "user_1001",
"sku_id": 42,
"status": "approved",
"reason_code": null,
"reason_message": null,
"created_at": "2026-07-27T10:00:00Z"
}
]
}Cards
Issue, activate, read, reveal, freeze and list transactions.
/v1/gateway/cardscards:writeSTEP 3 — issue one card against an APPROVED cardholder, charged to your pool. Virtual mints instantly; physical performs the assign only.
Idempotency · Idempotency-Key header required (any string ≥ 8 chars; a UUID is ideal). Same key + same body replays the original result — never a second charge.
Request fields
| Field | Type | Required | Description |
|---|---|---|---|
| external_id | string | Yes | Your opaque LABEL for the end-user — not a unique key and not a lookup handle. Re-POSTing /cards with the same external_id (and a fresh Idempotency-Key) mints a SEPARATE card; to check or reuse a card, poll GET /cards/{id} by the card_id we returned (the source of truth) or await card.issued. |
| sku_id | integer | Yes | A SKU from GET /products. |
| cardholder_id | string | Yes | An approved cardholder from POST /cardholders. Omit only for NEEDLESS SKUs. |
| deposit_amount | number ≥ 0 | — | Opening load in USD (virtual only; default 0). Must meet the SKU's open_min if set. |
| card_number | string | — | Physical only (required): the printed card number (8–19 digits). |
| pin | string | — | Physical, RP-family only: 6 digits set at assign. UQ-family send the PIN at /activate instead. |
| activation_code | string | — | Physical, conditional: required if the SKU sets requires_activation_code. |
Response fields
| Field | Type | Description |
|---|---|---|
| card_id | string | Opaque Gateway card id — use for all later calls. |
| status | string | Virtual: usually ACTIVE or PROCESSING. Physical: processing, resolving to ASSIGNED (then call /activate). |
| masked_number | string | null | ****1234, or null until known (physical). |
curl -X POST https://api.nexuscard.io/v1/gateway/cards \
-H "Authorization: Bearer pk_live_xxxxxxxxxxxxxxxx" \
-H "Idempotency-Key: order-8f3a91c2b7" \
-H "Content-Type: application/json" \
-d '{
"external_id": "user_1001",
"sku_id": 42,
"cardholder_id": "9b1e2f7a-3c5d-4e8a-b1f0-2a9c7d6e4b10",
"deposit_amount": 10
}'{
"card_id": "b2c9d4e1-7a63-42f5-9c08-1e5b3a2f6d84",
"status": "ACTIVE",
"masked_number": "****4242"
}- • 201 for a new card, 200 for an idempotent replay.
- • Cardholder gate: not yours → 404 ERR-GW-CARDHOLDER-NOTFOUND; still pending → 409 ERR-GW-CARDHOLDER-PENDING (wait, don't retry-spam); rejected → 409 ERR-GW-CARDHOLDER-REJECTED; different BIN → 409 ERR-GW-CARDHOLDER-BIN.
- • Physical: the pool is held at assign and only captured at /activate. Poll GET /cards/:id until ASSIGNED (or use the card.assigned webhook), then activate.
/v1/gateway/cards/:id/activatecards:writeSTEP 4 (physical only) — activate an ASSIGNED physical card and capture the pool hold. Virtual cards never call this.
Idempotency · Idempotency-Key supported (optional) — activate is naturally state-idempotent; re-activating an ACTIVE card replays with no second capture.
Request fields
| Field | Type | Required | Description |
|---|---|---|---|
| pin | string | — | UQ-family (required): 6 digits supplied here. RP-family omit it (set at assign). |
| activation_code | string | — | Accepted for SKUs that need it at activation. |
Response fields
| Field | Type | Description |
|---|---|---|
| card_id | string | Opaque card id. |
| status | string | ACTIVE on success, or processing if still settling (poll GET /cards/:id). |
| masked_number | string | null | ****1234. |
curl -X POST https://api.nexuscard.io/v1/gateway/cards/b2c9d4e1-7a63-42f5-9c08-1e5b3a2f6d84/activate \
-H "Authorization: Bearer pk_live_xxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{ "pin": "123456" }'{
"card_id": "b2c9d4e1-7a63-42f5-9c08-1e5b3a2f6d84",
"status": "ACTIVE",
"masked_number": "****4242"
}- • 200 = ACTIVE / idempotent replay; 202 = still settling (poll GET /cards/:id).
- • Guards: not assigned yet → 409 ERR-GW-CARD-NOT-ASSIGNED; still settling → 409 ERR-GW-CARD-STATUS-TRANSITION (retryable, hold kept); virtual card → 409 ERR-GW-NOT-ACTIVATABLE; missing pin → 400 ERR-GW-PIN.
/v1/gateway/cards/:idcards:readCurrent status of one of your cards, plus (for physical) the normalized activation sub-object.
Response fields
| Field | Type | Description |
|---|---|---|
| card_id | string | Opaque card id. |
| status | string | Lifecycle status (PROCESSING / ASSIGNED / ACTIVE / FROZEN / FAILED / …). |
| masked_number | string | null | ****1234. |
| external_id | string | Your id. |
| sku_id | integer | null | SKU the card was issued from. |
| created_at | string (ISO) | Creation timestamp. |
| activation | object | Physical only. { status, failed: boolean, reason: string | null } — reason is a normalized ERR-… token only when failed. |
curl https://api.nexuscard.io/v1/gateway/cards/b2c9d4e1-7a63-42f5-9c08-1e5b3a2f6d84 \
-H "Authorization: Bearer pk_live_xxxxxxxxxxxxxxxx"{
"card_id": "b2c9d4e1-7a63-42f5-9c08-1e5b3a2f6d84",
"status": "ASSIGNED",
"masked_number": "****4242",
"external_id": "user_1001",
"sku_id": 55,
"created_at": "2026-07-27T10:00:00Z",
"activation": { "status": "assigned", "failed": false, "reason": null }
}- • When status is ASSIGNED (activation.status assigned), call POST /cards/:id/activate.
- • `:id` is the card_id from POST /cards or from any row of GET /cards. The `card_…` form shown on the Cards screen is accepted here too — and on every other /cards/:id call — so an id copied off that table works as-is.
/v1/gateway/cardscards:readYour cards, newest first. The way to recover a card_id you no longer have — and the way to reach a card issued here in the console, which your own integration never saw a response for.
Query parameters
| Field | Type | Required | Description |
|---|---|---|---|
| external_id | string | — | Exact match on your own end-user reference. |
| sku_id | integer | — | Filter by SKU. |
| limit | integer | — | 1–200 (default 50). |
| offset | integer | — | Default 0. Page until offset + cards.length >= total. |
Response fields
| Field | Type | Description |
|---|---|---|
| cards[].card_id | string | Pass to every other /cards call. |
| cards[].cardholder_id | string | null | The approved cardholder it was issued against; null for a NEEDLESS product, which has no cardholder. |
| cards[].external_id | string | Your own reference, as sent to POST /cards. |
| cards[].sku_id | integer | null | SKU the card was issued from. |
| cards[].status | string | The STORED status — a list never fans out one network call per row. GET /cards/:id is the authoritative live status for one card. |
| cards[].masked_number | string | null | ****1234. |
| cards[].created_at | string (ISO) | Creation timestamp. |
| total | integer | Full count matching the filter, not just this page. |
| limit / offset | integer | Echoed back. |
curl "https://api.nexuscard.io/v1/gateway/cards?external_id=user_1001&limit=50" \
-H "Authorization: Bearer pk_live_xxxxxxxxxxxxxxxx"{
"cards": [
{
"card_id": "b2c9d4e1-7a63-42f5-9c08-1e5b3a2f6d84",
"cardholder_id": "9b1e2f7a-3c5d-4e8a-b1f0-2a9c7d6e4b10",
"external_id": "user_1001",
"sku_id": 55,
"status": "ACTIVE",
"masked_number": "****4242",
"created_at": "2026-07-27T10:00:00Z"
}
],
"total": 1,
"limit": 50,
"offset": 0
}- • Read-only — it never touches your pool.
- • external_id is a label, not a key: re-POSTing /cards with the same one mints a separate card, so this filter can legitimately return several rows for one end-user.
/v1/gateway/cards/:id/revealcards:revealReveal the plaintext card so you can deliver it to your end-user. A dedicated scope — not implied by cards:read.
Response fields
| Field | Type | Description |
|---|---|---|
| number | string | null | Full PAN. |
| expiry | string | null | MM/YYYY. |
| cvv | string | null | CVV / CVC. |
| embossed_name | string | null | Name on card. |
curl -X POST https://api.nexuscard.io/v1/gateway/cards/b2c9d4e1-7a63-42f5-9c08-1e5b3a2f6d84/reveal \
-H "Authorization: Bearer pk_live_xxxxxxxxxxxxxxxx"{
"number": "4111 1111 1111 4242",
"expiry": "08/2029",
"cvv": "123",
"embossed_name": "ADA LOVELACE"
}- • PCI-sensitive: call it server-side, deliver over your own secure channel, and never log the response.
- • Every reveal writes an append-only audit row (who / when / from where). Values above are illustrative (canonical test card).
/v1/gateway/cards/:id/freezecards:writeFreeze a card (ACTIVE → FROZEN). Reversible with unfreeze. No request body.
Response fields
| Field | Type | Description |
|---|---|---|
| card_id | string | Opaque card id. |
| status | string | New status (e.g. "FROZEN" or "FREEZING" while settling). |
curl -X POST https://api.nexuscard.io/v1/gateway/cards/b2c9d4e1-7a63-42f5-9c08-1e5b3a2f6d84/freeze \
-H "Authorization: Bearer pk_live_xxxxxxxxxxxxxxxx"{
"card_id": "b2c9d4e1-7a63-42f5-9c08-1e5b3a2f6d84",
"status": "FROZEN"
}- • A 409 ERR-GW-CARD-STATUS-TRANSITION just means the card is still settling a previous change — retry in a moment.
/v1/gateway/cards/:id/unfreezecards:writeUnfreeze a card (FROZEN → ACTIVE). No request body.
Response fields
| Field | Type | Description |
|---|---|---|
| card_id | string | Opaque card id. |
| status | string | New status (e.g. "ACTIVE"). |
curl -X POST https://api.nexuscard.io/v1/gateway/cards/b2c9d4e1-7a63-42f5-9c08-1e5b3a2f6d84/unfreeze \
-H "Authorization: Bearer pk_live_xxxxxxxxxxxxxxxx"{
"card_id": "b2c9d4e1-7a63-42f5-9c08-1e5b3a2f6d84",
"status": "ACTIVE"
}/v1/gateway/cards/:id/pincards:pinSet a NEW PIN on a physical card — and the answer for a cardholder who forgot theirs. The old PIN is never asked for, so there is no separate reset flow.
Request fields
| Field | Type | Required | Description |
|---|---|---|---|
| pin | string | Yes | The new PIN: 6 digits, the same length required at assign/activate. |
Response fields
| Field | Type | Description |
|---|---|---|
| card_id | string | Opaque card id. |
| success | boolean | true once the card network has taken the new PIN. |
curl -X POST https://api.nexuscard.io/v1/gateway/cards/b2c9d4e1-7a63-42f5-9c08-1e5b3a2f6d84/pin \
-H "Authorization: Bearer pk_live_xxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{ "pin": "482913" }'{
"card_id": "b2c9d4e1-7a63-42f5-9c08-1e5b3a2f6d84",
"success": true
}- • cards:pin is its OWN scope — not implied by cards:write and not in the default minted set. Mint a key with it for the flow that needs it.
- • Physical cards only: a virtual card → 400 ERR-GW-PIN-UNSUPPORTED. Not 6 digits → 400 ERR-GW-PIN.
- • Not activated yet, or closed → 409 ERR-GW-CARD-NOT-ACTIVE. Frozen → 409 telling you to unfreeze first. Still settling a previous change → 409 ERR-GW-CARD-STATUS-TRANSITION (retryable).
- • No Idempotency-Key: there is no charge, and re-sending the same PIN is harmless.
/v1/gateway/cards/:id/transactionscards:readA tenant-safe, whitelisted projection of the card's transactions. No raw upstream ids, no cost fields, no PAN.
Query parameters
| Field | Type | Required | Description |
|---|---|---|---|
| page_size (or limit) | integer | — | Page size, max 100. |
| page_number (or page) | integer | — | 1-based page number. |
| type | string | — | Filter by transaction type. |
Response fields
| Field | Type | Description |
|---|---|---|
| transactions[].id | string | Opaque txn_<hash> (never the raw id). |
| transactions[].amount | number | null | Transaction amount. |
| transactions[].currency | string | null | Currency. |
| transactions[].type | string | null | Transaction type. |
| transactions[].status | string | null | Transaction status. |
| transactions[].merchant_name | string | Present when known. |
| transactions[].created_at | string (ISO) | null | ISO-8601 UTC — the same format as every other gateway timestamp. null only when the card network sends no usable time. |
| transactions[].decline_reason | string | Present when declined; coarse token: insufficient_funds / card_expired / card_not_active / limit_exceeded / security_block / invalid_details / declined. |
| total | number | Total count. |
curl "https://api.nexuscard.io/v1/gateway/cards/b2c9d4e1-7a63-42f5-9c08-1e5b3a2f6d84/transactions?page_size=20" \
-H "Authorization: Bearer pk_live_xxxxxxxxxxxxxxxx"{
"transactions": [
{
"id": "txn_7f3a91c2b7d4",
"amount": 12.50,
"currency": "USD",
"type": "purchase",
"status": "settled",
"merchant_name": "Coffee Bar",
"created_at": "2026-07-27T12:34:56Z"
}
],
"total": 1
}Top-up
Load value onto an active card and read the fee-transparent history.
/v1/gateway/cards/:id/topupcards:writeLoad USD onto an ACTIVE card. Fee is ON-TOP: amount lands on the card, and your pool is debited amount + fee. Settlement is async.
Idempotency · Idempotency-Key header required. Same key + same body replays; same key + different body → 409 ERR-GW-IDEMPOTENCY-REUSE.
Request fields
| Field | Type | Required | Description |
|---|---|---|---|
| amount | number > 0 | Yes | USD that LANDS on the card. The fee is added on top, so your pool is debited amount + fee. Must meet the SKU's top-up minimum. |
Response fields
| Field | Type | Description |
|---|---|---|
| topup_id | string | Opaque top-up id. |
| status | string | "processing" (new), or "completed" / "failed" on replay. |
curl -X POST https://api.nexuscard.io/v1/gateway/cards/b2c9d4e1-7a63-42f5-9c08-1e5b3a2f6d84/topup \
-H "Authorization: Bearer pk_live_xxxxxxxxxxxxxxxx" \
-H "Idempotency-Key: topup-77af2213" \
-H "Content-Type: application/json" \
-d '{ "amount": 100.00 }'{
"topup_id": "t_9931ab27c4",
"status": "processing"
}- • 202 for a new (in-flight) top-up, 200 when replaying an already-settled key.
- • amount: 100.00 → $100 lands on the card; at a 2% rate your pool is debited $102. Poll GET /cards/:id/topups for the outcome.
/v1/gateway/cards/:id/topupscards:readFee-transparent top-up history, showing exactly Charged = Loaded + Fee.
Query parameters
| Field | Type | Required | Description |
|---|---|---|---|
| limit | integer | — | Default 50, max 200. |
Response fields
| Field | Type | Description |
|---|---|---|
| currency | string | "USD". |
| entries[].topup_id | string | Opaque top-up id. |
| entries[].status | string | processing / completed / failed. |
| entries[].charged | number | Gross debited from the pool (loaded + fee). |
| entries[].fee | number | Platform fee charged on top. |
| entries[].loaded | number | Net that landed on the card (equals your requested amount). |
| entries[].created_at | string (ISO) | When the top-up was requested. |
| entries[].completed_at | string | null | Set when completed. |
curl "https://api.nexuscard.io/v1/gateway/cards/b2c9d4e1-7a63-42f5-9c08-1e5b3a2f6d84/topups?limit=50" \
-H "Authorization: Bearer pk_live_xxxxxxxxxxxxxxxx"{
"currency": "USD",
"entries": [
{
"topup_id": "t_9931ab27c4",
"status": "completed",
"charged": 102.00,
"fee": 2.00,
"loaded": 100.00,
"created_at": "2026-07-27T12:00:00Z",
"completed_at": "2026-07-27T12:02:10Z"
}
]
}/v1/gateway/cards/:id/balancecards:readThe card's live spendable balance straight from the issuer — use it to self-audit after a top-up settles.
Response fields
| Field | Type | Description |
|---|---|---|
| currency | string | "USD". |
| available | number | Live spendable balance (0 if none). |
curl https://api.nexuscard.io/v1/gateway/cards/b2c9d4e1-7a63-42f5-9c08-1e5b3a2f6d84/balance \
-H "Authorization: Bearer pk_live_xxxxxxxxxxxxxxxx"{
"currency": "USD",
"available": 100.00
}- • 502 ERR-GW-BALANCE (with request_id) when the upstream read is momentarily unavailable — retry later.
Pool
Your prepaid USD balance, statement and partner-facing activity.
/v1/gateway/pool/balancepool:readYour prepaid pool's two custody buckets: available (spendable) and held (reserved, in-flight).
Response fields
| Field | Type | Description |
|---|---|---|
| currency | string | "USD". |
| available | number | Spendable now. |
| held | number | Reserved by in-flight issues / top-ups (not yet captured or released). |
curl https://api.nexuscard.io/v1/gateway/pool/balance \
-H "Authorization: Bearer pk_live_xxxxxxxxxxxxxxxx"{
"currency": "USD",
"available": 1250.00,
"held": 40.00
}/v1/gateway/pool/statementpool:readAppend-only ledger postings for your custody + funding movements. Platform-internal accounts are never returned.
Query parameters
| Field | Type | Required | Description |
|---|---|---|---|
| limit | integer | — | Default 50, max 500. |
| beforeId | number | — | Cursor — return postings before this id. |
Response fields
| Field | Type | Description |
|---|---|---|
| entries[].id | number | Posting id (cursor for beforeId). |
| entries[].entry_group | string | Groups the legs of one event. |
| entries[].account_type | string | One of available, held, external_funding (contra legs filtered out). |
| entries[].amount | number | Signed USD (+ credit, − debit). |
| entries[].op_type | string | fund / card_hold / card_capture / card_release / topup_debit / topup_reverse / refund / adjustment. |
| entries[].balance_after | number | Running balance of that account. |
| entries[].created_at | string (ISO) | Posting timestamp. |
curl "https://api.nexuscard.io/v1/gateway/pool/statement?limit=50" \
-H "Authorization: Bearer pk_live_xxxxxxxxxxxxxxxx"{
"entries": [
{
"id": 90312,
"entry_group": "grp_5f2a",
"account_type": "available",
"amount": -102.00,
"op_type": "topup_debit",
"balance_after": 1148.00,
"created_at": "2026-07-27T12:00:00Z"
}
]
}/v1/gateway/pool/activitypool:readPartner-facing pool-movement ledger: credits (funding) and debits (card captures, booked only after the card is ACTIVE) larger than 0.5 USDT.
Query parameters
| Field | Type | Required | Description |
|---|---|---|---|
| limit | integer | — | Max 500. |
| before | string (ISO) | — | created_at cursor — return activity before this time. |
Response fields
| Field | Type | Description |
|---|---|---|
| entries[].type | string | credit (funding) or debit (card capture). |
| entries[].amount | number | Movement amount in USD. |
| entries[].currency | string | "USD". |
| entries[].balance_after | number | Available balance after the movement. |
| entries[].card_id | string | null | Present on card-capture debits. |
| entries[].memo | string | Human-readable label. |
| entries[].created_at | string (ISO) | Movement timestamp. |
curl "https://api.nexuscard.io/v1/gateway/pool/activity?limit=50" \
-H "Authorization: Bearer pk_live_xxxxxxxxxxxxxxxx"{
"entries": [
{
"type": "debit",
"amount": 3.00,
"currency": "USD",
"balance_after": 1247.00,
"card_id": "b2c9d4e1-7a63-42f5-9c08-1e5b3a2f6d84",
"memo": "Card issued",
"created_at": "2026-07-27T10:05:00Z"
}
]
}/v1/gateway/pool/deposit-addresspool:readWhere to send USDT to fund the pool, per supported network. Two addresses: one EVM 0x… for BNB Smart Chain (BEP-20) + Ethereum (ERC-20), one TRON T… for Tron (TRC-20). An address is network-family-specific — a cross-network send (e.g. TRC-20 to the EVM address) is unrecoverable.
Response fields
| Field | Type | Description |
|---|---|---|
| currency | string | "USD" — the pool is always USD. |
| networks[].chain | string | bsc | ethereum | tron. |
| networks[].network | string | Human network name, e.g. BNB Smart Chain. |
| networks[].token | string | "USDT". |
| networks[].token_standard | string | BEP-20 | ERC-20 | TRC-20. |
| networks[].address | string | Send this network's USDT here. |
| supported_chains | string[] | The chain keys, same order as networks. |
curl "https://api.nexuscard.io/v1/gateway/pool/deposit-address" \
-H "Authorization: Bearer pk_live_xxxxxxxxxxxxxxxx"{
"currency": "USD",
"networks": [
{ "chain": "bsc", "network": "BNB Smart Chain", "token": "USDT", "token_standard": "BEP-20", "address": "0x62940b7fecc456b416203812d670a351ad8d1e18" },
{ "chain": "ethereum", "network": "Ethereum", "token": "USDT", "token_standard": "ERC-20", "address": "0x62940b7fecc456b416203812d670a351ad8d1e18" },
{ "chain": "tron", "network": "Tron", "token": "USDT", "token_standard": "TRC-20", "address": "TRrsC4vANg6HxToLc5gYS1Yq4Tj9c1abcd" }
],
"supported_chains": ["bsc", "ethereum", "tron"]
}Events
Webhooks
Subscribe to signed, real-time events instead of (or alongside) polling. Configure your receiver URL, pick events, and manage the signing secret on the Webhooks screen. Every delivery is a JSON POST with the envelope { event, id, timestamp, data }.
Event catalog
| Event | When it fires | data payload fields |
|---|---|---|
| kyc.approved | A cardholder's KYC was approved — issue cards against it now. | cardholder_id, external_id, sku_id, status |
| kyc.rejected | A cardholder's KYC was rejected. | cardholder_id, external_id, sku_id, status, reason_code, reason_message |
| card.assigned | A physical card was assigned to the holder and is awaiting your activate. | card_id, external_id, sku_id, status, masked_number |
| card.issued | A card finished issuing (virtual captured, or physical activated). | card_id, external_id, sku_id, status, masked_number, agency_price |
| card.failed | Card issuance terminally failed (hold released). | external_id, sku_id, code, reason, retryable |
| topup.completed / topup.failed | A top-up settled or was refunded. | card_id, topup_id, status, gross, fee, net_to_card (+ code / reason on failure) |
| transaction.authorized / .settled / .declined / .refunded | A card spend, relayed 1:1 from the card network. | card_id, external_id, amount, currency, type, status, merchant_name, decline_reason?, created_at |
| card.status_changed | A card's lifecycle status changed (ACTIVE / FROZEN / INACTIVE / CANCELLED). | card_id, external_id, status |
| card.3ds | A 3-D Secure / wallet-provisioning challenge (notify-only; no OTP-submit endpoint). | card_id, external_id, amount, merchant_name, verification_code, method, created_at (verification_code present only for the OTP method) |
| pool.credited | Funding landed (a deposit > 0.5 USDT). | amount, currency, new_available_balance, tx_hash, chain |
| pool.debited | A card capture debited the pool (> 0.5 USDT, booked after activation). | amount, currency, new_available_balance, card_id, reason |
POST https://your-app.example.com/webhooks/nexus
x-webhook-timestamp: 1753617900
x-webhook-signature: sha256=<hmac>
{
"event": "card.issued",
"id": "evt_5f2a91c2b7d4",
"timestamp": "2026-07-27T10:05:00Z",
"data": {
"card_id": "b2c9d4e1-7a63-42f5-9c08-1e5b3a2f6d84",
"external_id": "user_1001",
"sku_id": 42,
"status": "ACTIVE",
"masked_number": "****4242",
"agency_price": 3.00
}
}The internal RESERVE_MOVEMENT reserve-ledger event is platform/shared and is never relayed.
Verify the signature
Compute sha256=HMAC_SHA256(secret, x-webhook-timestamp + "." + rawBody) and constant-time compare it to the x-webhook-signature header (the x-webhook-timestamp header carries the timestamp). Reject on mismatch, and reject timestamps far from now to blunt replay. Deduplicate by the event id (stable across retries). Every attempt — delivered or failed — is recorded in the delivery log on the Webhooks screen.
Isolation: event payloads never contain issuer ids, PANs, or platform pricing internals — only the normalized, partner-safe fields listed above. Return a 2xx quickly; do heavy work asynchronously.
Events
Polling
Webhooks are optional — you can also poll (or use polling as a backstop for missed deliveries). The polling model:
- • Poll
GET /v1/gateway/cards/:id(after a purchase) andGET /v1/gateway/cards/:id/topups(after a top-up) every ~5 seconds, backing off as time passes. - • Settlement runs on a ~2-minute reconcile cycle — most operations resolve within a couple of minutes; allow up to ~5 minutes before surfacing a delay to your user.
- • Failures self-heal: an operation that can never settle is auto-released back to your pool, worst-case at the 6-hour deadline. Treat
status: failed/activation.failedas final. - • Poll with your normal read scopes — polling is cheap and rate limits (60/min per endpoint) leave ample headroom at a 5s interval.
Reliability
Errors & idempotency
Errors come back in one envelope. code is stable and safe to branch on (the message is human-readable and may change); raw issuer codes are never leaked. Search the full catalog below — a ↻ Retryable code is safe to retry with the same Idempotency-Key.
{
"error": "Insufficient pool balance for this top-up.",
"code": "ERR-POOL-INSUFFICIENT"
}Fields you may also see
- •
request_id— on5xx; quote it to support. - •
field+detail— a single flagged field and the exact validation sentence. - •
fields[]— on identity validation, every offending field at once. - •
cardholder_id— onERR-GW-CARDHOLDER-EXISTS, the id to reuse.
| Code | HTTP | Retry? | Meaning & fix |
|---|---|---|---|
| ERR-GW-BODY | 400 | Terminal | A required field is missing or malformed — the message names it (identity problems return every bad field in fields[]). Fix and resend. |
| ERR-GW-INVALID | 400 / 502 | Terminal | An upstream validation refusal about your payload; carries field + detail when a field was identified. Correct that field and resend. |
| ERR-GW-IDEMPOTENCY | 400 | Terminal | The Idempotency-Key header is missing or shorter than 8 chars on a money POST. Send a stable key (a UUID is ideal). |
| ERR-GW-CARD-NUMBER | 400 | Terminal | The printed card number isn't valid (must be 8–19 digits). |
| ERR-GW-PIN | 400 | Terminal | PIN must be 6 digits (UQ-family cards require it at activation; the same rule applies to POST /cards/:id/pin). |
| ERR-GW-PIN-UNSUPPORTED | 400 | Terminal | A PIN change was asked for on a VIRTUAL card. Only physical cards have a PIN. |
| ERR-GW-ACTIVATION-CODE | 400 | Terminal | This SKU requires an activation_code that wasn't supplied. |
| ERR-GW-AMOUNT | 400 | Terminal | amount must be a positive number (top-up). |
| ERR-GW-AMOUNT-TOO-SMALL | 400 | Terminal | After fees the net that would land on the card is ≤ 0 — increase the amount. |
| ERR-GW-TOPUP-MIN | 400 | Terminal | Below the SKU's minimum top-up (the minimum is in the message). |
| ERR-GW-OPEN-MIN | 400 | Terminal | Below the SKU's opening-deposit floor. |
| ERR-GW-CARDHOLDER-EXISTS | 409 | Terminal | A cardholder already exists for this email on this card program. The response includes cardholder_id — reuse it, don't duplicate. |
| ERR-GW-CARDHOLDER-NEEDLESS | 400 | Terminal | This product has no cardholder — skip POST /cardholders and issue the card directly. |
| ERR-GW-CARDHOLDER-NOTFOUND | 404 | Terminal | That cardholder_id isn't yours (or doesn't exist). |
| ERR-GW-CARDHOLDER-REQUIRED | 400 | Terminal | POST /cards needs a cardholder_id for this SKU. Create + approve a cardholder first. |
| ERR-GW-CARDHOLDER-PENDING | 409 | Wait / fund | The cardholder is still pending KYC. Poll GET /cardholders/:id (or the kyc.approved webhook) before issuing — don't retry-spam. |
| ERR-GW-CARDHOLDER-REJECTED | 409 | Terminal | The cardholder's KYC was rejected. Re-submit KYC (same external_id) with corrected identity, then retry. |
| ERR-GW-CARDHOLDER-BIN | 409 | Terminal | This cardholder is on a different BIN (card program). A holder serves every SKU on its BIN; a different BIN needs its own cardholder. |
| ERR-GW-NOTFOUND | 404 | Terminal | No such card under your key's agency. |
| ERR-GW-CARD | 409 / 500 / 502 | Terminal | A card create/activate failure (terminal at the network, FAILED, or issued-but-not-recorded). If FAILED, issue a fresh card with a new key; if unrecorded, an idempotent retry returns it. |
| ERR-GW-CARD-NUMBER-IN-USE | 409 | Terminal | This printed card number is already active on another card. A printed number is assigned once — use the existing card, or a different number. |
| ERR-GW-CARD-NOT-ACTIVE | 409 | Wait / fund | The card isn't ACTIVE yet, so it can't be topped up (or its PIN changed). Wait until ACTIVE — on a PIN change it also means the card is already closed. |
| ERR-GW-CARD-NOT-ASSIGNED | 409 | Wait / fund | The physical card isn't ASSIGNED yet. Poll until ASSIGNED, then activate. |
| ERR-GW-NOT-ACTIVATABLE | 409 | Terminal | This card doesn't require activation (virtual / NEEDLESS) — skip the activate call. |
| ERR-GW-CARD-STATUS-TRANSITION | 409 | Retryable | The card is mid-transition at the network — retry the activate after a short delay. |
| ERR-GW-ACTIVATION-PIN | 409 | Terminal | The card isn't active and activating it needs the 6-digit PIN — call /activate again with the pin. |
| ERR-GW-DECLINED | 502 | Terminal | The card network declined the operation (specific decline code hidden). |
| ERR-GW-BALANCE | 502 | Retryable | Live card balance is temporarily unavailable upstream. Includes request_id — retry shortly. |
| ERR-GW-UNSUPPORTED | 501 | Terminal | Card cancellation isn't offered by the network yet — a capability gap, not an incident. |
| ERR-GW-DEADLINE | webhook | Terminal | A physical assign/activate didn't finish within 24h (surfaced on GET /cards/:id + the card.failed webhook). Re-issue. |
| ERR-GW-TOPUP | 500 | Retryable | The top-up couldn't be recorded after the charge — retry (idempotent) with the same key. |
| ERR-GW-TOPUP-FAILED | webhook | Terminal | A load was declined/failed — the topup.failed webhook's reason. The pool was released 1:1 (no charge stands). |
| ERR-GW-SKU | 404 / 400 | Terminal | “This product is not available” — the SKU isn't assigned to you, or has no Nexus product. Use a sku_id from GET /products. |
| ERR-GW-NOT-NATIVE | 400 | Terminal | The SKU/card isn't on the native account. The gateway only serves native products — ask us to add it natively if needed. |
| ERR-GW-PRICE | 422 / 400 / 500 | Terminal | The product is unpriced, priced at 0, or below cost — a pricing misconfiguration our side. Contact the platform. |
| ERR-POOL-INSUFFICIENT | 402 | Wait / fund | Your prefunded pool can't cover this card / top-up. Fund the pool, then retry with the SAME key. |
| ERR-GW-BILLING | 500 | Terminal | A card issued but billing didn't complete — a money incident. Includes request_id (also alerted to our ops). Contact support with it. |
| ERR-GW-RETRY | 503 | Retryable | A transient upstream condition on a money op — the pool hold is kept for you. Retry with the SAME Idempotency-Key after a short delay. |
| ERR-GW-UPSTREAM | 502 / 400 | Retryable | The card processor is temporarily unavailable. Retry shortly (money ops upgrade this to ERR-GW-RETRY 503). |
| ERR-GW-NOT-READY | 503 | Retryable | The resource is still provisioning (e.g. a just-created cardholder). Retry after a short delay with the same key. |
| ERR-GW | 500 / 502 | Terminal | An unexpected error (includes request_id on the 500). Retry once; if it persists, contact support with the request_id. |
| ERR-GW-CONFIG | 503 | Terminal | A server-side configuration issue (not your key). Retry later; contact support if it persists. |
| ERR-GW-AUTH | 502 | Terminal | An upstream authentication failure — our credential to the processor, not your API key. Contact support. |
| ERR-GW-FORBIDDEN | 403 / 502 | Terminal | The operation isn't permitted for this product/account. |
| ERR-GW-SCOPE | 403 | Terminal | Your API key lacks the scope this call needs (e.g. cards:write, cards:reveal). Mint a key with the scope. |
| ERR-GW-IP | 401 | Terminal | Your key has an IP allowlist and this request's source IP isn't on it. Call from an allowed IP, or update the allowlist. |
| ERR-GW-TEST-UNSUPPORTED | 501 | Terminal | You used a pk_test key on a live-only endpoint — there's no sandbox yet. Use a pk_live_ key. |
| ERR-GW-IDEMPOTENCY-REUSE | 409 | Terminal | This Idempotency-Key was already used with a DIFFERENT body. Use a fresh key for a new operation. |
| ERR-GW-CONFLICT | 502 / 400 | Terminal | A submitted value conflicts with existing data (most 409s are adopted internally before surfacing) — change it and resend. |
HTTP status can vary — branch on the code
A normalized code carries a stable meaning but the HTTP status depends on the call: e.g. ERR-GW-INVALID arrives as 502 from card issuance and 400 from a top-up. Branch on code, not on the status.
Idempotency
Send a unique Idempotency-Key (>= 8 chars; a UUID is ideal) on every money-moving POST. The same key + the same body returns the original result — no double charge. The same key + a different body is rejected with ERR-GW-IDEMPOTENCY-REUSE (409). On a network timeout or a 5xx, retry with the SAME key — it is always safe.
Cardholders
KYC rejection reasons
A rejected cardholder is not an HTTP error — it returns 200/201 with status:"rejected" and a normalized reason_code / reason_message. A verdict on the applicant is a normal state, not a request failure.
Full address is required — a missing field is not a country ban
KYC needs the complete address: country, region, city, line, and postcode. Omitting region or postcode is the most common cause of a misleading ERR-KYC-COUNTRY-style rejection — the incomplete-address error is now returned as ERR-GW-INVALID (with the missing fields in detail). A genuine country restriction reaches the issuer; a missing-field fault never does.
| reason_code | Message the applicant sees | Triggered by |
|---|---|---|
| ERR-KYC-PHONE | This phone number can’t be accepted. Please use a different number. | phone · mobile · dial |
| ERR-KYC-EMAIL | This email can’t be accepted. Please use a different email. | email · e-mail |
| ERR-KYC-DOCUMENT | The ID document couldn’t be verified. Please re-submit a clear, valid document. | document · passport · expired · unclear · selfie |
| ERR-KYC-AGE | This applicant doesn’t meet the minimum age for this card. Please check the date of birth. | age · under-age · minimum age · 18+ / 21+ |
| ERR-KYC-COUNTRY | This country isn’t supported for this card. Please use a different card product. | country · jurisdiction · not supported · restricted region |
| ERR-KYC-SCREENING | This application didn’t pass our compliance screening and can’t be accepted. | sanction · watchlist · pep · aml · screening |
| ERR-KYC-DETAILS | The identity details couldn’t be verified. Please check and re-submit. | name · birth · gender · address · nationality |
| ERR-KYC-REJECTED | Verification wasn’t successful. Please review the details and re-submit. | catch-all — no signal recognized (may carry no real reason) |
Reliability
Failure matrix — what happens to your money
For the money-moving calls, the money effect is what matters: Held = funds moved to held and kept there awaiting a retry (retry the SAME Idempotency-Key to consume it); Released = refunded 1:1 back to available; Untouched = no hold was ever created.
| Code / condition | HTTP | Retry? | Money effect |
|---|---|---|---|
| ERR-POOL-INSUFFICIENT | 402 | After funding | Untouched (no hold) |
| ERR-GW-RETRY / ERR-GW-UPSTREAM | 503 | Retryable (same key) | Held (retry to consume) |
| ERR-GW-NOT-READY | — | Retryable (shortly) | Held (retry to consume) |
| ERR-GW-CARDHOLDER-PENDING (issue) | 409 | Wait for approval | Untouched (no hold) |
| ERR-GW-CARDHOLDER-REJECTED / -NOTFOUND (issue) | 409 / 404 | Terminal | Untouched (no hold) |
| ERR-GW-CARD (terminal upstream) | 502 | Terminal | Released (refunded 1:1) |
| Top-up declined / never lands (async) | — | Terminal | Released (refunded 1:1) |
| ERR-GW-TOPUP-MIN | 400 | Terminal | Untouched (no hold) |
| ERR-GW-AMOUNT-TOO-SMALL | 400 | Terminal | Untouched (no hold) |
| ERR-GW-SKU | 404 / 400 | Terminal | Untouched (no hold) |
| ERR-GW-PRICE | 422 / 400 | Terminal — contact platform | Untouched (no hold) |
| ERR-GW-CARD-NOT-ACTIVE | 409 | Terminal | Untouched (no hold) |
| ERR-GW-SCOPE | 403 | Terminal | Untouched (no hold) |
| ERR-GW-AUTH / ERR-GW-IP | 401 | Terminal | Untouched (no hold) |
Only ERR-GW-RETRY, ERR-GW-UPSTREAM, ERR-GW-NOT-READY (and ERR-POOL-INSUFFICIENT after you fund) are worth retrying — always with the SAME Idempotency-Key so a kept hold is consumed rather than double-charged. The complete per-API matrix is in the Agency Integration Guide (docs/gateway-api/AGENCY-INTEGRATION-GUIDE.md).
Copy-paste
Python quickstart
End to end with nothing but requests: authenticate (mints nothing) → buy a card → poll it active → top it up → poll the top-up → reveal the card. Create the key on the API Keys screen first.
import time, uuid, requests
BASE = "https://api.nexuscard.io"
KEY = "pk_live_..." # create it on the API Keys screen; keep it server-side
H = {"Authorization": f"Bearer {KEY}"}
# 0. First call: read your pool (proves the key works, moves no money)
print(requests.get(f"{BASE}/v1/gateway/pool/balance", headers=H, timeout=30).json())
# 1. Create the cardholder (KYC). Idempotent on external_id + sku_id.
r = requests.post(
f"{BASE}/v1/gateway/cardholders",
headers=H,
json={
"external_id": "your-user-42", # your own stable end-user id
"sku_id": 123, # from GET /v1/gateway/products
"identity": {"email": "ada@example.com", "first_name": "Ada", "last_name": "Lovelace"},
},
timeout=30,
)
r.raise_for_status()
cardholder_id, status = r.json()["cardholder_id"], r.json()["status"]
# 2. Wait until the cardholder is APPROVED (some tiers approve instantly).
while status == "pending":
time.sleep(5)
status = requests.get(
f"{BASE}/v1/gateway/cardholders/{cardholder_id}", headers=H, timeout=30
).json()["status"]
if status == "rejected":
raise RuntimeError("KYC rejected")
# 3. Issue the card against the approved cardholder. Idempotency-Key ONCE, reuse on retries.
buy_key = str(uuid.uuid4())
r = requests.post(
f"{BASE}/v1/gateway/cards",
headers={**H, "Idempotency-Key": buy_key},
json={
"external_id": "your-user-42",
"sku_id": 123,
"cardholder_id": cardholder_id, # the APPROVED holder from steps 1-2
"deposit_amount": 10, # optional initial load (USD)
},
timeout=30,
)
r.raise_for_status()
card_id = r.json()["card_id"]
# 4. Poll until the card is ACTIVE (settlement cycle ~2 min; allow up to ~5).
for _ in range(60):
card = requests.get(f"{BASE}/v1/gateway/cards/{card_id}", headers=H, timeout=30).json()
if str(card.get("status", "")).upper() == "ACTIVE":
break
if card.get("activation", {}).get("failed"):
raise RuntimeError(card["activation"]["reason"])
time.sleep(5)
# 5. Top up $50 (ON-TOP fee): $50 lands on the card; the fee is charged on
# top, so the pool is debited 50 + fee.
topup_key = str(uuid.uuid4())
r = requests.post(
f"{BASE}/v1/gateway/cards/{card_id}/topup",
headers={**H, "Idempotency-Key": topup_key},
json={"amount": 50},
timeout=30,
)
r.raise_for_status() # 200 = completed, 202 = processing
# 6. Poll the top-up history until it settles.
for _ in range(60):
entries = requests.get(
f"{BASE}/v1/gateway/cards/{card_id}/topups", headers=H, timeout=30
).json()["entries"]
if entries and entries[0]["status"] == "completed":
break
if entries and entries[0]["status"] == "failed":
raise RuntimeError("top-up failed - the held amount returns to your pool")
time.sleep(5)
# 7. Reveal PAN / expiry / CVV (PCI-sensitive - server-side only, never log it).
secret = requests.post(
f"{BASE}/v1/gateway/cards/{card_id}/reveal", headers=H, timeout=30
).json()
print(secret["number"], secret["expiry"], secret["cvv"])Questions, pricing, or pool funding? Contact the platform — your pool, SKU prices and top-up fee are configured operator-side; everything else here is self-service.