Documentation/Get Started

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.io

JSON 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.

  1. 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.
  2. 2Mint a pk_live_ key. Create it on the API Keys screen with the scopes you need (cards:write, cards:read, pool:read; add cards:reveal only if you show PANs). Keep it server-side — see Authentication.
  3. 3Create a cardholder, then wait until it is approved. POST /cardholders with the identity, then poll GET /cardholders/:id (or the kyc.approved webhook) until approved. One approved holder serves every SKU on its BIN — see Issue a card. (NEEDLESS SKUs skip this.)
  4. 4Issue the card. Virtual = one call POST /cards mints it ACTIVE. Physical = two calls POST /cards assigns the printed card (settles to ASSIGNED), then POST /cards/:id/activate activates it and captures the pool hold (UQ-family sends the 6-digit pin at activate). You can also do both from the Cards screen (Issue → Activate).
  5. 5Subscribe to webhooks & watch your pool. Configure your receiver on the Webhooks screen for kyc.*, card.*, transaction.* and pool.* events (see Webhooks), and query GET /pool/activity or 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).

Authenticate (first call, moves no money)
curl https://api.nexuscard.io/v1/gateway/pool/balance \
  -H "Authorization: Bearer pk_live_..."
# -> { "currency": "USD", "available": 1250.00, "held": 40.00 }
ScopeGrants
cards:writeCreate cardholders, buy cards, activate physical cards, and top them up (POST /cardholders, /cards, /cards/:id/activate, /cards/:id/topup).
cards:manageFreeze / 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:readRead cardholder status, card status, top-up history, live balance, transactions and products.
cards:revealReveal full PAN / expiry / CVV. A SEPARATE scope from cards:read — a read-only key cannot reveal card secrets.
cards:pinChange 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:readRead 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 amount you send lands on the card; the platform fee is charged on top, so your pool is debited amount + fee (charged = loaded + fee — the /topups endpoint 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. 1. POST /cardholders with the identity → { cardholder_id, status }.
  2. 2. Wait for approval — poll GET /cardholders/:id or the kyc.approved / kyc.rejected webhook.
  3. 3. POST /cards with { cardholder_id, sku_id, … }.

A SKU is either virtual or physical (the card_type from GET /products), and the two behave differently:

  • VirtualPOST /cards mints a brand-new card synchronously (the holder is pre-approved, so there is no KYC wait); the response status is a live status (e.g. ACTIVE), usable immediately after reveal. An optional deposit_amount pre-loads it.
  • Physical — a TWO-call flow that mirrors the card network (one gateway write = one Nexus write). POST /cards performs the assign only — it binds a printed card your user already holds (the "gift" model) and needs card_number (RP-family SKUs also send pin; activation_code per the SKU). The response status is processing, resolving to ASSIGNED (poll GET /v1/gateway/cards/:id or the card.assigned webhook). Then call POST /v1/gateway/cards/:id/activate (UQ-family SKUs send the pin here) — 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 (card FAILED).
Create + approve the cardholder
# 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" }
Issue the card
# 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 shortly

Every 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.

Top up a card
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.

LegendHold placedCaptured / ActiveReleased / FailedPoll / Processing

Issuing

Card Order — end to end

Rendering diagram…
No card is billed until it is confirmed ACTIVE. Virtual bills at issue-confirm; physical bills at activation. Stuck holds auto-release (virtual 6h, physical 24h).

Money

Top-up — end to end

Rendering diagram…
Top-up fee is on-top: your pool is debited amount + fee. Settlement is async (always 202 processing) — the hold is captured only on a confirmed settlement, released 1:1 on decline.

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.

EventWhenEffect on your pool
Deposit (fund)On-chain USDT confirms into your treasuryavailable + amount
Card issue — reservePOST /cards (virtual mint / physical assign), at request timeavailable − price → held + price
Card issued — consumedVirtual mint succeeds, or physical POST /cards/:id/activate succeedsheld − price (available unchanged)
Card issue — releaseTerminal assign/activate failure or the 24h physical deadlineheld − price → available + price (refund)
Top-up — reservePOST /cards/:id/topup, at request timeavailable − (amount+fee) → held + (amount+fee)
Top-up — consumedSettlement confirmedheld − (amount+fee) (available unchanged)
Top-up — releaseDeclined, or never landed past 6hheld − (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.

GET/v1/gateway/productscards:read

List exactly the SKUs assigned to your agency with a resolved price > 0 — everything issuable as-is.

Response fields

FieldTypeDescription
productsarrayYour sellable SKUs.
products[].sku_idintegerSKU id to pass to POST /cards and POST /cardholders.
products[].namestringDisplay name (your override, else the catalog name).
products[].card_typestring"virtual" or "physical".
products[].pricenumberWhat a purchase debits from your pool (your price for the SKU).
products[].topup_fee_pctnumberFractional top-up fee rate (e.g. 0.02 = 2%).
products[].currencystringAlways "USD".
products[].holder_requirementstringKYC tier — which identity fields to send: NEEDLESS / CONTACT / MOCK / PROVIDED / ACTUAL.
products[].min_topup / max_topupnumber | nullAllowed per-top-up range (null = unset).
products[].min_deposit / max_depositnumber | nullAllowed opening-deposit range at issue (null = unset).
products[].physicalobjectPhysical SKUs only — which assign fields are required: { card_number, pin, activation_code, card_number_last4 } (booleans).
Example request
curl https://api.nexuscard.io/v1/gateway/products \
  -H "Authorization: Bearer pk_live_xxxxxxxxxxxxxxxx"
Example response
{
  "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.

POST/v1/gateway/cardholderscards:write

STEP 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

FieldTypeRequiredDescription
external_idstringYesYour id for the end-user (opaque to us; the idempotency key).
sku_idintegerYesA SKU from GET /products — scopes the cardholder to that product's KYC tier.
identityobjectYesKYC pass-through (fields below).
identity.emailstringYesEnd-user email.
identity.first_namestringYesGiven name.
identity.last_namestringYesFamily name.
identity.dialstringInternational 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.mobilestringPhone (national part).
identity.birthdaystring (ISO date)e.g. "1990-01-15".
identity.gender"MALE" | "FEMALE"Gender.
identity.addressobject{ country, region?, city, postcode?, line }.
identity.documentobject{ 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

FieldTypeDescription
cardholder_idstringOpaque cardholder id — pass to POST /cards.
statusstringapproved (issue now) · pending (wait for approval) · rejected.
reason_codestring | nullNormalized ERR-KYC-… when rejected.
reason_messagestring | nullNormalized, partner-safe message when rejected.
Example request
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"
    }
  }'
Example response
{
  "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).
GET/v1/gateway/cardholders/:idcards:read

STEP 2 poll — refresh the cardholder's status live and persist any pending → approved/rejected transition.

Response fields

FieldTypeDescription
cardholder_idstringThe cardholder id.
statusstringapproved / pending / rejected.
reason_codestring | nullNormalized ERR-KYC-… when rejected.
reason_messagestring | nullNormalized, partner-safe message when rejected.
Example request
curl https://api.nexuscard.io/v1/gateway/cardholders/9b1e2f7a-3c5d-4e8a-b1f0-2a9c7d6e4b10 \
  -H "Authorization: Bearer pk_live_xxxxxxxxxxxxxxxx"
Example response
{
  "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.
GET/v1/gateway/cardholderscards:read

KYC 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

FieldTypeRequiredDescription
external_idstringFilter by your exact end-user id.
sku_idintegerFilter by SKU.

Response fields

FieldTypeDescription
cardholders[].cardholder_idstringThe 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_idstringYour id.
cardholders[].sku_idinteger | nullSKU the cardholder was created against.
cardholders[].statusstringapproved / rejected / pending.
cardholders[].reason_codestring | nullNormalized ERR-KYC-… when rejected.
cardholders[].reason_messagestring | nullNormalized, partner-safe message.
cardholders[].created_atstring (ISO)Creation timestamp.
Example request
curl "https://api.nexuscard.io/v1/gateway/cardholders?external_id=user_1001" \
  -H "Authorization: Bearer pk_live_xxxxxxxxxxxxxxxx"
Example response
{
  "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.

POST/v1/gateway/cardscards:write

STEP 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

FieldTypeRequiredDescription
external_idstringYesYour 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_idintegerYesA SKU from GET /products.
cardholder_idstringYesAn approved cardholder from POST /cardholders. Omit only for NEEDLESS SKUs.
deposit_amountnumber ≥ 0Opening load in USD (virtual only; default 0). Must meet the SKU's open_min if set.
card_numberstringPhysical only (required): the printed card number (8–19 digits).
pinstringPhysical, RP-family only: 6 digits set at assign. UQ-family send the PIN at /activate instead.
activation_codestringPhysical, conditional: required if the SKU sets requires_activation_code.

Response fields

FieldTypeDescription
card_idstringOpaque Gateway card id — use for all later calls.
statusstringVirtual: usually ACTIVE or PROCESSING. Physical: processing, resolving to ASSIGNED (then call /activate).
masked_numberstring | null****1234, or null until known (physical).
Example request
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
  }'
Example response
{
  "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.
POST/v1/gateway/cards/:id/activatecards:write

STEP 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

FieldTypeRequiredDescription
pinstringUQ-family (required): 6 digits supplied here. RP-family omit it (set at assign).
activation_codestringAccepted for SKUs that need it at activation.

Response fields

FieldTypeDescription
card_idstringOpaque card id.
statusstringACTIVE on success, or processing if still settling (poll GET /cards/:id).
masked_numberstring | null****1234.
Example request
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" }'
Example response
{
  "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.
GET/v1/gateway/cards/:idcards:read

Current status of one of your cards, plus (for physical) the normalized activation sub-object.

Response fields

FieldTypeDescription
card_idstringOpaque card id.
statusstringLifecycle status (PROCESSING / ASSIGNED / ACTIVE / FROZEN / FAILED / …).
masked_numberstring | null****1234.
external_idstringYour id.
sku_idinteger | nullSKU the card was issued from.
created_atstring (ISO)Creation timestamp.
activationobjectPhysical only. { status, failed: boolean, reason: string | null } — reason is a normalized ERR-… token only when failed.
Example request
curl https://api.nexuscard.io/v1/gateway/cards/b2c9d4e1-7a63-42f5-9c08-1e5b3a2f6d84 \
  -H "Authorization: Bearer pk_live_xxxxxxxxxxxxxxxx"
Example response
{
  "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.
GET/v1/gateway/cardscards:read

Your 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

FieldTypeRequiredDescription
external_idstringExact match on your own end-user reference.
sku_idintegerFilter by SKU.
limitinteger1–200 (default 50).
offsetintegerDefault 0. Page until offset + cards.length >= total.

Response fields

FieldTypeDescription
cards[].card_idstringPass to every other /cards call.
cards[].cardholder_idstring | nullThe approved cardholder it was issued against; null for a NEEDLESS product, which has no cardholder.
cards[].external_idstringYour own reference, as sent to POST /cards.
cards[].sku_idinteger | nullSKU the card was issued from.
cards[].statusstringThe 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_numberstring | null****1234.
cards[].created_atstring (ISO)Creation timestamp.
totalintegerFull count matching the filter, not just this page.
limit / offsetintegerEchoed back.
Example request
curl "https://api.nexuscard.io/v1/gateway/cards?external_id=user_1001&limit=50" \
  -H "Authorization: Bearer pk_live_xxxxxxxxxxxxxxxx"
Example response
{
  "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.
POST/v1/gateway/cards/:id/revealcards:reveal

Reveal the plaintext card so you can deliver it to your end-user. A dedicated scope — not implied by cards:read.

Response fields

FieldTypeDescription
numberstring | nullFull PAN.
expirystring | nullMM/YYYY.
cvvstring | nullCVV / CVC.
embossed_namestring | nullName on card.
Example request
curl -X POST https://api.nexuscard.io/v1/gateway/cards/b2c9d4e1-7a63-42f5-9c08-1e5b3a2f6d84/reveal \
  -H "Authorization: Bearer pk_live_xxxxxxxxxxxxxxxx"
Example response
{
  "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).
POST/v1/gateway/cards/:id/freezecards:write

Freeze a card (ACTIVE → FROZEN). Reversible with unfreeze. No request body.

Response fields

FieldTypeDescription
card_idstringOpaque card id.
statusstringNew status (e.g. "FROZEN" or "FREEZING" while settling).
Example request
curl -X POST https://api.nexuscard.io/v1/gateway/cards/b2c9d4e1-7a63-42f5-9c08-1e5b3a2f6d84/freeze \
  -H "Authorization: Bearer pk_live_xxxxxxxxxxxxxxxx"
Example response
{
  "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.
POST/v1/gateway/cards/:id/unfreezecards:write

Unfreeze a card (FROZEN → ACTIVE). No request body.

Response fields

FieldTypeDescription
card_idstringOpaque card id.
statusstringNew status (e.g. "ACTIVE").
Example request
curl -X POST https://api.nexuscard.io/v1/gateway/cards/b2c9d4e1-7a63-42f5-9c08-1e5b3a2f6d84/unfreeze \
  -H "Authorization: Bearer pk_live_xxxxxxxxxxxxxxxx"
Example response
{
  "card_id": "b2c9d4e1-7a63-42f5-9c08-1e5b3a2f6d84",
  "status": "ACTIVE"
}
POST/v1/gateway/cards/:id/pincards:pin

Set 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

FieldTypeRequiredDescription
pinstringYesThe new PIN: 6 digits, the same length required at assign/activate.

Response fields

FieldTypeDescription
card_idstringOpaque card id.
successbooleantrue once the card network has taken the new PIN.
Example request
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" }'
Example response
{
  "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.
GET/v1/gateway/cards/:id/transactionscards:read

A tenant-safe, whitelisted projection of the card's transactions. No raw upstream ids, no cost fields, no PAN.

Query parameters

FieldTypeRequiredDescription
page_size (or limit)integerPage size, max 100.
page_number (or page)integer1-based page number.
typestringFilter by transaction type.

Response fields

FieldTypeDescription
transactions[].idstringOpaque txn_<hash> (never the raw id).
transactions[].amountnumber | nullTransaction amount.
transactions[].currencystring | nullCurrency.
transactions[].typestring | nullTransaction type.
transactions[].statusstring | nullTransaction status.
transactions[].merchant_namestringPresent when known.
transactions[].created_atstring (ISO) | nullISO-8601 UTC — the same format as every other gateway timestamp. null only when the card network sends no usable time.
transactions[].decline_reasonstringPresent when declined; coarse token: insufficient_funds / card_expired / card_not_active / limit_exceeded / security_block / invalid_details / declined.
totalnumberTotal count.
Example request
curl "https://api.nexuscard.io/v1/gateway/cards/b2c9d4e1-7a63-42f5-9c08-1e5b3a2f6d84/transactions?page_size=20" \
  -H "Authorization: Bearer pk_live_xxxxxxxxxxxxxxxx"
Example response
{
  "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.

POST/v1/gateway/cards/:id/topupcards:write

Load 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

FieldTypeRequiredDescription
amountnumber > 0YesUSD 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

FieldTypeDescription
topup_idstringOpaque top-up id.
statusstring"processing" (new), or "completed" / "failed" on replay.
Example request
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 }'
Example response
{
  "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.
GET/v1/gateway/cards/:id/topupscards:read

Fee-transparent top-up history, showing exactly Charged = Loaded + Fee.

Query parameters

FieldTypeRequiredDescription
limitintegerDefault 50, max 200.

Response fields

FieldTypeDescription
currencystring"USD".
entries[].topup_idstringOpaque top-up id.
entries[].statusstringprocessing / completed / failed.
entries[].chargednumberGross debited from the pool (loaded + fee).
entries[].feenumberPlatform fee charged on top.
entries[].loadednumberNet that landed on the card (equals your requested amount).
entries[].created_atstring (ISO)When the top-up was requested.
entries[].completed_atstring | nullSet when completed.
Example request
curl "https://api.nexuscard.io/v1/gateway/cards/b2c9d4e1-7a63-42f5-9c08-1e5b3a2f6d84/topups?limit=50" \
  -H "Authorization: Bearer pk_live_xxxxxxxxxxxxxxxx"
Example response
{
  "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"
    }
  ]
}
GET/v1/gateway/cards/:id/balancecards:read

The card's live spendable balance straight from the issuer — use it to self-audit after a top-up settles.

Response fields

FieldTypeDescription
currencystring"USD".
availablenumberLive spendable balance (0 if none).
Example request
curl https://api.nexuscard.io/v1/gateway/cards/b2c9d4e1-7a63-42f5-9c08-1e5b3a2f6d84/balance \
  -H "Authorization: Bearer pk_live_xxxxxxxxxxxxxxxx"
Example response
{
  "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.

GET/v1/gateway/pool/balancepool:read

Your prepaid pool's two custody buckets: available (spendable) and held (reserved, in-flight).

Response fields

FieldTypeDescription
currencystring"USD".
availablenumberSpendable now.
heldnumberReserved by in-flight issues / top-ups (not yet captured or released).
Example request
curl https://api.nexuscard.io/v1/gateway/pool/balance \
  -H "Authorization: Bearer pk_live_xxxxxxxxxxxxxxxx"
Example response
{
  "currency": "USD",
  "available": 1250.00,
  "held": 40.00
}
GET/v1/gateway/pool/statementpool:read

Append-only ledger postings for your custody + funding movements. Platform-internal accounts are never returned.

Query parameters

FieldTypeRequiredDescription
limitintegerDefault 50, max 500.
beforeIdnumberCursor — return postings before this id.

Response fields

FieldTypeDescription
entries[].idnumberPosting id (cursor for beforeId).
entries[].entry_groupstringGroups the legs of one event.
entries[].account_typestringOne of available, held, external_funding (contra legs filtered out).
entries[].amountnumberSigned USD (+ credit, − debit).
entries[].op_typestringfund / card_hold / card_capture / card_release / topup_debit / topup_reverse / refund / adjustment.
entries[].balance_afternumberRunning balance of that account.
entries[].created_atstring (ISO)Posting timestamp.
Example request
curl "https://api.nexuscard.io/v1/gateway/pool/statement?limit=50" \
  -H "Authorization: Bearer pk_live_xxxxxxxxxxxxxxxx"
Example response
{
  "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"
    }
  ]
}
GET/v1/gateway/pool/activitypool:read

Partner-facing pool-movement ledger: credits (funding) and debits (card captures, booked only after the card is ACTIVE) larger than 0.5 USDT.

Query parameters

FieldTypeRequiredDescription
limitintegerMax 500.
beforestring (ISO)created_at cursor — return activity before this time.

Response fields

FieldTypeDescription
entries[].typestringcredit (funding) or debit (card capture).
entries[].amountnumberMovement amount in USD.
entries[].currencystring"USD".
entries[].balance_afternumberAvailable balance after the movement.
entries[].card_idstring | nullPresent on card-capture debits.
entries[].memostringHuman-readable label.
entries[].created_atstring (ISO)Movement timestamp.
Example request
curl "https://api.nexuscard.io/v1/gateway/pool/activity?limit=50" \
  -H "Authorization: Bearer pk_live_xxxxxxxxxxxxxxxx"
Example response
{
  "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"
    }
  ]
}
GET/v1/gateway/pool/deposit-addresspool:read

Where 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

FieldTypeDescription
currencystring"USD" — the pool is always USD.
networks[].chainstringbsc | ethereum | tron.
networks[].networkstringHuman network name, e.g. BNB Smart Chain.
networks[].tokenstring"USDT".
networks[].token_standardstringBEP-20 | ERC-20 | TRC-20.
networks[].addressstringSend this network's USDT here.
supported_chainsstring[]The chain keys, same order as networks.
Example request
curl "https://api.nexuscard.io/v1/gateway/pool/deposit-address" \
  -H "Authorization: Bearer pk_live_xxxxxxxxxxxxxxxx"
Example response
{
  "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

EventWhen it firesdata payload fields
kyc.approvedA cardholder's KYC was approved — issue cards against it now.cardholder_id, external_id, sku_id, status
kyc.rejectedA cardholder's KYC was rejected.cardholder_id, external_id, sku_id, status, reason_code, reason_message
card.assignedA physical card was assigned to the holder and is awaiting your activate.card_id, external_id, sku_id, status, masked_number
card.issuedA card finished issuing (virtual captured, or physical activated).card_id, external_id, sku_id, status, masked_number, agency_price
card.failedCard issuance terminally failed (hold released).external_id, sku_id, code, reason, retryable
topup.completed / topup.failedA top-up settled or was refunded.card_id, topup_id, status, gross, fee, net_to_card (+ code / reason on failure)
transaction.authorized / .settled / .declined / .refundedA 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_changedA card's lifecycle status changed (ACTIVE / FROZEN / INACTIVE / CANCELLED).card_id, external_id, status
card.3dsA 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.creditedFunding landed (a deposit > 0.5 USDT).amount, currency, new_available_balance, tx_hash, chain
pool.debitedA card capture debited the pool (> 0.5 USDT, booked after activation).amount, currency, new_available_balance, card_id, reason
Example delivery — card.issued
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) and GET /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.failed as 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 envelope
{
  "error": "Insufficient pool balance for this top-up.",
  "code": "ERR-POOL-INSUFFICIENT"
}

Fields you may also see

  • request_id — on 5xx; 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 — on ERR-GW-CARDHOLDER-EXISTS, the id to reuse.
49 / 49 codes
CodeHTTPRetry?Meaning & fix
ERR-GW-BODY400TerminalA required field is missing or malformed — the message names it (identity problems return every bad field in fields[]). Fix and resend.
ERR-GW-INVALID400 / 502TerminalAn upstream validation refusal about your payload; carries field + detail when a field was identified. Correct that field and resend.
ERR-GW-IDEMPOTENCY400TerminalThe 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-NUMBER400TerminalThe printed card number isn't valid (must be 8–19 digits).
ERR-GW-PIN400TerminalPIN must be 6 digits (UQ-family cards require it at activation; the same rule applies to POST /cards/:id/pin).
ERR-GW-PIN-UNSUPPORTED400TerminalA PIN change was asked for on a VIRTUAL card. Only physical cards have a PIN.
ERR-GW-ACTIVATION-CODE400TerminalThis SKU requires an activation_code that wasn't supplied.
ERR-GW-AMOUNT400Terminalamount must be a positive number (top-up).
ERR-GW-AMOUNT-TOO-SMALL400TerminalAfter fees the net that would land on the card is ≤ 0 — increase the amount.
ERR-GW-TOPUP-MIN400TerminalBelow the SKU's minimum top-up (the minimum is in the message).
ERR-GW-OPEN-MIN400TerminalBelow the SKU's opening-deposit floor.
ERR-GW-CARDHOLDER-EXISTS409TerminalA cardholder already exists for this email on this card program. The response includes cardholder_id — reuse it, don't duplicate.
ERR-GW-CARDHOLDER-NEEDLESS400TerminalThis product has no cardholder — skip POST /cardholders and issue the card directly.
ERR-GW-CARDHOLDER-NOTFOUND404TerminalThat cardholder_id isn't yours (or doesn't exist).
ERR-GW-CARDHOLDER-REQUIRED400TerminalPOST /cards needs a cardholder_id for this SKU. Create + approve a cardholder first.
ERR-GW-CARDHOLDER-PENDING409Wait / fundThe cardholder is still pending KYC. Poll GET /cardholders/:id (or the kyc.approved webhook) before issuing — don't retry-spam.
ERR-GW-CARDHOLDER-REJECTED409TerminalThe cardholder's KYC was rejected. Re-submit KYC (same external_id) with corrected identity, then retry.
ERR-GW-CARDHOLDER-BIN409TerminalThis 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-NOTFOUND404TerminalNo such card under your key's agency.
ERR-GW-CARD409 / 500 / 502TerminalA 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-USE409TerminalThis 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-ACTIVE409Wait / fundThe 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-ASSIGNED409Wait / fundThe physical card isn't ASSIGNED yet. Poll until ASSIGNED, then activate.
ERR-GW-NOT-ACTIVATABLE409TerminalThis card doesn't require activation (virtual / NEEDLESS) — skip the activate call.
ERR-GW-CARD-STATUS-TRANSITION409 RetryableThe card is mid-transition at the network — retry the activate after a short delay.
ERR-GW-ACTIVATION-PIN409TerminalThe card isn't active and activating it needs the 6-digit PIN — call /activate again with the pin.
ERR-GW-DECLINED502TerminalThe card network declined the operation (specific decline code hidden).
ERR-GW-BALANCE502 RetryableLive card balance is temporarily unavailable upstream. Includes request_id — retry shortly.
ERR-GW-UNSUPPORTED501TerminalCard cancellation isn't offered by the network yet — a capability gap, not an incident.
ERR-GW-DEADLINEwebhookTerminalA physical assign/activate didn't finish within 24h (surfaced on GET /cards/:id + the card.failed webhook). Re-issue.
ERR-GW-TOPUP500 RetryableThe top-up couldn't be recorded after the charge — retry (idempotent) with the same key.
ERR-GW-TOPUP-FAILEDwebhookTerminalA load was declined/failed — the topup.failed webhook's reason. The pool was released 1:1 (no charge stands).
ERR-GW-SKU404 / 400Terminal“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-NATIVE400TerminalThe SKU/card isn't on the native account. The gateway only serves native products — ask us to add it natively if needed.
ERR-GW-PRICE422 / 400 / 500TerminalThe product is unpriced, priced at 0, or below cost — a pricing misconfiguration our side. Contact the platform.
ERR-POOL-INSUFFICIENT402Wait / fundYour prefunded pool can't cover this card / top-up. Fund the pool, then retry with the SAME key.
ERR-GW-BILLING500TerminalA card issued but billing didn't complete — a money incident. Includes request_id (also alerted to our ops). Contact support with it.
ERR-GW-RETRY503 RetryableA 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-UPSTREAM502 / 400 RetryableThe card processor is temporarily unavailable. Retry shortly (money ops upgrade this to ERR-GW-RETRY 503).
ERR-GW-NOT-READY503 RetryableThe resource is still provisioning (e.g. a just-created cardholder). Retry after a short delay with the same key.
ERR-GW500 / 502TerminalAn unexpected error (includes request_id on the 500). Retry once; if it persists, contact support with the request_id.
ERR-GW-CONFIG503TerminalA server-side configuration issue (not your key). Retry later; contact support if it persists.
ERR-GW-AUTH502TerminalAn upstream authentication failure — our credential to the processor, not your API key. Contact support.
ERR-GW-FORBIDDEN403 / 502TerminalThe operation isn't permitted for this product/account.
ERR-GW-SCOPE403TerminalYour API key lacks the scope this call needs (e.g. cards:write, cards:reveal). Mint a key with the scope.
ERR-GW-IP401TerminalYour 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-UNSUPPORTED501TerminalYou used a pk_test key on a live-only endpoint — there's no sandbox yet. Use a pk_live_ key.
ERR-GW-IDEMPOTENCY-REUSE409TerminalThis Idempotency-Key was already used with a DIFFERENT body. Use a fresh key for a new operation.
ERR-GW-CONFLICT502 / 400TerminalA 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_codeMessage the applicant seesTriggered by
ERR-KYC-PHONEThis phone number can’t be accepted. Please use a different number.phone · mobile · dial
ERR-KYC-EMAILThis email can’t be accepted. Please use a different email.email · e-mail
ERR-KYC-DOCUMENTThe ID document couldn’t be verified. Please re-submit a clear, valid document.document · passport · expired · unclear · selfie
ERR-KYC-AGEThis 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-COUNTRYThis country isn’t supported for this card. Please use a different card product.country · jurisdiction · not supported · restricted region
ERR-KYC-SCREENINGThis application didn’t pass our compliance screening and can’t be accepted.sanction · watchlist · pep · aml · screening
ERR-KYC-DETAILSThe identity details couldn’t be verified. Please check and re-submit.name · birth · gender · address · nationality
ERR-KYC-REJECTEDVerification 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 / conditionHTTPRetry?Money effect
ERR-POOL-INSUFFICIENT402After fundingUntouched (no hold)
ERR-GW-RETRY / ERR-GW-UPSTREAM503Retryable (same key)Held (retry to consume)
ERR-GW-NOT-READYRetryable (shortly)Held (retry to consume)
ERR-GW-CARDHOLDER-PENDING (issue)409Wait for approvalUntouched (no hold)
ERR-GW-CARDHOLDER-REJECTED / -NOTFOUND (issue)409 / 404TerminalUntouched (no hold)
ERR-GW-CARD (terminal upstream)502TerminalReleased (refunded 1:1)
Top-up declined / never lands (async)TerminalReleased (refunded 1:1)
ERR-GW-TOPUP-MIN400TerminalUntouched (no hold)
ERR-GW-AMOUNT-TOO-SMALL400TerminalUntouched (no hold)
ERR-GW-SKU404 / 400TerminalUntouched (no hold)
ERR-GW-PRICE422 / 400Terminal — contact platformUntouched (no hold)
ERR-GW-CARD-NOT-ACTIVE409TerminalUntouched (no hold)
ERR-GW-SCOPE403TerminalUntouched (no hold)
ERR-GW-AUTH / ERR-GW-IP401TerminalUntouched (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.

quickstart.py
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.