Card
Issue a virtual card to your own account or to a PF sub-account, fund it from the holder's wallet and manage it, step by step.
Overview
The Hodle card is a virtual card issued by Hodle's card issuer partner. Its balance is a USD stablecoin balance (USDC) that the holder tops up from their own Hodle wallet, and it pays anywhere the card network is accepted.
Who holds the card. Every card has exactly one holder:
| You call with | The card belongs to |
|---|---|
no subAccountId | the account that owns the API key (you). |
subAccountId: "<id>" | that sub-account's holder: a person (PF) you created with POST /api/subaccount. |
Each holder has its own sign-in, its own onboarding, its own card and its own balance. A card is
never visible through another holder: not through the parent call without subAccountId, not
through a sibling sub-account, and never through another integrator's key (404).
The holder's wallet signs. Two things are signed by the holder's own Hodle wallet:
- the sign-in that opens the card session (step 1);
- each top-up that moves stablecoins from the wallet to the card (funding).
You authorize both exactly like a payout: send the holder's walletPin (and, optionally, the
protectedSymmetricKey from POST /api/wallet/keys), and Hodle unlocks
the wallet and signs. Same PIN rules as payouts: a wrong PIN answers 400 INVALID_PIN, and
three wrong PINs in a row lock that wallet for 24 hours (429 RATE_LIMITED). A holder who
signs with an external wallet they control can send the signature instead.
The card number is never returned by the API. No route returns the card number (PAN), CVV
or expiry. The holder sees them only inside Hodle's secure card-details iframe (PSE) in the Hodle
app, which talks to the card issuer partner directly. The API returns an opaque cardId and the
last four digits.
Limits
| What | Limit |
|---|---|
| Holders per account | one card account for you, plus one per PF sub-account |
| Top-up amount | greater than 0, up to 10000, up to 6 decimals, in the source asset |
| Top-ups in flight | one per holder; finish (or let expire) the current one before the next |
| Top-up quote validity | 60 seconds between POST /api/card/fund and its submit |
| New quotes | one every 3 seconds per holder (replaying a requestId is not limited) |
REQUEST_OTP, REQUEST_PHONE_CODE | once per 60 seconds per holder |
| Other onboarding actions | once per 2 seconds per action and holder, one action at a time |
| Card session | 1 hour; unfreezing needs a session signed in the last 5 minutes |
| Balance freshness | GET /api/card serves a balance up to 30 seconds old; a top-up refreshes it |
Prerequisites
-
Production plane with the card enabled. Your account's production plane must carry the card capability. There are two levels; ask support to enable them:
Capability Unlocks card identity sign-in, GET /api/card, and the identity steps:REQUEST_OTP,SIGN_UP,ACCEPT_TERMS,START_KYCcard everything above, plus the card steps, funding, transactions and freeze The capability is read from your account (the API-key owner) for your card and for every sub-account's card. Without it every card route answers
403CARD_NOT_ENABLED. -
A Hodle wallet for the holder, created with
POST /api/wallet/create. It is the card's signer:Holder Wallet walletPinyou sendyou your default wallet your platform wallet PIN a sub-account the sub-account's default wallet ( subAccountId)the sub-account wallet's own secret when it was created with pinScope: "WALLET"; otherwise your platform PIN, which it inheritsThe card is bound to that wallet on the first sign-in. Fund the card from the same holder's wallet.
-
Individuals only (PF). A sub-account must be a person verified with a valid CPF (KYC) and have an
email. The card's identity check is bound to the sub-account's own CPF, never to yours. A company (PJ) sub-account gets400SUBACCOUNT_NOT_INDIVIDUAL. -
A webhook endpoint for
CARD_KYC_APPROVED,CARD_KYC_REJECTEDandCARD_ISSUED(Webhooks). Optional, but it saves polling.
The onboarding state machine
GET /api/card and every onboarding response carry stage. Each stage has one way forward,
listed in nextStep.actions; nextStep.humanStep says when a person has to act.
AUTH_REQUIRED ──sign in──▶ SIGN_UP ──REQUEST_OTP + SIGN_UP(otp)──▶ TERMS
│
ACCEPT_TERMS
▼
┌──────────── KYC_ACTION_REQUIRED ◀──(issuer asks)── KYC ◀─┐
│ │ │ │
└──START_KYC (kycUrl)─┴───────────────────────────────┘ │
│
KYC approved ──▶ SOURCE_OF_FUNDS ──SUBMIT_SOURCE_OF_FUNDS──▶ PHONE │
│ │
REQUEST_PHONE_CODE + VERIFY_PHONE(code) │
▼ │
SAFE ──DEPLOY_SAFE──▶ CARD ──ISSUE_VIRTUAL_CARD──▶ READY| Stage | nextStep.actions | nextStep.humanStep | What happens |
|---|---|---|---|
AUTH_REQUIRED | — | SIGN_IN | No card session. Sign in with the holder wallet. |
SIGN_UP | REQUEST_OTP, SIGN_UP | EMAIL_CODE | REQUEST_OTP emails a 6-digit code to the holder; send it back with SIGN_UP. |
TERMS | ACCEPT_TERMS | — | Accepts the issuer's current terms (terms[] lists them with their URL). |
KYC | START_KYC | HOSTED_KYC | Returns kycUrl, the hosted identity check the holder opens and completes. |
KYC_ACTION_REQUIRED | START_KYC | HOSTED_KYC | The issuer needs more from the holder; a fresh kycUrl reopens the check. |
SOURCE_OF_FUNDS | SUBMIT_SOURCE_OF_FUNDS | SOURCE_OF_FUNDS_ANSWERS | Answer every question in sourceOfFundsQuestions with one of its listed answers. |
PHONE | REQUEST_PHONE_CODE, VERIFY_PHONE | SMS_CODE | Sends an SMS to the holder's phone; the holder types the code, you send it with VERIFY_PHONE. |
SAFE | DEPLOY_SAFE | — | Creates the card's on-chain account. |
CARD | ISSUE_VIRTUAL_CARD | — | Issues the virtual card. CARD_ISSUED fires. |
READY | — | — | The card can be funded and used. |
An action for a stage already passed is a no-op that returns the current status. An action for a
later stage is refused with 400 (CARD_REQUEST_FAILED): the flow never skips ahead.
Never guess a human step. The email code and the SMS code are typed by the holder, and the hosted KYC is done by the holder in their browser. Your integration relays them; it never invents them.
kycStatus is one of NOT_STARTED, PENDING, ACTION_REQUIRED, APPROVED, REJECTED.
REJECTED is terminal: once the card issuer partner rejects a holder, kycStatus stays
REJECTED and CARD_KYC_REJECTED is recorded once. For a sub-account whose KYC follows the KYC
order (CARD, then DOCUMENTS, then HOSTED), a REJECTED card KYC closes the CARD step and
the order can move the sub-account to the next provider.
Tutorial: your own card (account holder)
Every request uses your API key: Authorization: Bearer $API_KEY. Omit subAccountId.
1. Sign in with the holder wallet
Send the holder's wallet PIN. Hodle unlocks the wallet, builds the sign-in, signs it with the wallet and opens the session, in one call.
curl --request POST \
--url https://api.hodle.com.br/api/card/session \
--header "Authorization: Bearer $API_KEY" \
--header "Content-Type: application/json" \
--data '{ "walletPin": "$WALLET_PIN", "protectedSymmetricKey": "$PSK" }'| Field | Required | Description |
|---|---|---|
walletPin | yes | The holder wallet's PIN or secret (see prerequisites). Never echoed or logged. |
protectedSymmetricKey | no | From POST /api/wallet/keys. Defaults to the signer wallet's own. |
walletId | no | Must be the holder's card signer wallet; anything else answers 404 WALLET_NOT_FOUND. |
subAccountId | no | For a sub-account's card. |
For a holder whose signer is an external wallet they control (not a Hodle wallet), ask for the
sign-in message, sign it on the holder's side (EIP-191 personal_sign) and send it back:
curl --request POST \
--url https://api.hodle.com.br/api/card/session/prepare \
--header "Authorization: Bearer $API_KEY" \
--header "Content-Type: application/json" \
--data '{ "eoaAddress": "0x5B38Da6a701c568545dCfcB03FcB875f56beddC4" }'{
"success": true,
"data": {
"walletId": "6a8f0c2e1b7d4a0012ab34cd",
"requiresSignerAddress": false,
"message": "app.hodle.com.br wants you to sign in with your Ethereum account:\n0x5B38Da6a701c568545dCfcB03FcB875f56beddC4\n\n…\n\nURI: https://app.hodle.com.br\nVersion: 1\nChain ID: 100\nNonce: 8f2k1x9q0w\nIssued At: 2026-11-05T12:00:00.000Z",
"eoaAddress": "0x5B38Da6a701c568545dCfcB03FcB875f56beddC4",
"expiresAt": "2026-11-05T12:05:00.000Z",
"subAccountId": null
}
}eoaAddress is only taken when the wallet has no signer on record yet; a wallet that already has
one answers with its own. Then:
curl --request POST \
--url https://api.hodle.com.br/api/card/session \
--header "Authorization: Bearer $API_KEY" \
--header "Content-Type: application/json" \
--data '{ "message": "<message from prepare>", "signature": "0x<65-byte signature>" }'{
"success": true,
"data": {
"subAccountId": null,
"sessionExpiresAt": "2026-11-05T13:00:00.000Z",
"status": {
"stage": "SIGN_UP",
"registered": false,
"termsAccepted": false,
"kycStatus": "NOT_STARTED",
"kycUrl": null,
"sourceOfFundsAnswered": false,
"phoneValidated": false,
"safeDeployed": false,
"safeAddress": null,
"activeCardCount": 0,
"terms": [],
"sourceOfFundsQuestions": [],
"nextStep": { "actions": ["REQUEST_OTP", "SIGN_UP"], "humanStep": "EMAIL_CODE" }
}
}
}The session lasts one hour. When a later call answers 409 CARD_SESSION_REQUIRED (or
GET /api/card is back at AUTH_REQUIRED), repeat this same call: it renews the session. The
response never carries the PIN, the key or the signature.
2. Sign up (email code)
curl --request POST \
--url https://api.hodle.com.br/api/card/onboarding \
--header "Authorization: Bearer $API_KEY" \
--header "Content-Type: application/json" \
--data '{ "action": "REQUEST_OTP" }'The card issuer partner emails a 6-digit code to the holder's email. The holder reads it and gives it to you; then:
--data '{ "action": "SIGN_UP", "input": { "otp": "123456" } }'{
"success": true,
"data": {
"subAccountId": null,
"action": "SIGN_UP",
"status": {
"stage": "TERMS",
"registered": true,
"termsAccepted": false,
"kycStatus": "NOT_STARTED",
"terms": [
{ "type": "general-tos", "currentVersion": "v1", "accepted": false, "url": "https://…/terms" }
],
"nextStep": { "actions": ["ACCEPT_TERMS"], "humanStep": null }
}
}
}3. Accept the terms
Show terms[].url to the holder, then:
--data '{ "action": "ACCEPT_TERMS" }'Stage before: TERMS, after: KYC.
4. Identity check (hosted KYC)
--data '{ "action": "START_KYC" }'{
"success": true,
"data": {
"subAccountId": null,
"action": "START_KYC",
"status": {
"stage": "KYC",
"kycStatus": "NOT_STARTED",
"kycUrl": "https://…/kyc/session/…",
"nextStep": { "actions": ["START_KYC"], "humanStep": "HOSTED_KYC" }
}
}
}Send kycUrl to the holder, who completes the identity check in their own browser (document
and selfie). Then poll GET /api/card (or wait for CARD_KYC_APPROVED). kycStatus moves to
PENDING, then APPROVED (stage SOURCE_OF_FUNDS) or REJECTED (terminal, CARD_KYC_REJECTED).
If the stage becomes KYC_ACTION_REQUIRED, call START_KYC again for a fresh kycUrl.
5. Source of funds
status.sourceOfFundsQuestions lists each question with its allowed answers. Ask the holder and
send one answer per question:
--data '{
"action": "SUBMIT_SOURCE_OF_FUNDS",
"input": {
"sourceOfFunds": [
{ "question": "What is your occupation?", "answer": "Employed" },
{ "question": "What is the source of your funds?", "answer": "Salary" }
]
}
}'Stage before: SOURCE_OF_FUNDS, after: PHONE.
6. Phone (SMS code)
--data '{ "action": "REQUEST_PHONE_CODE", "input": { "phoneNumber": "+5511987654321" } }'The holder receives an SMS and types the code; send it:
--data '{ "action": "VERIFY_PHONE", "input": { "phoneCode": "4821" } }'Stage before: PHONE, after: SAFE.
7. Create the card account and issue the card
--data '{ "action": "DEPLOY_SAFE" }'
--data '{ "action": "ISSUE_VIRTUAL_CARD" }'Stage SAFE → CARD → READY. CARD_ISSUED is recorded once.
8. Read the card
curl --url https://api.hodle.com.br/api/card \
--header "Authorization: Bearer $API_KEY"{
"success": true,
"data": {
"subAccountId": null,
"stage": "READY",
"registered": true,
"termsAccepted": true,
"kycStatus": "APPROVED",
"kycUrl": null,
"sourceOfFundsAnswered": true,
"phoneValidated": true,
"safeDeployed": true,
"safeAddress": "0x4d62c475826F0614d4BfC82F5cDFd74dFB24C50a",
"activeCardCount": 1,
"terms": [],
"sourceOfFundsQuestions": [],
"nextStep": { "actions": [], "humanStep": null },
"cards": [
{
"cardId": "R25vc2lzUGF5TWFuYWdlZENhcmQ6Y2FyZC0x",
"lastFourDigits": "4242",
"virtual": true,
"statusCode": 1000,
"activatedAt": "2026-11-05T12:30:00.000Z"
}
],
"balance": {
"currency": "USDC",
"total": "25",
"spendable": "20",
"pending": "5",
"onChain": "25",
"requiresAuthentication": false,
"checkedAt": "2026-11-05T12:31:00.000Z"
}
}
}cards and balance are filled from stage CARD on. spendable is what the next purchase can
use; pending is held by authorizations not yet settled. statusCode: 1000 is an active card.
Tutorial: a sub-account's card
Same steps, same bodies, plus subAccountId on every call: in the JSON body for POST/DELETE,
in the query string for GET (GET /api/card?subAccountId=…).
Before step 1, make sure the sub-account:
- was created by your key (
POST /api/subaccount) with anemail(the card sign-up code goes there); - is a PF whose KYC recorded a valid CPF; the card is bound to that CPF;
- has its own default wallet (
POST /api/wallet/createwithsubAccountId), which signs.
curl --request POST \
--url https://api.hodle.com.br/api/card/session \
--header "Authorization: Bearer $API_KEY" \
--header "Content-Type: application/json" \
--data '{ "subAccountId": "a928c33c-1ad2-4bf9-b4ce-1f83b403f1e4", "walletPin": "$SUB_ACCOUNT_WALLET_SECRET" }'walletPin is the sub-account wallet's own secret when it was created with
pinScope: "WALLET", otherwise your platform PIN. Your own PIN never opens a pinScope: "WALLET"
wallet: it answers INVALID_PIN and counts toward that wallet's lockout.
{
"success": true,
"data": {
"subAccountId": "a928c33c-1ad2-4bf9-b4ce-1f83b403f1e4",
"sessionExpiresAt": "2026-11-05T13:00:00.000Z",
"status": { "stage": "SIGN_UP", "nextStep": { "actions": ["REQUEST_OTP", "SIGN_UP"], "humanStep": "EMAIL_CODE" } }
}
}--data '{ "subAccountId": "a928c33c-…", "action": "REQUEST_OTP" }'
--data '{ "subAccountId": "a928c33c-…", "action": "SIGN_UP", "input": { "otp": "123456" } }'
--data '{ "subAccountId": "a928c33c-…", "action": "ACCEPT_TERMS" }'
--data '{ "subAccountId": "a928c33c-…", "action": "START_KYC" }'The code goes to the sub-account's email, the kycUrl is for the sub-account's holder, and the
SMS goes to the phone the holder gives. Webhooks for this card carry its subAccountId.
Refusals specific to sub-accounts:
| Status | code | When |
|---|---|---|
404 | SUBACCOUNT_NOT_FOUND | The sub-account is not yours (or does not exist). |
400 | SUBACCOUNT_NOT_INDIVIDUAL | The sub-account is a company (PJ) or its CPF is invalid. |
400 | SUBACCOUNT_TAX_ID_REQUIRED | No verified CPF yet: finish the sub-account's KYC first. |
400 | SUBACCOUNT_EMAIL_REQUIRED | The sub-account has no email. |
400 | SUBACCOUNT_UNSUPPORTED | A legacy sub-account, created before sub-account references. |
Funding the card
A top-up moves a stablecoin from the holder's Hodle wallet to the card balance. Sources
(sourceKey): USDC_BASE, USDC_POLYGON, USDT_POLYGON, BRLA_POLYGON. The card must be
READY.
POST /api/card/fund ──▶ POST /api/card/fund/{fundingId}/submit (walletPin: Hodle signs)
(QUOTED) (PENDING)
│
GET /api/card/fund/{fundingId} ◀── poll ──────────┘ ──▶ COMPLETED1. Prepare
curl --request POST \
--url https://api.hodle.com.br/api/card/fund \
--header "Authorization: Bearer $API_KEY" \
--header "Content-Type: application/json" \
--data '{ "sourceKey": "USDC_BASE", "amount": "20", "requestId": "5f0e2a8c-4b1d-4c7e-9a3f-2d6b8e1c0a47" }'{
"success": true,
"data": {
"fundingId": "5f0e2a8c-4b1d-4c7e-9a3f-2d6b8e1c0a47",
"subAccountId": null,
"status": "QUOTED",
"sourceKey": "USDC_BASE",
"amount": "20",
"expectedAmount": "19.8",
"minimumAmount": "19.7",
"nativeFee": "0",
"feeUsd": "0.2",
"durationSeconds": 45,
"expiresAt": "2026-11-05T12:41:00.000Z",
"signingHash": "0xabababababababababababababababababababababababababababababababab",
"signingWalletId": "6a8f0c2e1b7d4a0012ab34cd",
"txHash": null,
"receivingTxHash": null
}
}requestId (a UUID you generate) is the idempotency key and becomes fundingId. Repeating the
same requestId with the same body returns the same funding; with a different body it is
refused. minimumAmount is the least the card receives.
2. Submit before expiresAt
Authorize it with the source wallet's PIN, like a payout; Hodle signs signingHash with the
wallet signingWalletId and broadcasts.
curl --request POST \
--url https://api.hodle.com.br/api/card/fund/5f0e2a8c-4b1d-4c7e-9a3f-2d6b8e1c0a47/submit \
--header "Authorization: Bearer $API_KEY" \
--header "Content-Type: application/json" \
--data '{ "walletPin": "$WALLET_PIN", "protectedSymmetricKey": "$PSK" }'protectedSymmetricKey is optional (defaults to the source wallet's own). A wrong PIN answers
400 INVALID_PIN and counts toward the same 3-strike lockout as payouts.
Sign signingHash as a raw digest (no message prefix) with the external signer, prefix the
65-byte signature with 12 zero bytes and send it:
const raw = await account.sign({ hash: funding.signingHash })
const signature = `0x${'0'.repeat(24)}${raw.slice(2)}`--data '{ "signature": "0x000000000000000000000000<130 hex chars>" }'status becomes PENDING. The operation is broadcast at most once: a retried submit answers
the funding's current state and never sends again.
3. Track it
GET /api/card/fund/{fundingId} refreshes the funding from the chains (at most every 5 seconds)
until COMPLETED. FAILED means nothing reached the card; RECONCILIATION_REQUIRED means
support is checking the delivery. Once completed, GET /api/card shows the new balance.
Card management
Transactions
curl --url "https://api.hodle.com.br/api/card/transactions?offset=0" \
--header "Authorization: Bearer $API_KEY"{
"success": true,
"data": {
"subAccountId": null,
"transactions": [
{
"createdAt": "2026-11-05T12:45:00.000Z",
"clearedAt": null,
"isPending": true,
"kind": "Payment",
"status": "Approved",
"merchant": "Coffee Shop",
"billingAmount": "1.5",
"billingCurrency": "USD",
"billingCurrencyDecimals": 6,
"billingCurrencySymbol": "$",
"amount": "7.5",
"currency": "BRL",
"currencyDecimals": 2,
"currencySymbol": "R$"
}
],
"hasNextPage": false
}
}kind is Payment, Refund or Reversal. Pages hold 20; pass the next offset.
Before the card is issued, card management calls answer 409 CARD_NOT_ISSUED with the
holder's stage:
{ "success": false, "error": "The card is not issued yet: … Current stage: SIGN_UP.", "code": "CARD_NOT_ISSUED", "errorCode": "CARD_NOT_ISSUED", "stage": "SIGN_UP" }Freeze and unfreeze
curl --request POST \
--url https://api.hodle.com.br/api/card/freeze \
--header "Authorization: Bearer $API_KEY" \
--header "Content-Type: application/json" \
--data '{ "cardId": "R25vc2lzUGF5TWFuYWdlZENhcmQ6Y2FyZC0x", "frozen": true, "requestId": "freeze-2026-11-05-0001" }'{ "success": true, "data": { "subAccountId": null, "cardId": "R25vc2lzUGF5TWFuYWdlZENhcmQ6Y2FyZC0x", "frozen": true } }requestId (16-80 letters, digits or -) makes the call idempotent. Freezing works with any
open session. Unfreezing needs a session signed in the last 5 minutes: sign in again first,
or the call answers 409 CARD_SESSION_REQUIRED.
Card number, CVV and expiry
Never through the API. The holder opens them in the Hodle app, inside Hodle's secure card-details iframe (PSE), served by the card issuer partner. Your systems never receive them.
Sign out
DELETE /api/card/session (with subAccountId for a sub-account) ends the card session.
Webhooks
Register CARD_KYC_APPROVED, CARD_KYC_REJECTED and CARD_ISSUED like any other
webhook. Each event is recorded once per holder, the first time Hodle
sees it. Failed deliveries are retried in the background, including after an
interrupted status request. Recorded acknowledgements skip endpoints that accepted
the event while other endpoints retry. Deliveries have at-least-once semantics.
{
"event": "CARD_KYC_APPROVED",
"data": {
"subAccountId": "a928c33c-1ad2-4bf9-b4ce-1f83b403f1e4",
"occurredAt": "2026-11-05T12:20:00.000Z",
"kycStatus": "APPROVED"
}
}{
"event": "CARD_KYC_REJECTED",
"data": {
"subAccountId": "a928c33c-1ad2-4bf9-b4ce-1f83b403f1e4",
"occurredAt": "2026-11-05T12:20:00.000Z",
"kycStatus": "REJECTED"
}
}{
"event": "CARD_ISSUED",
"data": {
"subAccountId": null,
"occurredAt": "2026-11-05T12:30:00.000Z",
"activeCardCount": 1
}
}subAccountId is null for your own card. Hodle notices a milestone when it reads the card
status (any card call or the Hodle app), so call GET /api/card while you wait for one.
MCP
The Hodle MCP server exposes the card to agents:
| Tool | Kind | What it does |
|---|---|---|
hodle_card_status | Read | GET /api/card: stage, kycStatus, cards (last four digits only) and balance. |
hodle_card_transactions | Read | GET /api/card/transactions. |
hodle_card_session | Write | Signs your own card in (or renews it) with the server's HODLE_WALLET_PIN; not for sub-accounts. |
hodle_card_onboarding | Write | One onboarding action. Asks for the holder's email code, SMS code or answers instead of guessing, and hands back kycUrl for the holder. |
hodle_card_fund | Write | Prepare (sourceKey, amount) or submit (fundingId, signed with the server PIN or an external signature) a top-up. Capped per call by HODLE_MCP_CARD_FUND_MAX_USD, default 50 USD. |
hodle_card_freeze | Write | Freeze or unfreeze a card by cardId. |
Write tools need HODLE_MCP_ALLOW_WRITES=true and confirm: true. hodle_card_session and a
hodle_card_fund submit without signature use the wallet PIN the MCP server holds for payouts
(HODLE_WALLET_PIN, plus HODLE_PROTECTED_SYMMETRIC_KEY when set): the account holder's only, so sub-account
cards are signed from your integration. The PIN is never a tool argument nor returned.
Errors
Every refusal is { "success": false, "error": "<message>", "code": "<CODE>", "errorCode": "<CODE>" } (errorCode repeats code, as on the other routes). error is a
human-readable message (often in Portuguese) for logs and support; branch on code.
| Status | code | Meaning |
|---|---|---|
400 | CARD_INVALID_REQUEST | The body or query failed validation (error names the field). |
400 | CARD_REQUEST_FAILED | The step was refused: wrong stage, invalid code, expired quote, insufficient balance… |
400 | SUBACCOUNT_* | See sub-account refusals. |
401 | — | Missing or invalid API key. |
403 | CARD_NOT_ENABLED | The production plane lacks the card capability (or only has card identity for a card step). |
404 | SUBACCOUNT_NOT_FOUND | The sub-account is not yours. |
404 | CARD_NOT_FOUND | The funding (or account) does not exist for this holder. |
400 | INVALID_PIN | Wrong walletPin (same code as payouts); counts toward the wallet lockout. |
404 | WALLET_NOT_FOUND | No card signer wallet for this holder, or walletId is not it. |
429 | RATE_LIMITED | Three wrong PINs: the wallet is locked for 24 hours (same as payouts). |
409 | CARD_SESSION_REQUIRED | No card session (or it expired), the holder wallet changed, or unfreeze needs a fresh sign-in: sign in again. |
409 | CARD_NOT_ISSUED | The session is valid but the card is not issued yet (cards, transactions, freeze, top-ups). stage says where the holder is; finish the onboarding. |
429 | CARD_RATE_LIMITED | A cooldown is running (see limits); retry later. |
Endpoints
| Method | Path | Capability |
|---|---|---|
POST | /api/card/session/prepare | card identity |
POST | /api/card/session | card identity |
DELETE | /api/card/session | card identity |
GET | /api/card | card identity |
POST | /api/card/onboarding | card identity for REQUEST_OTP, SIGN_UP, ACCEPT_TERMS, START_KYC; card for the rest |
POST | /api/card/fund | card |
POST | /api/card/fund/{fundingId}/submit | card |
GET | /api/card/fund/{fundingId} | card |
GET | /api/card/transactions | card |
POST | /api/card/freeze | card |
All accept subAccountId. Full schemas are in the API reference.