{
  "service": "cryptoinfra",
  "version": "1.0.0",
  "generatedAt": "2026-11-08T06:22:03.105Z",
  "pages": [
    {
      "slug": "introduction",
      "title": "Introduction",
      "summary": "What cryptoinfra is, how the custodial model works, and the shortest path from a new user to a paid-out balance.",
      "kind": "guide",
      "markdown": "# Introduction\n\ncryptoinfra is **custodial wallet infrastructure on TRON**. You bring your users; the\nplatform gives each of them a [Static Wallet Address](deposit-addresses), watches it, credits confirmed\ndeposits to a double-entry ledger, and executes payouts on their behalf.\n\nYou never handle a private key, and you never write chain-specific code. You call one\nREST API — `https://api.cryptoinfra.build` — with your merchant key, and the platform speaks the chain\nunderneath. `GET /v1/assets` is the live list of what can be received and sent — today TRX,\nUSDT (TRC20) and USDC (TRC20).\n\n> **Start here** In a hurry? The [Quickstart](quickstart) is six copy-paste calls from a\n> new user to a paid-out balance, with the five mistakes everyone makes. The rest of this\n> page is the model behind them.\n\n## The happy path\n\n```http\nPOST /v1/users            → a static wallet address\nGET  /v1/users/{id}/balances\nPOST /v1/withdrawals      → payout, with an idempotency key\n```\n\n1. **Create the user.** `POST /v1/users` with your own `external_user_id` — a number of\n   5 to 10 digits. You get one **[Static Wallet Address](deposit-addresses)** back — a permanent\n   TRON address that never expires and never rotates, so you can store and display it once. The call is idempotent on `external_user_id`:\n   replaying it returns the same user and the same address, still `201`.\n\n2. **Show the static address; wait for the deposit.** When your user sends funds, the platform\n   detects the transaction, waits for the chain's confirmation threshold, credits the\n   balance in full (deposits are free), and fires a\n   [`deposit.credited`](webhook-events#deposit-credited) webhook.\n\n3. **Read the balance.** `GET /v1/users/{externalId}/balances` returns per-asset\n   spendable balances. Assets with no activity are simply absent from the response.\n\n4. **Pay out.** `POST /v1/withdrawals` holds `amount + fee + platform_fee` from the user's\n   balance and queues the payout. `fee` is the live network cost of the send, gas included\n   (quote it first with `GET /v1/withdrawal-fee-quote`, which also says whether the figure\n   is live); `platform_fee` is the flat platform fee. `amount` is what the *recipient* receives. You get\n   `202 Accepted` back; the outcome arrives as\n   [`withdrawal.confirmed`](webhook-events#withdrawal-confirmed) or\n   [`withdrawal.failed`](webhook-events#withdrawal-failed) — and the failure path refunds\n   the hold automatically.\n\nPreview the exact cost of step 4 before you commit to it with\n`GET /v1/withdrawal-fee-quote`.\n\n## What \"custodial\" means here, precisely\n\nThree facts follow from the custodial model, and every integration decision comes back to\none of them.\n\n**Keys are ours, balances are yours.** The platform derives and holds the keys for every\naddress it hands out. Your API key authenticates *you*, the merchant — never your end\nuser. A user identifier is a number you choose (`external_user_id`, 5 to 10 digits); it\ncarries no authority of its own. Never ship the API key to a browser or a mobile app.\n\n**Balances are ledger rows, not chain state.** A user's balance is a double-entry ledger\nposition, not a `getBalance` call. It is credited when a deposit clears confirmations and\ndebited the moment a payout is *requested*, not when it confirms. That is deliberate: the\nbalance you read is always **spendable** and always net of holds, so it can read lower\nthan the chain while an operation is in flight, and never higher.\n\n**Every movement of money is on-chain.** A payout — to one of your own users or to any\naddress outside the platform — is a `POST /v1/withdrawals`, priced with our fee and the\nnetwork's gas. There is no off-chain book transfer between accounts.\n\n## Your account is live the moment you sign up\n\nYou create your account yourself at the dashboard — company, email, password and a\nTelegram username. There is no approval step: the account is active from the first\nminute. The one thing it waits on is your email. Until the address is verified you cannot\nsign in, and you cannot generate or see an API key (`403 email_unverified`); the link is\nin your inbox, and the sign-in page can send a fresh one. Verify it, sign in, and the\ndashboard is yours: generate your key, register webhooks, invite teammates, use the\nsandbox — and the API and every money feature are open.\n\nSignup is open, not unguarded. Use a mailbox you will keep — disposable addresses are\nrefused (`400 email_disposable`) — and, when the page shows one, complete the bot check\nbefore you submit (`400 bot_check_failed` otherwise). We keep where a signup and each\nsign-in came from, and we look at accounts that share a network, a Telegram username, a\ncompany e-mail domain, a payout address or their customers with another merchant; such a\nlink, or a day of earnings far out of line with your own history, can hold a profit\nwithdrawal for a look — the request says so — but never closes an account on its own.\n\n## Webhooks are hints; the poll is truth\n\nThis is the single most important operational rule in the API, and it is worth internalising\nbefore you write any code.\n\n> **Important** Webhook delivery is at-least-once and best-effort. The read endpoints are\n> authoritative. Before you credit a customer, issue a refund, or tell anyone that money\n> has moved, reconcile against the resource itself — `GET /v1/withdrawals/{id}`,\n> `GET /v1/swap/orders/{id}`,\n> `GET /v1/cards/orders/{order_id}`, `GET /v1/users/{externalId}/balances`.\n\nAnd some flows have no webhook at all. **Swaps and exchanges emit nothing** — there is\nno `swap.completed` and no `exchange.completed`. Code that waits\nfor one waits forever. See [Webhooks](webhooks) for the complete list and what to poll\ninstead.\n\n## What else is in the API\n\nBeyond the wallet core, the same key and the same conventions reach every flow. Each has a\nshort guide that walks it once, in order:\n\n- **[Payouts](payouts)** — quote, send, and learn the outcome.\n- **[Swaps and exchange](swaps-and-exchange)** — trade between assets a user holds, or sell\n  one and pay any of thousands of other coins out to an external address.\n- **[Cards, end to end](cards-guide)** — issue crypto debit cards to your users, funded from\n  their own balance. Cards settle in USDT; a card is issued **funded**.\n- **[Developer](developer)** — register webhook endpoints and read your own request log.\n\n## Conventions at a glance\n\n| Convention | What to expect |\n| --- | --- |\n| Base URL | `https://api.cryptoinfra.build` |\n| Auth | `Authorization: Bearer ck_…` — see [Authentication](authentication) |\n| Casing | `snake_case` everywhere, in both directions |\n| Money | decimal string + exact `*_base` integer string — see [Money and amounts](money-and-amounts) |\n| Timestamps | RFC 3339 with an offset; date filters accept a bare IST calendar day |\n| Errors | `{ \"error\": \"code\", \"message\": \"…\" }` — see [Errors](errors) |\n| Mutations | carry an `idempotency_key` — see [Idempotency](idempotency) |\n| Contract | OpenAPI 1.0.0, 68 operations, 56 schemas, 9 webhook events |\n\nEvery page of this reference is generated from that OpenAPI contract, so it cannot drift\nfrom the running service. Every page is also available as raw Markdown at `/md/{slug}`,\nand the whole site as one file at [`/llms-full.txt`](/llms-full.txt) — see\n[AI integration](ai-integration).\n",
      "sections": [
        {
          "anchor": "the-happy-path",
          "heading": "The happy path",
          "text": "POST /v1/users            → a static wallet address\nGET  /v1/users/{id}/balances\nPOST /v1/withdrawals      → payout, with an idempotency key\n\nCreate the user. POST /v1/users with your own external_user_id — a number of\n5 to 10 digits. You get one Static Wallet Address back — a permanent\nTRON address that never expires and never rotates, so you can store and display it once. The call is idempotent on external_user_id:\nreplaying it returns the same user and the same address, still 201.\n\nShow the static address; wait for the deposit. When your user sends funds, the platform\ndetects the transaction, waits for the chain's confirmation threshold, credits the\nbalance in full (deposits are free), and fires a\ndeposit.credited webhook.\n\nRead the balance. GET /v1/users/{externalId}/balances returns per-asset\nspendable balances. Assets with no activity are simply absent from the response.\n\nPay out. POST /v1/withdrawals holds amount + fee + platform_fee from the user's\nbalance and queues the payout. fee is the live network cost of the send, gas included\n(quote it first with GET /v1/withdrawal-fee-quote, which also says whether the figure\nis live); platform_fee is the flat platform fee. amount is what the recipient receives. You get\n202 Accepted back; the outcome arrives as\nwithdrawal.confirmed or\nwithdrawal.failed — and the failure path refunds\nthe hold automatically.\n\nPreview the exact cost of step 4 before you commit to it with\nGET /v1/withdrawal-fee-quote."
        },
        {
          "anchor": "what-custodial-means-here-precisely",
          "heading": "What \"custodial\" means here, precisely",
          "text": "Three facts follow from the custodial model, and every integration decision comes back to\none of them.\n\nKeys are ours, balances are yours. The platform derives and holds the keys for every\naddress it hands out. Your API key authenticates you, the merchant — never your end\nuser. A user identifier is a number you choose (external_user_id, 5 to 10 digits); it\ncarries no authority of its own. Never ship the API key to a browser or a mobile app.\n\nBalances are ledger rows, not chain state. A user's balance is a double-entry ledger\nposition, not a getBalance call. It is credited when a deposit clears confirmations and\ndebited the moment a payout is requested, not when it confirms. That is deliberate: the\nbalance you read is always spendable and always net of holds, so it can read lower\nthan the chain while an operation is in flight, and never higher.\n\nEvery movement of money is on-chain. A payout — to one of your own users or to any\naddress outside the platform — is a POST /v1/withdrawals, priced with our fee and the\nnetwork's gas. There is no off-chain book transfer between accounts."
        },
        {
          "anchor": "your-account-is-live-the-moment-you-sign-up",
          "heading": "Your account is live the moment you sign up",
          "text": "You create your account yourself at the dashboard — company, email, password and a\nTelegram username. There is no approval step: the account is active from the first\nminute. The one thing it waits on is your email. Until the address is verified you cannot\nsign in, and you cannot generate or see an API key (403 email_unverified); the link is\nin your inbox, and the sign-in page can send a fresh one. Verify it, sign in, and the\ndashboard is yours: generate your key, register webhooks, invite teammates, use the\nsandbox — and the API and every money feature are open.\n\nSignup is open, not unguarded. Use a mailbox you will keep — disposable addresses are\nrefused (400 email_disposable) — and, when the page shows one, complete the bot check\nbefore you submit (400 bot_check_failed otherwise). We keep where a signup and each\nsign-in came from, and we look at accounts that share a network, a Telegram username, a\ncompany e-mail domain, a payout address or their customers with another merchant; such a\nlink, or a day of earnings far out of line with your own history, can hold a profit\nwithdrawal for a look — the request says so — but never closes an account on its own."
        },
        {
          "anchor": "webhooks-are-hints-the-poll-is-truth",
          "heading": "Webhooks are hints; the poll is truth",
          "text": "This is the single most important operational rule in the API, and it is worth internalising\nbefore you write any code.\n\nImportant Webhook delivery is at-least-once and best-effort. The read endpoints are\nauthoritative. Before you credit a customer, issue a refund, or tell anyone that money\nhas moved, reconcile against the resource itself — GET /v1/withdrawals/{id},\nGET /v1/swap/orders/{id},\nGET /v1/cards/orders/{order_id}, GET /v1/users/{externalId}/balances.\n\nAnd some flows have no webhook at all. Swaps and exchanges emit nothing — there is\nno swap.completed and no exchange.completed. Code that waits\nfor one waits forever. See Webhooks for the complete list and what to poll\ninstead."
        },
        {
          "anchor": "what-else-is-in-the-api",
          "heading": "What else is in the API",
          "text": "Beyond the wallet core, the same key and the same conventions reach every flow. Each has a\nshort guide that walks it once, in order:\n\nPayouts — quote, send, and learn the outcome.\nSwaps and exchange — trade between assets a user holds, or sell\none and pay any of thousands of other coins out to an external address.\nCards, end to end — issue crypto debit cards to your users, funded from\ntheir own balance. Cards settle in USDT; a card is issued funded.\nDeveloper — register webhook endpoints and read your own request log."
        },
        {
          "anchor": "conventions-at-a-glance",
          "heading": "Conventions at a glance",
          "text": "Convention · What to expect\nBase URL · https://api.cryptoinfra.build\nAuth · Authorization: Bearer ck_… — see Authentication\nCasing · snake_case everywhere, in both directions\nMoney · decimal string + exact *_base integer string — see Money and amounts\nTimestamps · RFC 3339 with an offset; date filters accept a bare IST calendar day\nErrors · { \"error\": \"code\", \"message\": \"…\" } — see Errors\nMutations · carry an idempotency_key — see Idempotency\nContract · OpenAPI 1.0.0, 68 operations, 56 schemas, 9 webhook events\n\nEvery page of this reference is generated from that OpenAPI contract, so it cannot drift\nfrom the running service. Every page is also available as raw Markdown at /md/{slug},\nand the whole site as one file at /llms-full.txt — see\nAI integration."
        }
      ]
    },
    {
      "slug": "quickstart",
      "title": "Quickstart",
      "summary": "Your first deposit and your first payout in six calls — copy, paste, done. Everything else in these docs is detail you can read later.",
      "kind": "guide",
      "markdown": "# Quickstart\n\nSix calls, in order. Each one is copy-paste. Do them once against the sandbox, then\nswitch the key and the base URL to live.\n\n**Before you start:** sign up at the dashboard, verify your email, and copy your API key\nfrom **Settings → API keys**. The key is shown once. Keep it on your server — never in a\nbrowser or an app.\n\n```bash\nexport CRYPTOINFRA_KEY=ck_live_your_key\nexport API=https://api.cryptoinfra.build\n```\n\n## 1. Tell us where to send events\n\n```bash\ncurl -sX POST $API/v1/developer/webhooks \\\n  -H \"Authorization: Bearer $CRYPTOINFRA_KEY\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"url\":\"https://yourapp.example/cryptoinfra/webhook\"}'\n```\n\nThe response has `endpoint.secret`. **Store it now — it is never shown again.** Every\nevent we send is signed with it (`x-cryptoinfra-signature`, HMAC-SHA256 of the raw body).\n\n## 2. Create a user, get their address\n\n```bash\ncurl -sX POST $API/v1/users \\\n  -H \"Authorization: Bearer $CRYPTOINFRA_KEY\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"external_user_id\":\"10482\"}'\n```\n\n```json\n{ \"user_id\": \"10482\", \"addresses\": [ { \"chain\": \"tron\", \"address\": \"TJYBSfZf…\" } ] }\n```\n\n`external_user_id` is the id this user has in **your** system: a number, 5 to 10 digits.\nThe address is permanent. Show it to the user once, save it if you like — it never changes.\nCalling this again with the same id is safe: you get the same user and the same address.\n\n## 3. Money arrives — we tell you\n\nWhen the user sends USDT, USDC or TRX to that address and it confirms, your webhook gets:\n\n```json\n{\n  \"userExternalId\": \"10482\",\n  \"assetId\": \"usdt_trc20\",\n  \"amount\": \"<decimal string>\",\n  \"amountCredited\": \"<decimal string>\",\n  \"txid\": \"<txid>\",\n  \"fromAddress\": \"<who sent it, T…>\"\n}\n```\n\nwith header `x-cryptoinfra-event: deposit.credited`. Deposits are free, so\n`amountCredited` equals `amount` unless you set a fee of your own. Reply `200` fast; do the\nwork after.\n\n## 4. Read the balance\n\n```bash\ncurl -s $API/v1/users/10482/balances \\\n  -H \"Authorization: Bearer $CRYPTOINFRA_KEY\"\n```\n\n```json\n{ \"balances\": [ { \"asset_id\": \"usdt_trc20\", \"symbol\": \"USDT\", \"amount\": \"<decimal string>\" } ] }\n```\n\nThis is the number to trust. It is already net of anything in flight.\n\n## 5. Quote a payout, then send it\n\n```bash\ncurl -s \"$API/v1/withdrawal-fee-quote?asset_id=usdt_trc20&amount=25&user_id=10482&to_address=TAs66Ct…\" \\\n  -H \"Authorization: Bearer $CRYPTOINFRA_KEY\"\n```\n\n```json\n{ \"total_fee\": \"<decimal string>\", \"network_fee\": \"<decimal string>\", \"platform_fee\": \"<decimal string>\", \"dynamic\": true }\n```\n\nShow the user `total_fee`. It is charged **on top** of the amount, so the balance must cover\n`amount + total_fee`. No number is printed here on purpose: the fee is priced the moment you\nask and moves with the network — always read it from the quote, never from an example.\n\n```bash\ncurl -sX POST $API/v1/withdrawals \\\n  -H \"Authorization: Bearer $CRYPTOINFRA_KEY\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"external_user_id\":\"10482\",\"asset_id\":\"usdt_trc20\",\n       \"to_address\":\"TAs66Ct…\",\"amount\":\"25\",\"idempotency_key\":\"payout-8814\"}'\n```\n\nYou get `202` with `\"status\": \"queued\"`. `amount` is what the recipient receives.\n`idempotency_key` is your own order id: reuse it on every retry and you can never pay twice.\n\n## 6. Learn how it ended\n\nYour webhook gets `withdrawal.confirmed` (money delivered) or `withdrawal.failed` (hold\nrefunded, nothing lost). Before you tell the user it is done, read it once:\n\n```bash\ncurl -s $API/v1/withdrawals/186 \\\n  -H \"Authorization: Bearer $CRYPTOINFRA_KEY\"\n```\n\n`\"status\": \"confirmed\"` is the truth. Webhooks are a hint; the read is the answer.\n\nThat is the whole integration. Everything below is what people get wrong the first time.\n\n## Five mistakes, and the fix for each\n\n| Mistake | Fix |\n| --- | --- |\n| Matching a deposit to an order by amount | Give each order its own user, or keep the order on your side. An address tells you *who* paid, never *what for* |\n| Crediting your user from the webhook alone | Reply `200`, then read `GET /v1/users/{id}/balances` and credit from that |\n| Retrying a payout with a new `idempotency_key` | Same key, every retry. A timeout that did create the payout returns it, not a second one |\n| Printing a fee from these docs | Quote it. Every fee is priced when you ask; the quote says whether it is live |\n| Waiting for a swap webhook | There is none. Poll `GET /v1/swap/orders/{id}` |\n\n## Where next\n\n- [Static Wallet Address](deposit-addresses) — everything about the address and what lands on it\n- [Payouts](payouts) — statuses, batches, what each error means\n- [Webhooks](webhooks) — verifying the signature, retries, the full event list\n- [Fees and pricing](fees) — the rule and the live price sheet\n- [Fee calculator](fee-calculator) — the sheet applied to any amount, live\n- [Errors](errors) and [Idempotency](idempotency) — the two conventions worth reading before go-live\n",
      "sections": [
        {
          "anchor": "1-tell-us-where-to-send-events",
          "heading": "1. Tell us where to send events",
          "text": "curl -sX POST $API/v1/developer/webhooks \\\n-H \"Authorization: Bearer $CRYPTOINFRA_KEY\" \\\n-H \"Content-Type: application/json\" \\\n-d '{\"url\":\"https://yourapp.example/cryptoinfra/webhook\"}'\n\nThe response has endpoint.secret. Store it now — it is never shown again. Every\nevent we send is signed with it (x-cryptoinfra-signature, HMAC-SHA256 of the raw body)."
        },
        {
          "anchor": "2-create-a-user-get-their-address",
          "heading": "2. Create a user, get their address",
          "text": "curl -sX POST $API/v1/users \\\n-H \"Authorization: Bearer $CRYPTOINFRA_KEY\" \\\n-H \"Content-Type: application/json\" \\\n-d '{\"external_user_id\":\"10482\"}'\n\n{ \"user_id\": \"10482\", \"addresses\": [ { \"chain\": \"tron\", \"address\": \"TJYBSfZf…\" } ] }\n\nexternal_user_id is the id this user has in your system: a number, 5 to 10 digits.\nThe address is permanent. Show it to the user once, save it if you like — it never changes.\nCalling this again with the same id is safe: you get the same user and the same address."
        },
        {
          "anchor": "3-money-arrives-we-tell-you",
          "heading": "3. Money arrives — we tell you",
          "text": "When the user sends USDT, USDC or TRX to that address and it confirms, your webhook gets:\n\n{\n\"userExternalId\": \"10482\",\n\"assetId\": \"usdt_trc20\",\n\"amount\": \"<decimal string>\",\n\"amountCredited\": \"<decimal string>\",\n\"txid\": \"<txid>\",\n\"fromAddress\": \"<who sent it, T…>\"\n}\n\nwith header x-cryptoinfra-event: deposit.credited. Deposits are free, so\namountCredited equals amount unless you set a fee of your own. Reply 200 fast; do the\nwork after."
        },
        {
          "anchor": "4-read-the-balance",
          "heading": "4. Read the balance",
          "text": "curl -s $API/v1/users/10482/balances \\\n-H \"Authorization: Bearer $CRYPTOINFRA_KEY\"\n\n{ \"balances\": [ { \"asset_id\": \"usdt_trc20\", \"symbol\": \"USDT\", \"amount\": \"<decimal string>\" } ] }\n\nThis is the number to trust. It is already net of anything in flight."
        },
        {
          "anchor": "5-quote-a-payout-then-send-it",
          "heading": "5. Quote a payout, then send it",
          "text": "curl -s \"$API/v1/withdrawal-fee-quote?asset_id=usdt_trc20&amount=25&user_id=10482&to_address=TAs66Ct…\" \\\n-H \"Authorization: Bearer $CRYPTOINFRA_KEY\"\n\n{ \"total_fee\": \"<decimal string>\", \"network_fee\": \"<decimal string>\", \"platform_fee\": \"<decimal string>\", \"dynamic\": true }\n\nShow the user total_fee. It is charged on top of the amount, so the balance must cover\namount + total_fee. No number is printed here on purpose: the fee is priced the moment you\nask and moves with the network — always read it from the quote, never from an example.\n\ncurl -sX POST $API/v1/withdrawals \\\n-H \"Authorization: Bearer $CRYPTOINFRA_KEY\" \\\n-H \"Content-Type: application/json\" \\\n-d '{\"external_user_id\":\"10482\",\"asset_id\":\"usdt_trc20\",\n\"to_address\":\"TAs66Ct…\",\"amount\":\"25\",\"idempotency_key\":\"payout-8814\"}'\n\nYou get 202 with \"status\": \"queued\". amount is what the recipient receives.\nidempotency_key is your own order id: reuse it on every retry and you can never pay twice."
        },
        {
          "anchor": "6-learn-how-it-ended",
          "heading": "6. Learn how it ended",
          "text": "Your webhook gets withdrawal.confirmed (money delivered) or withdrawal.failed (hold\nrefunded, nothing lost). Before you tell the user it is done, read it once:\n\ncurl -s $API/v1/withdrawals/186 \\\n-H \"Authorization: Bearer $CRYPTOINFRA_KEY\"\n\n\"status\": \"confirmed\" is the truth. Webhooks are a hint; the read is the answer.\n\nThat is the whole integration. Everything below is what people get wrong the first time."
        },
        {
          "anchor": "five-mistakes-and-the-fix-for-each",
          "heading": "Five mistakes, and the fix for each",
          "text": "Mistake · Fix\nMatching a deposit to an order by amount · Give each order its own user, or keep the order on your side. An address tells you who paid, never what for\nCrediting your user from the webhook alone · Reply 200, then read GET /v1/users/{id}/balances and credit from that\nRetrying a payout with a new idempotency_key · Same key, every retry. A timeout that did create the payout returns it, not a second one\nPrinting a fee from these docs · Quote it. Every fee is priced when you ask; the quote says whether it is live\nWaiting for a swap webhook · There is none. Poll GET /v1/swap/orders/{id}"
        },
        {
          "anchor": "where-next",
          "heading": "Where next",
          "text": "Static Wallet Address — everything about the address and what lands on it\nPayouts — statuses, batches, what each error means\nWebhooks — verifying the signature, retries, the full event list\nFees and pricing — the rule and the live price sheet\nFee calculator — the sheet applied to any amount, live\nErrors and Idempotency — the two conventions worth reading before go-live"
        }
      ]
    },
    {
      "slug": "authentication",
      "title": "Authentication",
      "summary": "One bearer API key authenticates the merchant on every /v1 endpoint — except the public price sheet, a merchant's public affiliate ledger and the health checks.",
      "kind": "guide",
      "markdown": "# Authentication\n\nEvery `/v1` endpoint is authenticated with a single credential: your tenant API key,\npresented as a bearer token.\n\n```bash\ncurl https://api.cryptoinfra.build/v1/assets \\\n  -H 'Authorization: Bearer ck_live_your_key'\n```\n\nThere is no OAuth flow, no per-user token, and no signature on outbound requests. One\nkey, one header, on every call.\n\nYour own dashboard talks to the same API with a session cookie instead of the key. A few\nactions that need a second factor — rotating the key, inviting teammates, approving parked\npayouts, moving your own balance — exist only there, and answer `403 session_required` to\nan API key. Within the dashboard, `403 forbidden_role` means your login's role does not\ncover that action.\n\n## The key authenticates you, not your user\n\nThis is the part that most often goes wrong in a first integration.\n\nThe key identifies **the merchant**. It is scoped to your tenant, and it grants full access\nto every user, balance and payout under that tenant. An `external_user_id` in a path or a\nbody is only a *selector* — it carries no authority, and passing a different one does not\nreduce what the caller can do.\n\n> **Warning** Never ship the API key to a browser, a mobile app, or anything else your end\n> users control. Every call must originate from your own server. A leaked key can drain\n> every balance you custody.\n\n## Key format and lifecycle\n\nKeys look like `ck_…`. You mint and rotate your own from the dashboard (**Settings →\nAPI keys**), and the plaintext is shown **exactly once** — the platform stores only a hash,\nso a lost key is replaced, never recovered. Rotating needs your authenticator code and the\ncode we email you, like every change made on the dashboard; there is no way to obtain a\nkey without both. Signup does not hand you a key: generate it from the dashboard once\nyour account is set up.\n\nA missing, malformed or unknown key gets the same answer, deliberately:\n\n```json\n{ \"error\": \"unauthorized\" }\n```\n\nwith HTTP `401`. The response never distinguishes \"no key\" from \"wrong key\", so an\nattacker learns nothing from probing.\n\nA key that is real but whose account is not live answers `403` with a code that says\nwhy, because there is nothing to hide from its owner:\n\n| Code | Meaning | What to do |\n| --- | --- | --- |\n| `email_unverified` | The owner login has not verified its email | Open the link in your inbox, or ask for a new one from the sign-in page |\n| `account_paused` | An operator put the account on hold | Sign in; the dashboard says why. Nothing to retry |\n| `account_suspended` | The account was closed by the operator | Write to support |\n\nAll three answer every call on the key until the state changes on our side; nothing in\nthe request will fix them.\n\nRotating is one step: mint the new key on the dashboard, deploy it, confirm traffic is\nflowing on it. The old key stops working the moment the new one is minted — there is no\noverlap window — so have the new key ready to deploy, rotate during a window you can\nwatch, and expect `401` from anything still on the old key until you do. Every owner\nlogin is emailed when the key changes, whether a teammate rotated it or the operator did.\n\n## Your account and your team\n\nAn account is `active` from signup — there is no approval step. The operator can put it\non `paused` hold, or close it as `suspended`. A paused account keeps its dashboard: you\ncan read, register webhooks and turn the key while the hold lasts — not create users,\nmove money, open a card programme or invite teammates — and the key answers `200` again\nthe moment it is lifted. A suspended account reaches nothing.\nBefore any of that comes the email: an owner login that has not verified its address\ncannot sign in and cannot see, generate or rotate the API key (`403 email_unverified`),\nand a key on such an account answers the same.\n\nDashboard logins carry a role — `owner`, `finance`, `developer` or `viewer`. The API key\nis generated, rotated and inspected (`rotated_at`) by owner and developer logins only;\nmoney out and payout approvals are owner and finance; the team itself is owner-only. A\nlogin outside the role gets `403 forbidden_role`.\n\n## Signing in to the dashboard\n\nThe dashboard is a separate credential from the API key: email, password, and an\nauthenticator app you enrol at first sign-in. Every change made there — a payout, a swap,\na key rotation, a teammate, an approval — asks for the authenticator code **and** a\n6-digit code sent to your email, bound to that action and good for ten minutes. Security\nnotices for every change to your login (password, authenticator, email, keys, teammates,\naccess) are emailed and cannot be turned off.\n\n## What is outside the auth scope\n\n4 operations are declared `security: []` in the contract — they take\nno API key at all. They fall into three groups.\n\n| Endpoint | Summary |\n| --- | --- |\n| [`GET /v1/a/{token}`](reference#get-v1-a-token) | PUBLIC — a merchant's affiliate ledger |\n| [`GET /v1/pricing`](reference#get-v1-pricing) | PUBLIC — the live price sheet |\n| [`GET /healthz`](system#get-healthz) | Liveness |\n| [`GET /readyz`](system#get-readyz) | Readiness |\n\n\n**`GET /v1/a/{token}` — a merchant's public affiliate ledger.** The page behind the link\non your Profit page (`/a/{token}` on the merchant dashboard's origin). There is no key\nbecause there is no merchant on the reading side: the token is 256 bits and holding it is\nthe authorization to read that one ledger — your share of our fee, total and line by line,\nwith our own reference ids and nothing about your customers. Treat the link as you would\nany share link: hand it to whom you want, and replace it from the dashboard (owner, both\ncodes) if it gets around; the old one stops working at once.\n\n**`GET /v1/pricing` — the public price sheet.** Every price the platform charges, read\nlive and cached for a minute, with no key: the documentation renders its tables from it,\nand so can your own pricing page. It carries the platform's schedule only — your own\nrates, if your account has been given any, are on `GET /v1/fees` with your key.\n\nThe webhooks cryptoinfra sends *to you* are not an endpoint of ours at all: you register\nyour own URLs under `POST /v1/developer/webhooks` and each delivery is signed with that\nendpoint's HMAC secret. See [Webhooks](webhooks).\n\n**`/healthz` and `/readyz` — liveness and readiness.** Unauthenticated by design so an\nuptime monitor can reach them. They carry no tenant data.\n\n## Failure modes worth handling\n\n| Symptom | Cause | What to do |\n| --- | --- | --- |\n| `401 unauthorized` on every call | Key missing or wrong | Check the header spelling and the `Bearer ` prefix; if the key is lost, generate a new one from the dashboard |\n| `403 email_unverified` / `account_paused` / `account_suspended` on every call | Your account is not live | Nothing to fix in the request; verify the email, or sign in to the dashboard to see the state |\n| `403 session_required` / `forbidden_role` | The action needs a dashboard login, or a different role on it | Do it from the dashboard with an owner (or the named role) login |\n| `401` only on some calls | You are hitting a second environment with the wrong key | Keys are per-tenant and per-environment; they are not interchangeable |\n| `404 not_found` where you expected `403` | The record belongs to another tenant | By design — cross-tenant reads are indistinguishable from \"does not exist\" |\n\nThat last row is a deliberate property of the API, not a bug: an unknown id and another\nmerchant's id return the same `404`, so you cannot enumerate anyone else's records.\n\n## Seeing your own traffic\n\n`GET /v1/developer/logs` returns the method, route, status and duration of your recent\ncalls, kept for 14 days. It records no request bodies and no query strings, so it is safe\nto read while debugging an integration — and it is the fastest way to confirm whether a\ncall reached the platform at all before you go hunting for the response.\n",
      "sections": [
        {
          "anchor": "the-key-authenticates-you-not-your-user",
          "heading": "The key authenticates you, not your user",
          "text": "This is the part that most often goes wrong in a first integration.\n\nThe key identifies the merchant. It is scoped to your tenant, and it grants full access\nto every user, balance and payout under that tenant. An external_user_id in a path or a\nbody is only a selector — it carries no authority, and passing a different one does not\nreduce what the caller can do.\n\nWarning Never ship the API key to a browser, a mobile app, or anything else your end\nusers control. Every call must originate from your own server. A leaked key can drain\nevery balance you custody."
        },
        {
          "anchor": "key-format-and-lifecycle",
          "heading": "Key format and lifecycle",
          "text": "Keys look like ck_…. You mint and rotate your own from the dashboard (Settings →\nAPI keys), and the plaintext is shown exactly once — the platform stores only a hash,\nso a lost key is replaced, never recovered. Rotating needs your authenticator code and the\ncode we email you, like every change made on the dashboard; there is no way to obtain a\nkey without both. Signup does not hand you a key: generate it from the dashboard once\nyour account is set up.\n\nA missing, malformed or unknown key gets the same answer, deliberately:\n\n{ \"error\": \"unauthorized\" }\n\nwith HTTP 401. The response never distinguishes \"no key\" from \"wrong key\", so an\nattacker learns nothing from probing.\n\nA key that is real but whose account is not live answers 403 with a code that says\nwhy, because there is nothing to hide from its owner:\n\nCode · Meaning · What to do\nemail_unverified · The owner login has not verified its email · Open the link in your inbox, or ask for a new one from the sign-in page\naccount_paused · An operator put the account on hold · Sign in; the dashboard says why. Nothing to retry\naccount_suspended · The account was closed by the operator · Write to support\n\nAll three answer every call on the key until the state changes on our side; nothing in\nthe request will fix them.\n\nRotating is one step: mint the new key on the dashboard, deploy it, confirm traffic is\nflowing on it. The old key stops working the moment the new one is minted — there is no\noverlap window — so have the new key ready to deploy, rotate during a window you can\nwatch, and expect 401 from anything still on the old key until you do. Every owner\nlogin is emailed when the key changes, whether a teammate rotated it or the operator did."
        },
        {
          "anchor": "your-account-and-your-team",
          "heading": "Your account and your team",
          "text": "An account is active from signup — there is no approval step. The operator can put it\non paused hold, or close it as suspended. A paused account keeps its dashboard: you\ncan read, register webhooks and turn the key while the hold lasts — not create users,\nmove money, open a card programme or invite teammates — and the key answers 200 again\nthe moment it is lifted. A suspended account reaches nothing.\nBefore any of that comes the email: an owner login that has not verified its address\ncannot sign in and cannot see, generate or rotate the API key (403 email_unverified),\nand a key on such an account answers the same.\n\nDashboard logins carry a role — owner, finance, developer or viewer. The API key\nis generated, rotated and inspected (rotated_at) by owner and developer logins only;\nmoney out and payout approvals are owner and finance; the team itself is owner-only. A\nlogin outside the role gets 403 forbidden_role."
        },
        {
          "anchor": "signing-in-to-the-dashboard",
          "heading": "Signing in to the dashboard",
          "text": "The dashboard is a separate credential from the API key: email, password, and an\nauthenticator app you enrol at first sign-in. Every change made there — a payout, a swap,\na key rotation, a teammate, an approval — asks for the authenticator code and a\n6-digit code sent to your email, bound to that action and good for ten minutes. Security\nnotices for every change to your login (password, authenticator, email, keys, teammates,\naccess) are emailed and cannot be turned off."
        },
        {
          "anchor": "what-is-outside-the-auth-scope",
          "heading": "What is outside the auth scope",
          "text": "4 operations are declared security: [] in the contract — they take\nno API key at all. They fall into three groups.\n\nEndpoint · Summary\nGET /v1/a/{token} · PUBLIC — a merchant's affiliate ledger\nGET /v1/pricing · PUBLIC — the live price sheet\nGET /healthz · Liveness\nGET /readyz · Readiness\n\nGET /v1/a/{token} — a merchant's public affiliate ledger. The page behind the link\non your Profit page (/a/{token} on the merchant dashboard's origin). There is no key\nbecause there is no merchant on the reading side: the token is 256 bits and holding it is\nthe authorization to read that one ledger — your share of our fee, total and line by line,\nwith our own reference ids and nothing about your customers. Treat the link as you would\nany share link: hand it to whom you want, and replace it from the dashboard (owner, both\ncodes) if it gets around; the old one stops working at once.\n\nGET /v1/pricing — the public price sheet. Every price the platform charges, read\nlive and cached for a minute, with no key: the documentation renders its tables from it,\nand so can your own pricing page. It carries the platform's schedule only — your own\nrates, if your account has been given any, are on GET /v1/fees with your key.\n\nThe webhooks cryptoinfra sends to you are not an endpoint of ours at all: you register\nyour own URLs under POST /v1/developer/webhooks and each delivery is signed with that\nendpoint's HMAC secret. See Webhooks.\n\n/healthz and /readyz — liveness and readiness. Unauthenticated by design so an\nuptime monitor can reach them. They carry no tenant data."
        },
        {
          "anchor": "failure-modes-worth-handling",
          "heading": "Failure modes worth handling",
          "text": "Symptom · Cause · What to do\n401 unauthorized on every call · Key missing or wrong · Check the header spelling and the Bearer  prefix; if the key is lost, generate a new one from the dashboard\n403 email_unverified / account_paused / account_suspended on every call · Your account is not live · Nothing to fix in the request; verify the email, or sign in to the dashboard to see the state\n403 session_required / forbidden_role · The action needs a dashboard login, or a different role on it · Do it from the dashboard with an owner (or the named role) login\n401 only on some calls · You are hitting a second environment with the wrong key · Keys are per-tenant and per-environment; they are not interchangeable\n404 not_found where you expected 403 · The record belongs to another tenant · By design — cross-tenant reads are indistinguishable from \"does not exist\"\n\nThat last row is a deliberate property of the API, not a bug: an unknown id and another\nmerchant's id return the same 404, so you cannot enumerate anyone else's records."
        },
        {
          "anchor": "seeing-your-own-traffic",
          "heading": "Seeing your own traffic",
          "text": "GET /v1/developer/logs returns the method, route, status and duration of your recent\ncalls, kept for 14 days. It records no request bodies and no query strings, so it is safe\nto read while debugging an integration — and it is the fastest way to confirm whether a\ncall reached the platform at all before you go hunting for the response."
        }
      ]
    },
    {
      "slug": "deposit-addresses",
      "title": "Static Wallet Address",
      "summary": "Assign each of your users one permanent TRON address that never rotates — every payment to it is credited to that user automatically, with no address generation per payment and no manual matching.",
      "kind": "guide",
      "markdown": "# Static Wallet Address\n\nA **Static Wallet Address** is one permanent crypto address assigned to each of your\nusers. Create the user once and you get the address back; it stays active and reusable for\nevery payment that user ever makes. It does not rotate, it does not expire, and it is not\nreissued.\n\n**What it replaces.** The classic gateway model mints a fresh address for every payment and\nmakes you hand the customer new instructions each time. A static address ends that loop:\n\n- **No address generation per payment.** One address per user, created once.\n- **Automatic attribution.** Whatever lands on the address is credited to that user — you\n  never match a payment by hand.\n- **Fewer mistakes, fewer tickets.** The customer saves one address in their wallet and\n  reuses it; there is no \"which address was this one\" question.\n- **Fully automated over the API.** Create, read, credit and pay out with the same key.\n- **A clean history.** Every deposit is already filed under the user it belongs to.\n\nYou can print the address on a receipt, save it to a customer's profile, or show it in an\napp, and it will still be correct in five years. Everything else on the platform —\nbalances, payouts, cards — is built on top of these addresses.\n\n```bash\ncurl -X POST https://api.cryptoinfra.build/v1/users \\\n  -H \"Authorization: Bearer $CRYPTOINFRA_API_KEY\" \\\n  -H \"content-type: application/json\" \\\n  -d '{\"external_user_id\": \"10482\"}'\n```\n\n```json\n{\n  \"user_id\": \"10482\",\n  \"addresses\": [\n    { \"chain\": \"tron\", \"address\": \"TJYBSfZfwwa28mYZPkE9uodBwGbnwEqBK1\" }\n  ]\n}\n```\n\n`external_user_id` is a number of 5 to 10 digits — the id this user has in your own\nsystem. Anything else is refused with `400 validation_error`.\n\nRead the static address again at any time with `GET /v1/users/{externalId}/addresses`. You\nnever need to store it yourself, though you may.\n\n## One static address, not one per coin\n\nThe platform runs on **one network, TRON**. A static address belongs to the chain, and every\nasset on it lands on the same one.\n\n| Chain | The one address accepts | Confirmations before credit |\n| --- | --- | --- |\n| `tron` | TRX, USDT (TRC20), USDC (TRC20) | 19 |\n\nSo a customer sending USDT on TRON and a customer sending TRX use the *same* string. The\nplatform tells them apart by what the transaction actually moved, not by where it was sent.\n\nThis is why an address alone cannot tell you **which order** was paid — see\n[Matching a payment to an order](#matching-a-payment-to-an-order) below.\n\n### What is open\n\n`GET /v1/assets` is the authority on what can be received right now. If an asset is not\nlisted there, it is not being watched, and **coins sent as it are not credited**. The\naddress list is shaped the same way: it is filtered to chains that can actually take money,\nwhich is `tron` alone. It is still a list, so read it as one — by `chain`, not by position —\nrather than assuming the shape of the array.\n\n## How a payment becomes a balance\n\n```\ncustomer sends  →  confirming  →  credited\n                                      ↓\n                             balance + deposit.credited webhook\n```\n\n1. **`confirming`** — the transaction is in a block and the platform is counting\n   confirmations against the number in the table above. Nothing is credited yet. A chain\n   reorg here moves the deposit to `orphaned` and it is never credited.\n2. **`credited`** — the balance moves, in the double-entry ledger, and\n   [`deposit.credited`](webhooks) fires.\n\nA deposit below the asset's minimum is written as `ignored_dust` instead (see below). The\ncontract also lists a `seen` status; it is legacy and no deposit is created in it.\n\nYou do not poll a chain and you do not run a node. Watch for the webhook, or read\n`GET /v1/users/{externalId}/balances`.\n\n### There is no deposit fee\n\n**A deposit to an address we issue is credited in full.** A 100 USDT deposit credits\n100.00 USDT; the only thing ever taken out of it is your own fee, if you have set one\n(`merchant_fee` on the deposit). A deposit below `min_deposit` is never credited:\nit is marked `ignored_dust` — a dust floor, not a charge.\n`GET /v1/fees` returns your live schedule (`deposit.total` is `0`); read it rather than\nhard-coding anything.\n\nEvery deposit row carries both figures, and they are not interchangeable — `amount` is what\nthe customer sent, `amount_credited` (with its `amount_credited_base` twin) is what reached\nthe balance. Reconcile a balance against `amount_credited` and reconcile a customer's claim\nagainst `amount`. The `deposit.credited` webhook spells the same field `amountCredited`.\n\n**Who sent it.** Every deposit carries `from_address` (the webhook: `fromAddress`). For USDT\nand USDC it is the `from` of the token Transfer that paid the address — the wallet the money\ncame from, even when an exchange paid out through a batch or proxy contract signed by an\noperator address. For TRX it is the sender. Show it to your user as \"received from\"; there is\nno need to look the txid up on an explorer.\n\n### Amounts too small to bother with\n\nA deposit below an asset's `min_deposit` is marked `ignored_dust` and never credited: the\nfee to move a few hundred base units exceeds the units themselves, so crediting it would\ncost more than it is worth. `min_deposit` is per asset in `/v1/assets`;\nread it from there rather than from this page, and treat `0` as \"no floor\".\n\n## Matching a payment to an order\n\nA static address is shared across every payment that customer ever makes, so an arriving\ndeposit tells you *who* paid, never *what for*. The address is a running balance per\ncustomer — top-ups, wallets, exchange accounts, anything where money is just credit.\n\nIf you are billing per order, do **not** try to match on amount alone against a shared\naddress — two customers paying 25 USDT in the same minute are indistinguishable. Give each\norder its own user (a user is free and permanent) and credit the order from that user's\n`deposit.credited` event, or keep the order on your side and let the customer spend from\ntheir balance.\n\n## Ways money is actually lost\n\nThese are the failure modes that cost real customers real coins. All of them are the\ncustomer's action, and none of them can be reversed by us, so the wording on your own\ncheckout matters more than anything in this API.\n\n**Wrong network, right-looking address.** USDT exists on many chains and wallets happily\nsend the wrong one. USDT sent over any network other than TRON does not arrive and cannot be\nrecovered. Always print the network next to the address — \"TRON (TRC20) only\" — not just the\ncoin name.\n\n**An asset that is not open.** Covered [above](#what-is-open): if it is not in\n`/v1/assets`, it is not watched.\n\n**A token nobody listed.** The TRON address accepts TRX, USDT and USDC. A different TRC20\ntoken sent to it is not credited — the address is watched for the assets in `/v1/assets` and\nnothing else.\n\n**A stale address from your own database.** A static address never changes, so this should\nbe impossible — but if you cache them, cache them against the `external_user_id` you asked\nfor. Showing customer A the address of customer B credits B.\n\n## What happens behind the static address\n\nTwo things worth knowing, both invisible to you:\n\n**Keys are not on the API server.** Addresses are derived from watch-only extended public\nkeys. The server that answers these requests can generate an address and can never spend from\none.\n\n**The ledger is the balance, not the chain.** A user's coins sit at their own address and\ntheir payouts leave from it, so the two usually agree — but the ledger is net of holds and\nof fees the moment they are charged, while the chain catches up a few seconds later, and\nbalances that existed before 4 September 2026 live in the platform's own wallets. Read\n`GET /v1/users/{externalId}/balances`; a block explorer will mislead you here, and it is\nthe single most common support question merchants receive. Since 19 September 2026 that\nresponse also carries `on_chain` per asset — what the address holds at that moment — so\nthe two figures sit side by side. They differ by design: our fees leave the address for\nthe platform's profit wallet as they settle, and earnings (your commission, your own fees)\nare credited to the balance from there. `amount` is what a payout may spend.\n\n## Reference\n\n- [`POST /v1/users`](users) — create a user, get their static wallet address\n- [`GET /v1/users/{externalId}/addresses`](users) — read it again\n- [`GET /v1/users/{externalId}/balances`](balances-deposits) — the authoritative balance\n- [`GET /v1/users/{externalId}/deposits`](balances-deposits) — one user's deposit history with both amounts\n- `GET /v1/deposits` — every user's deposits, newest first, with `user_external_id` on each row; filters `status`, `asset_id`, `search` (txid, address or user id), `from`/`to` (IST days), `sort`/`dir`, `limit`/`offset`; dust transfers are hidden unless `dust=show`\n- [`GET /v1/assets`](reference) — what can be received, with `min_deposit` and confirmations\n- [`GET /v1/fees`](reference) — the live schedule (deposits are free; your own fee, if any)\n",
      "sections": [
        {
          "anchor": "one-static-address-not-one-per-coin",
          "heading": "One static address, not one per coin",
          "text": "The platform runs on one network, TRON. A static address belongs to the chain, and every\nasset on it lands on the same one.\n\nChain · The one address accepts · Confirmations before credit\ntron · TRX, USDT (TRC20), USDC (TRC20) · 19\n\nSo a customer sending USDT on TRON and a customer sending TRX use the same string. The\nplatform tells them apart by what the transaction actually moved, not by where it was sent.\n\nThis is why an address alone cannot tell you which order was paid — see\nMatching a payment to an order below.\n\nWhat is open\n\nGET /v1/assets is the authority on what can be received right now. If an asset is not\nlisted there, it is not being watched, and coins sent as it are not credited. The\naddress list is shaped the same way: it is filtered to chains that can actually take money,\nwhich is tron alone. It is still a list, so read it as one — by chain, not by position —\nrather than assuming the shape of the array."
        },
        {
          "anchor": "what-is-open",
          "heading": "What is open",
          "text": "GET /v1/assets is the authority on what can be received right now. If an asset is not\nlisted there, it is not being watched, and coins sent as it are not credited. The\naddress list is shaped the same way: it is filtered to chains that can actually take money,\nwhich is tron alone. It is still a list, so read it as one — by chain, not by position —\nrather than assuming the shape of the array."
        },
        {
          "anchor": "how-a-payment-becomes-a-balance",
          "heading": "How a payment becomes a balance",
          "text": "customer sends  →  confirming  →  credited\n↓\nbalance + deposit.credited webhook\n\nconfirming — the transaction is in a block and the platform is counting\nconfirmations against the number in the table above. Nothing is credited yet. A chain\nreorg here moves the deposit to orphaned and it is never credited.\ncredited — the balance moves, in the double-entry ledger, and\ndeposit.credited fires.\n\nA deposit below the asset's minimum is written as ignored_dust instead (see below). The\ncontract also lists a seen status; it is legacy and no deposit is created in it.\n\nYou do not poll a chain and you do not run a node. Watch for the webhook, or read\nGET /v1/users/{externalId}/balances.\n\nThere is no deposit fee\n\nA deposit to an address we issue is credited in full. A 100 USDT deposit credits\n100.00 USDT; the only thing ever taken out of it is your own fee, if you have set one\n(merchant_fee on the deposit). A deposit below min_deposit is never credited:\nit is marked ignored_dust — a dust floor, not a charge.\nGET /v1/fees returns your live schedule (deposit.total is 0); read it rather than\nhard-coding anything.\n\nEvery deposit row carries both figures, and they are not interchangeable — amount is what\nthe customer sent, amount_credited (with its amount_credited_base twin) is what reached\nthe balance. Reconcile a balance against amount_credited and reconcile a customer's claim\nagainst amount. The deposit.credited webhook spells the same field amountCredited.\n\nWho sent it. Every deposit carries from_address (the webhook: fromAddress). For USDT\nand USDC it is the from of the token Transfer that paid the address — the wallet the money\ncame from, even when an exchange paid out through a batch or proxy contract signed by an\noperator address. For TRX it is the sender. Show it to your user as \"received from\"; there is\nno need to look the txid up on an explorer.\n\nAmounts too small to bother with\n\nA deposit below an asset's min_deposit is marked ignored_dust and never credited: the\nfee to move a few hundred base units exceeds the units themselves, so crediting it would\ncost more than it is worth. min_deposit is per asset in /v1/assets;\nread it from there rather than from this page, and treat 0 as \"no floor\"."
        },
        {
          "anchor": "there-is-no-deposit-fee",
          "heading": "There is no deposit fee",
          "text": "A deposit to an address we issue is credited in full. A 100 USDT deposit credits\n100.00 USDT; the only thing ever taken out of it is your own fee, if you have set one\n(merchant_fee on the deposit). A deposit below min_deposit is never credited:\nit is marked ignored_dust — a dust floor, not a charge.\nGET /v1/fees returns your live schedule (deposit.total is 0); read it rather than\nhard-coding anything.\n\nEvery deposit row carries both figures, and they are not interchangeable — amount is what\nthe customer sent, amount_credited (with its amount_credited_base twin) is what reached\nthe balance. Reconcile a balance against amount_credited and reconcile a customer's claim\nagainst amount. The deposit.credited webhook spells the same field amountCredited.\n\nWho sent it. Every deposit carries from_address (the webhook: fromAddress). For USDT\nand USDC it is the from of the token Transfer that paid the address — the wallet the money\ncame from, even when an exchange paid out through a batch or proxy contract signed by an\noperator address. For TRX it is the sender. Show it to your user as \"received from\"; there is\nno need to look the txid up on an explorer."
        },
        {
          "anchor": "amounts-too-small-to-bother-with",
          "heading": "Amounts too small to bother with",
          "text": "A deposit below an asset's min_deposit is marked ignored_dust and never credited: the\nfee to move a few hundred base units exceeds the units themselves, so crediting it would\ncost more than it is worth. min_deposit is per asset in /v1/assets;\nread it from there rather than from this page, and treat 0 as \"no floor\"."
        },
        {
          "anchor": "matching-a-payment-to-an-order",
          "heading": "Matching a payment to an order",
          "text": "A static address is shared across every payment that customer ever makes, so an arriving\ndeposit tells you who paid, never what for. The address is a running balance per\ncustomer — top-ups, wallets, exchange accounts, anything where money is just credit.\n\nIf you are billing per order, do not try to match on amount alone against a shared\naddress — two customers paying 25 USDT in the same minute are indistinguishable. Give each\norder its own user (a user is free and permanent) and credit the order from that user's\ndeposit.credited event, or keep the order on your side and let the customer spend from\ntheir balance."
        },
        {
          "anchor": "ways-money-is-actually-lost",
          "heading": "Ways money is actually lost",
          "text": "These are the failure modes that cost real customers real coins. All of them are the\ncustomer's action, and none of them can be reversed by us, so the wording on your own\ncheckout matters more than anything in this API.\n\nWrong network, right-looking address. USDT exists on many chains and wallets happily\nsend the wrong one. USDT sent over any network other than TRON does not arrive and cannot be\nrecovered. Always print the network next to the address — \"TRON (TRC20) only\" — not just the\ncoin name.\n\nAn asset that is not open. Covered above: if it is not in\n/v1/assets, it is not watched.\n\nA token nobody listed. The TRON address accepts TRX, USDT and USDC. A different TRC20\ntoken sent to it is not credited — the address is watched for the assets in /v1/assets and\nnothing else.\n\nA stale address from your own database. A static address never changes, so this should\nbe impossible — but if you cache them, cache them against the external_user_id you asked\nfor. Showing customer A the address of customer B credits B."
        },
        {
          "anchor": "what-happens-behind-the-static-address",
          "heading": "What happens behind the static address",
          "text": "Two things worth knowing, both invisible to you:\n\nKeys are not on the API server. Addresses are derived from watch-only extended public\nkeys. The server that answers these requests can generate an address and can never spend from\none.\n\nThe ledger is the balance, not the chain. A user's coins sit at their own address and\ntheir payouts leave from it, so the two usually agree — but the ledger is net of holds and\nof fees the moment they are charged, while the chain catches up a few seconds later, and\nbalances that existed before 4 September 2026 live in the platform's own wallets. Read\nGET /v1/users/{externalId}/balances; a block explorer will mislead you here, and it is\nthe single most common support question merchants receive. Since 19 September 2026 that\nresponse also carries on_chain per asset — what the address holds at that moment — so\nthe two figures sit side by side. They differ by design: our fees leave the address for\nthe platform's profit wallet as they settle, and earnings (your commission, your own fees)\nare credited to the balance from there. amount is what a payout may spend."
        },
        {
          "anchor": "reference",
          "heading": "Reference",
          "text": "POST /v1/users — create a user, get their static wallet address\nGET /v1/users/{externalId}/addresses — read it again\nGET /v1/users/{externalId}/balances — the authoritative balance\nGET /v1/users/{externalId}/deposits — one user's deposit history with both amounts\nGET /v1/deposits — every user's deposits, newest first, with user_external_id on each row; filters status, asset_id, search (txid, address or user id), from/to (IST days), sort/dir, limit/offset; dust transfers are hidden unless dust=show\nGET /v1/assets — what can be received, with min_deposit and confirmations\nGET /v1/fees — the live schedule (deposits are free; your own fee, if any)"
        }
      ]
    },
    {
      "slug": "payouts",
      "title": "Payouts",
      "summary": "Send money out of a user's balance to any address — quote the exact fee, create the payout with an idempotency key, learn the outcome from the webhook and confirm it with the read.",
      "kind": "guide",
      "markdown": "# Payouts\n\nA payout is `POST /v1/withdrawals`: the platform holds the amount and the fee from the\nuser's balance, signs the transaction from the user's own address, broadcasts it, and tells\nyou how it ended. This page walks the flow once, in order. Every figure comes from the API,\nnever from this page: the [price sheet](fees) is live, and the quote is the price.\n\n**Everything here is server-to-server.** Your API key moves every balance on your account\n— call from your backend, never from a browser or an app.\n\n## 1. Quote it\n\n`GET /v1/withdrawal-fee-quote` with the asset, the amount, `to_address` and `user_id` returns\nwhat this exact send will cost. Three fields are all you need:\n\n| Field | What it is |\n| --- | --- |\n| `total_fee` | **what is held on top of `amount`**, and what you show the user |\n| `network_fee` | the part that is the network's price for this send, priced now |\n| `platform_fee` | the part that is the flat platform fee from your schedule |\n\nTwo more fields tell you how fresh the figure is; ignore them on day one:\n\n| Field | What it is |\n| --- | --- |\n| `dynamic` | false when no live price could be read and a conservative fallback applies — label the figure \"approx\" then |\n| `quoted_at` | when this quote was priced — every figure is computed for the response, never stored |\n\n(`recipient_priced` and `network_cost` are there for reconciliation; you never need them\nto charge a customer.)\n\nThe figure the quote returns is the one that is charged. Show `total_fee` before the user\nconfirms, and check `amount + total_fee` against their balance first: a payout the balance\ncannot cover is refused with `409 insufficient_balance` and nothing is held.\n\n## 2. Create it\n\n```\nPOST /v1/withdrawals\n{\n  \"external_user_id\": \"10482\",\n  \"asset_id\": \"usdt_trc20\",\n  \"to_address\": \"TJYBSfZfwwa28mYZPkE9uodBwGbnwEqBK1\",\n  \"amount\": \"25\",\n  \"idempotency_key\": \"payout-8814\"\n}\n```\n\n- `amount` is what the **recipient receives**; the fees are charged on top of it.\n- `asset_id` is an id from `GET /v1/assets`; `to_address` is validated for that asset's\n  chain (`400 invalid_address` otherwise) and the amount against the asset's decimals\n  (`400 invalid_amount` — more places than the asset supports is refused, never rounded).\n- **There is no minimum amount.** How much you send is your call; the only condition is\n  that the balance covers `amount + total_fee`. A zero or negative amount is\n  `400 amount_below_minimum`.\n- `idempotency_key` is required. Derive it from your own record (`payout-8814`), store it\n  with that record, and reuse it on every retry — see [Idempotency](idempotency).\n\nYou get **`202 Accepted`** with the withdrawal, `status: requested` or `queued`. The hold\nis placed now: the balance already reads lower.\n\n## 3. Watch it settle\n\n| Status | Meaning |\n| --- | --- |\n| `requested` → `queued` | accepted; waiting for the signer |\n| `awaiting_approval` | parked by your own maker-checker threshold (set under Settings → Security); an owner or finance login approves it from the Payouts page |\n| `signing` → `broadcast` → `confirming` | on its way; `txid` is on the row once broadcast |\n| `confirmed` | done — the recipient has the money |\n| `failed` | never broadcast; the hold is refunded automatically |\n| `reversed` | broadcast, then definitively failed on-chain; the hold is refunded and an operator reconciles |\n\nTwo webhooks carry the outcome: [`withdrawal.confirmed`](webhook-events#withdrawal-confirmed)\nand [`withdrawal.failed`](webhook-events#withdrawal-failed). A rejection from the approvals\nqueue arrives as `withdrawal.failed` too. Delivery is at-least-once, so deduplicate on the\nwithdrawal id — see [Webhooks](webhooks).\n\n**The read is the truth.** Before you tell the user anything final, read\n`GET /v1/withdrawals/{id}`. A webhook that did not arrive must not be able to strand a payout\nin your system: sweep your non-terminal payouts against the read on a schedule.\n\n## 4. When you do not know what happened\n\nA request that timed out may or may not have created the payout. Two safe moves, and they\nagree:\n\n- **Re-POST with the same `idempotency_key`.** Either it creates the payout or it returns the\n  existing one — with its status as it stands now, so the replay doubles as a status read.\n- **Or look the key up:** `GET /v1/withdrawals?idempotency_key=payout-8814` returns the\n  withdrawal, or `404` if none was ever created.\n\nNever retry with a fresh key. A key reused with *different* parameters answers\n`409 idempotency_conflict` — a collision in your key generation, not something to retry.\n\n## Many at once\n\n`POST /v1/withdrawals/batch` takes up to 50 sends in one call. Each item runs the same path\nas a single payout — fees and approval thresholds apply per item — and carries its own\n`idempotency_key`, so re-posting a batch after a timeout re-creates nothing. Items are\nindependent: one bad address never sinks the rest.\n\n## What can go wrong, and what to do\n\n| Answer | Meaning | What to do |\n| --- | --- | --- |\n| `400 invalid_address`, `invalid_amount`, `amount_below_minimum` | the request is wrong | fix it; nothing was held |\n| `400 self_transfer` | the recipient is the user's own deposit address | send elsewhere; nothing was held |\n| `409 insufficient_balance` | the balance cannot cover `amount + total_fee` | nothing was held; show the user the total |\n| `409 idempotency_conflict` | that key was used with different parameters | fix your key generation; do not retry |\n| `403 account_*` | your account is not live | nothing to fix in the request — see [Authentication](authentication) |\n| `503 chain_unavailable` | that asset's chain is not reachable right now | retry later, unchanged, with the same key |\n| `500` | ours | retry with backoff and the **same** key, then resolve with the key lookup |\n\n## Fees, in one line\n\nEvery payout is our live network cost plus a flat platform fee, charged on top of `amount`\nin the asset being sent. The [price sheet](fees#withdrawals) shows today's figures; the\nquote shows the exact one.\n\n## Checklist\n\n- [ ] Quote first; show `total_fee`; check the balance covers `amount + total_fee`\n- [ ] `idempotency_key` derived from your own record and reused on every retry\n- [ ] `202` handled as pending; the user told only after `confirmed` on the read\n- [ ] `withdrawal.failed` and `reversed` refund the hold — nothing for you to reverse\n- [ ] A reconciliation sweep over non-terminal payouts, whatever webhooks arrived\n",
      "sections": [
        {
          "anchor": "1-quote-it",
          "heading": "1. Quote it",
          "text": "GET /v1/withdrawal-fee-quote with the asset, the amount, to_address and user_id returns\nwhat this exact send will cost. Three fields are all you need:\n\nField · What it is\ntotal_fee · what is held on top of amount, and what you show the user\nnetwork_fee · the part that is the network's price for this send, priced now\nplatform_fee · the part that is the flat platform fee from your schedule\n\nTwo more fields tell you how fresh the figure is; ignore them on day one:\n\nField · What it is\ndynamic · false when no live price could be read and a conservative fallback applies — label the figure \"approx\" then\nquoted_at · when this quote was priced — every figure is computed for the response, never stored\n\n(recipient_priced and network_cost are there for reconciliation; you never need them\nto charge a customer.)\n\nThe figure the quote returns is the one that is charged. Show total_fee before the user\nconfirms, and check amount + total_fee against their balance first: a payout the balance\ncannot cover is refused with 409 insufficient_balance and nothing is held."
        },
        {
          "anchor": "2-create-it",
          "heading": "2. Create it",
          "text": "POST /v1/withdrawals\n{\n\"external_user_id\": \"10482\",\n\"asset_id\": \"usdt_trc20\",\n\"to_address\": \"TJYBSfZfwwa28mYZPkE9uodBwGbnwEqBK1\",\n\"amount\": \"25\",\n\"idempotency_key\": \"payout-8814\"\n}\n\namount is what the recipient receives; the fees are charged on top of it.\nasset_id is an id from GET /v1/assets; to_address is validated for that asset's\nchain (400 invalid_address otherwise) and the amount against the asset's decimals\n(400 invalid_amount — more places than the asset supports is refused, never rounded).\nThere is no minimum amount. How much you send is your call; the only condition is\nthat the balance covers amount + total_fee. A zero or negative amount is\n400 amount_below_minimum.\nidempotency_key is required. Derive it from your own record (payout-8814), store it\nwith that record, and reuse it on every retry — see Idempotency.\n\nYou get 202 Accepted with the withdrawal, status: requested or queued. The hold\nis placed now: the balance already reads lower."
        },
        {
          "anchor": "3-watch-it-settle",
          "heading": "3. Watch it settle",
          "text": "Status · Meaning\nrequested → queued · accepted; waiting for the signer\nawaiting_approval · parked by your own maker-checker threshold (set under Settings → Security); an owner or finance login approves it from the Payouts page\nsigning → broadcast → confirming · on its way; txid is on the row once broadcast\nconfirmed · done — the recipient has the money\nfailed · never broadcast; the hold is refunded automatically\nreversed · broadcast, then definitively failed on-chain; the hold is refunded and an operator reconciles\n\nTwo webhooks carry the outcome: withdrawal.confirmed\nand withdrawal.failed. A rejection from the approvals\nqueue arrives as withdrawal.failed too. Delivery is at-least-once, so deduplicate on the\nwithdrawal id — see Webhooks.\n\nThe read is the truth. Before you tell the user anything final, read\nGET /v1/withdrawals/{id}. A webhook that did not arrive must not be able to strand a payout\nin your system: sweep your non-terminal payouts against the read on a schedule."
        },
        {
          "anchor": "4-when-you-do-not-know-what-happened",
          "heading": "4. When you do not know what happened",
          "text": "A request that timed out may or may not have created the payout. Two safe moves, and they\nagree:\n\nRe-POST with the same idempotency_key. Either it creates the payout or it returns the\nexisting one — with its status as it stands now, so the replay doubles as a status read.\nOr look the key up: GET /v1/withdrawals?idempotency_key=payout-8814 returns the\nwithdrawal, or 404 if none was ever created.\n\nNever retry with a fresh key. A key reused with different parameters answers\n409 idempotency_conflict — a collision in your key generation, not something to retry."
        },
        {
          "anchor": "many-at-once",
          "heading": "Many at once",
          "text": "POST /v1/withdrawals/batch takes up to 50 sends in one call. Each item runs the same path\nas a single payout — fees and approval thresholds apply per item — and carries its own\nidempotency_key, so re-posting a batch after a timeout re-creates nothing. Items are\nindependent: one bad address never sinks the rest."
        },
        {
          "anchor": "what-can-go-wrong-and-what-to-do",
          "heading": "What can go wrong, and what to do",
          "text": "Answer · Meaning · What to do\n400 invalid_address, invalid_amount, amount_below_minimum · the request is wrong · fix it; nothing was held\n400 self_transfer · the recipient is the user's own deposit address · send elsewhere; nothing was held\n409 insufficient_balance · the balance cannot cover amount + total_fee · nothing was held; show the user the total\n409 idempotency_conflict · that key was used with different parameters · fix your key generation; do not retry\n403 account_* · your account is not live · nothing to fix in the request — see Authentication\n503 chain_unavailable · that asset's chain is not reachable right now · retry later, unchanged, with the same key\n500 · ours · retry with backoff and the same key, then resolve with the key lookup"
        },
        {
          "anchor": "fees-in-one-line",
          "heading": "Fees, in one line",
          "text": "Every payout is our live network cost plus a flat platform fee, charged on top of amount\nin the asset being sent. The price sheet shows today's figures; the\nquote shows the exact one."
        },
        {
          "anchor": "checklist",
          "heading": "Checklist",
          "text": "[ ] Quote first; show total_fee; check the balance covers amount + total_fee\n[ ] idempotency_key derived from your own record and reused on every retry\n[ ] 202 handled as pending; the user told only after confirmed on the read\n[ ] withdrawal.failed and reversed refund the hold — nothing for you to reverse\n[ ] A reconciliation sweep over non-terminal payouts, whatever webhooks arrived"
        }
      ]
    },
    {
      "slug": "swaps-and-exchange",
      "title": "Swaps and exchange",
      "summary": "Trade between two coins a user holds, or sell one and pay any of thousands of other coins out to an external address — quote, create, poll; there is no webhook.",
      "kind": "guide",
      "markdown": "# Swaps and exchange\n\nA swap and an exchange share one order shape and one fee, and differ in where the money lands:\n\n| | Swap | Exchange |\n| --- | --- | --- |\n| Sell side | one of our assets, from the user's balance | the same |\n| Buy side | one of our assets — named by our `asset_id` | any of thousands of coins — named by the exchange's own identifier |\n| Where it lands | credited to the user's balance | paid out to an address you supply |\n| Amounts on the buy side | exact, with `*_base` twins | decimal estimates only — no `*_base` |\n| Endpoints | `POST /v1/swap/quote`, `POST /v1/swap/orders` | `POST /v1/exchange/quote`, `POST /v1/exchange/orders` |\n\nBoth are read with `GET /v1/swap/orders/{id}`, and **neither emits a webhook**. Poll.\n\n## Swap\n\n1. **Quote.** `POST /v1/swap/quote` with `sell_asset_id`, `buy_asset_id` and `amount`. It\n   is indicative: no reservation, no hold, re-quoted at create time — `expires_at` is when\n   to ask again, not a price lock. `receive_estimate` is the estimate, `receive_minimum`\n   the floor this route guarantees; the user is credited **what actually arrives**, which\n   is why both are published.\n2. **Create.** `POST /v1/swap/orders` with the same fields plus `user_id` (and an\n   `idempotency_key`, optional but recommended). Answers `201` with the order.\n3. **Poll** `GET /v1/swap/orders/{id}` while `status` is not terminal:\n   `pending` → `sent` → `swapping` → `completed`, or `refunded` / `failed`.\n   `GET /v1/swap/orders/{id}/detail` adds the addresses, the send leg and a timeline.\n\nThe output lands on the user's own static wallet address and is credited to their balance; there\nis no destination to supply and none to validate.\n\n**USDT ⇄ TRX is traded on-chain, on TRON itself.** The trade is signed by the user's own\naddress and pays back to it, so it prices small amounts at the market rate — a few dollars\nswap as well as thousands. Its order goes `pending` → `sent` → `completed` within a minute or\ntwo; there is no `swapping` step. `receive_minimum` is a hard floor: if the market moves\nfurther before the trade lands, the trade reverts, the order ends `failed` and the held amount\nreturns to the balance in full — nothing is traded and nothing is charged. `received` is what\narrived, to the unit.\n\n## Exchange\n\n1. **Read the catalogue.** `GET /v1/exchange/assets` lists what can be bought —\n   thousands of coins, refreshed hourly — and `GET /v1/exchange/chains` where they can be\n   paid to. `asset` there is the exchange's identifier (`SOL.SOL`,\n   `ETH.USDC-0xa0b8…`), and it is exactly what `buy_asset` expects. Take it from the\n   catalogue; a value we cannot route is refused outright.\n2. **Quote.** `POST /v1/exchange/quote` with `sell_asset_id`, `buy_asset`, `amount` and\n   `destination_address` — the quote validates the address for that coin's chain.\n3. **Create.** `POST /v1/exchange/orders` with the same fields plus `user_id`. `201`.\n4. **Poll** `GET /v1/swap/orders/{id}` — the same read, the same statuses. `GET /v1/swap/orders`\n   lists both kinds.\n\nTreat the buy-side numbers of an exchange as **display estimates**: the platform does not\nmodel that coin's chain, so it publishes the decimal it was given and stops rather than\ninventing a base-unit scale. The authoritative outcome is the settled order, not the quote.\n\n## The fee\n\n**A percentage of the amount, with a dollar minimum, plus the order's own network cost**, on the sell side.\nIt is taken *out of* `amount` — `send_amount = amount − fee − merchant_fee` is published so the\nnumbers add up — and `fee` already includes the network part, shown on its own as\n`network_fee`. Today's figures are on the [price sheet](fees#swaps-and-exchanges); your own\nfee, if you set one, is `merchant_fee` on the quote and the order.\n\nAn amount the fee would swallow is refused with `400 invalid_amount`, and the message says\nwhere swaps start right now.\n\n## What can go wrong\n\n| Answer | Meaning |\n| --- | --- |\n| `400 invalid_pair` | one side is not swappable — `swappable: false` on `GET /v1/assets`; leave it out of your picker |\n| `400 no_route` | the exchange cannot carry this pair right now |\n| `400 invalid_amount` | more decimal places than the sell asset supports, or too small to cover the fee — the message names today's smallest amount |\n| `409 insufficient_balance` | the balance cannot cover `amount` |\n| `503 swap_disabled`, `swap_unavailable`, `exchange_error` | a dependency is down; retry later, unchanged |\n\n## Checklist\n\n- [ ] Buy-side identifiers for an exchange taken from `GET /v1/exchange/assets`, never constructed\n- [ ] `receive_minimum` shown as the floor, `receive_estimate` as the estimate\n- [ ] Orders polled on `GET /v1/swap/orders/{id}` — no webhook exists\n- [ ] Exchange buy-side amounts treated as display estimates until the order settles\n",
      "sections": [
        {
          "anchor": "swap",
          "heading": "Swap",
          "text": "Quote. POST /v1/swap/quote with sell_asset_id, buy_asset_id and amount. It\nis indicative: no reservation, no hold, re-quoted at create time — expires_at is when\nto ask again, not a price lock. receive_estimate is the estimate, receive_minimum\nthe floor this route guarantees; the user is credited what actually arrives, which\nis why both are published.\nCreate. POST /v1/swap/orders with the same fields plus user_id (and an\nidempotency_key, optional but recommended). Answers 201 with the order.\nPoll GET /v1/swap/orders/{id} while status is not terminal:\npending → sent → swapping → completed, or refunded / failed.\nGET /v1/swap/orders/{id}/detail adds the addresses, the send leg and a timeline.\n\nThe output lands on the user's own static wallet address and is credited to their balance; there\nis no destination to supply and none to validate.\n\nUSDT ⇄ TRX is traded on-chain, on TRON itself. The trade is signed by the user's own\naddress and pays back to it, so it prices small amounts at the market rate — a few dollars\nswap as well as thousands. Its order goes pending → sent → completed within a minute or\ntwo; there is no swapping step. receive_minimum is a hard floor: if the market moves\nfurther before the trade lands, the trade reverts, the order ends failed and the held amount\nreturns to the balance in full — nothing is traded and nothing is charged. received is what\narrived, to the unit."
        },
        {
          "anchor": "exchange",
          "heading": "Exchange",
          "text": "Read the catalogue. GET /v1/exchange/assets lists what can be bought —\nthousands of coins, refreshed hourly — and GET /v1/exchange/chains where they can be\npaid to. asset there is the exchange's identifier (SOL.SOL,\nETH.USDC-0xa0b8…), and it is exactly what buy_asset expects. Take it from the\ncatalogue; a value we cannot route is refused outright.\nQuote. POST /v1/exchange/quote with sell_asset_id, buy_asset, amount and\ndestination_address — the quote validates the address for that coin's chain.\nCreate. POST /v1/exchange/orders with the same fields plus user_id. 201.\nPoll GET /v1/swap/orders/{id} — the same read, the same statuses. GET /v1/swap/orders\nlists both kinds.\n\nTreat the buy-side numbers of an exchange as display estimates: the platform does not\nmodel that coin's chain, so it publishes the decimal it was given and stops rather than\ninventing a base-unit scale. The authoritative outcome is the settled order, not the quote."
        },
        {
          "anchor": "the-fee",
          "heading": "The fee",
          "text": "A percentage of the amount, with a dollar minimum, plus the order's own network cost, on the sell side.\nIt is taken out of amount — send_amount = amount − fee − merchant_fee is published so the\nnumbers add up — and fee already includes the network part, shown on its own as\nnetwork_fee. Today's figures are on the price sheet; your own\nfee, if you set one, is merchant_fee on the quote and the order.\n\nAn amount the fee would swallow is refused with 400 invalid_amount, and the message says\nwhere swaps start right now."
        },
        {
          "anchor": "what-can-go-wrong",
          "heading": "What can go wrong",
          "text": "Answer · Meaning\n400 invalid_pair · one side is not swappable — swappable: false on GET /v1/assets; leave it out of your picker\n400 no_route · the exchange cannot carry this pair right now\n400 invalid_amount · more decimal places than the sell asset supports, or too small to cover the fee — the message names today's smallest amount\n409 insufficient_balance · the balance cannot cover amount\n503 swap_disabled, swap_unavailable, exchange_error · a dependency is down; retry later, unchanged"
        },
        {
          "anchor": "checklist",
          "heading": "Checklist",
          "text": "[ ] Buy-side identifiers for an exchange taken from GET /v1/exchange/assets, never constructed\n[ ] receive_minimum shown as the floor, receive_estimate as the estimate\n[ ] Orders polled on GET /v1/swap/orders/{id} — no webhook exists\n[ ] Exchange buy-side amounts treated as display estimates until the order settles"
        }
      ]
    },
    {
      "slug": "cards-guide",
      "title": "Cards, end to end",
      "summary": "The whole card flow in order — register the holder, verify identity, read the shelf, issue a funded card, show it, load it, watch it spend, and close it — with what each step charges and what to do when a call answers 202 or 503.",
      "kind": "guide",
      "markdown": "# Cards, end to end\n\nA card is a VISA/Mastercard-network debit card issued to one of **your users**, funded\nfrom **that user's own balance** on cryptoinfra, and priced per programme and BIN. Every\ncost — issuing, each load, closing — is charged to the cardholder's balance and to nobody\nelse; your own fee, if you set one, lands in your balance in the same movement. This page\nwalks the flow once, in order. The exact numbers on every step come from the API, never\nfrom this page: see [Fees and pricing](fees) for today's price sheet and the reasoning.\n\n**Everything here is server-to-server.** Your API key operates every card on your\naccount — proxy from your backend, never call from a browser or an app.\n\n## Before you start\n\n- A user (`user_id` is your own reference for them, 5–10 digits) with a balance — see\n  [Static Wallet Address](deposit-addresses). A card is issued only from what that user holds.\n- Webhook endpoints registered (`POST /v1/developer/webhooks`) — card events are how you\n  learn that an asynchronous step finished. See [Webhooks](webhooks).\n- The product you will issue, read live from `GET /v1/cards/products` (step 3), never\n  hard-coded: codes, minimums and prices change without a deploy on your side.\n\n## 1. Register the holder\n\n`POST /v1/cards/holders` with `user_id`, `email`, and as much of `first_name`, `last_name`,\n`phone`, `date_of_birth`, `country`, `address`, `city`, `state`, `zip_code` as you have.\nAnswers `201` with the holder and a `kyc_status`. Idempotent per user: calling it again for\nthe same `user_id` returns the same holder. The same customer may already hold a card\nthrough another merchant with the same email: that never blocks yours. Nor does the\nnumber of cards they already hold with you — see\n[How many cards one user can hold](#how-many-cards-one-user-can-hold).\n\n## 2. Verify identity, when the product needs it\n\nRead `requires_kyc` on the product you intend to issue (step 3). When it is true:\n\n1. `POST /v1/kyc/start` with `user_id` (optional `language`) → a hosted verification `url`.\n   Send the user there. An already-verified user gets `status: approved` and no URL —\n   nobody is sent around twice.\n2. Poll `GET /v1/kyc/status?user_id=…` until `approved`. A `card.kyc` webhook also tells\n   you. `declined` and `review` are terminal until the user tries again.\n\nIssuing a product that needs verification before it is `approved` answers\n`409 kyc_not_approved` with the current status in `details.kyc_status`.\n\nSome programmes take a document dossier instead of the hosted flow —\n`GET /v1/cards/kyc/countries`, `POST /v1/cards/kyc/upload`, `POST /v1/cards/kyc`,\n`GET /v1/cards/kyc/status`. The product tells you which applies; you never need both.\n\n## 3. Read the shelf and quote the issue\n\n`GET /v1/cards/products` is the live catalogue. Each row carries the programme it belongs\nto (`core_id`), its `bin`, `code`, `card_nature` (VIRTUAL or PHYSICAL), and what the\ncardholder pays:\n\n| Field | What it is |\n| --- | --- |\n| `issue_fee` | our one-time fee to issue this product, on this programme |\n| `min_opening_balance` | the smallest `initial_amount` the issue accepts — a card is issued **funded** |\n| `load_fee_bps` / `load_fee_min` / `load_fee_flat` | our fee on every load: basis points of the amount (with its floor) plus a flat platform part, plus gas |\n| `close_fee` | our fee to close the card |\n| `issue_total` | everything the cardholder is debited to open the card at the minimum — budget from this |\n| `cardholder_fees` | what the card itself charges when used: per purchase, 3-D Secure, foreign currency, ATM, cancellation, dispute |\n\n**Read `core_id` and `bin` together.** A BIN number is unique only inside its issuer, so\nthe same number under another programme is a different card with different prices.\n\nFor any other opening balance, `GET /v1/cards/issue-quote?product_code=…&initial_amount=…`\nreturns `total` — our issue fee, yours, the opening balance and its load fee and gas,\ncharged **on top** of it. At the minimum that is `issue_fee + min_opening_balance + its\nload fee + gas` (`issue_total_at_minimum` on the public [price sheet](fees#card-fees)), and the\ncard opens holding the full minimum. The quote is priced by the same code that charges, so\nit cannot differ from the debit.\n\n## 4. Issue the card\n\n```\nPOST /v1/cards\n{\n  \"user_id\": \"804080379\",\n  \"core_id\": \"core-1\",\n  \"product_code\": \"100096\",\n  \"initial_amount\": \"5.00\",\n  \"idempotency_key\": \"issue-804080379-2026-09-03-a\",\n  \"name_on_card\": \"A HOLDER\"          // optional; not every card is printed with one\n}\n```\n\n- `201` — the card exists (`card` populated, `status_normalized: issuing`). It turns\n  `active` once the opening balance lands at the issuer, a few minutes later; a\n  `card.status` webhook tells you. **Do not read `issuing` on a 201 as an error.**\n  Until it turns `active`, `GET /v1/cards/{id}/balance` and `POST /v1/cards/{id}/reveal`\n  answer `409 card_issuing` — wait for the webhook or poll, then retry.\n- `202` — the application is still resolving (`card` is null); a `card.status` webhook or\n  the reconciler links it. Treat as pending.\n- `409 insufficient_balance` — the holder's balance is short of `total`. Nothing is\n  charged or applied for.\n- `400 card_core_required` — you hold more than one programme and sent no `core_id`;\n  `details.cores` lists them.\n- `409 provider_rejected` — definitive; retrying the identical request will not help.\n\n`idempotency_key` is required and replay-safe: a network failure is retried with the\n**same** key, and the second call returns the first result. See [Idempotency](idempotency).\n\n`delivery_address_id` (from `POST /v1/cards/delivery/address`) is required for PHYSICAL\nproducts; `GET /v1/cards/delivery/regions` lists where they can be mailed.\n\n### How many cards one user can hold\n\nAs many as you issue. We set no limit per user: a user who already holds cards gets the\nnext one the same way, under the same `user_id`.\n\n`limits.cards_per_user` on a product is a different number: the rail's ceiling for **one\naccount** on that BIN. Every card ever issued under that account counts towards it, closed\nones included. When the next card would go over it, we open a further account for the\nsame user and issue the card under that one, inside the same `POST /v1/cards`. There is\nnothing for you to call first. `GET /v1/cards?user_id=…` lists the user's cards across\nevery account they have.\n\n## 5. Show the card\n\n`GET /v1/cards/{id}?user_id=…` returns everything a screen needs and **no secrets**:\n`last4`, `status_normalized`, `balance.available` / `balance.pending`, `expire_date`, and\n`card_pan_url` — a link on our domain to that card's secure card page, good for about ten\nminutes.\n\n**To show the card number, CVV and expiry, put one iframe on your page.** For current cards\n(BINs 493 and 404) no API returns the raw details — the issuer's security policy does not\nallow it — so the card is shown on our card page, inside your app.\n\n1. When your user asks to see the card, call `POST /v1/cards/{id}/reveal` (POST, never GET)\n   with `user_id`. It answers `reveal_url`, with `pan`, `cvv` and `expiry` null.\n2. Put `reveal_url` in this iframe:\n\n```html\n<iframe src=\"REVEAL_URL\" title=\"Card details\"\n        style=\"display:block;width:100%;max-width:400px;aspect-ratio:1.589/1;border:0;border-radius:14px;color-scheme:light\"\n        referrerpolicy=\"no-referrer\" allow=\"clipboard-write\"></iframe>\n```\n\nThat is the whole integration. Our page shows a card in your company name and logo while it\nloads, then the card itself, with a pointer to the eye that shows the number. Your user never\nleaves your app, and there is nothing to style or size on your side.\n\nClosing the card and opening it again on the same screen? Hide the iframe instead of removing\nit, and it reopens instantly for the link's ten minutes — no new request, no loading.\n\n`card_pan_url` from `GET /v1/cards/{id}` opens the same page. Should a card ever carry raw\ndetails, reveal fills `pan`, `cvv` and `expiry` and leaves `reveal_url` null — branch on\n`pan`.\n\nChecklist:\n\n- Fetch a fresh link each time you show the card; it expires within ten minutes. Keep it\n  exactly as received.\n- Your page must be served over **HTTPS**.\n- No `sandbox` attribute on the iframe.\n- If your page sends a Content-Security-Policy, its `frame-src` must allow HTTPS frames\n  (`frame-src https:`).\n- Do not send `Cross-Origin-Embedder-Policy: require-corp` on that page.\n- In a mobile app, show the link in a WebView.\n\nNever log, cache or persist these links.\n\n**One-time codes.** A 3-D Secure challenge at checkout, and adding the card to a phone\nwallet, both ask the cardholder for a code — and the code comes from **us**, not by SMS.\nWhen a `card.wallet_otp` event arrives (it carries no code), or the cardholder is at a\ncheckout, read `GET /v1/cards/{id}/otp?user_id=…` and show `code` while they are looking:\nit is single-read and short-lived (`expires_in_seconds`), and `kind` says which of the two\nit is for. A payment code also carries `merchant_name` and `amount` when the issuer sends\nthem, so your customer can check it is their own checkout. Your server and your dashboard\neach get one read: fetching the code from your server does not hide it from the card's\npage in the dashboard, where it also appears live as it arrives.\n\n## 6. Load the card\n\nQuote first: `GET /v1/cards/topup-quote?amount=100&card_id=…&user_id=…` returns `fee`\n(ours), `gas`, `merchant_fee` (yours), `credited` and `total`. **`total` is the number to\nshow.** `gas` is the network's price for the send, computed when you ask, and `dynamic`\nsays whether it was live (`true`) or a conservative fallback (`false`) — label it \"live\" or\n\"approx\", never print it as a fixed charge. The tariff on `GET /v1/merchant/card-fees`\ncarries the same `gas`, `gas_dynamic` and `gas_priced_at`. The order and its `card.topup`\nwebhook carry the `gas` actually charged. **Fees are on top:**\n`credited` equals `amount` and is what lands on the card; `total` is what the balance is\ndebited. Loading `amount` puts exactly `amount` on the card and charges `amount + fee + gas` (`total` on the quote).\n\n```\nPOST /v1/cards/{id}/topup\n{ \"user_id\": \"804080379\", \"amount\": \"100\", \"idempotency_key\": \"load-804080379-0042\" }\n```\n\nAnswers **`202`** with a card order. The cardholder is charged now; the money is sent\non-chain to the card rail and the card is funded once the rail confirms it, so the order\ngoes `funding` → `requested` → `sent` → `confirmed` and takes minutes. A `card.topup`\nwebhook carries the outcome; `GET /v1/cards/orders/{order_id}` is the authoritative poll.\n**202 is pending, never failure.** Refund your user only on a definitive failure — a\n`409 provider_rejected` at request time, or an order whose `status` is `failed`; the\ncharge is reversed automatically then.\n\nA load below the product's minimum answers `400` naming it; a balance short of `total`\nanswers `409 insufficient_balance`; nothing is charged in either case. Show `total`\nbefore the user confirms, and check it against their balance first.\n\n### When a load fails\n\nThe **whole** charge — `amount`, `fee`, `gas` and your `merchant_fee`, i.e. `total` — goes\nback to the cardholder's balance, in the same moment the order turns `failed`. Nothing\nlands anywhere else: not on the card, not in your treasury. `provider_status` says where\nit failed: `send_failed` means the on-chain send to the rail reverted — that is on our\nside, and no coins left the cardholder's address either, so a block explorer and the\nbalance agree. `sent_on_chain` on the order is what the send was *for*, not what moved.\n\nEvery load and every issue with an opening balance carries `txid` — the hash of its\non-chain send — on `GET /v1/cards/orders/{id}` and on the `card.topup` / `card.status`\nwebhook, so your receipt can link to a block explorer the way it does for a deposit or a\npayout. It is null until the send is broadcast; a failed order keeps the reverted hash.\n\nSo after a failure the money is exactly where it was before the load: on the cardholder.\nThe cleanest next step is to load again from that balance — nothing has to be moved\nfirst. If you funded the cardholder from your own treasury just for this load and want\nit back, the only path is a payout (`POST /v1/withdrawals` from the cardholder to your\ntreasury address); like any payout its fee is charged on top, so a retry is cheaper than a\nround trip.\n\n## 7. Watch it spend\n\n`GET /v1/cards/{id}/transactions?user_id=…` is the live history — purchases, refunds,\nATM withdrawals, the card's own fees — and a `card.transaction` webhook arrives for every\npurchase, refund, fee, ATM withdrawal and pre-authorization as it happens.\n\nThe history's `type` / `status` and the webhook's `transaction.transType` /\n`transaction.transStatus` carry the same codes (a string in the history, a number in the\nwebhook):\n\n| Type | Meaning |\n| --- | --- |\n| `170` | card payment |\n| `171` | refund — `originalTransactionId` is the payment it refunds |\n| `172` | card fee |\n| `173` | ATM cash withdrawal |\n| `174` | pre-authorization |\n| `150` | card top-up (history only) |\n| `140` | card issue (history only) |\n\n| Status | Meaning |\n| --- | --- |\n| `1` | processing |\n| `2` | success |\n| `3` | failed — the webhook's `respCodeDesc` says why |\n\nA refund is its own transaction (`171`) pointing at the payment; the payment itself is not\nrewritten. These codes cover every card we issue today. The card's own charges (per purchase, 3-D Secure, foreign currency, ATM) are\n**inside** the transaction amount, not added to it: a $3.86 purchase with a $0.25 fee takes\n$3.86 from the card. `cardholder_fees` on the product is the schedule; the\n[fees page](fees) explains each line.\n\n`POST /v1/cards/{id}/freeze` and `/unfreeze` (with `user_id`) stop and resume spending\nwithout closing the card.\n\n## 8. Money only leaves a card by closing it\n\nThere is no partial unload. `POST /v1/cards/{id}/withdraw` is retired and answers\n`410 card_unload_retired`: an unload would credit the holder's balance with coins that never\nreach their own address. To return a card's balance, close the card (next section) — the\nwhole balance comes back to the holder's own address on-chain.\n\n## 9. Close the card\n\nA close is the only way value leaves a card, and it is **always accepted**: whatever the card\nholds, and whatever state our refund wallet is in, `DELETE /v1/cards/{id}` never fails for\nmoney reasons. Everything a close costs comes out of the balance the card returns — nothing\nis taken from the holder's wallet balance, which is usually empty by the time somebody closes\na card.\n\n> **These terms belong to one rail and one BIN.** Every number in this section — our close\n> fee, the issuer's cancellation charge, the rail's release charge, the settlement period, the\n> limits and the features — is what the card's own `(core_id, bin)` carries today. A card on a\n> different BIN, or on a different rail, can differ in all of them. Never copy a figure from\n> here into your app: read the close quote for the card in front of you, and show your\n> customer what comes back.\n\n### What it costs, and what your user gets back\n\n`GET /v1/cards/{id}/close-quote?user_id=…` answers both questions. `fee` is our flat part,\n`gas` the live network figure for the on-chain refund (`dynamic` says whether it was priced\nnow), `merchant_fee` yours, and `total` the three together. Beside them, `refund_quote` does\nthe arithmetic on the card's balance read live at that moment:\n\n```\ncard_balance\n  − issuer_fee        the issuer's cancellation charge, kept on the card\n  − rail_return_fee   what the card rail charges to release the rest to us\n  − close_fee         ours\n  − gas               the on-chain send\n  − merchant_fee      yours, if you set one\n  = refund            reaches the holder's own address\n```\n\nShow `refund` before they confirm. When `live` is `false` (and `card_balance` is `null`) the\nrail could not be asked at that moment: show no figure rather than a guessed one, and try\nagain.\n\n`minimum_card_balance` is always `0` — there is no amount a card must hold to be closed. A\ncard holding less than the issuer's charge, or nothing at all, closes like any other and the\nissuer keeps whatever is there.\n\n### Making the close\n\n`DELETE /v1/cards/{id}` with `user_id` in the body does the whole thing. **One call is enough.**\n\n1. A card holding more than the issuer's amount answers **`202 card_close_in_progress`**: its\n   balance is on its way to the rail (`details.order_id` is that withdraw order). **The card is\n   not closed yet** — a 202 is a close that has started, not one that has finished. Until it\n   finishes, `GET /v1/cards/{id}` carries `close_in_progress: true`.\n2. Within about a minute of that order settling, we finish the close ourselves: the fees are\n   charged once, the card terminates and the issuer keeps its amount. `card.status` tells you,\n   and the card then reads `status_normalized: closed`.\n\nA card at or below the issuer's amount closes on the first call (`200`). You may still call\nclose again yourself once the order is confirmed — that finishes it at once — and a `DELETE` on\na card we already closed answers `200` with the card, never an error. Nothing is ever charged\ntwice.\n\n> **If your client treats every 2xx as success**, make the close screen wait for\n> `status_normalized: closed` (or the `card.status` webhook), not for the first response. Only\n> then show the refund state below.\n\n### Where the refund is\n\nPaying the holder on-chain is a **state you can read**, never an error. `GET /v1/cards/{id}`\ncarries `close_refund` for a closed card:\n\n| `state` | What it means |\n| --- | --- |\n| `waiting` | The card's money is at the rail; we are bringing it on-chain. |\n| `approved` | Released for payment; the send is being made. |\n| `sending` | On-chain, waiting for confirmations. `txid` is set. |\n| `paid` | At the holder's own address. |\n| `held` | We have held it back; `reason` says why. Rare, and we will have contacted you. |\n\nThe block also carries `amount` (exactly the `refund` the quote showed), `closed_at`, and the\ntwo timings below. Show the state to your user; there is nothing for them to retry.\n\n### How long it takes\n\nCancelling a card opens the **issuer's settlement period**: the balance is not released until\nit ends, and the issuer states this can run to **45 days** (`refund_eta_max_days`). In\npractice it has settled within 24 to 48 hours (`refund_eta_typical`). Quote your customer the\nissuer's limit and let a fast refund be a pleasant surprise.\n\nOnce released, the balance reaches the holder's own address within\n**`refund_eta_working_days` working days** (7), paid from a wallet cryptoinfra funds by hand.\nUntil it lands the holder cannot load another card with that money — a load needs the coins at\ntheir own address, rule 2 above.\n\n## Your own fee, on top of ours\n\n`GET /v1/merchant/card-fees` shows ours, yours and what the cardholder pays;\n`PUT /v1/merchant/card-fees/{issue|load|close}` sets a percentage (`percent_bps`, 100 = 1%,\nat most 1000), a flat amount, or both; `DELETE` removes it. Every quote already includes\nit as `merchant_fee`, and it is credited to your balance in the same movement that charges\nthe cardholder.\n\n## Webhooks you will receive\n\n| Event | When |\n| --- | --- |\n| `card.kyc` | the identity verdict changed |\n| `card.status` | the card's status changed — issued, active, frozen, closed |\n| `card.topup` | a load settled or failed |\n| `card.withdraw` | a card→wallet withdrawal settled or failed — including the return that precedes a close |\n| `card.transaction` | the card was used |\n| `card.wallet_otp` | a one-time code is waiting — fetch it from `GET /v1/cards/{id}/otp`; the event never carries it |\n\nDelivery is at-least-once: deduplicate on the event name and the entity id\n(`card_id`, `order_id`). Verify `x-cryptoinfra-signature` over the raw body before parsing.\n\n## When a call answers 503\n\n`503` on any card call means **your request was valid and nothing was charged** — retry\nwith the same idempotency key. `error` says why: `provider_unavailable` (the rail did not\nanswer), `card_rail_unavailable` (that card's rail is down; other cards are unaffected),\n`rail_unfunded` (the platform could not fund the load right now), `cards_disabled` (the\nfeature is off for this environment — the sandbox has no card rail). A `409` is the\nopposite: definitive, and retrying the identical request will answer the same.\n\n## Checklist\n\n- [ ] Products, minimums and prices read from `GET /v1/cards/products` at run time\n- [ ] `core_id` sent on issue; (`core_id`, `bin`) used as the key for prices\n- [ ] Every money call carries an `idempotency_key` and retries with the same one\n- [ ] `202` handled as pending; the user refunded only on `failed` or `409 provider_rejected`\n- [ ] `total` from the quote shown before every issue, load and close\n- [ ] Secrets from `reveal` and `otp` shown once, never logged or stored\n- [ ] Webhooks verified over the raw body and deduplicated\n",
      "sections": [
        {
          "anchor": "before-you-start",
          "heading": "Before you start",
          "text": "A user (user_id is your own reference for them, 5–10 digits) with a balance — see\nStatic Wallet Address. A card is issued only from what that user holds.\nWebhook endpoints registered (POST /v1/developer/webhooks) — card events are how you\nlearn that an asynchronous step finished. See Webhooks.\nThe product you will issue, read live from GET /v1/cards/products (step 3), never\nhard-coded: codes, minimums and prices change without a deploy on your side."
        },
        {
          "anchor": "1-register-the-holder",
          "heading": "1. Register the holder",
          "text": "POST /v1/cards/holders with user_id, email, and as much of first_name, last_name,\nphone, date_of_birth, country, address, city, state, zip_code as you have.\nAnswers 201 with the holder and a kyc_status. Idempotent per user: calling it again for\nthe same user_id returns the same holder. The same customer may already hold a card\nthrough another merchant with the same email: that never blocks yours. Nor does the\nnumber of cards they already hold with you — see\nHow many cards one user can hold."
        },
        {
          "anchor": "2-verify-identity-when-the-product-needs-it",
          "heading": "2. Verify identity, when the product needs it",
          "text": "Read requires_kyc on the product you intend to issue (step 3). When it is true:\n\nPOST /v1/kyc/start with user_id (optional language) → a hosted verification url.\nSend the user there. An already-verified user gets status: approved and no URL —\nnobody is sent around twice.\nPoll GET /v1/kyc/status?user_id=… until approved. A card.kyc webhook also tells\nyou. declined and review are terminal until the user tries again.\n\nIssuing a product that needs verification before it is approved answers\n409 kyc_not_approved with the current status in details.kyc_status.\n\nSome programmes take a document dossier instead of the hosted flow —\nGET /v1/cards/kyc/countries, POST /v1/cards/kyc/upload, POST /v1/cards/kyc,\nGET /v1/cards/kyc/status. The product tells you which applies; you never need both."
        },
        {
          "anchor": "3-read-the-shelf-and-quote-the-issue",
          "heading": "3. Read the shelf and quote the issue",
          "text": "GET /v1/cards/products is the live catalogue. Each row carries the programme it belongs\nto (core_id), its bin, code, card_nature (VIRTUAL or PHYSICAL), and what the\ncardholder pays:\n\nField · What it is\nissue_fee · our one-time fee to issue this product, on this programme\nmin_opening_balance · the smallest initial_amount the issue accepts — a card is issued funded\nload_fee_bps / load_fee_min / load_fee_flat · our fee on every load: basis points of the amount (with its floor) plus a flat platform part, plus gas\nclose_fee · our fee to close the card\nissue_total · everything the cardholder is debited to open the card at the minimum — budget from this\ncardholder_fees · what the card itself charges when used: per purchase, 3-D Secure, foreign currency, ATM, cancellation, dispute\n\nRead core_id and bin together. A BIN number is unique only inside its issuer, so\nthe same number under another programme is a different card with different prices.\n\nFor any other opening balance, GET /v1/cards/issue-quote?product_code=…&initial_amount=…\nreturns total — our issue fee, yours, the opening balance and its load fee and gas,\ncharged on top of it. At the minimum that is `issue_fee + min_opening_balance + its\nload fee + gas (issue_total_at_minimum` on the public price sheet), and the\ncard opens holding the full minimum. The quote is priced by the same code that charges, so\nit cannot differ from the debit."
        },
        {
          "anchor": "4-issue-the-card",
          "heading": "4. Issue the card",
          "text": "POST /v1/cards\n{\n\"user_id\": \"804080379\",\n\"core_id\": \"core-1\",\n\"product_code\": \"100096\",\n\"initial_amount\": \"5.00\",\n\"idempotency_key\": \"issue-804080379-2026-09-03-a\",\n\"name_on_card\": \"A HOLDER\"          // optional; not every card is printed with one\n}\n\n201 — the card exists (card populated, status_normalized: issuing). It turns\nactive once the opening balance lands at the issuer, a few minutes later; a\ncard.status webhook tells you. Do not read issuing on a 201 as an error.\nUntil it turns active, GET /v1/cards/{id}/balance and POST /v1/cards/{id}/reveal\nanswer 409 card_issuing — wait for the webhook or poll, then retry.\n202 — the application is still resolving (card is null); a card.status webhook or\nthe reconciler links it. Treat as pending.\n409 insufficient_balance — the holder's balance is short of total. Nothing is\ncharged or applied for.\n400 card_core_required — you hold more than one programme and sent no core_id;\ndetails.cores lists them.\n409 provider_rejected — definitive; retrying the identical request will not help.\n\nidempotency_key is required and replay-safe: a network failure is retried with the\nsame key, and the second call returns the first result. See Idempotency.\n\ndelivery_address_id (from POST /v1/cards/delivery/address) is required for PHYSICAL\nproducts; GET /v1/cards/delivery/regions lists where they can be mailed.\n\nHow many cards one user can hold\n\nAs many as you issue. We set no limit per user: a user who already holds cards gets the\nnext one the same way, under the same user_id.\n\nlimits.cards_per_user on a product is a different number: the rail's ceiling for one\naccount on that BIN. Every card ever issued under that account counts towards it, closed\nones included. When the next card would go over it, we open a further account for the\nsame user and issue the card under that one, inside the same POST /v1/cards. There is\nnothing for you to call first. GET /v1/cards?user_id=… lists the user's cards across\nevery account they have."
        },
        {
          "anchor": "how-many-cards-one-user-can-hold",
          "heading": "How many cards one user can hold",
          "text": "As many as you issue. We set no limit per user: a user who already holds cards gets the\nnext one the same way, under the same user_id.\n\nlimits.cards_per_user on a product is a different number: the rail's ceiling for one\naccount on that BIN. Every card ever issued under that account counts towards it, closed\nones included. When the next card would go over it, we open a further account for the\nsame user and issue the card under that one, inside the same POST /v1/cards. There is\nnothing for you to call first. GET /v1/cards?user_id=… lists the user's cards across\nevery account they have."
        },
        {
          "anchor": "5-show-the-card",
          "heading": "5. Show the card",
          "text": "GET /v1/cards/{id}?user_id=… returns everything a screen needs and no secrets:\nlast4, status_normalized, balance.available / balance.pending, expire_date, and\ncard_pan_url — a link on our domain to that card's secure card page, good for about ten\nminutes.\n\nTo show the card number, CVV and expiry, put one iframe on your page. For current cards\n(BINs 493 and 404) no API returns the raw details — the issuer's security policy does not\nallow it — so the card is shown on our card page, inside your app.\n\nWhen your user asks to see the card, call POST /v1/cards/{id}/reveal (POST, never GET)\nwith user_id. It answers reveal_url, with pan, cvv and expiry null.\nPut reveal_url in this iframe:\n\n<iframe src=\"REVEAL_URL\" title=\"Card details\"\nstyle=\"display:block;width:100%;max-width:400px;aspect-ratio:1.589/1;border:0;border-radius:14px;color-scheme:light\"\nreferrerpolicy=\"no-referrer\" allow=\"clipboard-write\"></iframe>\n\nThat is the whole integration. Our page shows a card in your company name and logo while it\nloads, then the card itself, with a pointer to the eye that shows the number. Your user never\nleaves your app, and there is nothing to style or size on your side.\n\nClosing the card and opening it again on the same screen? Hide the iframe instead of removing\nit, and it reopens instantly for the link's ten minutes — no new request, no loading.\n\ncard_pan_url from GET /v1/cards/{id} opens the same page. Should a card ever carry raw\ndetails, reveal fills pan, cvv and expiry and leaves reveal_url null — branch on\npan.\n\nChecklist:\n\nFetch a fresh link each time you show the card; it expires within ten minutes. Keep it\nexactly as received.\nYour page must be served over HTTPS.\nNo sandbox attribute on the iframe.\nIf your page sends a Content-Security-Policy, its frame-src must allow HTTPS frames\n(frame-src https:).\nDo not send Cross-Origin-Embedder-Policy: require-corp on that page.\nIn a mobile app, show the link in a WebView.\n\nNever log, cache or persist these links.\n\nOne-time codes. A 3-D Secure challenge at checkout, and adding the card to a phone\nwallet, both ask the cardholder for a code — and the code comes from us, not by SMS.\nWhen a card.wallet_otp event arrives (it carries no code), or the cardholder is at a\ncheckout, read GET /v1/cards/{id}/otp?user_id=… and show code while they are looking:\nit is single-read and short-lived (expires_in_seconds), and kind says which of the two\nit is for. A payment code also carries merchant_name and amount when the issuer sends\nthem, so your customer can check it is their own checkout. Your server and your dashboard\neach get one read: fetching the code from your server does not hide it from the card's\npage in the dashboard, where it also appears live as it arrives."
        },
        {
          "anchor": "6-load-the-card",
          "heading": "6. Load the card",
          "text": "Quote first: GET /v1/cards/topup-quote?amount=100&card_id=…&user_id=… returns fee\n(ours), gas, merchant_fee (yours), credited and total. total is the number to\nshow. gas is the network's price for the send, computed when you ask, and dynamic\nsays whether it was live (true) or a conservative fallback (false) — label it \"live\" or\n\"approx\", never print it as a fixed charge. The tariff on GET /v1/merchant/card-fees\ncarries the same gas, gas_dynamic and gas_priced_at. The order and its card.topup\nwebhook carry the gas actually charged. Fees are on top:\ncredited equals amount and is what lands on the card; total is what the balance is\ndebited. Loading amount puts exactly amount on the card and charges amount + fee + gas (total on the quote).\n\nPOST /v1/cards/{id}/topup\n{ \"user_id\": \"804080379\", \"amount\": \"100\", \"idempotency_key\": \"load-804080379-0042\" }\n\nAnswers 202 with a card order. The cardholder is charged now; the money is sent\non-chain to the card rail and the card is funded once the rail confirms it, so the order\ngoes funding → requested → sent → confirmed and takes minutes. A card.topup\nwebhook carries the outcome; GET /v1/cards/orders/{order_id} is the authoritative poll.\n202 is pending, never failure. Refund your user only on a definitive failure — a\n409 provider_rejected at request time, or an order whose status is failed; the\ncharge is reversed automatically then.\n\nA load below the product's minimum answers 400 naming it; a balance short of total\nanswers 409 insufficient_balance; nothing is charged in either case. Show total\nbefore the user confirms, and check it against their balance first.\n\nWhen a load fails\n\nThe whole charge — amount, fee, gas and your merchant_fee, i.e. total — goes\nback to the cardholder's balance, in the same moment the order turns failed. Nothing\nlands anywhere else: not on the card, not in your treasury. provider_status says where\nit failed: send_failed means the on-chain send to the rail reverted — that is on our\nside, and no coins left the cardholder's address either, so a block explorer and the\nbalance agree. sent_on_chain on the order is what the send was for, not what moved.\n\nEvery load and every issue with an opening balance carries txid — the hash of its\non-chain send — on GET /v1/cards/orders/{id} and on the card.topup / card.status\nwebhook, so your receipt can link to a block explorer the way it does for a deposit or a\npayout. It is null until the send is broadcast; a failed order keeps the reverted hash.\n\nSo after a failure the money is exactly where it was before the load: on the cardholder.\nThe cleanest next step is to load again from that balance — nothing has to be moved\nfirst. If you funded the cardholder from your own treasury just for this load and want\nit back, the only path is a payout (POST /v1/withdrawals from the cardholder to your\ntreasury address); like any payout its fee is charged on top, so a retry is cheaper than a\nround trip."
        },
        {
          "anchor": "when-a-load-fails",
          "heading": "When a load fails",
          "text": "The whole charge — amount, fee, gas and your merchant_fee, i.e. total — goes\nback to the cardholder's balance, in the same moment the order turns failed. Nothing\nlands anywhere else: not on the card, not in your treasury. provider_status says where\nit failed: send_failed means the on-chain send to the rail reverted — that is on our\nside, and no coins left the cardholder's address either, so a block explorer and the\nbalance agree. sent_on_chain on the order is what the send was for, not what moved.\n\nEvery load and every issue with an opening balance carries txid — the hash of its\non-chain send — on GET /v1/cards/orders/{id} and on the card.topup / card.status\nwebhook, so your receipt can link to a block explorer the way it does for a deposit or a\npayout. It is null until the send is broadcast; a failed order keeps the reverted hash.\n\nSo after a failure the money is exactly where it was before the load: on the cardholder.\nThe cleanest next step is to load again from that balance — nothing has to be moved\nfirst. If you funded the cardholder from your own treasury just for this load and want\nit back, the only path is a payout (POST /v1/withdrawals from the cardholder to your\ntreasury address); like any payout its fee is charged on top, so a retry is cheaper than a\nround trip."
        },
        {
          "anchor": "7-watch-it-spend",
          "heading": "7. Watch it spend",
          "text": "GET /v1/cards/{id}/transactions?user_id=… is the live history — purchases, refunds,\nATM withdrawals, the card's own fees — and a card.transaction webhook arrives for every\npurchase, refund, fee, ATM withdrawal and pre-authorization as it happens.\n\nThe history's type / status and the webhook's transaction.transType /\ntransaction.transStatus carry the same codes (a string in the history, a number in the\nwebhook):\n\nType · Meaning\n170 · card payment\n171 · refund — originalTransactionId is the payment it refunds\n172 · card fee\n173 · ATM cash withdrawal\n174 · pre-authorization\n150 · card top-up (history only)\n140 · card issue (history only)\n\nStatus · Meaning\n1 · processing\n2 · success\n3 · failed — the webhook's respCodeDesc says why\n\nA refund is its own transaction (171) pointing at the payment; the payment itself is not\nrewritten. These codes cover every card we issue today. The card's own charges (per purchase, 3-D Secure, foreign currency, ATM) are\ninside the transaction amount, not added to it: a $3.86 purchase with a $0.25 fee takes\n$3.86 from the card. cardholder_fees on the product is the schedule; the\nfees page explains each line.\n\nPOST /v1/cards/{id}/freeze and /unfreeze (with user_id) stop and resume spending\nwithout closing the card."
        },
        {
          "anchor": "8-money-only-leaves-a-card-by-closing-it",
          "heading": "8. Money only leaves a card by closing it",
          "text": "There is no partial unload. POST /v1/cards/{id}/withdraw is retired and answers\n410 card_unload_retired: an unload would credit the holder's balance with coins that never\nreach their own address. To return a card's balance, close the card (next section) — the\nwhole balance comes back to the holder's own address on-chain."
        },
        {
          "anchor": "9-close-the-card",
          "heading": "9. Close the card",
          "text": "A close is the only way value leaves a card, and it is always accepted: whatever the card\nholds, and whatever state our refund wallet is in, DELETE /v1/cards/{id} never fails for\nmoney reasons. Everything a close costs comes out of the balance the card returns — nothing\nis taken from the holder's wallet balance, which is usually empty by the time somebody closes\na card.\n\nThese terms belong to one rail and one BIN. Every number in this section — our close\nfee, the issuer's cancellation charge, the rail's release charge, the settlement period, the\nlimits and the features — is what the card's own (core_id, bin) carries today. A card on a\ndifferent BIN, or on a different rail, can differ in all of them. Never copy a figure from\nhere into your app: read the close quote for the card in front of you, and show your\ncustomer what comes back.\n\nWhat it costs, and what your user gets back\n\nGET /v1/cards/{id}/close-quote?user_id=… answers both questions. fee is our flat part,\ngas the live network figure for the on-chain refund (dynamic says whether it was priced\nnow), merchant_fee yours, and total the three together. Beside them, refund_quote does\nthe arithmetic on the card's balance read live at that moment:\n\ncard_balance\n− issuer_fee        the issuer's cancellation charge, kept on the card\n− rail_return_fee   what the card rail charges to release the rest to us\n− close_fee         ours\n− gas               the on-chain send\n− merchant_fee      yours, if you set one\n= refund            reaches the holder's own address\n\nShow refund before they confirm. When live is false (and card_balance is null) the\nrail could not be asked at that moment: show no figure rather than a guessed one, and try\nagain.\n\nminimum_card_balance is always 0 — there is no amount a card must hold to be closed. A\ncard holding less than the issuer's charge, or nothing at all, closes like any other and the\nissuer keeps whatever is there.\n\nMaking the close\n\nDELETE /v1/cards/{id} with user_id in the body does the whole thing. One call is enough.\n\nA card holding more than the issuer's amount answers 202 card_close_in_progress: its\nbalance is on its way to the rail (details.order_id is that withdraw order). The card is\nnot closed yet — a 202 is a close that has started, not one that has finished. Until it\nfinishes, GET /v1/cards/{id} carries close_in_progress: true.\nWithin about a minute of that order settling, we finish the close ourselves: the fees are\ncharged once, the card terminates and the issuer keeps its amount. card.status tells you,\nand the card then reads status_normalized: closed.\n\nA card at or below the issuer's amount closes on the first call (200). You may still call\nclose again yourself once the order is confirmed — that finishes it at once — and a DELETE on\na card we already closed answers 200 with the card, never an error. Nothing is ever charged\ntwice.\n\nIf your client treats every 2xx as success, make the close screen wait for\nstatus_normalized: closed (or the card.status webhook), not for the first response. Only\nthen show the refund state below.\n\nWhere the refund is\n\nPaying the holder on-chain is a state you can read, never an error. GET /v1/cards/{id}\ncarries close_refund for a closed card:\n\nstate · What it means\nwaiting · The card's money is at the rail; we are bringing it on-chain.\napproved · Released for payment; the send is being made.\nsending · On-chain, waiting for confirmations. txid is set.\npaid · At the holder's own address.\nheld · We have held it back; reason says why. Rare, and we will have contacted you.\n\nThe block also carries amount (exactly the refund the quote showed), closed_at, and the\ntwo timings below. Show the state to your user; there is nothing for them to retry.\n\nHow long it takes\n\nCancelling a card opens the issuer's settlement period: the balance is not released until\nit ends, and the issuer states this can run to 45 days (refund_eta_max_days). In\npractice it has settled within 24 to 48 hours (refund_eta_typical). Quote your customer the\nissuer's limit and let a fast refund be a pleasant surprise.\n\nOnce released, the balance reaches the holder's own address within\nrefund_eta_working_days working days (7), paid from a wallet cryptoinfra funds by hand.\nUntil it lands the holder cannot load another card with that money — a load needs the coins at\ntheir own address, rule 2 above."
        },
        {
          "anchor": "what-it-costs-and-what-your-user-gets-back",
          "heading": "What it costs, and what your user gets back",
          "text": "GET /v1/cards/{id}/close-quote?user_id=… answers both questions. fee is our flat part,\ngas the live network figure for the on-chain refund (dynamic says whether it was priced\nnow), merchant_fee yours, and total the three together. Beside them, refund_quote does\nthe arithmetic on the card's balance read live at that moment:\n\ncard_balance\n− issuer_fee        the issuer's cancellation charge, kept on the card\n− rail_return_fee   what the card rail charges to release the rest to us\n− close_fee         ours\n− gas               the on-chain send\n− merchant_fee      yours, if you set one\n= refund            reaches the holder's own address\n\nShow refund before they confirm. When live is false (and card_balance is null) the\nrail could not be asked at that moment: show no figure rather than a guessed one, and try\nagain.\n\nminimum_card_balance is always 0 — there is no amount a card must hold to be closed. A\ncard holding less than the issuer's charge, or nothing at all, closes like any other and the\nissuer keeps whatever is there."
        },
        {
          "anchor": "making-the-close",
          "heading": "Making the close",
          "text": "DELETE /v1/cards/{id} with user_id in the body does the whole thing. One call is enough.\n\nA card holding more than the issuer's amount answers 202 card_close_in_progress: its\nbalance is on its way to the rail (details.order_id is that withdraw order). The card is\nnot closed yet — a 202 is a close that has started, not one that has finished. Until it\nfinishes, GET /v1/cards/{id} carries close_in_progress: true.\nWithin about a minute of that order settling, we finish the close ourselves: the fees are\ncharged once, the card terminates and the issuer keeps its amount. card.status tells you,\nand the card then reads status_normalized: closed.\n\nA card at or below the issuer's amount closes on the first call (200). You may still call\nclose again yourself once the order is confirmed — that finishes it at once — and a DELETE on\na card we already closed answers 200 with the card, never an error. Nothing is ever charged\ntwice.\n\nIf your client treats every 2xx as success, make the close screen wait for\nstatus_normalized: closed (or the card.status webhook), not for the first response. Only\nthen show the refund state below."
        },
        {
          "anchor": "where-the-refund-is",
          "heading": "Where the refund is",
          "text": "Paying the holder on-chain is a state you can read, never an error. GET /v1/cards/{id}\ncarries close_refund for a closed card:\n\nstate · What it means\nwaiting · The card's money is at the rail; we are bringing it on-chain.\napproved · Released for payment; the send is being made.\nsending · On-chain, waiting for confirmations. txid is set.\npaid · At the holder's own address.\nheld · We have held it back; reason says why. Rare, and we will have contacted you.\n\nThe block also carries amount (exactly the refund the quote showed), closed_at, and the\ntwo timings below. Show the state to your user; there is nothing for them to retry."
        },
        {
          "anchor": "how-long-it-takes",
          "heading": "How long it takes",
          "text": "Cancelling a card opens the issuer's settlement period: the balance is not released until\nit ends, and the issuer states this can run to 45 days (refund_eta_max_days). In\npractice it has settled within 24 to 48 hours (refund_eta_typical). Quote your customer the\nissuer's limit and let a fast refund be a pleasant surprise.\n\nOnce released, the balance reaches the holder's own address within\nrefund_eta_working_days working days (7), paid from a wallet cryptoinfra funds by hand.\nUntil it lands the holder cannot load another card with that money — a load needs the coins at\ntheir own address, rule 2 above."
        },
        {
          "anchor": "your-own-fee-on-top-of-ours",
          "heading": "Your own fee, on top of ours",
          "text": "GET /v1/merchant/card-fees shows ours, yours and what the cardholder pays;\nPUT /v1/merchant/card-fees/{issue · load · close} sets a percentage (percent_bps, 100 = 1%,\nat most 1000), a flat amount, or both; DELETE removes it. Every quote already includes\nit as merchant_fee, and it is credited to your balance in the same movement that charges\nthe cardholder."
        },
        {
          "anchor": "webhooks-you-will-receive",
          "heading": "Webhooks you will receive",
          "text": "Event · When\ncard.kyc · the identity verdict changed\ncard.status · the card's status changed — issued, active, frozen, closed\ncard.topup · a load settled or failed\ncard.withdraw · a card→wallet withdrawal settled or failed — including the return that precedes a close\ncard.transaction · the card was used\ncard.wallet_otp · a one-time code is waiting — fetch it from GET /v1/cards/{id}/otp; the event never carries it\n\nDelivery is at-least-once: deduplicate on the event name and the entity id\n(card_id, order_id). Verify x-cryptoinfra-signature over the raw body before parsing."
        },
        {
          "anchor": "when-a-call-answers-503",
          "heading": "When a call answers 503",
          "text": "503 on any card call means your request was valid and nothing was charged — retry\nwith the same idempotency key. error says why: provider_unavailable (the rail did not\nanswer), card_rail_unavailable (that card's rail is down; other cards are unaffected),\nrail_unfunded (the platform could not fund the load right now), cards_disabled (the\nfeature is off for this environment — the sandbox has no card rail). A 409 is the\nopposite: definitive, and retrying the identical request will answer the same."
        },
        {
          "anchor": "checklist",
          "heading": "Checklist",
          "text": "[ ] Products, minimums and prices read from GET /v1/cards/products at run time\n[ ] core_id sent on issue; (core_id, bin) used as the key for prices\n[ ] Every money call carries an idempotency_key and retries with the same one\n[ ] 202 handled as pending; the user refunded only on failed or 409 provider_rejected\n[ ] total from the quote shown before every issue, load and close\n[ ] Secrets from reveal and otp shown once, never logged or stored\n[ ] Webhooks verified over the raw body and deduplicated"
        }
      ]
    },
    {
      "slug": "money-and-amounts",
      "title": "Money and amounts",
      "summary": "Every amount is a decimal string paired with an exact integer *_base twin — why the pairing exists, where it is missing, and the rounding rules that are refusals rather than roundings.",
      "kind": "guide",
      "markdown": "# Money and amounts\n\nGetting money encoding wrong is the failure mode that costs real money and is discovered\nlate. The API is designed so that the correct thing is available on every response — but\nit is available *alongside* the convenient thing, and it is easy to reach for the wrong\none.\n\n## The rule\n\nEvery monetary value in a response is returned **twice**:\n\n- a human-readable **decimal string** — `\"1.5\"`, in the asset's major units, and\n- an exact **integer base-unit string** in a sibling field suffixed `_base` —\n  `\"1500000\"`, in sun or the token's own smallest unit.\n\n```json\n{\n  \"asset_id\": \"usdt_trc20\",\n  \"amount\": \"1.5\",\n  \"amount_base\": \"1500000\"\n}\n```\n\n**Compute with `_base`. Display `amount`.** That is the whole rule.\n\n`_base` is a *string* rather than a number because base units can exceed\n`Number.MAX_SAFE_INTEGER` — a balance of ten billion USDT is `10000000000000000` base units,\nalready past the point where IEEE-754 doubles stop being exact. Parse it into\nyour language's big-integer type (`BigInt` in JavaScript, `int` in Python, `decimal.Decimal`\nor a 128-bit integer elsewhere) and never let it become a float, not even in transit\nthrough `JSON.parse`.\n\n```js\n// wrong — silently lossy at scale\nconst total = Number(a.amount) + Number(b.amount);\n\n// right\nconst total = BigInt(a.amount_base) + BigInt(b.amount_base);\n```\n\nThe decimal string exists for humans and for logs. It is exact as written, but the moment\nit passes through a binary float it stops being exact, and the error compounds across a\nledger.\n\n## Where the `_base` twin is missing, and why\n\nThere are two systematic exceptions, and they are worth understanding rather than\nworking around.\n\n**The USD figures on `GET /v1/users` have no `_base` twin.** `balance_usd`, each\n`balances[].usd` and `card_balance` on that list are display estimates from the price\nfeed — they exist so a dashboard can sort and eyeball, not so a ledger can reconcile. Use\n`GET /v1/users/{externalId}/balances` for settlement figures.\n\n**An exchange quote's buy side has no `*_base` field.** On `POST /v1/exchange/quote`, the\nsell side is an asset the platform custodies, so `sell_amount` / `sell_amount_base`,\n`fee` / `fee_base` and `send_amount` / `send_amount_base` all come in pairs as usual. The\nbuy side does not: `buy_asset` can be any of thousands of coins routed through the\nexchange, on chains the platform does not model. Without owning that chain's decimals, the\nplatform cannot honestly produce a base-unit integer for it — so it publishes the decimal\nestimate and stops, rather than inventing a scale.\n\nContrast this with **swaps** (`POST /v1/swap/quote`), where both sides are assets the\nplatform holds. There, every amount does carry its exact `*_base` twin.\n\nSo: on an exchange, treat the buy-side numbers as **indicative decimals for display**, and\nnever as a settlement figure. The authoritative outcome of an exchange is what the order\nread reports after it settles, not what the quote estimated.\n\n## Amounts you send in\n\nAmounts in *request* bodies are decimal strings in major units, and they are validated\nstrictly.\n\n**More decimal places than the asset supports is a refusal, not a rounding.**\n`\"15.1234567\"` on a six-decimal asset such as `usdt_trc20` is refused: `400 invalid_amount`\non withdrawals and swaps, `400 invalid_request` on card\namounts. Nothing is truncated and nothing is rounded up. This is the property\nthat makes the API safe to build a ledger on: **the amount you send is always exactly the\namount that moves.**\n\nRead each asset's `decimals` from `GET /v1/assets` and validate on your side before you\nsend, so a user's typo becomes a form error rather than a `400` on a payout path.\n\n## Whole-cent rules on cards\n\nCard money is denominated in fiat rather than in an asset's own units, and it counts in a\nwhole minor unit.\n\n**Cards settle in USD cents.** Card money settles in USDT (`usdt_trc20`), with\n1 USDT = 1 USD, and USD cards only. `initial_amount` on `POST /v1/cards` is an opening\nbalance in USD in **whole cents** — two decimal places, no more; a sub-cent opening balance\nis refused with `400 invalid_request`, as is one below the product's `min_opening_balance`\n(the message names the required amount). Send top-up and withdraw amounts in whole cents\ntoo: the rail bills in cents and our percentage fees are rounded **up** to the cent, so a\nsub-cent tail buys nothing.\n\nRead `min_opening_balance` from `GET /v1/cards/products` on every issue. Do not hard-code\nit: it moves with platform policy *and* with the issuer's own floor, and it is computed per\nprovider rather than per product. Where a product also publishes a higher\n`active_min_limit`, open the card with at least that much or it can sit at `issuing` until\nyou top it up.\n\n## Fees, and which side of the amount they sit on\n\nFees are set by the operator: a platform-wide schedule applies to every merchant unless\nyour account has been given its own rate, and a rate can be `0`. Read the effective\nschedule from `GET /v1/fees` — it returns yours. Where a fee lands differs by operation,\nand the difference matters for what you quote your user:\n\n| Operation | Fee direction |\n| --- | --- |\n| **Deposit** | Free. `amount_credited = amount`, less only your own `merchant_fee` if you charge one |\n| **Withdrawal** | Charged **on top**: the hold is `amount + fee + platform_fee`, and `amount` is what the recipient receives. `fee` is the live network cost of the send, gas included; `platform_fee` is the flat platform fee. `GET /v1/withdrawal-fee-quote` shows both |\n| **Swap / exchange** | Taken **out of** `sell_amount` — `send_amount = sell_amount − fee` is published so the numbers add up |\n| **Card issue / load** | **On top**, from the cardholder: the opening balance or load amount plus our fee, gas and your own fee — `total` on the quote, `issue_total` on `GET /v1/cards/products` |\n\nFor a payout, `GET /v1/withdrawal-fee-quote` returns the exact figures before you commit,\nso you can show your user a total they will recognise on the statement.\n\n## A short checklist\n\n- Parse every `*_base` field as a big integer, never a float.\n- Sum, compare and reconcile on `*_base` only.\n- Format for display from the decimal string, or from `_base` with the asset's `decimals`.\n- Validate outbound decimal places against `GET /v1/assets` before sending.\n- Treat exchange buy-side amounts as display estimates; poll the order for the truth.\n- Never persist a monetary value in a floating-point column.\n",
      "sections": [
        {
          "anchor": "the-rule",
          "heading": "The rule",
          "text": "Every monetary value in a response is returned twice:\n\na human-readable decimal string — \"1.5\", in the asset's major units, and\nan exact integer base-unit string in a sibling field suffixed _base —\n\"1500000\", in sun or the token's own smallest unit.\n\n{\n\"asset_id\": \"usdt_trc20\",\n\"amount\": \"1.5\",\n\"amount_base\": \"1500000\"\n}\n\nCompute with _base. Display amount. That is the whole rule.\n\n_base is a string rather than a number because base units can exceed\nNumber.MAX_SAFE_INTEGER — a balance of ten billion USDT is 10000000000000000 base units,\nalready past the point where IEEE-754 doubles stop being exact. Parse it into\nyour language's big-integer type (BigInt in JavaScript, int in Python, decimal.Decimal\nor a 128-bit integer elsewhere) and never let it become a float, not even in transit\nthrough JSON.parse.\n\n// wrong — silently lossy at scale\nconst total = Number(a.amount) + Number(b.amount);\n\n// right\nconst total = BigInt(a.amount_base) + BigInt(b.amount_base);\n\nThe decimal string exists for humans and for logs. It is exact as written, but the moment\nit passes through a binary float it stops being exact, and the error compounds across a\nledger."
        },
        {
          "anchor": "where-the-base-twin-is-missing-and-why",
          "heading": "Where the _base twin is missing, and why",
          "text": "There are two systematic exceptions, and they are worth understanding rather than\nworking around.\n\nThe USD figures on GET /v1/users have no _base twin. balance_usd, each\nbalances[].usd and card_balance on that list are display estimates from the price\nfeed — they exist so a dashboard can sort and eyeball, not so a ledger can reconcile. Use\nGET /v1/users/{externalId}/balances for settlement figures.\n\nAn exchange quote's buy side has no _base field.* On POST /v1/exchange/quote, the\nsell side is an asset the platform custodies, so sell_amount / sell_amount_base,\nfee / fee_base and send_amount / send_amount_base all come in pairs as usual. The\nbuy side does not: buy_asset can be any of thousands of coins routed through the\nexchange, on chains the platform does not model. Without owning that chain's decimals, the\nplatform cannot honestly produce a base-unit integer for it — so it publishes the decimal\nestimate and stops, rather than inventing a scale.\n\nContrast this with swaps (POST /v1/swap/quote), where both sides are assets the\nplatform holds. There, every amount does carry its exact *_base twin.\n\nSo: on an exchange, treat the buy-side numbers as indicative decimals for display, and\nnever as a settlement figure. The authoritative outcome of an exchange is what the order\nread reports after it settles, not what the quote estimated."
        },
        {
          "anchor": "amounts-you-send-in",
          "heading": "Amounts you send in",
          "text": "Amounts in request bodies are decimal strings in major units, and they are validated\nstrictly.\n\nMore decimal places than the asset supports is a refusal, not a rounding.\n\"15.1234567\" on a six-decimal asset such as usdt_trc20 is refused: 400 invalid_amount\non withdrawals and swaps, 400 invalid_request on card\namounts. Nothing is truncated and nothing is rounded up. This is the property\nthat makes the API safe to build a ledger on: the amount you send is always exactly the\namount that moves.\n\nRead each asset's decimals from GET /v1/assets and validate on your side before you\nsend, so a user's typo becomes a form error rather than a 400 on a payout path."
        },
        {
          "anchor": "whole-cent-rules-on-cards",
          "heading": "Whole-cent rules on cards",
          "text": "Card money is denominated in fiat rather than in an asset's own units, and it counts in a\nwhole minor unit.\n\nCards settle in USD cents. Card money settles in USDT (usdt_trc20), with\n1 USDT = 1 USD, and USD cards only. initial_amount on POST /v1/cards is an opening\nbalance in USD in whole cents — two decimal places, no more; a sub-cent opening balance\nis refused with 400 invalid_request, as is one below the product's min_opening_balance\n(the message names the required amount). Send top-up and withdraw amounts in whole cents\ntoo: the rail bills in cents and our percentage fees are rounded up to the cent, so a\nsub-cent tail buys nothing.\n\nRead min_opening_balance from GET /v1/cards/products on every issue. Do not hard-code\nit: it moves with platform policy and with the issuer's own floor, and it is computed per\nprovider rather than per product. Where a product also publishes a higher\nactive_min_limit, open the card with at least that much or it can sit at issuing until\nyou top it up."
        },
        {
          "anchor": "fees-and-which-side-of-the-amount-they-sit-on",
          "heading": "Fees, and which side of the amount they sit on",
          "text": "Fees are set by the operator: a platform-wide schedule applies to every merchant unless\nyour account has been given its own rate, and a rate can be 0. Read the effective\nschedule from GET /v1/fees — it returns yours. Where a fee lands differs by operation,\nand the difference matters for what you quote your user:\n\nOperation · Fee direction\nDeposit · Free. amount_credited = amount, less only your own merchant_fee if you charge one\nWithdrawal · Charged on top: the hold is amount + fee + platform_fee, and amount is what the recipient receives. fee is the live network cost of the send, gas included; platform_fee is the flat platform fee. GET /v1/withdrawal-fee-quote shows both\nSwap / exchange · Taken out of sell_amount — send_amount = sell_amount − fee is published so the numbers add up\nCard issue / load · On top, from the cardholder: the opening balance or load amount plus our fee, gas and your own fee — total on the quote, issue_total on GET /v1/cards/products\n\nFor a payout, GET /v1/withdrawal-fee-quote returns the exact figures before you commit,\nso you can show your user a total they will recognise on the statement."
        },
        {
          "anchor": "a-short-checklist",
          "heading": "A short checklist",
          "text": "Parse every *_base field as a big integer, never a float.\nSum, compare and reconcile on *_base only.\nFormat for display from the decimal string, or from _base with the asset's decimals.\nValidate outbound decimal places against GET /v1/assets before sending.\nTreat exchange buy-side amounts as display estimates; poll the order for the truth.\nNever persist a monetary value in a floating-point column."
        }
      ]
    },
    {
      "slug": "idempotency",
      "title": "Idempotency",
      "summary": "Every mutation that moves money takes an idempotency_key. What a replay returns, when a replay becomes a 409, and how to resolve a request whose outcome you never learned.",
      "kind": "guide",
      "markdown": "# Idempotency\n\nNetworks drop connections in the middle of payouts. The API is built so that the correct\nresponse to that is always the same: **retry with the same key**.\n\n## The key\n\nEvery mutation that moves money accepts an `idempotency_key` in the request body — a string\nyou generate, 1–255 characters, unique per merchant per logical operation.\n\n| Endpoint | Summary |\n| --- | --- |\n| [`POST /v1/withdrawals`](withdrawals#post-v1-withdrawals) | Request a payout |\n| [`POST /v1/exchange/orders`](exchange#post-v1-exchange-orders) | Start an exchange — sell a held coin, pay another one out |\n| [`POST /v1/swap/orders`](swaps#post-v1-swap-orders) | Start a swap — sell one coin the user holds, credit them another |\n| [`POST /v1/cards`](cards#post-v1-cards) | Issue a card |\n| [`POST /v1/cards/{id}/topup`](cards#post-v1-cards-id-topup) | Top up a card |\n| [`POST /v1/cards/{id}/withdraw`](cards#post-v1-cards-id-withdraw) | Retired — moving part of a card balance back to the wallet |\n\nIt is **required** on withdrawals and the three card money mutations (issue,\ntop-up, withdraw). It is **optional but strongly recommended** on swap and exchange\norders — optional only because those rails were added later, not because replay safety\nmatters less there.\n\nA UUID v4 per logical operation is the right default. Deriving it from your own primary key\n— `payout-8814`, `card-topup-2291` — is better still, because it survives a process restart\nthat a freshly generated UUID would not.\n\n> **Important** \"Per logical operation\" means per *intended movement of money*, not per HTTP\n> attempt. Generate the key once, store it next to the record that motivated the payout, and\n> reuse it for every retry of that payout — including retries days later.\n\n## What a replay returns\n\nA replay with the same key and the same parameters returns **the original record,\nunchanged**. No second hold, no second payout, no second order.\n\nThe status code follows the resource:\n\n| Endpoint | First call | Replay |\n| --- | --- | --- |\n| `POST /v1/withdrawals` | `202` | `202`, with the original withdrawal |\n| `POST /v1/cards`, `/topup`, `/withdraw` | `201` / `202` | same code, original order |\n| `POST /v1/swap/orders`, `/v1/exchange/orders` | `201` | `201`, with the original order |\n\nThe body is the record as it stands *now*, not as it stood when it was created — so a\nreplay of a withdrawal that has since confirmed returns it with `status: confirmed`. That\nis exactly what you want: the replay doubles as a status read.\n\n## Reusing a key for different parameters is an error\n\nOn withdrawals, card mutations, swaps and exchanges, if the key matches an\nexisting record but the parameters do not, the API refuses rather than guessing:\n\n```json\n{\n  \"error\": \"idempotency_conflict\",\n  \"message\": \"idempotency key payout-8814 was already used with different parameters\"\n}\n```\n\nwith HTTP `409`. It will never silently return the original record for a request that asked\nfor something else, and it will never quietly create a second one. If you see this, your key\ngeneration is colliding — two different payouts are being assigned the same key.\n\n\"The same parameters\" means the fields that describe the movement. For a withdrawal that\nis the user, the asset, the address and the amount; the quoted fees are not compared,\nbecause they may legitimately differ between genuine retries of one payout.\n\n## Resolving an indeterminate request\n\nThe hard case is not a rejection. It is a create that **timed out** — you sent it, the\nconnection dropped, and you do not know whether a payout exists.\n\nThere are two safe moves, and they agree with each other.\n\n**Re-POST with the same key.** This is safe by construction. Either it creates the payout\n(it never landed) or it returns the existing one (it did). One call, one answer.\n\n**Or look the key up.** `GET /v1/withdrawals?idempotency_key=…` is the read path built for\nexactly this: it returns the withdrawal for that key, or `404` if no withdrawal was ever\ncreated. Use this when\nyou would rather not issue a write at all until you know the state.\n\nWhat you must **not** do is retry with a *fresh* key. That is how a customer gets paid\ntwice.\n\n## Cards: what 202 means, and what it does not\n\nThe card money endpoints return `202 Accepted`. The ledger move has happened; the\n*provider* outcome has not been confirmed yet. It arrives later as a `card.topup` /\n`card.withdraw` webhook, or through the reconciler.\n\n**Treat `202` as pending, never as failure.** Refund your user only on a *definitive*\nfailure:\n\n- a `409 provider_rejected` at request time, or\n- a webhook whose order `status` is `failed`.\n\nA top-up's charge is refunded automatically on definitive failure. A withdrawal credits the\nuser only once the provider confirms, and nothing is held meanwhile. Issuance behaves the\nsame way: the `issue_fee` is charged at request time and auto-refunded if the application\ndefinitively fails.\n\n## Retry policy that works\n\n1. Generate the key **before** the first attempt and persist it with your own record.\n2. On a timeout, a `5xx`, or a dropped connection: retry with the **same** key, backing off.\n3. On a `4xx` other than `409 idempotency_conflict`: do not retry — fix the request.\n4. On `409 idempotency_conflict`: do not retry — you have a key collision to fix.\n5. When you stop retrying, resolve the state with the `idempotency_key` lookup or the\n   resource read before you tell anyone anything final.\n\nIdempotency keys are scoped to your merchant, so they never collide with another\nmerchant's. They do not expire.\n",
      "sections": [
        {
          "anchor": "the-key",
          "heading": "The key",
          "text": "Every mutation that moves money accepts an idempotency_key in the request body — a string\nyou generate, 1–255 characters, unique per merchant per logical operation.\n\nEndpoint · Summary\nPOST /v1/withdrawals · Request a payout\nPOST /v1/exchange/orders · Start an exchange — sell a held coin, pay another one out\nPOST /v1/swap/orders · Start a swap — sell one coin the user holds, credit them another\nPOST /v1/cards · Issue a card\nPOST /v1/cards/{id}/topup · Top up a card\nPOST /v1/cards/{id}/withdraw · Retired — moving part of a card balance back to the wallet\n\nIt is required on withdrawals and the three card money mutations (issue,\ntop-up, withdraw). It is optional but strongly recommended on swap and exchange\norders — optional only because those rails were added later, not because replay safety\nmatters less there.\n\nA UUID v4 per logical operation is the right default. Deriving it from your own primary key\n— payout-8814, card-topup-2291 — is better still, because it survives a process restart\nthat a freshly generated UUID would not.\n\nImportant \"Per logical operation\" means per intended movement of money, not per HTTP\nattempt. Generate the key once, store it next to the record that motivated the payout, and\nreuse it for every retry of that payout — including retries days later."
        },
        {
          "anchor": "what-a-replay-returns",
          "heading": "What a replay returns",
          "text": "A replay with the same key and the same parameters returns the original record,\nunchanged. No second hold, no second payout, no second order.\n\nThe status code follows the resource:\n\nEndpoint · First call · Replay\nPOST /v1/withdrawals · 202 · 202, with the original withdrawal\nPOST /v1/cards, /topup, /withdraw · 201 / 202 · same code, original order\nPOST /v1/swap/orders, /v1/exchange/orders · 201 · 201, with the original order\n\nThe body is the record as it stands now, not as it stood when it was created — so a\nreplay of a withdrawal that has since confirmed returns it with status: confirmed. That\nis exactly what you want: the replay doubles as a status read."
        },
        {
          "anchor": "reusing-a-key-for-different-parameters-is-an-error",
          "heading": "Reusing a key for different parameters is an error",
          "text": "On withdrawals, card mutations, swaps and exchanges, if the key matches an\nexisting record but the parameters do not, the API refuses rather than guessing:\n\n{\n\"error\": \"idempotency_conflict\",\n\"message\": \"idempotency key payout-8814 was already used with different parameters\"\n}\n\nwith HTTP 409. It will never silently return the original record for a request that asked\nfor something else, and it will never quietly create a second one. If you see this, your key\ngeneration is colliding — two different payouts are being assigned the same key.\n\n\"The same parameters\" means the fields that describe the movement. For a withdrawal that\nis the user, the asset, the address and the amount; the quoted fees are not compared,\nbecause they may legitimately differ between genuine retries of one payout."
        },
        {
          "anchor": "resolving-an-indeterminate-request",
          "heading": "Resolving an indeterminate request",
          "text": "The hard case is not a rejection. It is a create that timed out — you sent it, the\nconnection dropped, and you do not know whether a payout exists.\n\nThere are two safe moves, and they agree with each other.\n\nRe-POST with the same key. This is safe by construction. Either it creates the payout\n(it never landed) or it returns the existing one (it did). One call, one answer.\n\nOr look the key up. GET /v1/withdrawals?idempotency_key=… is the read path built for\nexactly this: it returns the withdrawal for that key, or 404 if no withdrawal was ever\ncreated. Use this when\nyou would rather not issue a write at all until you know the state.\n\nWhat you must not do is retry with a fresh key. That is how a customer gets paid\ntwice."
        },
        {
          "anchor": "cards-what-202-means-and-what-it-does-not",
          "heading": "Cards: what 202 means, and what it does not",
          "text": "The card money endpoints return 202 Accepted. The ledger move has happened; the\nprovider outcome has not been confirmed yet. It arrives later as a card.topup /\ncard.withdraw webhook, or through the reconciler.\n\nTreat 202 as pending, never as failure. Refund your user only on a definitive\nfailure:\n\na 409 provider_rejected at request time, or\na webhook whose order status is failed.\n\nA top-up's charge is refunded automatically on definitive failure. A withdrawal credits the\nuser only once the provider confirms, and nothing is held meanwhile. Issuance behaves the\nsame way: the issue_fee is charged at request time and auto-refunded if the application\ndefinitively fails."
        },
        {
          "anchor": "retry-policy-that-works",
          "heading": "Retry policy that works",
          "text": "Generate the key before the first attempt and persist it with your own record.\nOn a timeout, a 5xx, or a dropped connection: retry with the same key, backing off.\nOn a 4xx other than 409 idempotency_conflict: do not retry — fix the request.\nOn 409 idempotency_conflict: do not retry — you have a key collision to fix.\nWhen you stop retrying, resolve the state with the idempotency_key lookup or the\nresource read before you tell anyone anything final.\n\nIdempotency keys are scoped to your merchant, so they never collide with another\nmerchant's. They do not expire."
        }
      ]
    },
    {
      "slug": "errors",
      "title": "Errors",
      "summary": "One error envelope for the whole API, what each status class means for a retry, and the full catalogue of error responses generated from the contract.",
      "kind": "guide",
      "markdown": "# Errors\n\nEvery error, on every endpoint, is the same JSON shape. There is no second format to\nhandle.\n\n## The envelope\n\n```json\n{\n  \"error\": \"insufficient_balance\",\n  \"message\": \"optional human-readable detail\",\n  \"details\": [{ \"path\": \"amount\", \"message\": \"Invalid\" }]\n}\n```\n\n| Field | Type | Notes |\n| --- | --- | --- |\n| `error` | `string` | **Always present.** A stable, machine-readable code. Branch on this. |\n| `message` | `string` | Optional prose for a human. May change without notice — never parse it. |\n| `details` | `array` | Present on `validation_error`: one `{ path, message }` per failing field. |\n\n`error` is the contract. `message` is a courtesy. Code that switches on `message` text will\nbreak the first time someone improves the wording.\n\n## What each status class means for a retry\n\nThe status code answers one question — *should I send this again?* — and the answer is\ndifferent for each class.\n\n**`400` — the request is wrong.** Body or query failed validation, or a domain rule was\nviolated: `validation_error`, `unknown_asset`, `asset_disabled`, `invalid_amount`,\n`bad_amount`, `invalid_address`, `invalid_pair`, `no_route`; on withdrawals also\n`amount_below_minimum` (a zero or negative amount — payouts have no other floor),\n`self_transfer` (the recipient is the user's own deposit address — nothing would move but\nthe network fee) and `invalid_request`.\n**Never retry unchanged.** Nothing was\ncreated and nothing was charged. Fix the request, or surface the problem to whoever typed\nit. On `validation_error`, `details[]` names the exact field.\n\n**`401 unauthorized` — the key is missing, malformed or unknown.** Never retry; no amount\nof backoff produces a valid key. See [Authentication](authentication).\n\n**`403` — the key is real, but your account, your login or the feature may not do this.**\n`email_unverified` (the owner login has not verified its email), `account_paused` (an\noperator hold) and `account_suspended` answer every call on the key until the state\nchanges — see [Authentication](authentication). `session_required` and `forbidden_role`\nmean the action exists only on the dashboard, with a second factor and the right role;\n`own_account_only` is a dashboard session trying to move a customer's balance, which only\nyour integration may do. None of these is a bug in your request, and retrying does not help; the change that\nlifts them is ours, and the dashboard shows the state.\n\n**`400 unknown_asset`** — an asset id we do not support. Take ids from `GET /v1/assets`.\n\n**`404` — not found, or not yours.** An unknown id and another merchant's id are\ndeliberately **indistinguishable**, so you cannot enumerate anyone else's records. Also\nreturned by the idempotency-key lookups when no record was created for that key — which is\na *meaningful* answer there, not an error. See [Idempotency](idempotency).\n\n**`409` — a conflict with existing state.** The request was well-formed but the world\ndisagreed: `insufficient_balance`, `idempotency_conflict`, `card_state`,\n`kyc_not_approved`, `provider_rejected`, `not_cancellable`, `too_many_endpoints`,\n`no_own_account` (your own balance has not been opened yet) and `not_pending` (a\nparked payout someone already decided). Do not blind-retry. Read the current state,\ndecide, and act — most of these change only when something else changes first (a deposit\nlands, KYC is approved, a card is unfrozen).\n\n**Two card errors are retired** (20 September 2026) and will never be raised again:\n`409 card_close_underfunded` (a card had to hold the issuer's cancellation charge before it\ncould be closed) and `409 card_refund_wallet_empty` (a close waited on the wallet that pays\nrefunds). A close is now always accepted, and where the money is reads as\n`close_refund.state` on `GET /v1/cards/{id}`. Delete any handling of the two codes.\n\n`409 provider_rejected` is worth calling out: it is a **definitive** failure. The provider\nlooked and refused, and any charge is already reversed. `details.order_id` names the order.\n\n**`500 internal_error`** — something broke on our side. The body carries no `message`. Safe\nto retry with backoff **and the same idempotency key**; whether the operation landed is\nindeterminate, so resolve it with the key lookup rather than assuming either way.\n\n**`503` — a dependency is unavailable, and your request was fine.** `chain_unavailable`\n(no adapter configured for that asset's chain), `cards_disabled`, `swap_disabled`,\n`swap_unavailable`, `exchange_error`. **Retry later, unchanged.** Do not adjust the request to work around it,\nand do not queue a modified version of it on your side.\n\n## Retry decision table\n\n| Status | Retry? | With the same idempotency key? |\n| --- | --- | --- |\n| `400`, `401`, `403` | No | — |\n| `404` | No (it is an answer) | — |\n| `409 idempotency_conflict` | No — fix key generation | — |\n| `409` others | Only after state changes | Yes |\n| `429` | Yes, after backing off | Yes |\n| `500`, `502`, `504` | Yes, with backoff | **Yes — always** |\n| `503` | Yes, later, unchanged | Yes |\n\nThe one rule that never bends: **when you retry a mutation, reuse the original\n`idempotency_key`.** A fresh key on a retry is how a customer gets paid twice.\n\nThe same instinct applies everywhere: before you refund, re-charge, or tell a customer\nsomething final, reconcile against the resource read. Webhooks are hints; the poll is truth.\n\n## Catalogue\n\nGenerated from the `components.responses` block of the OpenAPI contract\n(v1.0.0) — this is every named error response and the `error` codes it can\ncarry.\n\n| Response | HTTP | `error` codes | What it means |\n| --- | --- | --- | --- |\n| `Unauthorized` | 401 | `unauthorized` | Missing, malformed or unknown API key. |\n| `AccountNotLive` | — | `email_unverified`, `account_paused`, `account_suspended` | The key is valid but the account is not live, so every request answers this until it is. |\n| `ValidationError` | 400 | `validation_error`, `unknown_asset`, `asset_disabled`, `invalid_amount`, `amount_below_minimum`, `bad_amount`, `invalid_address`, `invalid_request` | Body/params failed validation, or a domain rule was violated. |\n| `UserNotFound` | 404 | `user_not_found` | Unknown external_user_id for this tenant. |\n| `NotFound` | 404 | `not_found` | Record not found (or belongs to another tenant). |\n| `InsufficientBalance` | — | `insufficient_balance` | The hold would overdraw the user's balance. |\n| `ChainUnavailable` | 503 | `chain_unavailable` | No chain adapter configured for the asset's chain. |\n| `CardBadRequest` | 400 | `validation_error`, `invalid_request`, `invalid_amount`, `card_core_required`, `card_provider_required`, `unknown_provider`, `not_supported_by_provider` | Request validation failed or a domain rule was violated. |\n| `CardHolderNotFound` | 404 | `holder_not_found`, `user_not_found` | No matching card holder (`holder_not_found`), or an unknown user/payer (`user_not_found`), for this tenant. |\n| `CardNotFound` | 404 | `card_not_found`, `user_not_found` | Unknown card (`card_not_found`), or an unknown payer user (`user_not_found`), for this tenant. |\n| `CardConflict` | 409 | `kyc_not_approved`, `card_state`, `card_issuing`, `provider_rejected`, `email_taken`, `idempotency_conflict`, `insufficient_balance`, `no_own_account` | A conflicting card state. |\n| `CardUnavailable` | 503 | `provider_unavailable`, `card_rail_unavailable`, `cards_disabled`, `rail_unfunded`, `answered` | Temporarily unable to serve this card call — your request was valid. |\n| `ExchangeBadRequest` | 400 | `validation_error`, `invalid_amount`, `invalid_pair`, `no_route` | The request was refused before anything was charged. |\n| `SwapBadRequest` | 400 | `validation_error`, `invalid_amount`, `invalid_pair`, `no_route` | The request was refused before anything was charged. |\n| `SwapConflict` | 409 | `insufficient_balance`, `idempotency_conflict` | `error` is one of: insufficient_balance (the sell amount would overdraw the user's balance), idempotency_conflict (this `idempotency_key` was already used with a DIFFERENT payload — your replay is not the request you think it is). |\n| `SwapOrderNotFound` | 404 | `order_not_found` | Unknown order, or one belonging to another merchant — the two are indistinguishable by design. |\n| `SwapDisabled` | 403 | `swap_disabled` | Swaps and exchanges are turned off, or the exchange integration is not configured. |\n| `ExchangeError` | 502 | `exchange_error` | The exchange failed or was unreachable (your request was valid). |\n| `ExchangeUnavailable` | 503 | `swap_unavailable` | Our side is not ready for this chain. |\n\n## Debugging an integration\n\n`GET /v1/developer/logs` returns the method, route, status and duration of your recent\ncalls, kept for 14 days, with no bodies and no query strings. When a call seems to have\nvanished, check there first: it tells you whether the request reached the platform at all,\nand what status it left with.\n\n### Daily limits\n\nCalls that cost a third party money per request are capped per account per day:\nstarting a new identity verification (`POST /v1/kyc/start`, resuming one is free) and\nuploading card KYC documents (`POST /v1/cards/kyc/upload`). Past the cap the answer is\n`429 daily_limit_reached` until the next UTC day. If your volume genuinely needs more,\nask us to raise it.\n",
      "sections": [
        {
          "anchor": "the-envelope",
          "heading": "The envelope",
          "text": "{\n\"error\": \"insufficient_balance\",\n\"message\": \"optional human-readable detail\",\n\"details\": [{ \"path\": \"amount\", \"message\": \"Invalid\" }]\n}\n\nField · Type · Notes\nerror · string · Always present. A stable, machine-readable code. Branch on this.\nmessage · string · Optional prose for a human. May change without notice — never parse it.\ndetails · array · Present on validation_error: one { path, message } per failing field.\n\nerror is the contract. message is a courtesy. Code that switches on message text will\nbreak the first time someone improves the wording."
        },
        {
          "anchor": "what-each-status-class-means-for-a-retry",
          "heading": "What each status class means for a retry",
          "text": "The status code answers one question — should I send this again? — and the answer is\ndifferent for each class.\n\n400 — the request is wrong. Body or query failed validation, or a domain rule was\nviolated: validation_error, unknown_asset, asset_disabled, invalid_amount,\nbad_amount, invalid_address, invalid_pair, no_route; on withdrawals also\namount_below_minimum (a zero or negative amount — payouts have no other floor),\nself_transfer (the recipient is the user's own deposit address — nothing would move but\nthe network fee) and invalid_request.\nNever retry unchanged. Nothing was\ncreated and nothing was charged. Fix the request, or surface the problem to whoever typed\nit. On validation_error, details[] names the exact field.\n\n401 unauthorized — the key is missing, malformed or unknown. Never retry; no amount\nof backoff produces a valid key. See Authentication.\n\n403 — the key is real, but your account, your login or the feature may not do this.\nemail_unverified (the owner login has not verified its email), account_paused (an\noperator hold) and account_suspended answer every call on the key until the state\nchanges — see Authentication. session_required and forbidden_role\nmean the action exists only on the dashboard, with a second factor and the right role;\nown_account_only is a dashboard session trying to move a customer's balance, which only\nyour integration may do. None of these is a bug in your request, and retrying does not help; the change that\nlifts them is ours, and the dashboard shows the state.\n\n400 unknown_asset — an asset id we do not support. Take ids from GET /v1/assets.\n\n404 — not found, or not yours. An unknown id and another merchant's id are\ndeliberately indistinguishable, so you cannot enumerate anyone else's records. Also\nreturned by the idempotency-key lookups when no record was created for that key — which is\na meaningful answer there, not an error. See Idempotency.\n\n409 — a conflict with existing state. The request was well-formed but the world\ndisagreed: insufficient_balance, idempotency_conflict, card_state,\nkyc_not_approved, provider_rejected, not_cancellable, too_many_endpoints,\nno_own_account (your own balance has not been opened yet) and not_pending (a\nparked payout someone already decided). Do not blind-retry. Read the current state,\ndecide, and act — most of these change only when something else changes first (a deposit\nlands, KYC is approved, a card is unfrozen).\n\nTwo card errors are retired (20 September 2026) and will never be raised again:\n409 card_close_underfunded (a card had to hold the issuer's cancellation charge before it\ncould be closed) and 409 card_refund_wallet_empty (a close waited on the wallet that pays\nrefunds). A close is now always accepted, and where the money is reads as\nclose_refund.state on GET /v1/cards/{id}. Delete any handling of the two codes.\n\n409 provider_rejected is worth calling out: it is a definitive failure. The provider\nlooked and refused, and any charge is already reversed. details.order_id names the order.\n\n500 internal_error — something broke on our side. The body carries no message. Safe\nto retry with backoff and the same idempotency key; whether the operation landed is\nindeterminate, so resolve it with the key lookup rather than assuming either way.\n\n503 — a dependency is unavailable, and your request was fine. chain_unavailable\n(no adapter configured for that asset's chain), cards_disabled, swap_disabled,\nswap_unavailable, exchange_error. Retry later, unchanged. Do not adjust the request to work around it,\nand do not queue a modified version of it on your side."
        },
        {
          "anchor": "retry-decision-table",
          "heading": "Retry decision table",
          "text": "Status · Retry? · With the same idempotency key?\n400, 401, 403 · No · —\n404 · No (it is an answer) · —\n409 idempotency_conflict · No — fix key generation · —\n409 others · Only after state changes · Yes\n429 · Yes, after backing off · Yes\n500, 502, 504 · Yes, with backoff · Yes — always\n503 · Yes, later, unchanged · Yes\n\nThe one rule that never bends: when you retry a mutation, reuse the original\nidempotency_key. A fresh key on a retry is how a customer gets paid twice.\n\nThe same instinct applies everywhere: before you refund, re-charge, or tell a customer\nsomething final, reconcile against the resource read. Webhooks are hints; the poll is truth."
        },
        {
          "anchor": "catalogue",
          "heading": "Catalogue",
          "text": "Generated from the components.responses block of the OpenAPI contract\n(v1.0.0) — this is every named error response and the error codes it can\ncarry.\n\nResponse · HTTP · error codes · What it means\nUnauthorized · 401 · unauthorized · Missing, malformed or unknown API key.\nAccountNotLive · — · email_unverified, account_paused, account_suspended · The key is valid but the account is not live, so every request answers this until it is.\nValidationError · 400 · validation_error, unknown_asset, asset_disabled, invalid_amount, amount_below_minimum, bad_amount, invalid_address, invalid_request · Body/params failed validation, or a domain rule was violated.\nUserNotFound · 404 · user_not_found · Unknown external_user_id for this tenant.\nNotFound · 404 · not_found · Record not found (or belongs to another tenant).\nInsufficientBalance · — · insufficient_balance · The hold would overdraw the user's balance.\nChainUnavailable · 503 · chain_unavailable · No chain adapter configured for the asset's chain.\nCardBadRequest · 400 · validation_error, invalid_request, invalid_amount, card_core_required, card_provider_required, unknown_provider, not_supported_by_provider · Request validation failed or a domain rule was violated.\nCardHolderNotFound · 404 · holder_not_found, user_not_found · No matching card holder (holder_not_found), or an unknown user/payer (user_not_found), for this tenant.\nCardNotFound · 404 · card_not_found, user_not_found · Unknown card (card_not_found), or an unknown payer user (user_not_found), for this tenant.\nCardConflict · 409 · kyc_not_approved, card_state, card_issuing, provider_rejected, email_taken, idempotency_conflict, insufficient_balance, no_own_account · A conflicting card state.\nCardUnavailable · 503 · provider_unavailable, card_rail_unavailable, cards_disabled, rail_unfunded, answered · Temporarily unable to serve this card call — your request was valid.\nExchangeBadRequest · 400 · validation_error, invalid_amount, invalid_pair, no_route · The request was refused before anything was charged.\nSwapBadRequest · 400 · validation_error, invalid_amount, invalid_pair, no_route · The request was refused before anything was charged.\nSwapConflict · 409 · insufficient_balance, idempotency_conflict · error is one of: insufficient_balance (the sell amount would overdraw the user's balance), idempotency_conflict (this idempotency_key was already used with a DIFFERENT payload — your replay is not the request you think it is).\nSwapOrderNotFound · 404 · order_not_found · Unknown order, or one belonging to another merchant — the two are indistinguishable by design.\nSwapDisabled · 403 · swap_disabled · Swaps and exchanges are turned off, or the exchange integration is not configured.\nExchangeError · 502 · exchange_error · The exchange failed or was unreachable (your request was valid).\nExchangeUnavailable · 503 · swap_unavailable · Our side is not ready for this chain."
        },
        {
          "anchor": "debugging-an-integration",
          "heading": "Debugging an integration",
          "text": "GET /v1/developer/logs returns the method, route, status and duration of your recent\ncalls, kept for 14 days, with no bodies and no query strings. When a call seems to have\nvanished, check there first: it tells you whether the request reached the platform at all,\nand what status it left with.\n\nDaily limits\n\nCalls that cost a third party money per request are capped per account per day:\nstarting a new identity verification (POST /v1/kyc/start, resuming one is free) and\nuploading card KYC documents (POST /v1/cards/kyc/upload). Past the cap the answer is\n429 daily_limit_reached until the next UTC day. If your volume genuinely needs more,\nask us to raise it."
        },
        {
          "anchor": "daily-limits",
          "heading": "Daily limits",
          "text": "Calls that cost a third party money per request are capped per account per day:\nstarting a new identity verification (POST /v1/kyc/start, resuming one is free) and\nuploading card KYC documents (POST /v1/cards/kyc/upload). Past the cap the answer is\n429 daily_limit_reached until the next UTC day. If your volume genuinely needs more,\nask us to raise it."
        }
      ]
    },
    {
      "slug": "webhooks",
      "title": "Webhooks",
      "summary": "Every event cryptoinfra sends, how to verify the HMAC, how to survive at-least-once delivery — and the flows that emit nothing at all and must be polled.",
      "kind": "guide",
      "markdown": "# Webhooks\n\ncryptoinfra pushes events to HTTPS endpoints you register. Delivery is signed, retried, and\n**at-least-once** — which shapes everything below.\n\n## Registering an endpoint\n\n`POST /v1/developer/webhooks` with `{ \"url\": \"https://…\" }`. HTTPS only, public hostnames\nonly, at most **5** endpoints per merchant. Every event goes to **every enabled endpoint**\n— there is no per-event subscription.\n\nThe response contains `endpoint.secret`. **It is returned once, there, and never again.**\nStore it before you close the connection. `POST /v1/developer/webhooks/{id}/regenerate`\nissues a new one — also shown once — and the old secret stops verifying immediately, so\nrotate during a window you can watch.\n\n`POST /v1/developer/webhooks/{id}/test` sends a signed test event through the real\ndispatcher, so it exercises your actual verification path rather than a simulation. It\naccepts an `event_type` so you can rehearse a specific shape.\n\nThe full [Developer](developer) reference covers listing, enabling, disabling and deleting\nendpoints.\n\n## The complete event list\n\nThese 9 events are the **complete** set. Nothing else is ever emitted.\n\n| Event | Fires when |\n| --- | --- |\n| [`deposit.credited`](webhook-events#deposit-credited) | A deposit cleared confirmations and was credited |\n| [`withdrawal.confirmed`](webhook-events#withdrawal-confirmed) | A payout confirmed on-chain |\n| [`withdrawal.failed`](webhook-events#withdrawal-failed) | A payout failed and the hold was refunded |\n| [`card.kyc`](webhook-events#card-kyc) | A card holder's KYC status changed |\n| [`card.status`](webhook-events#card-status) | A card's status changed (also emitted when an issue order settles) |\n| [`card.topup`](webhook-events#card-topup) | A card top-up order reached a terminal state (confirmed or failed) |\n| [`card.withdraw`](webhook-events#card-withdraw) | A card→wallet withdrawal order reached a terminal state (confirmed or failed) |\n| [`card.transaction`](webhook-events#card-transaction) | A card transaction was created |\n| [`card.wallet_otp`](webhook-events#card-wallet-otp) | A live 3DS wallet OTP is available (notification only — the code is NOT in the payload) |\n\nFull payload schemas and examples for each are on the generated\n[Webhook events](webhook-events) page.\n\n## What emits nothing\n\n> **Warning** **Swaps and exchanges emit NO webhook.** There is no `swap.completed` and no\n> `exchange.completed`. A flow that waits for one waits forever.\n\nPoll the order read instead, while `status` is non-terminal:\n\n| Flow | Poll this |\n| --- | --- |\n| Swaps **and** exchanges | `GET /v1/swap/orders/{id}` |\n\nIt is the authoritative read. It has no event, and will not get one.\n\n## Delivery mechanics\n\nEach delivery is a `POST` with two headers:\n\n```http\nPOST /your/endpoint HTTP/1.1\nContent-Type: application/json\nx-cryptoinfra-event: deposit.credited\nx-cryptoinfra-signature: 9f2b…   (hex hmac-sha256 of the raw body)\n```\n\n- **Timeout:** 10 seconds per attempt.\n- **Success:** any `2xx`. Anything else — including a redirect — is a failure.\n- **Redirects are not followed.** Register the final URL.\n- **Retries:** exponential backoff, `min(2^attempts × 30s, 1h)` — roughly 60s, 2m, 4m, 8m,\n  16m, 32m, then hourly — abandoned as failed after **8 attempts**.\n\nAcknowledge with a `2xx` immediately and do the work asynchronously. If your handler takes\nlonger than 10 seconds, the delivery is recorded as failed and retried, and you will\nprocess the same event again.\n\n## Verifying the signature\n\n`x-cryptoinfra-signature` is the hex HMAC-SHA256 of the **exact raw request bytes**, keyed\nwith that endpoint's secret.\n\n> **Important** Verify over the raw body, **before** parsing. Re-serialising the parsed JSON\n> produces different bytes (key order, whitespace, number formatting) and the signature will\n> not match.\n\n```js\nimport { createHmac, timingSafeEqual } from 'node:crypto';\nimport express from 'express';\n\nconst app = express();\n\n// give the handler the raw bytes, not a parsed object\napp.post('/webhooks/cryptoinfra', express.raw({ type: 'application/json' }), (req, res) => {\n  const expected = createHmac('sha256', process.env.CRYPTOINFRA_WEBHOOK_SECRET)\n    .update(req.body)           // Buffer of the exact bytes received\n    .digest('hex');\n  const given = req.get('x-cryptoinfra-signature') ?? '';\n\n  const a = Buffer.from(expected, 'utf8');\n  const b = Buffer.from(given, 'utf8');\n  if (a.length !== b.length || !timingSafeEqual(a, b)) {\n    return res.sendStatus(401);\n  }\n\n  const event = req.get('x-cryptoinfra-event');\n  const payload = JSON.parse(req.body.toString('utf8'));\n\n  enqueue(event, payload);      // do the work off the request path\n  res.sendStatus(200);          // acknowledge fast\n});\n```\n\n```python\nimport hmac, hashlib\nfrom flask import Flask, request, abort\n\napp = Flask(__name__)\n\n@app.post(\"/webhooks/cryptoinfra\")\ndef cryptoinfra_webhook():\n    raw = request.get_data()                      # exact bytes, unparsed\n    expected = hmac.new(SECRET.encode(), raw, hashlib.sha256).hexdigest()\n    given = request.headers.get(\"x-cryptoinfra-signature\", \"\")\n    if not hmac.compare_digest(expected, given):\n        abort(401)\n\n    event = request.headers.get(\"x-cryptoinfra-event\")\n    enqueue(event, request.get_json())\n    return \"\", 200\n```\n\nAlways compare in constant time. Reject anything that fails, and reject anything with a\nmissing signature header — never fall through to processing it.\n\n## Replay and idempotency on your side\n\nDelivery is at-least-once. You **will** receive the same event twice: after a timeout that\nactually succeeded, after a `5xx` on your side, after a network blip.\n\nThere is no delivery-id header. **Deduplicate on the pair\n(`x-cryptoinfra-event`, entity id)** — the entity id being `depositId`, `withdrawalId`,\n`card_id`, `order_id` or the equivalent field on that payload.\n\n```sql\ncreate table webhook_seen (\n  event_type text not null,\n  entity_id  text not null,\n  seen_at    timestamptz not null default now(),\n  primary key (event_type, entity_id)\n);\n```\n\nInsert with `on conflict do nothing`; if no row was inserted, you have already handled this\none — acknowledge `200` and stop. Make the handler itself idempotent too: crediting a user\nshould be safe to attempt twice even if the dedupe table is lost.\n\nNote that a *status-transition* event can legitimately arrive more than once with different\ncontent — `card.status` fires on each change. Key those on\n(`event`, entity id, new status) rather than on the entity alone.\n\n## Webhooks are hints; the poll is truth\n\nEven where a webhook exists, it is a notification that something *may* have changed — not\nthe record of what changed. The read endpoints are authoritative:\n\n- `GET /v1/withdrawals/{id}`\n- `GET /v1/swap/orders/{id}`\n- `GET /v1/cards/orders/{order_id}`\n- `GET /v1/users/{externalId}/balances`\n\nBefore you credit a customer, issue a refund, or tell anyone that money has moved,\nreconcile against one of those. A webhook that never arrives — because your endpoint was\ndown for longer than the retry window — must not be able to strand money in your system.\nRun a reconciliation sweep over non-terminal records regardless of what did or did not\narrive.\n\n## A handler checklist\n\n- Read the **raw bytes** before any body parser touches them.\n- Verify the HMAC in constant time; reject on mismatch or a missing header.\n- Return `2xx` in well under 10 seconds; queue the work.\n- Deduplicate on (`event`, entity id) — and make the handler idempotent anyway.\n- Never trust the payload as final: reconcile against the resource read.\n- Store the secret like a credential; rotate it through `/regenerate` when it leaks.\n",
      "sections": [
        {
          "anchor": "registering-an-endpoint",
          "heading": "Registering an endpoint",
          "text": "POST /v1/developer/webhooks with { \"url\": \"https://…\" }. HTTPS only, public hostnames\nonly, at most 5 endpoints per merchant. Every event goes to every enabled endpoint\n— there is no per-event subscription.\n\nThe response contains endpoint.secret. It is returned once, there, and never again.\nStore it before you close the connection. POST /v1/developer/webhooks/{id}/regenerate\nissues a new one — also shown once — and the old secret stops verifying immediately, so\nrotate during a window you can watch.\n\nPOST /v1/developer/webhooks/{id}/test sends a signed test event through the real\ndispatcher, so it exercises your actual verification path rather than a simulation. It\naccepts an event_type so you can rehearse a specific shape.\n\nThe full Developer reference covers listing, enabling, disabling and deleting\nendpoints."
        },
        {
          "anchor": "the-complete-event-list",
          "heading": "The complete event list",
          "text": "These 9 events are the complete set. Nothing else is ever emitted.\n\nEvent · Fires when\ndeposit.credited · A deposit cleared confirmations and was credited\nwithdrawal.confirmed · A payout confirmed on-chain\nwithdrawal.failed · A payout failed and the hold was refunded\ncard.kyc · A card holder's KYC status changed\ncard.status · A card's status changed (also emitted when an issue order settles)\ncard.topup · A card top-up order reached a terminal state (confirmed or failed)\ncard.withdraw · A card→wallet withdrawal order reached a terminal state (confirmed or failed)\ncard.transaction · A card transaction was created\ncard.wallet_otp · A live 3DS wallet OTP is available (notification only — the code is NOT in the payload)\n\nFull payload schemas and examples for each are on the generated\nWebhook events page."
        },
        {
          "anchor": "what-emits-nothing",
          "heading": "What emits nothing",
          "text": "Warning Swaps and exchanges emit NO webhook. There is no swap.completed and no\nexchange.completed. A flow that waits for one waits forever.\n\nPoll the order read instead, while status is non-terminal:\n\nFlow · Poll this\nSwaps and exchanges · GET /v1/swap/orders/{id}\n\nIt is the authoritative read. It has no event, and will not get one."
        },
        {
          "anchor": "delivery-mechanics",
          "heading": "Delivery mechanics",
          "text": "Each delivery is a POST with two headers:\n\nPOST /your/endpoint HTTP/1.1\nContent-Type: application/json\nx-cryptoinfra-event: deposit.credited\nx-cryptoinfra-signature: 9f2b…   (hex hmac-sha256 of the raw body)\n\nTimeout: 10 seconds per attempt.\nSuccess: any 2xx. Anything else — including a redirect — is a failure.\nRedirects are not followed. Register the final URL.\nRetries: exponential backoff, min(2^attempts × 30s, 1h) — roughly 60s, 2m, 4m, 8m,\n16m, 32m, then hourly — abandoned as failed after 8 attempts.\n\nAcknowledge with a 2xx immediately and do the work asynchronously. If your handler takes\nlonger than 10 seconds, the delivery is recorded as failed and retried, and you will\nprocess the same event again."
        },
        {
          "anchor": "verifying-the-signature",
          "heading": "Verifying the signature",
          "text": "x-cryptoinfra-signature is the hex HMAC-SHA256 of the exact raw request bytes, keyed\nwith that endpoint's secret.\n\nImportant Verify over the raw body, before parsing. Re-serialising the parsed JSON\nproduces different bytes (key order, whitespace, number formatting) and the signature will\nnot match.\n\nimport { createHmac, timingSafeEqual } from 'node:crypto';\nimport express from 'express';\n\nconst app = express();\n\n// give the handler the raw bytes, not a parsed object\napp.post('/webhooks/cryptoinfra', express.raw({ type: 'application/json' }), (req, res) => {\nconst expected = createHmac('sha256', process.env.CRYPTOINFRA_WEBHOOK_SECRET)\n.update(req.body)           // Buffer of the exact bytes received\n.digest('hex');\nconst given = req.get('x-cryptoinfra-signature') ?? '';\n\nconst a = Buffer.from(expected, 'utf8');\nconst b = Buffer.from(given, 'utf8');\nif (a.length !== b.length || !timingSafeEqual(a, b)) {\nreturn res.sendStatus(401);\n}\n\nconst event = req.get('x-cryptoinfra-event');\nconst payload = JSON.parse(req.body.toString('utf8'));\n\nenqueue(event, payload);      // do the work off the request path\nres.sendStatus(200);          // acknowledge fast\n});\n\nimport hmac, hashlib\nfrom flask import Flask, request, abort\n\napp = Flask(__name__)\n\n@app.post(\"/webhooks/cryptoinfra\")\ndef cryptoinfra_webhook():\nraw = request.get_data()                      # exact bytes, unparsed\nexpected = hmac.new(SECRET.encode(), raw, hashlib.sha256).hexdigest()\ngiven = request.headers.get(\"x-cryptoinfra-signature\", \"\")\nif not hmac.compare_digest(expected, given):\nabort(401)\n\nevent = request.headers.get(\"x-cryptoinfra-event\")\nenqueue(event, request.get_json())\nreturn \"\", 200\n\nAlways compare in constant time. Reject anything that fails, and reject anything with a\nmissing signature header — never fall through to processing it."
        },
        {
          "anchor": "replay-and-idempotency-on-your-side",
          "heading": "Replay and idempotency on your side",
          "text": "Delivery is at-least-once. You will receive the same event twice: after a timeout that\nactually succeeded, after a 5xx on your side, after a network blip.\n\nThere is no delivery-id header. Deduplicate on the pair\n(x-cryptoinfra-event, entity id) — the entity id being depositId, withdrawalId,\ncard_id, order_id or the equivalent field on that payload.\n\ncreate table webhook_seen (\nevent_type text not null,\nentity_id  text not null,\nseen_at    timestamptz not null default now(),\nprimary key (event_type, entity_id)\n);\n\nInsert with on conflict do nothing; if no row was inserted, you have already handled this\none — acknowledge 200 and stop. Make the handler itself idempotent too: crediting a user\nshould be safe to attempt twice even if the dedupe table is lost.\n\nNote that a status-transition event can legitimately arrive more than once with different\ncontent — card.status fires on each change. Key those on\n(event, entity id, new status) rather than on the entity alone."
        },
        {
          "anchor": "webhooks-are-hints-the-poll-is-truth",
          "heading": "Webhooks are hints; the poll is truth",
          "text": "Even where a webhook exists, it is a notification that something may have changed — not\nthe record of what changed. The read endpoints are authoritative:\n\nGET /v1/withdrawals/{id}\nGET /v1/swap/orders/{id}\nGET /v1/cards/orders/{order_id}\nGET /v1/users/{externalId}/balances\n\nBefore you credit a customer, issue a refund, or tell anyone that money has moved,\nreconcile against one of those. A webhook that never arrives — because your endpoint was\ndown for longer than the retry window — must not be able to strand money in your system.\nRun a reconciliation sweep over non-terminal records regardless of what did or did not\narrive."
        },
        {
          "anchor": "a-handler-checklist",
          "heading": "A handler checklist",
          "text": "Read the raw bytes before any body parser touches them.\nVerify the HMAC in constant time; reject on mismatch or a missing header.\nReturn 2xx in well under 10 seconds; queue the work.\nDeduplicate on (event, entity id) — and make the handler idempotent anyway.\nNever trust the payload as final: reconcile against the resource read.\nStore the secret like a credential; rotate it through /regenerate when it leaks."
        }
      ]
    },
    {
      "slug": "fees",
      "title": "Fees and pricing",
      "summary": "Every fee cryptoinfra charges, and for each one exactly which balance it comes out of — your customer's balance or the card itself.",
      "kind": "guide",
      "markdown": "# Fees and pricing\n\n## In one minute\n\n- **Deposits are free.** What the customer sends is what is credited.\n- **A payout costs the network's real price plus one flat platform fee**, charged on top of\n  the amount. Quote it with `GET /v1/withdrawal-fee-quote` and show `total_fee`.\n- **A swap costs a percentage of the amount, with a dollar minimum, plus its own network cost**, taken out of the sold amount.\n- **A card has an issue fee, a load fee and a close fee**, paid by the cardholder. A load\n  carries the network price of its send and a flat platform part beside the percentage; a\n  close is paid out of the balance the card returns, never from the wallet: our flat part, the\n  network price of the on-chain send, your own fee and what the card rail charges to release\n  the balance all come off it, and the holder receives the rest — quoted live per card as\n  `refund_quote` on the close quote. There\n  is no partial unload: the close is the only way value leaves a card.\n- **Every card movement is real coins, on-chain.** A load leaves the holder's own address\n  and only goes when the coins are there; a close's balance comes back to that address after\n  the card network's settlement period (the typical and longest times are in the card table\n  below). A close is always accepted — read `close_refund.state` on the card for where the\n  money is. The ledger never says one thing and the chain another.\n- **Your own fee, if you set one, goes on top** and lands in your balance; your affiliate\n  share of ours is paid on every payout, swap, card issue, load and close.\n- **Never type a number from this page into your code.** Every figure is priced when you\n  ask and the response says whether it is live. The tables below read the live sheet.\n- **Want a number for a given amount?** The [fee calculator](fee-calculator) applies this\n  minute's sheet to any amount, your own fee included.\n\nThis is the complete list. There is no monthly fee, no setup fee, no minimum volume and\nno per-API-call charge — the platform earns on movement, and only where a rate is stated\nbelow.\n\nEvery number here is also readable from the API, and **the API is the authority**. Rates can\nbe set per merchant, so if your account has been given a different rate, `GET /v1/fees`\nreturns yours, not the ones on this page. Read it at runtime rather than hard-coding.\n\n## Which balance pays\n\nTwo different balances pay for things on this platform, and mixing them up is the single\nmost common source of \"where did that money go\". Every fee below names the one it comes\nfrom.\n\n| Balance | What it is | What comes out of it |\n| --- | --- | --- |\n| **Customer balance** | One of your end-users' balances | Withdrawal fee, swap and exchange fees — and **every card cost**: issue, each load, close. Deposits are free |\n| **Card balance** | Money sitting on a card | The issuer's charges when the card is used |\n\n**Every card cost comes out of the cardholder's own balance.** Issue, every load and the\nclose are charged to the customer who holds the card, and to nobody else.\n\n**Loads are sent on-chain.** When a customer loads a card, cryptoinfra sends what the rail\nneeds to the rail's wallet in real time and funds the card once that send is confirmed and\ncredited. That is why a load passes through a `funding` stage and takes minutes rather than\nseconds, and why a load carries its own **gas** line — the network's live price for that\nsend, on top of the amount.\n\n### You can hold more than one programme\n\nA **programme** is a rail and its prices — the BIN a card is issued on and the fees that\nBIN carries. Your first card with a `core_id` opens that programme for you (or open it\nfrom the dashboard first), and you may hold several — Core-1, Core-2 — each with its own\nrail, its own BIN and its own prices.\n\n**A programme holds no money.** It decides the rail a card is on and the BIN that prices\nit; the card carries that programme for life. Every card cost is charged to the\ncardholder, and everything a card returns goes back to the cardholder.\n\nThat is why every card carries `core_id`, and why calls that could mean either programme\nask rather than guess:\n\n- `POST /v1/cards` takes `core_id`. Send it — with one programme open it is optional, and\n  the day you open a second, an omitted `core_id` is refused with `card_core_required`\n  rather than priced off a programme you did not mean.\n- `GET /v1/cards/products?core_id=…` is the shelf for one programme, priced for it.\n- Every card reads back the `core_id` it belongs to, and the `bin` it carries. An order\n  names its `card_id`; the programme that priced it is that card's.\n\n## At a glance\n\n**One rule for every fee: our live network cost, plus a flat platform fee.** The network\npart is what the send actually costs us on-chain, priced at the moment you ask; the flat\npart is ours. Quote before you promise — `GET /v1/withdrawal-fee-quote` returns the exact\ndeduction for the amount, the recipient and the sender.\n\n**No price on this page is typed in.** Every table below is read from the public\n[`GET /v1/pricing`](reference) when the page loads — no key needed — so what you see is\nthe price of this minute, and the same sheet an operator changes. Build against the\nendpoint, never against a number you copied from here.\n\n## Every figure says whether it is live\n\nA network figure is never a constant. It is priced when you ask, from the energy market\nand the TRX price of that moment, and it moves. Two small fields travel with it, the same\ntwo everywhere, and that is all you need to read:\n\n| Field | Meaning |\n| --- | --- |\n| `dynamic` | `true` — priced live now. `false` — the live price could not be read and a conservative fallback applies. Label the figure \"live\" or \"approx\" |\n| `quoted_at` on a quote, `priced_at` on `GET /v1/fees`, `as_of` on the price sheet, `gas_priced_at` on the card tariff | when the figure was computed — for this response, never stored |\n\nShow the total, label it, and re-read it before you show it again. A quote's\n`dynamic: false` is not an error: the send still goes, at the fallback.\nThe order you create afterwards carries the figure actually charged (`fee` on a\nwithdrawal, `gas` on a card order and its webhook) — reconcile against that, not against\nthe quote.\n\n<div class=\"live-pricing\" data-pricing=\"at-a-glance\" data-pricing-src=\"https://api.cryptoinfra.build/v1/pricing\"><p class=\"live-pricing-fallback\">Live prices: <code>GET https://api.cryptoinfra.build/v1/pricing</code> (no key needed) — this table fills in from it.</p></div>\n\nThe network figures move with the market, in both directions; the quote is the price.\n\n## Two ways to earn\n\nEverything above is what **we** charge. You earn on top of it in two ways, together or\nalone, and both land in **your own balance the moment your customer pays** — nothing to\nsettle, nothing to claim; pay it out from Payouts like any of your own money.\n\n### 1. Affiliate commission — a share of our fee, nothing to set\n\nOn every payout, swap and card your customers make, a share of **our platform fee** is\nyours. It is paid to your balance in the same moment our fee settles, automatically. Your\nshare is `affiliate.share_percent` on [`GET /v1/fees`](reference) (the platform's figure is\non the public price sheet above); the operator sets it and can give your account its own.\n\nThe share is of our fee, never of a cost: a payout's network bill, the network figure\ninside a swap fee, what the card rail takes, the issuer's cancellation on a close and\nthe network part of a close carry no commission — on a close your share is of our own flat\npart alone. Deposits are free, so they carry none either.\n\n### 2. Your own fee — a price above ours\n\nCharge **your own fee** on every operation — a percentage, a flat amount, or both, with no\nceiling. It is charged to your customer on top of ours and credited to your balance in the\nsame movement. Set it in the dashboard under **Profit → Your fees**, or with\n`PUT /v1/merchant/fees/{deposit|withdrawal|swap}`; cards have their own settings.\n\n| Where | How it is taken | Where you see it |\n| --- | --- | --- |\n| Deposit | skimmed from the credited amount after ours | `merchant_fee` on the deposit and the `deposit.credited` webhook; `amount_credited` is after both |\n| Withdrawal | held on top of the amount with ours | `merchant_fee` on `GET /v1/withdrawal-fee-quote` and the withdrawal; `total_fee` includes it |\n| Swap / exchange | from the sell side with ours | `merchant_fee` on the quote and the order |\n| Card issue / load / close | on top, charged to the cardholder | `merchant_fee` on the card quotes |\n\nYour fee is never charged on your own balance's operations or on a card programme pot. `GET /v1/fees` shows your fee beside ours under\n`merchant_fee` for each operation, and your commission share under `affiliate`.\n\n### Withdrawing your profit\n\n**Profit → Withdraw profit** sends what you have earned — commission and your own fees, less\nwhat you already withdrew — to any address. Our ordinary withdrawal fee is charged on top,\nso the amount plus that fee may not exceed the profit available (the API answers\n`409 profit_insufficient` past it). It needs your authenticator code, like every dashboard send.\n\nA profit withdrawal is a request to cryptoinfra. Every fee, ours and yours, is moved on-chain\nto our profit wallet as it settles, so that is where your earnings physically are; the request\nis approved there and then leaves that wallet in real time. Most go on their own the moment\nyou ask. A few wait for a look first: a large amount, an address we have never paid you at\n(it waits a fixed number of hours, then goes on its own), or a day's total past the cap. There is a small minimum, a limit on how many can be on the way at\nonce, and a short wait between two requests; the dialog shows the figures that apply today,\nand a refused request comes back with the reason (`409 profit_below_minimum`,\n`profit_too_many_open`, `profit_cooldown`). Owner and finance logins get a mail at each turn:\nrequested, waiting, approved, refused. Your Profit page lists every request with where it\nstands and why. A refused request returns the whole hold to your profit.\n\nAn earning matures before it can be withdrawn. It lands in your balance the moment the\ncustomer pays, but counts as profit available only after a wait: a short one for what was\nearned on-chain (payouts, deposits, swaps — final once the block is), a longer one for what\nwas earned on cards, which can still be refunded or disputed for days. The Profit page\nshows what is available and what is still pending, with the moment it becomes available;\nthe dialog shows the two waits that apply today. A request while nothing has matured yet\nanswers `409 profit_not_matured`, with the pending amount and that moment.\n\nIf an operation comes back — a swap the exchange refunds, a card load the rail refunds —\nthe earning on it comes back too: your own fee returns to the customer or\nto us, whichever refunded them, and your share of our fee returns to us. What your balance\nholds is taken at once. What it does not — you had withdrawn it already — is owed back: it is\nnetted from your next earnings, never from money you fund your account with, and no profit\nwithdrawal goes until it is settled (`409 profit_owed`). The Profit page lists every reversed\nearning with the reason, and `GET /v1/merchant/profit` carries them under `reversals`, the\npending figures under `profit_pending`, and what is owed under `profit_owed`.\n\n## Deposits\n\n**Free.** A deposit to an address we issue is credited in full: a 100 USDT deposit credits\n100.00 USDT. There is no platform fee and no network charge — the sender pays the\nchain's fee to reach the address, and we take nothing when it lands. The deposit row's\n`fee` is `0`.\n\n<div class=\"live-pricing\" data-pricing=\"deposit\" data-pricing-src=\"https://api.cryptoinfra.build/v1/pricing\"><p class=\"live-pricing-fallback\">Live prices: <code>GET https://api.cryptoinfra.build/v1/pricing</code> (no key needed) — this table fills in from it.</p></div>\n\nThe only thing ever taken out of a deposit is **your own fee**, if you have set one\n(`merchant_fee`; see *Your own fee* above), and then `amount_credited = amount − merchant_fee`.\n\n**A deposit below the asset's minimum is not credited at all.** It is marked `ignored_dust`\nand left alone — a dust floor, so that a balance never moves by an amount the chain cannot\neconomically move again. The floor is `min_deposit` in the table above and on\n[`GET /v1/assets`](reference); show it wherever you show a static wallet address.\n\nThe deposit row carries both figures and they are not interchangeable: `amount` is what the\ncustomer sent, `amount_credited` is what reached the balance (equal unless you charge a fee).\n\nLive schedule: [`GET /v1/fees`](reference) (`deposit.total` is `0`). See\n[Static Wallet Address](deposit-addresses) for how a payment becomes a balance.\n\n## Withdrawals\n\n**The flat platform fee + the network cost of the send, quoted live, on top of the amount.**\n\n**Charged to: the customer's balance.** `amount` is exactly what the recipient receives; the\nfee is held alongside it. A send of `amount` quoted at fee `f` holds **`amount + f`** and the\nrecipient gets **`amount`** — so a balance one cent short of `amount + f` cannot send it.\n\n<div class=\"live-pricing\" data-pricing=\"withdrawal\" data-pricing-src=\"https://api.cryptoinfra.build/v1/pricing\"><p class=\"live-pricing-fallback\">Live prices: <code>GET https://api.cryptoinfra.build/v1/pricing</code> (no key needed) — this table fills in from it.</p></div>\n\nThe network part is what the send costs us: the energy we rent for **two transfers** — the\npayout and our fee's own move to our wallet — the bandwidth of each, and, on a user's first\nsend, the TRX that creates their address on-chain. It is converted at the live TRX price\nwith a cushion (`cushion_pct` under `priced_from` on the sheet) and rounded up to the cent. `GET /v1/fees` shows today's figures\n(`withdrawal.network_fee` for a recipient that holds the token,\n`network_fee_fresh_recipient` for one that does not, `first_send_activation_fee` for the\none-time part); [`GET /v1/withdrawal-fee-quote`](withdrawals) with `to_address` and `user_id`\nprices the exact send, and `network_cost` on it says what the figure was built from.\n**The figure the quote returns is the one that is charged.**\n\nWhether a recipient already holds the token is your customer's wallet, not anything you can\nsee — pass `to_address` to the quote and show the customer the total before they confirm.\n\nWhen the live TRX price cannot be read, the send is charged the asset's static fallback —\n`static_fallback` on the price sheet — and the quote says so with `dynamic: false`. When\nonly the energy market is unreadable, the energy part is priced at a fixed fallback rate\nand the rest of the figure stays live; the quote is still `dynamic: true`. In both cases\nthe figure the quote returns is the one that is charged.\n\n**A network figure is never below what a send cost us yesterday.** Live or fallback, it\nis lifted to the dearest confirmed send of the last 24 hours, priced and cushioned like a\nquote. Nothing for you to read or handle — it is inside the figure the quote returns.\n\n### What you never do\n\n- **you never hold TRX**, prepay gas, or top anything up — every network cost is inside the quote;\n- guess a fee: the quote is the price, and it is rounded up, never down.\n\n## Swaps and exchanges\n\n**A percentage of the amount, with a dollar minimum, plus the swap's network cost** — both taken out of the\nsold amount and returned together as `fee` on the quote, with the network part on its own as\n`network_fee`.\n\n**Charged to: the balance being sold from** — the customer's, not yours.\n\nA USDT ⇄ TRX swap is traded on the TRON network itself, from the user's own address, so the\nnetwork part is that trade's real gas, priced live the way a payout is. The first USDT sale\nfrom an address also pays, once, for approving the trade. An exchange out of USDT or TRX pays\nthe same percentage and minimum plus the network cost of its own send. Nothing else is added.\n\n<div class=\"live-pricing\" data-pricing=\"swap\" data-pricing-src=\"https://api.cryptoinfra.build/v1/pricing\"><p class=\"live-pricing-fallback\">Live prices: <code>GET https://api.cryptoinfra.build/v1/pricing</code> (no key needed) — this table fills in from it.</p></div>\n\n**USDC on TRON cannot be swapped or exchanged today** — no route carries it. It can still be\ndeposited and withdrawn. `GET /v1/assets` marks such assets `swappable: false`; leave them\nout of your picker. A swap that names one on either side, or an exchange that sells one, is\nrefused with `400 invalid_pair` before anything is charged — the same answer a disabled\nasset gets.\n\nA **swap** moves between coins the platform custodies and credits the balance. An\n**exchange** sells a coin you hold and pays a different one out to an address you supply —\nsame fee rule, but the payout is as final as a withdrawal. The route itself may also carry a\nrouting fee of up to 0.5%, taken inside the route; `receive_estimate` and `receive_minimum`\nalready reflect it, so display those rather than working the buy side out from the sell\namount yourself. Quote first in both cases: the quote is the only thing that validates a\ndestination address, and it rejects a bad one before anything is charged.\n\nLive rate: [`POST /v1/swap/quote`](swaps) and [`POST /v1/exchange/quote`](exchange).\n\n## Card fees\n\n> **Every card cost is charged to the cardholder's own balance** — issue, each load, and the\n> close. Not the card, and not a programme balance. Your customer pays; if you have set your\n> own fee, your share lands in your own balance in the same movement.\n\n**The rules, in one place** (each is a fact you can read from the API, never a number to type in):\n\n1. **Issue** — a one-time issue fee plus a funded opening balance, priced as a load.\n2. **Load** — the load rate (`load_fee_bps`, floor `load_fee_min`) plus the flat platform\n   part (`load_fee_flat`) plus the live network figure (`gas`), all on top of the amount.\n   Sent on-chain from the holder's own address; refused with `409 card_coins_not_on_chain`\n   while the coins are not there.\n3. **No partial unload** — `POST /v1/cards/{id}/withdraw` is retired (`410`); the close is\n   the only way value leaves a card.\n4. **Close** — our flat close fee (`close_fee`) plus the live network figure for the on-chain\n   send, the issuer's cancellation kept on the card and the rail's release charge, all taken\n   from the balance being returned; the rest reaches the holder's own address. A close is\n   **never refused for money reasons**: read `close_refund.state` for where the money is.\n5. **Your share** — your affiliate commission on our part of every one of these, and your\n   own fee on top if you set one.\n\n### What a load costs, and where the money goes\n\nLoading a card is an on-chain send, and **the fees are charged on top of the amount**. Ask\nto load `amount` and the card holds `amount`; the cardholder's balance is debited more:\n\n| | Amount | |\n| --- | ---: | --- |\n| Lands on the card | **`amount`** | exactly what you asked for — sent on-chain to the card rail, plus the rail's own small fee so our balance there nets to zero |\n| Our fee | **+ `load_fee_bps` of `amount` + `load_fee_flat`** | the product's load rate on the price sheet (with its floor, `load_fee_min`) plus the flat platform part |\n| Gas | **+ `gas`** | the network's live price for that send — the load and our fee's own transfer, priced for an address that holds none of the token, so it is its own figure, not a withdrawal's |\n| Your fee | **+ `merchant_fee`** | unless you set none (below) |\n| **Charged to the cardholder** | **the sum** | `total` on the quote |\n\nThe number the cardholder chose is the number that reaches the card, and every fee is a\nvisible line of its own — the way every card product they have ever used behaves.\n\n`GET /v1/cards/topup-quote?amount=100&card_id=…&user_id=…` returns exactly these lines —\n`fee`, `gas`, `merchant_fee`, `credited`, `total` — priced for that card. `credited` is\nwhat reaches the card (the amount itself) and **`total` is what the balance is debited**.\nShow `total` wherever you tell someone what a load will cost, and check it against the\nbalance first: a load the balance cannot cover is refused before anything is charged.\n\n**A load below the product's minimum is refused** with `400`, and the error names the\nminimum.\n\n**It takes minutes.** The order is `funding` while the coins travel, `requested` once the\nchain confirms them, `sent` once the rail is asked, `confirmed` once the card is funded. A\n`card.topup` webhook tells you at the end; treat everything before it as pending. If the\nrail refuses, everything — including gas — is refunded to the cardholder.\n\n**A card's price belongs to its programme and BIN together, not to the platform.** Two BINs\ncan be open at once, priced completely differently, and the same BIN number under a\ndifferent programme is a different card with different prices.\n\nSo the pair is the key. Read `core_id` **and** `card_bin` out of a response before comparing\nprices — a BIN number on its own does not identify a card's terms, and code that treats it\nas if it does will price one programme's card off another's sheet.\n\n`GET /v1/cards/products` is the per-programme version of the table below and the one to\nbuild against with your key; add `?core_id=…` to see one programme's shelf priced for that\nprogramme. Its `issue_total` is everything the cardholder pays to issue that card at the\nminimum opening balance, your own fee included. The table here is the public sheet, live:\n\n<div class=\"live-pricing\" data-pricing=\"cards\" data-pricing-src=\"https://api.cryptoinfra.build/v1/pricing\"><p class=\"live-pricing-fallback\">Live prices: <code>GET https://api.cryptoinfra.build/v1/pricing</code> (no key needed) — this table fills in from it.</p></div>\n\nTake the virtual product as the worked example; the same shape applies to any row.\n\n**Issue fee — one time, from the cardholder.** Charged when `POST /v1/cards` is accepted.\nFlat, not a percentage of the opening load, and it is the whole price of the card itself:\nnothing else is charged for issuing.\n\n**Minimum opening load — required, from the cardholder.** A card is issued *funded, never\nempty*: `initial_amount` on `POST /v1/cards` must be at least `min_opening_balance`. It is\npriced exactly like a load — the load rate + gas on top, so the card opens holding the full\namount — and it goes on-chain with the issue, so a new card takes a few minutes to arrive.\nIssuing at the minimum charges the cardholder\n**issue fee + minimum opening load + its load fee + gas** — `issue_total_at_minimum` on the\nsheet above, `issue_total` on the products shelf (yours included) — and\n`GET /v1/cards/issue-quote` prices any other opening balance. An issue for a holder whose\nbalance is short is refused with `409 insufficient_balance` before anything is charged.\n\n**Load fee — the load rate + gas, on top of the amount, on every load after that.** Loading\n`amount` puts exactly `amount` on the card and charges `amount + fee + gas`. A load pays\nits own way **on-chain from the holder's own address**: the coins have to be there. A\nbalance the ledger shows but the chain does not yet — a close refund or a deposit still on\nits way — answers `409 card_coins_not_on_chain`; nothing is charged, load again once it has\narrived.\n\n**Close fee — flat plus the live network figure, taken from the balance the card returns.**\nThe flat part is ours; the network part pays for returning the card's balance to the\nholder's own address on-chain. Two more things come off that balance before it arrives, and\nneither is ours:\n\n- **the issuer's cancellation charge**, kept on the card (a card holding less simply keeps\n  what it has — a close is never refused for it);\n- **the card network's release charge** — a percentage of the balance with a minimum, capped\n  below a threshold — shown per card as `close_refund` on the price sheet.\n\nThe refund then reaches the holder's address after the settlement period — usually quickly,\nnever later than the longest time on the price sheet. `GET /v1/cards/{id}/close-quote` does\nthe whole sum for one card as `refund_quote`; show its `refund` before your user confirms.\n\nThere is no partial unload of a card, and there is no monthly or dormancy fee on any BIN\ntoday; a future row would say so explicitly if that\nchanged. The issuer may net its own unload tariff out of what it releases, so the balance\nis credited what actually came off the card — `amount_credited` on the order, which can be\nless than `amount`.\n\n### Your own fee, on top of ours\n\nOurs is the floor and is always charged. On top of it you may add a percentage of the\namount, a flat amount, or both — separately for issuing, loading and closing — over the\nAPI:\n\n```\nGET    /v1/merchant/card-fees                 ours, yours, and what the cardholder pays\nPUT    /v1/merchant/card-fees/load            { \"percent_bps\": 50, \"flat\": \"0.25\" }\nPUT    /v1/merchant/card-fees/issue           { \"flat\": \"2\" }\nDELETE /v1/merchant/card-fees/close\n```\n\n`percent_bps` is basis points: 100 = 1%, at most 1000 (10%). Your fee is charged to the\ncardholder with everything else and credited to **your own balance in the same movement** —\nthere is nothing to collect. Every quote (`topup-quote`, `issue-quote`, `close-quote`,\n`issue_total`) and every `card.*` webhook shows it as `merchant_fee`.\n\nYour account needs a balance of its own for the fee to land in; setting a fee without one is\nrefused with `409 no_own_account`.\n\n### Adding a card to a phone wallet\n\nBIN 493 provisions into **Apple Pay, Google Pay and Samsung Pay**. Wallet support is a\nproperty of the BIN, not of the platform: do not assume a future BIN carries it.\n\n**BIN 493 prints no cardholder name.** The issuer has no such field, so nothing is\nembossed on the card itself. `name_on_card` is still worth sending: we store it against\nthe card and return it on every read, so your dashboard and ours can tell one cardholder\nfrom another — `last4` cannot.\n\nProvisioning asks the cardholder to verify the card, and that verification code comes from\nus rather than from the wallet. It appears live on the card's page in your dashboard the\nmoment it arrives; over the API, read it with [`GET /v1/cards/{id}/otp`](cards) and show it\nto the cardholder. The code is short-lived and read once per channel — once with your API\nkey, once in the dashboard, so your server fetching it never hides it from the dashboard —\nso fetch it when they are looking at the screen, not in advance. Without one of the two the\ncustomer waits for an SMS that is never coming.\n\n### Closing a card\n\n**Everything below is per programme and BIN.** A card's close fee, the issuer's cancellation\ncharge, the rail's release charge and the settlement period are properties of the rail and BIN\nthat card sits on — not of cryptoinfra, and not of your account. Two of your programmes can\nanswer differently on the same day. The close quote prices the card you name; treat it as the\nonly source.\n\n**Our close fee has two parts.** A flat part (`close_fee` on the sheet — our own charge, and\nnothing else) and the live network figure (`gas` on the close quote, with `dynamic`) for\nreturning the card's balance to the holder's own address on-chain. Separately the issuer\nkeeps its own cancellation charge on the card, which is also the least the card must hold\n(`cancellation` under the cardholder fees on the sheet, `minimum_card_balance` on the close\nquote), and the card rail charges to release the rest to us (`rail_return_fee`).\n\n**Everything is taken from the balance the card returns**, never from the holder's wallet.\n`refund_quote` on the close quote does the arithmetic live and names every line; the holder\nreceives `refund`.\n\n**The balance comes back on-chain.** Cancelling a card opens the issuer's settlement period,\nwhich the issuer says can run to **45 days** (`refund_eta_max_days` on the close quote) —\nthough it has settled within 24–48 hours in practice. Once released, the balance reaches the\nholder's own address within `refund_eta_working_days` working days, paid from a wallet\ncryptoinfra funds by hand. The close itself is never refused while that is arranged: read\n`close_refund.state` on `GET /v1/cards/{id}` for where the money is (`waiting` → `approved` →\n`sending` → `paid`). Until the refund lands the holder cannot load another card with that\nbalance (a load needs the coins at their address, rule 2 above).\n\n| Card balance at close | What happens |\n| --- | --- |\n| **More than C** | The excess returns to the cardholder's balance; **C stays on the card** and the issuer takes it. |\n| **Exactly C** | The issuer takes it from the card. Nothing returns. |\n| **Less than C, or empty** | **Closes anyway** (20 September 2026). The issuer keeps whatever is on the card as its cancellation charge, there is nothing to return, and nobody is asked to load a card they want to be rid of. |\n\nA card holding **B**, more than C, closes as: **B − C** released by the rail, less the rail's\nown release charge and less our close fee, the network figure and yours — and what remains is\nthe holder's `refund`. Nothing is ever debited from their wallet balance, so an empty wallet\nnever blocks a close.\n\n`GET /v1/cards/{id}/close-quote?user_id=…` shows what the close itself will debit the\ncardholder — `fee` (our flat part), `gas` (the live network figure, `dynamic` says whether it\nwas priced now), `merchant_fee` (yours) and `total` — with `charged_to: \"cardholder\"` and\n`refund_eta_working_days`. There is nothing for you to credit afterwards: the residual lands in your\ncustomer's balance on its own, and if you want to charge them for closing, set your own\nclose fee rather than moving money by hand.\n\n**Read the settled amount, not the card's last known balance.** The residual comes back as\na withdraw order and is credited when the rail confirms it; `amount_credited` on that order\nis what actually reached the balance, and it can be less than `amount` if the issuer netted\nits own unload tariff.\n\n#### The call\n\n`DELETE /v1/cards/{id}` (with `user_id` in the body) does the return itself — you do not\nunload first:\n\n1. The first call unloads everything above C and answers **`202 card_close_in_progress`**;\n   `details.order_id` is the withdraw order and `details.card_id` the card. A `card.withdraw`\n   webhook tells you when it settles.\n2. Call it again once that order is confirmed; our close fee (and yours, if set) is charged\n   to the balance, the card terminates and the issuer keeps C.\n\nA card at or below C closes on the first call. Calling close again on a card whose\nreturn is still travelling answers the same `202` with the same order; retrying a close\nnever double-charges, and a card already closed answers `409 card_state`.\n\n**Anything still pending** → `409 card_state`, whatever the balance reads. It can settle\nafter the card is gone, so it has to clear first. A balance the rail cannot confirm right\nnow is `400 invalid_request` — try again, rather than closing on an unknown figure.\n\n### Fees the issuer charges on card use\n\nEverything above is what the **cardholder** pays us. These are the **issuer's** charges,\nand they never touch a cryptoinfra balance.\n\n**Charged to: the card balance.** They appear on the card's own transaction history, so\nyour customer sees them.\n\n<div class=\"live-pricing\" data-pricing=\"card-holder\" data-pricing-src=\"https://api.cryptoinfra.build/v1/pricing\"><p class=\"live-pricing-fallback\">Live prices: <code>GET https://api.cryptoinfra.build/v1/pricing</code> (no key needed) — this table fills in from it.</p></div>\n\nEvery one of these follows the same rule: **taken from the card when it happens.** When the\ncard cannot cover it — a dispute landing on a spent-down card is the usual case — the\nissuer bills the difference to us, not to you and not to your customer's balance.\n\n**The fee is inside the transaction, not added to it.** A purchase shown as $3.86 with a\n$0.25 fee takes $3.86 from the card in total — the merchant received $3.61 and the issuer\nkept $0.25. Card loads read the same way: a $50.35 movement puts $50.00 on the card.\n\n**3-D Secure is charged per authentication, not per purchase.** A checkout that challenges\nthe cardholder costs the 3-D Secure fee in the table above whether or not the purchase\ncompletes, so a customer who abandons at the 3DS step still generates one. It is small, but\nit is the only fee here that can fire without a sale behind it.\n\n### Spending limits on the card\n\nEvery BIN carries the issuer's own ceilings: how much one purchase can be, how much the card\ncan spend in a day and in a month, how much cash it can draw at an ATM, and how long it is\nvalid. They are the same for every merchant on that BIN, and nothing on our side raises them.\n\n<div class=\"live-pricing\" data-pricing=\"card-limits\" data-pricing-src=\"https://api.cryptoinfra.build/v1/pricing\"><p class=\"live-pricing-fallback\">Live prices: <code>GET https://api.cryptoinfra.build/v1/pricing</code> (no key needed) — this table fills in from it.</p></div>\n\n**A purchase over a limit is declined**, whatever the card holds. Show these before a\ncustomer loads a card for one large payment.\nThe same figures are on every product as `limits` on `GET /v1/cards/products`; read them\nthere for the BIN in front of you rather than copying one from this page. A BIN marked *not\npublished yet* has limits of its own; they are not on the sheet yet.\n\n## Gas, on TRON\n\n**Nothing to do.** Where the gas shows up depends on the movement:\n\n- **A payout** — inside the withdrawal fee, nothing extra. See [Withdrawals](#withdrawals)\n  for what that covers.\n- **A card load or issue** — its own line, `gas`, on top of the amount. See\n  [What a load costs](#what-a-load-costs-and-where-the-money-goes).\n\nBoth are priced live; [Every figure says whether it is live](#every-figure-says-whether-it-is-live)\nexplains how to read the figure you are shown.\n\nYou never hold TRX, never top up a gas balance and never switch anything on.\n\nSweeps between our own addresses never cost you gas at all — those are ours.\n\n## What is never charged\n\n- **The API itself.** No monthly plan, no per-call fee, no tiers. Rate limits, not bills,\n  are what keep usage sane.\n- **Creating users or addresses.** Unlimited, free, permanent.\n- **Receiving a deposit.** Nothing is taken when money lands.\n- **Webhooks, retries, the dashboard, or the documentation MCP server.**\n\n## Reading rates from the API\n\n| Call | Returns |\n| --- | --- |\n| [`GET /v1/fees`](reference) | The withdrawal fee per asset — `network_fee` plus any rate on your account — and `deposit.total` (`0`), with `dynamic` and `priced_at`. Swap and card prices are on their quote endpoints |\n| [`GET /v1/withdrawal-fee-quote`](withdrawals) | The exact fee for a specific withdrawal — `total_fee` is the number to show — with `dynamic` and `quoted_at` |\n| [`GET /v1/cards/products`](cards) | Card issue fee, load rate, close fee and minimum opening balance per BIN, with `issue_total`; the `gas` inside it, `gas_dynamic` and `gas_priced_at` |\n| [`GET /v1/cards/issue-quote`](cards) | An issue priced end to end for a chosen opening balance — `total` is the number to show — with `dynamic` |\n| [`GET /v1/cards/topup-quote`](cards) | A load priced end to end — fee, gas, your fee, `total` — with `dynamic` |\n| [`GET /v1/cards/{id}/close-quote`](cards) | What closing a card debits the cardholder |\n| [`POST /v1/swap/quote`](swaps) | A swap priced end to end, fee included |\n| [`POST /v1/exchange/quote`](exchange) | An exchange priced end to end, fee included |\n\nQuote endpoints return the number that will actually be charged. If a quote and this page\never disagree, the quote is right and the page is a bug — please report it. Every quote\nalso says when it was priced and whether the figure is live; carry that to your own\ncustomers rather than printing a number as a fixed charge.\n",
      "sections": [
        {
          "anchor": "in-one-minute",
          "heading": "In one minute",
          "text": "Deposits are free. What the customer sends is what is credited.\nA payout costs the network's real price plus one flat platform fee, charged on top of\nthe amount. Quote it with GET /v1/withdrawal-fee-quote and show total_fee.\nA swap costs a percentage of the amount, with a dollar minimum, plus its own network cost, taken out of the sold amount.\nA card has an issue fee, a load fee and a close fee, paid by the cardholder. A load\ncarries the network price of its send and a flat platform part beside the percentage; a\nclose is paid out of the balance the card returns, never from the wallet: our flat part, the\nnetwork price of the on-chain send, your own fee and what the card rail charges to release\nthe balance all come off it, and the holder receives the rest — quoted live per card as\nrefund_quote on the close quote. There\nis no partial unload: the close is the only way value leaves a card.\nEvery card movement is real coins, on-chain. A load leaves the holder's own address\nand only goes when the coins are there; a close's balance comes back to that address after\nthe card network's settlement period (the typical and longest times are in the card table\nbelow). A close is always accepted — read close_refund.state on the card for where the\nmoney is. The ledger never says one thing and the chain another.\nYour own fee, if you set one, goes on top and lands in your balance; your affiliate\nshare of ours is paid on every payout, swap, card issue, load and close.\nNever type a number from this page into your code. Every figure is priced when you\nask and the response says whether it is live. The tables below read the live sheet.\nWant a number for a given amount? The fee calculator applies this\nminute's sheet to any amount, your own fee included.\n\nThis is the complete list. There is no monthly fee, no setup fee, no minimum volume and\nno per-API-call charge — the platform earns on movement, and only where a rate is stated\nbelow.\n\nEvery number here is also readable from the API, and the API is the authority. Rates can\nbe set per merchant, so if your account has been given a different rate, GET /v1/fees\nreturns yours, not the ones on this page. Read it at runtime rather than hard-coding."
        },
        {
          "anchor": "which-balance-pays",
          "heading": "Which balance pays",
          "text": "Two different balances pay for things on this platform, and mixing them up is the single\nmost common source of \"where did that money go\". Every fee below names the one it comes\nfrom.\n\nBalance · What it is · What comes out of it\nCustomer balance · One of your end-users' balances · Withdrawal fee, swap and exchange fees — and every card cost: issue, each load, close. Deposits are free\nCard balance · Money sitting on a card · The issuer's charges when the card is used\n\nEvery card cost comes out of the cardholder's own balance. Issue, every load and the\nclose are charged to the customer who holds the card, and to nobody else.\n\nLoads are sent on-chain. When a customer loads a card, cryptoinfra sends what the rail\nneeds to the rail's wallet in real time and funds the card once that send is confirmed and\ncredited. That is why a load passes through a funding stage and takes minutes rather than\nseconds, and why a load carries its own gas line — the network's live price for that\nsend, on top of the amount.\n\nYou can hold more than one programme\n\nA programme is a rail and its prices — the BIN a card is issued on and the fees that\nBIN carries. Your first card with a core_id opens that programme for you (or open it\nfrom the dashboard first), and you may hold several — Core-1, Core-2 — each with its own\nrail, its own BIN and its own prices.\n\nA programme holds no money. It decides the rail a card is on and the BIN that prices\nit; the card carries that programme for life. Every card cost is charged to the\ncardholder, and everything a card returns goes back to the cardholder.\n\nThat is why every card carries core_id, and why calls that could mean either programme\nask rather than guess:\n\nPOST /v1/cards takes core_id. Send it — with one programme open it is optional, and\nthe day you open a second, an omitted core_id is refused with card_core_required\nrather than priced off a programme you did not mean.\nGET /v1/cards/products?core_id=… is the shelf for one programme, priced for it.\nEvery card reads back the core_id it belongs to, and the bin it carries. An order\nnames its card_id; the programme that priced it is that card's."
        },
        {
          "anchor": "you-can-hold-more-than-one-programme",
          "heading": "You can hold more than one programme",
          "text": "A programme is a rail and its prices — the BIN a card is issued on and the fees that\nBIN carries. Your first card with a core_id opens that programme for you (or open it\nfrom the dashboard first), and you may hold several — Core-1, Core-2 — each with its own\nrail, its own BIN and its own prices.\n\nA programme holds no money. It decides the rail a card is on and the BIN that prices\nit; the card carries that programme for life. Every card cost is charged to the\ncardholder, and everything a card returns goes back to the cardholder.\n\nThat is why every card carries core_id, and why calls that could mean either programme\nask rather than guess:\n\nPOST /v1/cards takes core_id. Send it — with one programme open it is optional, and\nthe day you open a second, an omitted core_id is refused with card_core_required\nrather than priced off a programme you did not mean.\nGET /v1/cards/products?core_id=… is the shelf for one programme, priced for it.\nEvery card reads back the core_id it belongs to, and the bin it carries. An order\nnames its card_id; the programme that priced it is that card's."
        },
        {
          "anchor": "at-a-glance",
          "heading": "At a glance",
          "text": "One rule for every fee: our live network cost, plus a flat platform fee. The network\npart is what the send actually costs us on-chain, priced at the moment you ask; the flat\npart is ours. Quote before you promise — GET /v1/withdrawal-fee-quote returns the exact\ndeduction for the amount, the recipient and the sender.\n\nNo price on this page is typed in. Every table below is read from the public\nGET /v1/pricing when the page loads — no key needed — so what you see is\nthe price of this minute, and the same sheet an operator changes. Build against the\nendpoint, never against a number you copied from here."
        },
        {
          "anchor": "every-figure-says-whether-it-is-live",
          "heading": "Every figure says whether it is live",
          "text": "A network figure is never a constant. It is priced when you ask, from the energy market\nand the TRX price of that moment, and it moves. Two small fields travel with it, the same\ntwo everywhere, and that is all you need to read:\n\nField · Meaning\ndynamic · true — priced live now. false — the live price could not be read and a conservative fallback applies. Label the figure \"live\" or \"approx\"\nquoted_at on a quote, priced_at on GET /v1/fees, as_of on the price sheet, gas_priced_at on the card tariff · when the figure was computed — for this response, never stored\n\nShow the total, label it, and re-read it before you show it again. A quote's\ndynamic: false is not an error: the send still goes, at the fallback.\nThe order you create afterwards carries the figure actually charged (fee on a\nwithdrawal, gas on a card order and its webhook) — reconcile against that, not against\nthe quote.\n\n<div class=\"live-pricing\" data-pricing=\"at-a-glance\" data-pricing-src=\"https://api.cryptoinfra.build/v1/pricing\"><p class=\"live-pricing-fallback\">Live prices: <code>GET https://api.cryptoinfra.build/v1/pricing</code> (no key needed) — this table fills in from it.</p></div>\n\nThe network figures move with the market, in both directions; the quote is the price."
        },
        {
          "anchor": "two-ways-to-earn",
          "heading": "Two ways to earn",
          "text": "Everything above is what we charge. You earn on top of it in two ways, together or\nalone, and both land in your own balance the moment your customer pays — nothing to\nsettle, nothing to claim; pay it out from Payouts like any of your own money.\n\nAffiliate commission — a share of our fee, nothing to set\n\nOn every payout, swap and card your customers make, a share of our platform fee is\nyours. It is paid to your balance in the same moment our fee settles, automatically. Your\nshare is affiliate.share_percent on GET /v1/fees (the platform's figure is\non the public price sheet above); the operator sets it and can give your account its own.\n\nThe share is of our fee, never of a cost: a payout's network bill, the network figure\ninside a swap fee, what the card rail takes, the issuer's cancellation on a close and\nthe network part of a close carry no commission — on a close your share is of our own flat\npart alone. Deposits are free, so they carry none either.\n\nYour own fee — a price above ours\n\nCharge your own fee on every operation — a percentage, a flat amount, or both, with no\nceiling. It is charged to your customer on top of ours and credited to your balance in the\nsame movement. Set it in the dashboard under Profit → Your fees, or with\nPUT /v1/merchant/fees/{deposit · withdrawal · swap}; cards have their own settings.\n\nWhere · How it is taken · Where you see it\nDeposit · skimmed from the credited amount after ours · merchant_fee on the deposit and the deposit.credited webhook; amount_credited is after both\nWithdrawal · held on top of the amount with ours · merchant_fee on GET /v1/withdrawal-fee-quote and the withdrawal; total_fee includes it\nSwap / exchange · from the sell side with ours · merchant_fee on the quote and the order\nCard issue / load / close · on top, charged to the cardholder · merchant_fee on the card quotes\n\nYour fee is never charged on your own balance's operations or on a card programme pot. GET /v1/fees shows your fee beside ours under\nmerchant_fee for each operation, and your commission share under affiliate.\n\nWithdrawing your profit\n\nProfit → Withdraw profit sends what you have earned — commission and your own fees, less\nwhat you already withdrew — to any address. Our ordinary withdrawal fee is charged on top,\nso the amount plus that fee may not exceed the profit available (the API answers\n409 profit_insufficient past it). It needs your authenticator code, like every dashboard send.\n\nA profit withdrawal is a request to cryptoinfra. Every fee, ours and yours, is moved on-chain\nto our profit wallet as it settles, so that is where your earnings physically are; the request\nis approved there and then leaves that wallet in real time. Most go on their own the moment\nyou ask. A few wait for a look first: a large amount, an address we have never paid you at\n(it waits a fixed number of hours, then goes on its own), or a day's total past the cap. There is a small minimum, a limit on how many can be on the way at\nonce, and a short wait between two requests; the dialog shows the figures that apply today,\nand a refused request comes back with the reason (409 profit_below_minimum,\nprofit_too_many_open, profit_cooldown). Owner and finance logins get a mail at each turn:\nrequested, waiting, approved, refused. Your Profit page lists every request with where it\nstands and why. A refused request returns the whole hold to your profit.\n\nAn earning matures before it can be withdrawn. It lands in your balance the moment the\ncustomer pays, but counts as profit available only after a wait: a short one for what was\nearned on-chain (payouts, deposits, swaps — final once the block is), a longer one for what\nwas earned on cards, which can still be refunded or disputed for days. The Profit page\nshows what is available and what is still pending, with the moment it becomes available;\nthe dialog shows the two waits that apply today. A request while nothing has matured yet\nanswers 409 profit_not_matured, with the pending amount and that moment.\n\nIf an operation comes back — a swap the exchange refunds, a card load the rail refunds —\nthe earning on it comes back too: your own fee returns to the customer or\nto us, whichever refunded them, and your share of our fee returns to us. What your balance\nholds is taken at once. What it does not — you had withdrawn it already — is owed back: it is\nnetted from your next earnings, never from money you fund your account with, and no profit\nwithdrawal goes until it is settled (409 profit_owed). The Profit page lists every reversed\nearning with the reason, and GET /v1/merchant/profit carries them under reversals, the\npending figures under profit_pending, and what is owed under profit_owed."
        },
        {
          "anchor": "1-affiliate-commission-a-share-of-our-fee-nothing-to-set",
          "heading": "1. Affiliate commission — a share of our fee, nothing to set",
          "text": "On every payout, swap and card your customers make, a share of our platform fee is\nyours. It is paid to your balance in the same moment our fee settles, automatically. Your\nshare is affiliate.share_percent on GET /v1/fees (the platform's figure is\non the public price sheet above); the operator sets it and can give your account its own.\n\nThe share is of our fee, never of a cost: a payout's network bill, the network figure\ninside a swap fee, what the card rail takes, the issuer's cancellation on a close and\nthe network part of a close carry no commission — on a close your share is of our own flat\npart alone. Deposits are free, so they carry none either."
        },
        {
          "anchor": "2-your-own-fee-a-price-above-ours",
          "heading": "2. Your own fee — a price above ours",
          "text": "Charge your own fee on every operation — a percentage, a flat amount, or both, with no\nceiling. It is charged to your customer on top of ours and credited to your balance in the\nsame movement. Set it in the dashboard under Profit → Your fees, or with\nPUT /v1/merchant/fees/{deposit · withdrawal · swap}; cards have their own settings.\n\nWhere · How it is taken · Where you see it\nDeposit · skimmed from the credited amount after ours · merchant_fee on the deposit and the deposit.credited webhook; amount_credited is after both\nWithdrawal · held on top of the amount with ours · merchant_fee on GET /v1/withdrawal-fee-quote and the withdrawal; total_fee includes it\nSwap / exchange · from the sell side with ours · merchant_fee on the quote and the order\nCard issue / load / close · on top, charged to the cardholder · merchant_fee on the card quotes\n\nYour fee is never charged on your own balance's operations or on a card programme pot. GET /v1/fees shows your fee beside ours under\nmerchant_fee for each operation, and your commission share under affiliate."
        },
        {
          "anchor": "withdrawing-your-profit",
          "heading": "Withdrawing your profit",
          "text": "Profit → Withdraw profit sends what you have earned — commission and your own fees, less\nwhat you already withdrew — to any address. Our ordinary withdrawal fee is charged on top,\nso the amount plus that fee may not exceed the profit available (the API answers\n409 profit_insufficient past it). It needs your authenticator code, like every dashboard send.\n\nA profit withdrawal is a request to cryptoinfra. Every fee, ours and yours, is moved on-chain\nto our profit wallet as it settles, so that is where your earnings physically are; the request\nis approved there and then leaves that wallet in real time. Most go on their own the moment\nyou ask. A few wait for a look first: a large amount, an address we have never paid you at\n(it waits a fixed number of hours, then goes on its own), or a day's total past the cap. There is a small minimum, a limit on how many can be on the way at\nonce, and a short wait between two requests; the dialog shows the figures that apply today,\nand a refused request comes back with the reason (409 profit_below_minimum,\nprofit_too_many_open, profit_cooldown). Owner and finance logins get a mail at each turn:\nrequested, waiting, approved, refused. Your Profit page lists every request with where it\nstands and why. A refused request returns the whole hold to your profit.\n\nAn earning matures before it can be withdrawn. It lands in your balance the moment the\ncustomer pays, but counts as profit available only after a wait: a short one for what was\nearned on-chain (payouts, deposits, swaps — final once the block is), a longer one for what\nwas earned on cards, which can still be refunded or disputed for days. The Profit page\nshows what is available and what is still pending, with the moment it becomes available;\nthe dialog shows the two waits that apply today. A request while nothing has matured yet\nanswers 409 profit_not_matured, with the pending amount and that moment.\n\nIf an operation comes back — a swap the exchange refunds, a card load the rail refunds —\nthe earning on it comes back too: your own fee returns to the customer or\nto us, whichever refunded them, and your share of our fee returns to us. What your balance\nholds is taken at once. What it does not — you had withdrawn it already — is owed back: it is\nnetted from your next earnings, never from money you fund your account with, and no profit\nwithdrawal goes until it is settled (409 profit_owed). The Profit page lists every reversed\nearning with the reason, and GET /v1/merchant/profit carries them under reversals, the\npending figures under profit_pending, and what is owed under profit_owed."
        },
        {
          "anchor": "deposits",
          "heading": "Deposits",
          "text": "Free. A deposit to an address we issue is credited in full: a 100 USDT deposit credits\n100.00 USDT. There is no platform fee and no network charge — the sender pays the\nchain's fee to reach the address, and we take nothing when it lands. The deposit row's\nfee is 0.\n\n<div class=\"live-pricing\" data-pricing=\"deposit\" data-pricing-src=\"https://api.cryptoinfra.build/v1/pricing\"><p class=\"live-pricing-fallback\">Live prices: <code>GET https://api.cryptoinfra.build/v1/pricing</code> (no key needed) — this table fills in from it.</p></div>\n\nThe only thing ever taken out of a deposit is your own fee, if you have set one\n(merchant_fee; see Your own fee above), and then amount_credited = amount − merchant_fee.\n\nA deposit below the asset's minimum is not credited at all. It is marked ignored_dust\nand left alone — a dust floor, so that a balance never moves by an amount the chain cannot\neconomically move again. The floor is min_deposit in the table above and on\nGET /v1/assets; show it wherever you show a static wallet address.\n\nThe deposit row carries both figures and they are not interchangeable: amount is what the\ncustomer sent, amount_credited is what reached the balance (equal unless you charge a fee).\n\nLive schedule: GET /v1/fees (deposit.total is 0). See\nStatic Wallet Address for how a payment becomes a balance."
        },
        {
          "anchor": "withdrawals",
          "heading": "Withdrawals",
          "text": "The flat platform fee + the network cost of the send, quoted live, on top of the amount.\n\nCharged to: the customer's balance. amount is exactly what the recipient receives; the\nfee is held alongside it. A send of amount quoted at fee f holds amount + f and the\nrecipient gets amount — so a balance one cent short of amount + f cannot send it.\n\n<div class=\"live-pricing\" data-pricing=\"withdrawal\" data-pricing-src=\"https://api.cryptoinfra.build/v1/pricing\"><p class=\"live-pricing-fallback\">Live prices: <code>GET https://api.cryptoinfra.build/v1/pricing</code> (no key needed) — this table fills in from it.</p></div>\n\nThe network part is what the send costs us: the energy we rent for two transfers — the\npayout and our fee's own move to our wallet — the bandwidth of each, and, on a user's first\nsend, the TRX that creates their address on-chain. It is converted at the live TRX price\nwith a cushion (cushion_pct under priced_from on the sheet) and rounded up to the cent. GET /v1/fees shows today's figures\n(withdrawal.network_fee for a recipient that holds the token,\nnetwork_fee_fresh_recipient for one that does not, first_send_activation_fee for the\none-time part); GET /v1/withdrawal-fee-quote with to_address and user_id\nprices the exact send, and network_cost on it says what the figure was built from.\nThe figure the quote returns is the one that is charged.\n\nWhether a recipient already holds the token is your customer's wallet, not anything you can\nsee — pass to_address to the quote and show the customer the total before they confirm.\n\nWhen the live TRX price cannot be read, the send is charged the asset's static fallback —\nstatic_fallback on the price sheet — and the quote says so with dynamic: false. When\nonly the energy market is unreadable, the energy part is priced at a fixed fallback rate\nand the rest of the figure stays live; the quote is still dynamic: true. In both cases\nthe figure the quote returns is the one that is charged.\n\nA network figure is never below what a send cost us yesterday. Live or fallback, it\nis lifted to the dearest confirmed send of the last 24 hours, priced and cushioned like a\nquote. Nothing for you to read or handle — it is inside the figure the quote returns.\n\nWhat you never do\n\nyou never hold TRX, prepay gas, or top anything up — every network cost is inside the quote;\nguess a fee: the quote is the price, and it is rounded up, never down."
        },
        {
          "anchor": "what-you-never-do",
          "heading": "What you never do",
          "text": "you never hold TRX, prepay gas, or top anything up — every network cost is inside the quote;\nguess a fee: the quote is the price, and it is rounded up, never down."
        },
        {
          "anchor": "swaps-and-exchanges",
          "heading": "Swaps and exchanges",
          "text": "A percentage of the amount, with a dollar minimum, plus the swap's network cost — both taken out of the\nsold amount and returned together as fee on the quote, with the network part on its own as\nnetwork_fee.\n\nCharged to: the balance being sold from — the customer's, not yours.\n\nA USDT ⇄ TRX swap is traded on the TRON network itself, from the user's own address, so the\nnetwork part is that trade's real gas, priced live the way a payout is. The first USDT sale\nfrom an address also pays, once, for approving the trade. An exchange out of USDT or TRX pays\nthe same percentage and minimum plus the network cost of its own send. Nothing else is added.\n\n<div class=\"live-pricing\" data-pricing=\"swap\" data-pricing-src=\"https://api.cryptoinfra.build/v1/pricing\"><p class=\"live-pricing-fallback\">Live prices: <code>GET https://api.cryptoinfra.build/v1/pricing</code> (no key needed) — this table fills in from it.</p></div>\n\nUSDC on TRON cannot be swapped or exchanged today — no route carries it. It can still be\ndeposited and withdrawn. GET /v1/assets marks such assets swappable: false; leave them\nout of your picker. A swap that names one on either side, or an exchange that sells one, is\nrefused with 400 invalid_pair before anything is charged — the same answer a disabled\nasset gets.\n\nA swap moves between coins the platform custodies and credits the balance. An\nexchange sells a coin you hold and pays a different one out to an address you supply —\nsame fee rule, but the payout is as final as a withdrawal. The route itself may also carry a\nrouting fee of up to 0.5%, taken inside the route; receive_estimate and receive_minimum\nalready reflect it, so display those rather than working the buy side out from the sell\namount yourself. Quote first in both cases: the quote is the only thing that validates a\ndestination address, and it rejects a bad one before anything is charged.\n\nLive rate: POST /v1/swap/quote and POST /v1/exchange/quote."
        },
        {
          "anchor": "card-fees",
          "heading": "Card fees",
          "text": "Every card cost is charged to the cardholder's own balance — issue, each load, and the\nclose. Not the card, and not a programme balance. Your customer pays; if you have set your\nown fee, your share lands in your own balance in the same movement.\n\nThe rules, in one place (each is a fact you can read from the API, never a number to type in):\n\nIssue — a one-time issue fee plus a funded opening balance, priced as a load.\nLoad — the load rate (load_fee_bps, floor load_fee_min) plus the flat platform\npart (load_fee_flat) plus the live network figure (gas), all on top of the amount.\nSent on-chain from the holder's own address; refused with 409 card_coins_not_on_chain\nwhile the coins are not there.\nNo partial unload — POST /v1/cards/{id}/withdraw is retired (410); the close is\nthe only way value leaves a card.\nClose — our flat close fee (close_fee) plus the live network figure for the on-chain\nsend, the issuer's cancellation kept on the card and the rail's release charge, all taken\nfrom the balance being returned; the rest reaches the holder's own address. A close is\nnever refused for money reasons: read close_refund.state for where the money is.\nYour share — your affiliate commission on our part of every one of these, and your\nown fee on top if you set one.\n\nWhat a load costs, and where the money goes\n\nLoading a card is an on-chain send, and the fees are charged on top of the amount. Ask\nto load amount and the card holds amount; the cardholder's balance is debited more:\n\n· Amount ·\nLands on the card · amount · exactly what you asked for — sent on-chain to the card rail, plus the rail's own small fee so our balance there nets to zero\nOur fee · + load_fee_bps of amount + load_fee_flat · the product's load rate on the price sheet (with its floor, load_fee_min) plus the flat platform part\nGas · + gas · the network's live price for that send — the load and our fee's own transfer, priced for an address that holds none of the token, so it is its own figure, not a withdrawal's\nYour fee · + merchant_fee · unless you set none (below)\nCharged to the cardholder · the sum · total on the quote\n\nThe number the cardholder chose is the number that reaches the card, and every fee is a\nvisible line of its own — the way every card product they have ever used behaves.\n\nGET /v1/cards/topup-quote?amount=100&card_id=…&user_id=… returns exactly these lines —\nfee, gas, merchant_fee, credited, total — priced for that card. credited is\nwhat reaches the card (the amount itself) and total is what the balance is debited.\nShow total wherever you tell someone what a load will cost, and check it against the\nbalance first: a load the balance cannot cover is refused before anything is charged.\n\nA load below the product's minimum is refused with 400, and the error names the\nminimum.\n\nIt takes minutes. The order is funding while the coins travel, requested once the\nchain confirms them, sent once the rail is asked, confirmed once the card is funded. A\ncard.topup webhook tells you at the end; treat everything before it as pending. If the\nrail refuses, everything — including gas — is refunded to the cardholder.\n\nA card's price belongs to its programme and BIN together, not to the platform. Two BINs\ncan be open at once, priced completely differently, and the same BIN number under a\ndifferent programme is a different card with different prices.\n\nSo the pair is the key. Read core_id and card_bin out of a response before comparing\nprices — a BIN number on its own does not identify a card's terms, and code that treats it\nas if it does will price one programme's card off another's sheet.\n\nGET /v1/cards/products is the per-programme version of the table below and the one to\nbuild against with your key; add ?core_id=… to see one programme's shelf priced for that\nprogramme. Its issue_total is everything the cardholder pays to issue that card at the\nminimum opening balance, your own fee included. The table here is the public sheet, live:\n\n<div class=\"live-pricing\" data-pricing=\"cards\" data-pricing-src=\"https://api.cryptoinfra.build/v1/pricing\"><p class=\"live-pricing-fallback\">Live prices: <code>GET https://api.cryptoinfra.build/v1/pricing</code> (no key needed) — this table fills in from it.</p></div>\n\nTake the virtual product as the worked example; the same shape applies to any row.\n\nIssue fee — one time, from the cardholder. Charged when POST /v1/cards is accepted.\nFlat, not a percentage of the opening load, and it is the whole price of the card itself:\nnothing else is charged for issuing.\n\nMinimum opening load — required, from the cardholder. A card is issued *funded, never\nempty*: initial_amount on POST /v1/cards must be at least min_opening_balance. It is\npriced exactly like a load — the load rate + gas on top, so the card opens holding the full\namount — and it goes on-chain with the issue, so a new card takes a few minutes to arrive.\nIssuing at the minimum charges the cardholder\nissue fee + minimum opening load + its load fee + gas — issue_total_at_minimum on the\nsheet above, issue_total on the products shelf (yours included) — and\nGET /v1/cards/issue-quote prices any other opening balance. An issue for a holder whose\nbalance is short is refused with 409 insufficient_balance before anything is charged.\n\nLoad fee — the load rate + gas, on top of the amount, on every load after that. Loading\namount puts exactly amount on the card and charges amount + fee + gas. A load pays\nits own way on-chain from the holder's own address: the coins have to be there. A\nbalance the ledger shows but the chain does not yet — a close refund or a deposit still on\nits way — answers 409 card_coins_not_on_chain; nothing is charged, load again once it has\narrived.\n\nClose fee — flat plus the live network figure, taken from the balance the card returns.\nThe flat part is ours; the network part pays for returning the card's balance to the\nholder's own address on-chain. Two more things come off that balance before it arrives, and\nneither is ours:\n\nthe issuer's cancellation charge, kept on the card (a card holding less simply keeps\nwhat it has — a close is never refused for it);\nthe card network's release charge — a percentage of the balance with a minimum, capped\nbelow a threshold — shown per card as close_refund on the price sheet.\n\nThe refund then reaches the holder's address after the settlement period — usually quickly,\nnever later than the longest time on the price sheet. GET /v1/cards/{id}/close-quote does\nthe whole sum for one card as refund_quote; show its refund before your user confirms.\n\nThere is no partial unload of a card, and there is no monthly or dormancy fee on any BIN\ntoday; a future row would say so explicitly if that\nchanged. The issuer may net its own unload tariff out of what it releases, so the balance\nis credited what actually came off the card — amount_credited on the order, which can be\nless than amount.\n\nYour own fee, on top of ours\n\nOurs is the floor and is always charged. On top of it you may add a percentage of the\namount, a flat amount, or both — separately for issuing, loading and closing — over the\nAPI:\n\nGET    /v1/merchant/card-fees                 ours, yours, and what the cardholder pays\nPUT    /v1/merchant/card-fees/load            { \"percent_bps\": 50, \"flat\": \"0.25\" }\nPUT    /v1/merchant/card-fees/issue           { \"flat\": \"2\" }\nDELETE /v1/merchant/card-fees/close\n\npercent_bps is basis points: 100 = 1%, at most 1000 (10%). Your fee is charged to the\ncardholder with everything else and credited to your own balance in the same movement —\nthere is nothing to collect. Every quote (topup-quote, issue-quote, close-quote,\nissue_total) and every card.* webhook shows it as merchant_fee.\n\nYour account needs a balance of its own for the fee to land in; setting a fee without one is\nrefused with 409 no_own_account.\n\nAdding a card to a phone wallet\n\nBIN 493 provisions into Apple Pay, Google Pay and Samsung Pay. Wallet support is a\nproperty of the BIN, not of the platform: do not assume a future BIN carries it.\n\nBIN 493 prints no cardholder name. The issuer has no such field, so nothing is\nembossed on the card itself. name_on_card is still worth sending: we store it against\nthe card and return it on every read, so your dashboard and ours can tell one cardholder\nfrom another — last4 cannot.\n\nProvisioning asks the cardholder to verify the card, and that verification code comes from\nus rather than from the wallet. It appears live on the card's page in your dashboard the\nmoment it arrives; over the API, read it with GET /v1/cards/{id}/otp and show it\nto the cardholder. The code is short-lived and read once per channel — once with your API\nkey, once in the dashboard, so your server fetching it never hides it from the dashboard —\nso fetch it when they are looking at the screen, not in advance. Without one of the two the\ncustomer waits for an SMS that is never coming.\n\nClosing a card\n\nEverything below is per programme and BIN. A card's close fee, the issuer's cancellation\ncharge, the rail's release charge and the settlement period are properties of the rail and BIN\nthat card sits on — not of cryptoinfra, and not of your account. Two of your programmes can\nanswer differently on the same day. The close quote prices the card you name; treat it as the\nonly source.\n\nOur close fee has two parts. A flat part (close_fee on the sheet — our own charge, and\nnothing else) and the live network figure (gas on the close quote, with dynamic) for\nreturning the card's balance to the holder's own address on-chain. Separately the issuer\nkeeps its own cancellation charge on the card, which is also the least the card must hold\n(cancellation under the cardholder fees on the sheet, minimum_card_balance on the close\nquote), and the card rail charges to release the rest to us (rail_return_fee).\n\nEverything is taken from the balance the card returns, never from the holder's wallet.\nrefund_quote on the close quote does the arithmetic live and names every line; the holder\nreceives refund.\n\nThe balance comes back on-chain. Cancelling a card opens the issuer's settlement period,\nwhich the issuer says can run to 45 days (refund_eta_max_days on the close quote) —\nthough it has settled within 24–48 hours in practice. Once released, the balance reaches the\nholder's own address within refund_eta_working_days working days, paid from a wallet\ncryptoinfra funds by hand. The close itself is never refused while that is arranged: read\nclose_refund.state on GET /v1/cards/{id} for where the money is (waiting → approved →\nsending → paid). Until the refund lands the holder cannot load another card with that\nbalance (a load needs the coins at their address, rule 2 above).\n\nCard balance at close · What happens\nMore than C · The excess returns to the cardholder's balance; C stays on the card and the issuer takes it.\nExactly C · The issuer takes it from the card. Nothing returns.\nLess than C, or empty · Closes anyway (20 September 2026). The issuer keeps whatever is on the card as its cancellation charge, there is nothing to return, and nobody is asked to load a card they want to be rid of.\n\nA card holding B, more than C, closes as: B − C released by the rail, less the rail's\nown release charge and less our close fee, the network figure and yours — and what remains is\nthe holder's refund. Nothing is ever debited from their wallet balance, so an empty wallet\nnever blocks a close.\n\nGET /v1/cards/{id}/close-quote?user_id=… shows what the close itself will debit the\ncardholder — fee (our flat part), gas (the live network figure, dynamic says whether it\nwas priced now), merchant_fee (yours) and total — with charged_to: \"cardholder\" and\nrefund_eta_working_days. There is nothing for you to credit afterwards: the residual lands in your\ncustomer's balance on its own, and if you want to charge them for closing, set your own\nclose fee rather than moving money by hand.\n\nRead the settled amount, not the card's last known balance. The residual comes back as\na withdraw order and is credited when the rail confirms it; amount_credited on that order\nis what actually reached the balance, and it can be less than amount if the issuer netted\nits own unload tariff.\n\nThe call\n\nDELETE /v1/cards/{id} (with user_id in the body) does the return itself — you do not\nunload first:\n\nThe first call unloads everything above C and answers 202 card_close_in_progress;\ndetails.order_id is the withdraw order and details.card_id the card. A card.withdraw\nwebhook tells you when it settles.\nCall it again once that order is confirmed; our close fee (and yours, if set) is charged\nto the balance, the card terminates and the issuer keeps C.\n\nA card at or below C closes on the first call. Calling close again on a card whose\nreturn is still travelling answers the same 202 with the same order; retrying a close\nnever double-charges, and a card already closed answers 409 card_state.\n\nAnything still pending → 409 card_state, whatever the balance reads. It can settle\nafter the card is gone, so it has to clear first. A balance the rail cannot confirm right\nnow is 400 invalid_request — try again, rather than closing on an unknown figure.\n\nFees the issuer charges on card use\n\nEverything above is what the cardholder pays us. These are the issuer's charges,\nand they never touch a cryptoinfra balance.\n\nCharged to: the card balance. They appear on the card's own transaction history, so\nyour customer sees them.\n\n<div class=\"live-pricing\" data-pricing=\"card-holder\" data-pricing-src=\"https://api.cryptoinfra.build/v1/pricing\"><p class=\"live-pricing-fallback\">Live prices: <code>GET https://api.cryptoinfra.build/v1/pricing</code> (no key needed) — this table fills in from it.</p></div>\n\nEvery one of these follows the same rule: taken from the card when it happens. When the\ncard cannot cover it — a dispute landing on a spent-down card is the usual case — the\nissuer bills the difference to us, not to you and not to your customer's balance.\n\nThe fee is inside the transaction, not added to it. A purchase shown as $3.86 with a\n$0.25 fee takes $3.86 from the card in total — the merchant received $3.61 and the issuer\nkept $0.25. Card loads read the same way: a $50.35 movement puts $50.00 on the card.\n\n3-D Secure is charged per authentication, not per purchase. A checkout that challenges\nthe cardholder costs the 3-D Secure fee in the table above whether or not the purchase\ncompletes, so a customer who abandons at the 3DS step still generates one. It is small, but\nit is the only fee here that can fire without a sale behind it.\n\nSpending limits on the card\n\nEvery BIN carries the issuer's own ceilings: how much one purchase can be, how much the card\ncan spend in a day and in a month, how much cash it can draw at an ATM, and how long it is\nvalid. They are the same for every merchant on that BIN, and nothing on our side raises them.\n\n<div class=\"live-pricing\" data-pricing=\"card-limits\" data-pricing-src=\"https://api.cryptoinfra.build/v1/pricing\"><p class=\"live-pricing-fallback\">Live prices: <code>GET https://api.cryptoinfra.build/v1/pricing</code> (no key needed) — this table fills in from it.</p></div>\n\nA purchase over a limit is declined, whatever the card holds. Show these before a\ncustomer loads a card for one large payment.\nThe same figures are on every product as limits on GET /v1/cards/products; read them\nthere for the BIN in front of you rather than copying one from this page. A BIN marked *not\npublished yet* has limits of its own; they are not on the sheet yet."
        },
        {
          "anchor": "what-a-load-costs-and-where-the-money-goes",
          "heading": "What a load costs, and where the money goes",
          "text": "Loading a card is an on-chain send, and the fees are charged on top of the amount. Ask\nto load amount and the card holds amount; the cardholder's balance is debited more:\n\n· Amount ·\nLands on the card · amount · exactly what you asked for — sent on-chain to the card rail, plus the rail's own small fee so our balance there nets to zero\nOur fee · + load_fee_bps of amount + load_fee_flat · the product's load rate on the price sheet (with its floor, load_fee_min) plus the flat platform part\nGas · + gas · the network's live price for that send — the load and our fee's own transfer, priced for an address that holds none of the token, so it is its own figure, not a withdrawal's\nYour fee · + merchant_fee · unless you set none (below)\nCharged to the cardholder · the sum · total on the quote\n\nThe number the cardholder chose is the number that reaches the card, and every fee is a\nvisible line of its own — the way every card product they have ever used behaves.\n\nGET /v1/cards/topup-quote?amount=100&card_id=…&user_id=… returns exactly these lines —\nfee, gas, merchant_fee, credited, total — priced for that card. credited is\nwhat reaches the card (the amount itself) and total is what the balance is debited.\nShow total wherever you tell someone what a load will cost, and check it against the\nbalance first: a load the balance cannot cover is refused before anything is charged.\n\nA load below the product's minimum is refused with 400, and the error names the\nminimum.\n\nIt takes minutes. The order is funding while the coins travel, requested once the\nchain confirms them, sent once the rail is asked, confirmed once the card is funded. A\ncard.topup webhook tells you at the end; treat everything before it as pending. If the\nrail refuses, everything — including gas — is refunded to the cardholder.\n\nA card's price belongs to its programme and BIN together, not to the platform. Two BINs\ncan be open at once, priced completely differently, and the same BIN number under a\ndifferent programme is a different card with different prices.\n\nSo the pair is the key. Read core_id and card_bin out of a response before comparing\nprices — a BIN number on its own does not identify a card's terms, and code that treats it\nas if it does will price one programme's card off another's sheet.\n\nGET /v1/cards/products is the per-programme version of the table below and the one to\nbuild against with your key; add ?core_id=… to see one programme's shelf priced for that\nprogramme. Its issue_total is everything the cardholder pays to issue that card at the\nminimum opening balance, your own fee included. The table here is the public sheet, live:\n\n<div class=\"live-pricing\" data-pricing=\"cards\" data-pricing-src=\"https://api.cryptoinfra.build/v1/pricing\"><p class=\"live-pricing-fallback\">Live prices: <code>GET https://api.cryptoinfra.build/v1/pricing</code> (no key needed) — this table fills in from it.</p></div>\n\nTake the virtual product as the worked example; the same shape applies to any row.\n\nIssue fee — one time, from the cardholder. Charged when POST /v1/cards is accepted.\nFlat, not a percentage of the opening load, and it is the whole price of the card itself:\nnothing else is charged for issuing.\n\nMinimum opening load — required, from the cardholder. A card is issued *funded, never\nempty*: initial_amount on POST /v1/cards must be at least min_opening_balance. It is\npriced exactly like a load — the load rate + gas on top, so the card opens holding the full\namount — and it goes on-chain with the issue, so a new card takes a few minutes to arrive.\nIssuing at the minimum charges the cardholder\nissue fee + minimum opening load + its load fee + gas — issue_total_at_minimum on the\nsheet above, issue_total on the products shelf (yours included) — and\nGET /v1/cards/issue-quote prices any other opening balance. An issue for a holder whose\nbalance is short is refused with 409 insufficient_balance before anything is charged.\n\nLoad fee — the load rate + gas, on top of the amount, on every load after that. Loading\namount puts exactly amount on the card and charges amount + fee + gas. A load pays\nits own way on-chain from the holder's own address: the coins have to be there. A\nbalance the ledger shows but the chain does not yet — a close refund or a deposit still on\nits way — answers 409 card_coins_not_on_chain; nothing is charged, load again once it has\narrived.\n\nClose fee — flat plus the live network figure, taken from the balance the card returns.\nThe flat part is ours; the network part pays for returning the card's balance to the\nholder's own address on-chain. Two more things come off that balance before it arrives, and\nneither is ours:\n\nthe issuer's cancellation charge, kept on the card (a card holding less simply keeps\nwhat it has — a close is never refused for it);\nthe card network's release charge — a percentage of the balance with a minimum, capped\nbelow a threshold — shown per card as close_refund on the price sheet.\n\nThe refund then reaches the holder's address after the settlement period — usually quickly,\nnever later than the longest time on the price sheet. GET /v1/cards/{id}/close-quote does\nthe whole sum for one card as refund_quote; show its refund before your user confirms.\n\nThere is no partial unload of a card, and there is no monthly or dormancy fee on any BIN\ntoday; a future row would say so explicitly if that\nchanged. The issuer may net its own unload tariff out of what it releases, so the balance\nis credited what actually came off the card — amount_credited on the order, which can be\nless than amount."
        },
        {
          "anchor": "your-own-fee-on-top-of-ours",
          "heading": "Your own fee, on top of ours",
          "text": "Ours is the floor and is always charged. On top of it you may add a percentage of the\namount, a flat amount, or both — separately for issuing, loading and closing — over the\nAPI:\n\nGET    /v1/merchant/card-fees                 ours, yours, and what the cardholder pays\nPUT    /v1/merchant/card-fees/load            { \"percent_bps\": 50, \"flat\": \"0.25\" }\nPUT    /v1/merchant/card-fees/issue           { \"flat\": \"2\" }\nDELETE /v1/merchant/card-fees/close\n\npercent_bps is basis points: 100 = 1%, at most 1000 (10%). Your fee is charged to the\ncardholder with everything else and credited to your own balance in the same movement —\nthere is nothing to collect. Every quote (topup-quote, issue-quote, close-quote,\nissue_total) and every card.* webhook shows it as merchant_fee.\n\nYour account needs a balance of its own for the fee to land in; setting a fee without one is\nrefused with 409 no_own_account."
        },
        {
          "anchor": "adding-a-card-to-a-phone-wallet",
          "heading": "Adding a card to a phone wallet",
          "text": "BIN 493 provisions into Apple Pay, Google Pay and Samsung Pay. Wallet support is a\nproperty of the BIN, not of the platform: do not assume a future BIN carries it.\n\nBIN 493 prints no cardholder name. The issuer has no such field, so nothing is\nembossed on the card itself. name_on_card is still worth sending: we store it against\nthe card and return it on every read, so your dashboard and ours can tell one cardholder\nfrom another — last4 cannot.\n\nProvisioning asks the cardholder to verify the card, and that verification code comes from\nus rather than from the wallet. It appears live on the card's page in your dashboard the\nmoment it arrives; over the API, read it with GET /v1/cards/{id}/otp and show it\nto the cardholder. The code is short-lived and read once per channel — once with your API\nkey, once in the dashboard, so your server fetching it never hides it from the dashboard —\nso fetch it when they are looking at the screen, not in advance. Without one of the two the\ncustomer waits for an SMS that is never coming."
        },
        {
          "anchor": "closing-a-card",
          "heading": "Closing a card",
          "text": "Everything below is per programme and BIN. A card's close fee, the issuer's cancellation\ncharge, the rail's release charge and the settlement period are properties of the rail and BIN\nthat card sits on — not of cryptoinfra, and not of your account. Two of your programmes can\nanswer differently on the same day. The close quote prices the card you name; treat it as the\nonly source.\n\nOur close fee has two parts. A flat part (close_fee on the sheet — our own charge, and\nnothing else) and the live network figure (gas on the close quote, with dynamic) for\nreturning the card's balance to the holder's own address on-chain. Separately the issuer\nkeeps its own cancellation charge on the card, which is also the least the card must hold\n(cancellation under the cardholder fees on the sheet, minimum_card_balance on the close\nquote), and the card rail charges to release the rest to us (rail_return_fee).\n\nEverything is taken from the balance the card returns, never from the holder's wallet.\nrefund_quote on the close quote does the arithmetic live and names every line; the holder\nreceives refund.\n\nThe balance comes back on-chain. Cancelling a card opens the issuer's settlement period,\nwhich the issuer says can run to 45 days (refund_eta_max_days on the close quote) —\nthough it has settled within 24–48 hours in practice. Once released, the balance reaches the\nholder's own address within refund_eta_working_days working days, paid from a wallet\ncryptoinfra funds by hand. The close itself is never refused while that is arranged: read\nclose_refund.state on GET /v1/cards/{id} for where the money is (waiting → approved →\nsending → paid). Until the refund lands the holder cannot load another card with that\nbalance (a load needs the coins at their address, rule 2 above).\n\nCard balance at close · What happens\nMore than C · The excess returns to the cardholder's balance; C stays on the card and the issuer takes it.\nExactly C · The issuer takes it from the card. Nothing returns.\nLess than C, or empty · Closes anyway (20 September 2026). The issuer keeps whatever is on the card as its cancellation charge, there is nothing to return, and nobody is asked to load a card they want to be rid of.\n\nA card holding B, more than C, closes as: B − C released by the rail, less the rail's\nown release charge and less our close fee, the network figure and yours — and what remains is\nthe holder's refund. Nothing is ever debited from their wallet balance, so an empty wallet\nnever blocks a close.\n\nGET /v1/cards/{id}/close-quote?user_id=… shows what the close itself will debit the\ncardholder — fee (our flat part), gas (the live network figure, dynamic says whether it\nwas priced now), merchant_fee (yours) and total — with charged_to: \"cardholder\" and\nrefund_eta_working_days. There is nothing for you to credit afterwards: the residual lands in your\ncustomer's balance on its own, and if you want to charge them for closing, set your own\nclose fee rather than moving money by hand.\n\nRead the settled amount, not the card's last known balance. The residual comes back as\na withdraw order and is credited when the rail confirms it; amount_credited on that order\nis what actually reached the balance, and it can be less than amount if the issuer netted\nits own unload tariff.\n\nThe call\n\nDELETE /v1/cards/{id} (with user_id in the body) does the return itself — you do not\nunload first:\n\nThe first call unloads everything above C and answers 202 card_close_in_progress;\ndetails.order_id is the withdraw order and details.card_id the card. A card.withdraw\nwebhook tells you when it settles.\nCall it again once that order is confirmed; our close fee (and yours, if set) is charged\nto the balance, the card terminates and the issuer keeps C.\n\nA card at or below C closes on the first call. Calling close again on a card whose\nreturn is still travelling answers the same 202 with the same order; retrying a close\nnever double-charges, and a card already closed answers 409 card_state.\n\nAnything still pending → 409 card_state, whatever the balance reads. It can settle\nafter the card is gone, so it has to clear first. A balance the rail cannot confirm right\nnow is 400 invalid_request — try again, rather than closing on an unknown figure."
        },
        {
          "anchor": "fees-the-issuer-charges-on-card-use",
          "heading": "Fees the issuer charges on card use",
          "text": "Everything above is what the cardholder pays us. These are the issuer's charges,\nand they never touch a cryptoinfra balance.\n\nCharged to: the card balance. They appear on the card's own transaction history, so\nyour customer sees them.\n\n<div class=\"live-pricing\" data-pricing=\"card-holder\" data-pricing-src=\"https://api.cryptoinfra.build/v1/pricing\"><p class=\"live-pricing-fallback\">Live prices: <code>GET https://api.cryptoinfra.build/v1/pricing</code> (no key needed) — this table fills in from it.</p></div>\n\nEvery one of these follows the same rule: taken from the card when it happens. When the\ncard cannot cover it — a dispute landing on a spent-down card is the usual case — the\nissuer bills the difference to us, not to you and not to your customer's balance.\n\nThe fee is inside the transaction, not added to it. A purchase shown as $3.86 with a\n$0.25 fee takes $3.86 from the card in total — the merchant received $3.61 and the issuer\nkept $0.25. Card loads read the same way: a $50.35 movement puts $50.00 on the card.\n\n3-D Secure is charged per authentication, not per purchase. A checkout that challenges\nthe cardholder costs the 3-D Secure fee in the table above whether or not the purchase\ncompletes, so a customer who abandons at the 3DS step still generates one. It is small, but\nit is the only fee here that can fire without a sale behind it."
        },
        {
          "anchor": "spending-limits-on-the-card",
          "heading": "Spending limits on the card",
          "text": "Every BIN carries the issuer's own ceilings: how much one purchase can be, how much the card\ncan spend in a day and in a month, how much cash it can draw at an ATM, and how long it is\nvalid. They are the same for every merchant on that BIN, and nothing on our side raises them.\n\n<div class=\"live-pricing\" data-pricing=\"card-limits\" data-pricing-src=\"https://api.cryptoinfra.build/v1/pricing\"><p class=\"live-pricing-fallback\">Live prices: <code>GET https://api.cryptoinfra.build/v1/pricing</code> (no key needed) — this table fills in from it.</p></div>\n\nA purchase over a limit is declined, whatever the card holds. Show these before a\ncustomer loads a card for one large payment.\nThe same figures are on every product as limits on GET /v1/cards/products; read them\nthere for the BIN in front of you rather than copying one from this page. A BIN marked *not\npublished yet* has limits of its own; they are not on the sheet yet."
        },
        {
          "anchor": "gas-on-tron",
          "heading": "Gas, on TRON",
          "text": "Nothing to do. Where the gas shows up depends on the movement:\n\nA payout — inside the withdrawal fee, nothing extra. See Withdrawals\nfor what that covers.\nA card load or issue — its own line, gas, on top of the amount. See\nWhat a load costs.\n\nBoth are priced live; Every figure says whether it is live\nexplains how to read the figure you are shown.\n\nYou never hold TRX, never top up a gas balance and never switch anything on.\n\nSweeps between our own addresses never cost you gas at all — those are ours."
        },
        {
          "anchor": "what-is-never-charged",
          "heading": "What is never charged",
          "text": "The API itself. No monthly plan, no per-call fee, no tiers. Rate limits, not bills,\nare what keep usage sane.\nCreating users or addresses. Unlimited, free, permanent.\nReceiving a deposit. Nothing is taken when money lands.\nWebhooks, retries, the dashboard, or the documentation MCP server."
        },
        {
          "anchor": "reading-rates-from-the-api",
          "heading": "Reading rates from the API",
          "text": "Call · Returns\nGET /v1/fees · The withdrawal fee per asset — network_fee plus any rate on your account — and deposit.total (0), with dynamic and priced_at. Swap and card prices are on their quote endpoints\nGET /v1/withdrawal-fee-quote · The exact fee for a specific withdrawal — total_fee is the number to show — with dynamic and quoted_at\nGET /v1/cards/products · Card issue fee, load rate, close fee and minimum opening balance per BIN, with issue_total; the gas inside it, gas_dynamic and gas_priced_at\nGET /v1/cards/issue-quote · An issue priced end to end for a chosen opening balance — total is the number to show — with dynamic\nGET /v1/cards/topup-quote · A load priced end to end — fee, gas, your fee, total — with dynamic\nGET /v1/cards/{id}/close-quote · What closing a card debits the cardholder\nPOST /v1/swap/quote · A swap priced end to end, fee included\nPOST /v1/exchange/quote · An exchange priced end to end, fee included\n\nQuote endpoints return the number that will actually be charged. If a quote and this page\never disagree, the quote is right and the page is a bug — please report it. Every quote\nalso says when it was priced and whether the figure is live; carry that to your own\ncustomers rather than printing a number as a fixed charge."
        }
      ]
    },
    {
      "slug": "fee-calculator",
      "title": "Fee calculator",
      "summary": "Type an amount and read the exact deduction for a payout, a card load, a card issue or a deposit — every figure live from the public price sheet, nothing typed in.",
      "kind": "guide",
      "markdown": "# Fee calculator\n\nType an amount, pick the operation, and read what is charged and to whom. Every figure\nbelow is read from the public [`GET /v1/pricing`](reference) the moment this page loads —\nthe same sheet your integration should read — so what you see is the price of this minute.\nAdd your own fee to see what your customer pays all in.\n\n<div class=\"live-pricing\" data-pricing=\"calculator\" data-pricing-src=\"https://api.cryptoinfra.build/v1/pricing\"><p class=\"live-pricing-fallback\">Live prices: <code>GET https://api.cryptoinfra.build/v1/pricing</code> (no key needed) — this table fills in from it.</p></div>\n\n## How to read it\n\n- **Payout** — the recipient receives the amount; the network fee, our flat platform fee and,\n  once per customer, the first-send activation are held from the customer's balance on top.\n  The network figure is priced live and is higher for a recipient that has never held the\n  token. Your affiliate share of our platform fee is paid to your balance as the fee settles.\n- **Card load** — the amount lands on the card; the load fee (a percentage) and the gas for\n  the send are charged to the cardholder on top.\n- **Card issue** — the issue fee plus an opening balance, which is priced as a load. An\n  amount under the product's minimum opening balance is lifted to it.\n- **Deposit** — free. What the customer sends is what is credited, less any fee you set.\n\n## In your code\n\nNever type a figure from this page into your integration. The exact deduction for one\nsend is `GET /v1/withdrawal-fee-quote` (amount, recipient and sender known), and the card\ntariff is `GET /v1/cards/products`; both say whether the network figure is live\n(`dynamic`) and when it was priced. See [Fees and pricing](fees) for every rule.\n",
      "sections": [
        {
          "anchor": "how-to-read-it",
          "heading": "How to read it",
          "text": "Payout — the recipient receives the amount; the network fee, our flat platform fee and,\nonce per customer, the first-send activation are held from the customer's balance on top.\nThe network figure is priced live and is higher for a recipient that has never held the\ntoken. Your affiliate share of our platform fee is paid to your balance as the fee settles.\nCard load — the amount lands on the card; the load fee (a percentage) and the gas for\nthe send are charged to the cardholder on top.\nCard issue — the issue fee plus an opening balance, which is priced as a load. An\namount under the product's minimum opening balance is lifted to it.\nDeposit — free. What the customer sends is what is credited, less any fee you set."
        },
        {
          "anchor": "in-your-code",
          "heading": "In your code",
          "text": "Never type a figure from this page into your integration. The exact deduction for one\nsend is GET /v1/withdrawal-fee-quote (amount, recipient and sender known), and the card\ntariff is GET /v1/cards/products; both say whether the network figure is live\n(dynamic) and when it was priced. See Fees and pricing for every rule."
        }
      ]
    },
    {
      "slug": "ai-integration",
      "title": "AI integration",
      "summary": "Machine-readable views of this documentation — llms.txt, llms-full.txt, per-page Markdown and a structured corpus — plus the facts an agent most often gets wrong about this API.",
      "kind": "guide",
      "markdown": "# AI integration\n\nThis site is built to be read by machines as well as people. Every page exists in a\nportable Markdown form, and the whole corpus is available as a single file you can paste\ninto a chat.\n\n## The machine-readable endpoints\n\n| Path | What it is | Use it for |\n| --- | --- | --- |\n| [`/llms.txt`](/llms.txt) | An [llmstxt.org](https://llmstxt.org) index: one line per page, absolute links | Letting an agent discover what exists and fetch only what it needs |\n| [`/llms-full.txt`](/llms-full.txt) | The **entire** documentation as one Markdown file | Pasting into a chat, or seeding a context window in one shot |\n| `/md/{slug}` | Any single page as raw Markdown, no site chrome | Fetching one page cheaply |\n| [`/corpus.json`](/corpus.json) | Every page as structured JSON, split into sections | Building an index, a RAG store, or your own search |\n| [`/search-index.json`](/search-index.json) | The index this site's own search box uses | Reusing the same section-level search |\n\nEvery HTML page also declares its Markdown twin in the head, so a crawler can find it\nwithout guessing:\n\n```html\n<link rel=\"alternate\" type=\"text/markdown\" href=\"/md/authentication\">\n```\n\n### Fetching the whole thing\n\n```bash\n# one file, everything\ncurl -s https://docs.cryptoinfra.net/llms-full.txt -o cryptoinfra-docs.md\n\n# or just the index, then the pages you need\ncurl -s https://docs.cryptoinfra.net/llms.txt\ncurl -s https://docs.cryptoinfra.net/md/idempotency\n```\n\n### `corpus.json`\n\n```json\n{\n  \"service\": \"cryptoinfra\",\n  \"version\": \"1.0.0\",\n  \"generatedAt\": \"2026-08-31T00:00:00.000Z\",\n  \"pages\": [\n    {\n      \"slug\": \"authentication\",\n      \"title\": \"Authentication\",\n      \"summary\": \"…\",\n      \"kind\": \"guide\",\n      \"markdown\": \"# Authentication\\n…\",\n      \"sections\": [\n        { \"anchor\": \"the-key\", \"heading\": \"The key\", \"text\": \"…\" }\n      ]\n    }\n  ]\n}\n```\n\n`kind` is `guide` for hand-written prose and `reference` for pages generated from the\nOpenAPI contract. `sections` is the page split at its headings, so a search can return an\nexcerpt rather than a whole page.\n\n## The OpenAPI contract itself\n\nEverything on the reference pages is generated from a single OpenAPI 1.0.0\ndocument — 68 operations, 56 schemas and\n9 webhook events. If you are generating a client, generate it from that\ndocument rather than from this prose. Ask your operator contact for the current\n`openapi.yaml`; this site is rebuilt from it, so the two cannot disagree.\n\n## Facts an agent should not have to infer\n\nModels writing an integration against this API tend to make the same handful of wrong\nassumptions. Each of these is stated here so it can be retrieved directly.\n\n**There is no `swap.completed` or `exchange.completed` webhook.** Swaps and exchanges\nemit **nothing**. Generated code that awaits one will hang. Poll\n`GET /v1/swap/orders/{id}` (swaps *and* exchanges) while `status` is non-terminal.\n\n**The complete event list is 9 long:** `deposit.credited`, `withdrawal.confirmed`, `withdrawal.failed`, `card.kyc`, `card.status`, `card.topup`, `card.withdraw`, `card.transaction`, `card.wallet_otp`. Nothing\nelse is emitted, ever.\n\n**The API key authenticates the merchant, not the end user.** An `external_user_id` is a\nselector with no authority. Never put the key in client-side code.\n\n**Amounts come in pairs.** A decimal string for display, and an exact integer `*_base`\nstring for arithmetic. Use `BigInt` (or the equivalent) on `*_base`. The one systematic\nexception is an *exchange* quote's buy side, which has no `_base` twin because that coin's\nchain is not modelled by the platform — swap quotes, where both sides are ours, do carry it.\n\n**Excess decimal places are refused, not rounded.** `\"15.1234567\"` on a six-decimal asset is\n`400 invalid_amount` on a withdrawal or swap; a card\namount past six decimal places is `400 invalid_request`. The amount you send is always\nthe amount that moves.\n\n**`202` on a card money endpoint means pending, not failed.** Refund only on a definitive\nfailure: `409 provider_rejected`, or a webhook whose order `status` is `failed`.\n\n**A newly issued card comes back `status_normalized: issuing`, not `active`.** It turns\nactive once the opening balance lands at the issuer. Do not read `issuing` on a `201` as an\nerror.\n\n**Cards are issued funded.** `initial_amount` is required on `POST /v1/cards` and must meet\nthat product's `min_opening_balance`, read from `GET /v1/cards/products` — never\nhard-coded. The cardholder is debited `issue_total` from `GET /v1/cards/products` — our\nissue fee, the opening balance priced as a load (with its load fee and gas) and your own\nfee — not just `issue_fee + initial_amount`.\n\n**A `404` may mean \"belongs to another merchant\".** Unknown and not-yours are\nindistinguishable by design.\n\n**Retry with the same `idempotency_key`.** A fresh key on a retry is a double payment.\n\n**Webhooks are hints; the poll is truth.** Reconcile against the resource read before\ntelling a customer anything final.\n\n## A prompt that works\n\nIf you are pointing an assistant at this API, this framing avoids most of the failure modes\nabove:\n\n```text\nYou are integrating against the cryptoinfra Merchant API.\nRead https://docs.cryptoinfra.net/llms-full.txt first and treat it as the\ncontract. Rules that override your priors:\n  - Only these webhooks exist: `deposit.credited`, `withdrawal.confirmed`, `withdrawal.failed`, `card.kyc`, `card.status`, `card.topup`, `card.withdraw`, `card.transaction`, `card.wallet_otp`.\n    Swaps and exchanges emit NO webhook — poll their order reads.\n  - Compute with the *_base integer strings, never the decimal strings.\n  - Every money mutation carries an idempotency_key; retries reuse it.\n  - Treat 202 as pending. Refund only on a definitive failure.\nIf the docs do not state something, say so rather than inventing it.\n```\n\n## Documentation MCP server\n\nAn agent that speaks the [Model Context Protocol](https://modelcontextprotocol.io) can read\nthese docs itself, instead of you pasting them in:\n\n```text\nhttps://docs.cryptoinfra.net/mcp\n```\n\nPublic, read-only, no API key, and it speaks Streamable HTTP — so anything that can reach a\nremote MCP server can use it.\n\n**It serves documentation and nothing else.** It cannot read a balance, create a payment or\nmove a coin, and it holds no credential — the process drops credential-shaped environment\nvariables before it loads any request-handling code, and reads exactly one file. Giving it to\nan assistant costs you nothing.\n\n### Connect a client\n\nThe URL is the whole configuration. Where each client wants to be told about it:\n\n<!-- cards -->\n\n#### Claude & Claude Code\n\nAnswers from these pages inside a conversation, and inside `claude` in your terminal.\nIn Claude itself, add it as a custom connector by URL.\n\n```bash\nclaude mcp add --transport http cryptoinfra-docs https://docs.cryptoinfra.net/mcp\n```\n\n#### ChatGPT & Codex\n\nChatGPT connects to it as a custom connector by URL. Codex reads it from\n`~/.codex/config.toml`, and will then cite these pages while it writes your client.\n\n```toml\n[mcp_servers.cryptoinfra_docs]\nurl = \"https://docs.cryptoinfra.net/mcp\"\n```\n\n#### Cursor\n\nPuts the reference in the composer's reach, so a generated integration is written against\nthe real contract instead of a remembered one. Project-scoped, in `.cursor/mcp.json`.\n\n```json\n{\n  \"mcpServers\": {\n    \"cryptoinfra-docs\": { \"url\": \"https://docs.cryptoinfra.net/mcp\" }\n  }\n}\n```\n\n#### GitHub Copilot\n\nAgent mode in VS Code picks the server up from `.vscode/mcp.json`, so it is shared with\neveryone who checks the repository out.\n\n```json\n{\n  \"servers\": {\n    \"cryptoinfra-docs\": { \"type\": \"http\", \"url\": \"https://docs.cryptoinfra.net/mcp\" }\n  }\n}\n```\n\n<!-- /cards -->\n\nAnything else that speaks Streamable HTTP takes the same one line:\n\n```json\n{\n  \"mcpServers\": {\n    \"cryptoinfra-docs\": { \"url\": \"https://docs.cryptoinfra.net/mcp\" }\n  }\n}\n```\n\n### Tools\n\n| Tool | When an agent should reach for it |\n|---|---|\n| `list_docs` | Discover every topic and its resource URI. |\n| `search_docs` | Find a section-level answer with ranked excerpts and links, instead of loading the whole corpus. |\n| `get_doc` | Read one page in full, as portable Markdown. |\n| `get_integration_guide` | Get an ordered reading plan for one pattern: `hosted-checkout`, `deposits-and-balances`, `payouts`, `swap-and-exchange`, `cards`, `webhooks`. |\n\nEvery result carries structured JSON alongside the human-readable text, and search returns\nresource links so an agent fetches only the pages it needs.\n\n### Resources\n\n| Resource | What it is |\n|---|---|\n| `docs://cryptoinfra/index` | Machine-readable service and page index. |\n| `docs://cryptoinfra/full` | The complete reference as one document. |\n| `docs://cryptoinfra/{slug}` | A single page. Supports slug completion, so a client can discover valid URIs rather than guess. |\n\n### Prompts\n\n- **`integrate-cryptoinfra`** — reads the right guide for your pattern and produces\n  implementation code, tests, configuration and a go-live checklist.\n- **`review-cryptoinfra-integration`** — reviews code you already have for the things that\n  actually go wrong here: auth, idempotency on every mutation, `*_base` versus decimal\n  handling, webhook signature verification and replay, polling for the operations that emit\n  no webhook, and reconciliation gaps.\n\nIf a `get_integration_guide` pattern is missing a page, it says so rather than inventing the\nstep — a gap in our docs should read as a gap, not as confident filler.\n\n## Licence and freshness\n\nThe reference pages are generated on every build from the live contract, so a page that\ndisagrees with the running service is a bug — report it rather than working around it. This\nbuild documents OpenAPI version **1.0.0**.\n",
      "sections": [
        {
          "anchor": "the-machine-readable-endpoints",
          "heading": "The machine-readable endpoints",
          "text": "Path · What it is · Use it for\n/llms.txt · An llmstxt.org index: one line per page, absolute links · Letting an agent discover what exists and fetch only what it needs\n/llms-full.txt · The entire documentation as one Markdown file · Pasting into a chat, or seeding a context window in one shot\n/md/{slug} · Any single page as raw Markdown, no site chrome · Fetching one page cheaply\n/corpus.json · Every page as structured JSON, split into sections · Building an index, a RAG store, or your own search\n/search-index.json · The index this site's own search box uses · Reusing the same section-level search\n\nEvery HTML page also declares its Markdown twin in the head, so a crawler can find it\nwithout guessing:\n\n<link rel=\"alternate\" type=\"text/markdown\" href=\"/md/authentication\">\n\nFetching the whole thing\n\n# one file, everything\ncurl -s https://docs.cryptoinfra.net/llms-full.txt -o cryptoinfra-docs.md\n\n# or just the index, then the pages you need\ncurl -s https://docs.cryptoinfra.net/llms.txt\ncurl -s https://docs.cryptoinfra.net/md/idempotency\n\ncorpus.json\n\n{\n\"service\": \"cryptoinfra\",\n\"version\": \"1.0.0\",\n\"generatedAt\": \"2026-08-31T00:00:00.000Z\",\n\"pages\": [\n{\n\"slug\": \"authentication\",\n\"title\": \"Authentication\",\n\"summary\": \"…\",\n\"kind\": \"guide\",\n\"markdown\": \"# Authentication\\n…\",\n\"sections\": [\n{ \"anchor\": \"the-key\", \"heading\": \"The key\", \"text\": \"…\" }\n]\n}\n]\n}\n\nkind is guide for hand-written prose and reference for pages generated from the\nOpenAPI contract. sections is the page split at its headings, so a search can return an\nexcerpt rather than a whole page."
        },
        {
          "anchor": "fetching-the-whole-thing",
          "heading": "Fetching the whole thing",
          "text": "# one file, everything\ncurl -s https://docs.cryptoinfra.net/llms-full.txt -o cryptoinfra-docs.md\n\n# or just the index, then the pages you need\ncurl -s https://docs.cryptoinfra.net/llms.txt\ncurl -s https://docs.cryptoinfra.net/md/idempotency"
        },
        {
          "anchor": "corpus-json",
          "heading": "corpus.json",
          "text": "{\n\"service\": \"cryptoinfra\",\n\"version\": \"1.0.0\",\n\"generatedAt\": \"2026-08-31T00:00:00.000Z\",\n\"pages\": [\n{\n\"slug\": \"authentication\",\n\"title\": \"Authentication\",\n\"summary\": \"…\",\n\"kind\": \"guide\",\n\"markdown\": \"# Authentication\\n…\",\n\"sections\": [\n{ \"anchor\": \"the-key\", \"heading\": \"The key\", \"text\": \"…\" }\n]\n}\n]\n}\n\nkind is guide for hand-written prose and reference for pages generated from the\nOpenAPI contract. sections is the page split at its headings, so a search can return an\nexcerpt rather than a whole page."
        },
        {
          "anchor": "the-openapi-contract-itself",
          "heading": "The OpenAPI contract itself",
          "text": "Everything on the reference pages is generated from a single OpenAPI 1.0.0\ndocument — 68 operations, 56 schemas and\n9 webhook events. If you are generating a client, generate it from that\ndocument rather than from this prose. Ask your operator contact for the current\nopenapi.yaml; this site is rebuilt from it, so the two cannot disagree."
        },
        {
          "anchor": "facts-an-agent-should-not-have-to-infer",
          "heading": "Facts an agent should not have to infer",
          "text": "Models writing an integration against this API tend to make the same handful of wrong\nassumptions. Each of these is stated here so it can be retrieved directly.\n\nThere is no swap.completed or exchange.completed webhook. Swaps and exchanges\nemit nothing. Generated code that awaits one will hang. Poll\nGET /v1/swap/orders/{id} (swaps and exchanges) while status is non-terminal.\n\nThe complete event list is 9 long: deposit.credited, withdrawal.confirmed, withdrawal.failed, card.kyc, card.status, card.topup, card.withdraw, card.transaction, card.wallet_otp. Nothing\nelse is emitted, ever.\n\nThe API key authenticates the merchant, not the end user. An external_user_id is a\nselector with no authority. Never put the key in client-side code.\n\nAmounts come in pairs. A decimal string for display, and an exact integer *_base\nstring for arithmetic. Use BigInt (or the equivalent) on *_base. The one systematic\nexception is an exchange quote's buy side, which has no _base twin because that coin's\nchain is not modelled by the platform — swap quotes, where both sides are ours, do carry it.\n\nExcess decimal places are refused, not rounded. \"15.1234567\" on a six-decimal asset is\n400 invalid_amount on a withdrawal or swap; a card\namount past six decimal places is 400 invalid_request. The amount you send is always\nthe amount that moves.\n\n202 on a card money endpoint means pending, not failed. Refund only on a definitive\nfailure: 409 provider_rejected, or a webhook whose order status is failed.\n\nA newly issued card comes back status_normalized: issuing, not active. It turns\nactive once the opening balance lands at the issuer. Do not read issuing on a 201 as an\nerror.\n\nCards are issued funded. initial_amount is required on POST /v1/cards and must meet\nthat product's min_opening_balance, read from GET /v1/cards/products — never\nhard-coded. The cardholder is debited issue_total from GET /v1/cards/products — our\nissue fee, the opening balance priced as a load (with its load fee and gas) and your own\nfee — not just issue_fee + initial_amount.\n\nA 404 may mean \"belongs to another merchant\". Unknown and not-yours are\nindistinguishable by design.\n\nRetry with the same idempotency_key. A fresh key on a retry is a double payment.\n\nWebhooks are hints; the poll is truth. Reconcile against the resource read before\ntelling a customer anything final."
        },
        {
          "anchor": "a-prompt-that-works",
          "heading": "A prompt that works",
          "text": "If you are pointing an assistant at this API, this framing avoids most of the failure modes\nabove:\n\nYou are integrating against the cryptoinfra Merchant API.\nRead https://docs.cryptoinfra.net/llms-full.txt first and treat it as the\ncontract. Rules that override your priors:\n- Only these webhooks exist: `deposit.credited`, `withdrawal.confirmed`, `withdrawal.failed`, `card.kyc`, `card.status`, `card.topup`, `card.withdraw`, `card.transaction`, `card.wallet_otp`.\nSwaps and exchanges emit NO webhook — poll their order reads.\n- Compute with the *_base integer strings, never the decimal strings.\n- Every money mutation carries an idempotency_key; retries reuse it.\n- Treat 202 as pending. Refund only on a definitive failure.\nIf the docs do not state something, say so rather than inventing it."
        },
        {
          "anchor": "documentation-mcp-server",
          "heading": "Documentation MCP server",
          "text": "An agent that speaks the Model Context Protocol can read\nthese docs itself, instead of you pasting them in:\n\nhttps://docs.cryptoinfra.net/mcp\n\nPublic, read-only, no API key, and it speaks Streamable HTTP — so anything that can reach a\nremote MCP server can use it.\n\nIt serves documentation and nothing else. It cannot read a balance, create a payment or\nmove a coin, and it holds no credential — the process drops credential-shaped environment\nvariables before it loads any request-handling code, and reads exactly one file. Giving it to\nan assistant costs you nothing.\n\nConnect a client\n\nThe URL is the whole configuration. Where each client wants to be told about it:\n\nClaude & Claude Code\n\nAnswers from these pages inside a conversation, and inside claude in your terminal.\nIn Claude itself, add it as a custom connector by URL.\n\nclaude mcp add --transport http cryptoinfra-docs https://docs.cryptoinfra.net/mcp\n\nChatGPT & Codex\n\nChatGPT connects to it as a custom connector by URL. Codex reads it from\n~/.codex/config.toml, and will then cite these pages while it writes your client.\n\n[mcp_servers.cryptoinfra_docs]\nurl = \"https://docs.cryptoinfra.net/mcp\"\n\nCursor\n\nPuts the reference in the composer's reach, so a generated integration is written against\nthe real contract instead of a remembered one. Project-scoped, in .cursor/mcp.json.\n\n{\n\"mcpServers\": {\n\"cryptoinfra-docs\": { \"url\": \"https://docs.cryptoinfra.net/mcp\" }\n}\n}\n\nGitHub Copilot\n\nAgent mode in VS Code picks the server up from .vscode/mcp.json, so it is shared with\neveryone who checks the repository out.\n\n{\n\"servers\": {\n\"cryptoinfra-docs\": { \"type\": \"http\", \"url\": \"https://docs.cryptoinfra.net/mcp\" }\n}\n}\n\nAnything else that speaks Streamable HTTP takes the same one line:\n\n{\n\"mcpServers\": {\n\"cryptoinfra-docs\": { \"url\": \"https://docs.cryptoinfra.net/mcp\" }\n}\n}\n\nTools\n\nTool · When an agent should reach for it\nlist_docs · Discover every topic and its resource URI.\nsearch_docs · Find a section-level answer with ranked excerpts and links, instead of loading the whole corpus.\nget_doc · Read one page in full, as portable Markdown.\nget_integration_guide · Get an ordered reading plan for one pattern: hosted-checkout, deposits-and-balances, payouts, swap-and-exchange, cards, webhooks.\n\nEvery result carries structured JSON alongside the human-readable text, and search returns\nresource links so an agent fetches only the pages it needs.\n\nResources\n\nResource · What it is\ndocs://cryptoinfra/index · Machine-readable service and page index.\ndocs://cryptoinfra/full · The complete reference as one document.\ndocs://cryptoinfra/{slug} · A single page. Supports slug completion, so a client can discover valid URIs rather than guess.\n\nPrompts\n\nintegrate-cryptoinfra — reads the right guide for your pattern and produces\nimplementation code, tests, configuration and a go-live checklist.\nreview-cryptoinfra-integration — reviews code you already have for the things that\nactually go wrong here: auth, idempotency on every mutation, *_base versus decimal\nhandling, webhook signature verification and replay, polling for the operations that emit\nno webhook, and reconciliation gaps.\n\nIf a get_integration_guide pattern is missing a page, it says so rather than inventing the\nstep — a gap in our docs should read as a gap, not as confident filler."
        },
        {
          "anchor": "connect-a-client",
          "heading": "Connect a client",
          "text": "The URL is the whole configuration. Where each client wants to be told about it:\n\nClaude & Claude Code\n\nAnswers from these pages inside a conversation, and inside claude in your terminal.\nIn Claude itself, add it as a custom connector by URL.\n\nclaude mcp add --transport http cryptoinfra-docs https://docs.cryptoinfra.net/mcp\n\nChatGPT & Codex\n\nChatGPT connects to it as a custom connector by URL. Codex reads it from\n~/.codex/config.toml, and will then cite these pages while it writes your client.\n\n[mcp_servers.cryptoinfra_docs]\nurl = \"https://docs.cryptoinfra.net/mcp\"\n\nCursor\n\nPuts the reference in the composer's reach, so a generated integration is written against\nthe real contract instead of a remembered one. Project-scoped, in .cursor/mcp.json.\n\n{\n\"mcpServers\": {\n\"cryptoinfra-docs\": { \"url\": \"https://docs.cryptoinfra.net/mcp\" }\n}\n}\n\nGitHub Copilot\n\nAgent mode in VS Code picks the server up from .vscode/mcp.json, so it is shared with\neveryone who checks the repository out.\n\n{\n\"servers\": {\n\"cryptoinfra-docs\": { \"type\": \"http\", \"url\": \"https://docs.cryptoinfra.net/mcp\" }\n}\n}\n\nAnything else that speaks Streamable HTTP takes the same one line:\n\n{\n\"mcpServers\": {\n\"cryptoinfra-docs\": { \"url\": \"https://docs.cryptoinfra.net/mcp\" }\n}\n}"
        },
        {
          "anchor": "tools",
          "heading": "Tools",
          "text": "Tool · When an agent should reach for it\nlist_docs · Discover every topic and its resource URI.\nsearch_docs · Find a section-level answer with ranked excerpts and links, instead of loading the whole corpus.\nget_doc · Read one page in full, as portable Markdown.\nget_integration_guide · Get an ordered reading plan for one pattern: hosted-checkout, deposits-and-balances, payouts, swap-and-exchange, cards, webhooks.\n\nEvery result carries structured JSON alongside the human-readable text, and search returns\nresource links so an agent fetches only the pages it needs."
        },
        {
          "anchor": "resources",
          "heading": "Resources",
          "text": "Resource · What it is\ndocs://cryptoinfra/index · Machine-readable service and page index.\ndocs://cryptoinfra/full · The complete reference as one document.\ndocs://cryptoinfra/{slug} · A single page. Supports slug completion, so a client can discover valid URIs rather than guess."
        },
        {
          "anchor": "prompts",
          "heading": "Prompts",
          "text": "integrate-cryptoinfra — reads the right guide for your pattern and produces\nimplementation code, tests, configuration and a go-live checklist.\nreview-cryptoinfra-integration — reviews code you already have for the things that\nactually go wrong here: auth, idempotency on every mutation, *_base versus decimal\nhandling, webhook signature verification and replay, polling for the operations that emit\nno webhook, and reconciliation gaps.\n\nIf a get_integration_guide pattern is missing a page, it says so rather than inventing the\nstep — a gap in our docs should read as a gap, not as confident filler."
        },
        {
          "anchor": "licence-and-freshness",
          "heading": "Licence and freshness",
          "text": "The reference pages are generated on every build from the live contract, so a page that\ndisagrees with the running service is a bug — report it rather than working around it. This\nbuild documents OpenAPI version 1.0.0."
        }
      ]
    },
    {
      "slug": "changelog",
      "title": "Changelog",
      "summary": "Every change to this API and these docs, newest first — what changed, whether it breaks an existing integration, and what to do about it.",
      "kind": "guide",
      "markdown": "# Changelog\n\nEvery change to this API and these docs, newest first. Each entry says whether it can\nbreak an integration that already works, and what — if anything — you need to do.\n\nMachine-readable at [`/changelog.json`](/changelog.json): poll it, compare `version`,\nand act on `breaking`. That feed is generated from the same source as this page, so it\ncannot fall behind it.\n\n## 8.1.0 — Card close: one call is enough\n\n**07 Nov 2026, 4:40 PM IST**\n\nClosing a card that still holds a balance used to take two `DELETE /v1/cards/{id}` calls: the first returned the balance and answered `202`, and only a second call, made after the return settled, actually closed the card. A client that read the `202` as success left the card open and the holder's refund waiting. We now finish the close ourselves within about a minute of the return settling. Until then `GET /v1/cards/{id}` carries `close_in_progress: true`.\n\n- A close whose balance return has settled is finished by us — close fee, terminate, refund queue — without a second call.\n- `GET /v1/cards/{id}` carries `close_in_progress: true` while a close has started and the card is not yet terminated.\n- `DELETE /v1/cards/{id}` on a card closed through us answers `200` with the card.\n\n**What you need to do:** Nothing to change if you already call close twice: the second call still works, and a `DELETE` on a card we already closed now answers `200` with the card instead of `409 card_state`. If your close screen treats the first response as done, make it wait for `status_normalized: closed` or the `card.status` webhook instead.\n\n## 8.0.7 — Card transactions: every payment by webhook, codes documented\n\n**05 Nov 2026, 4:30 PM IST**\n\nSome card payment (`170`) `card.transaction` webhooks were not delivered until today; that is fixed on our side, and every payment, refund, card fee, ATM withdrawal and pre-authorization now arrives. The cards guide, section 7, lists every `type` and `status` code: 170 payment, 171 refund (with `originalTransactionId`), 172 card fee, 173 ATM, 174 pre-authorization; status 1 processing, 2 success, 3 failed.\n\n- `card.transaction` is now delivered for card payments (`transType` 170); before today some were not.\n- Cards guide, section 7, and the API reference: every `type` / `transType` and `status` / `transStatus` code with its meaning.\n\n**What you need to do:** Handle `card.transaction` for type 170 as well as 171 if you only saw refunds before. Payments made before today that never reached you by webhook are in `GET /v1/cards/{id}/transactions`.\n\n## 8.0.6 — Card details: one iframe, and we draw the card\n\n**04 Nov 2026, 4:48 PM IST**\n\n`reveal_url` and `card_pan_url` now open our card page. Put the link in one iframe and it does the rest: a card in your company name and logo while it loads, then the card itself fitted to the frame, with a pointer to the eye that shows the number. There is nothing to style or size on your side.\n\n- `reveal_url` (`POST /v1/cards/{id}/reveal`) and `card_pan_url` (`GET /v1/cards/{id}`) open our card page.\n- Cards guide, section 5: a single iframe snippet; opening the card in a new tab is no longer documented.\n\n**What you need to do:** Use the one iframe in the cards guide, section 5, and remove any box or styling you added around the old page. If your page sends a Content-Security-Policy, `frame-src https:` covers the new page.\n\n## 8.0.5 — Card details are shown on an embedded card page\n\n**04 Nov 2026, 2:52 PM IST**\n\nThe card issuer's security policy changed for current cards (BINs 493 and 404): the card number, CVV and expiry can no longer be returned by any API. They are shown on a secure card page that you embed in an iframe — `card_pan_url` on `GET /v1/cards/{id}`, or `reveal_url` on `POST /v1/cards/{id}/reveal`. The cards guide now has a copy-paste iframe, a card-shaped version that hides the page's empty space, and the checklist the page needs.\n\n- Cards guide, section 5: embed snippet, card-shaped snippet and checklist (HTTPS, no sandbox, `frame-src https:` when you send a CSP, no `Cross-Origin-Embedder-Policy: require-corp`, WebView in mobile apps).\n- `POST /v1/cards/{id}/reveal` for these cards: `reveal_url` filled, `pan`, `cvv` and `expiry` null.\n\n**What you need to do:** Show the card by embedding `card_pan_url` (or `reveal_url`) in an iframe on an HTTPS page, with a fresh link each time. Follow the checklist in the cards guide, section 5.\n\n## 8.0.4 — Card reveal gives its hosted card page again\n\n**04 Nov 2026, 12:17 PM IST**\n\nSince 3 November, `POST /v1/cards/{id}/reveal` could answer 200 with neither the card details nor a `reveal_url`, and `card_pan_url` on `GET /v1/cards/{id}` came back `null`. The card issuer changed how its card page hands over the details, so the raw details could not be read, and the hosted-page link a reveal falls back to was not being built on our side. The link is back: a reveal now always carries one of the two forms, as the API reference says. For these cards the issuer now shows the details only on its embedded card page (see 8.0.5).\n\n- `POST /v1/cards/{id}/reveal`: `reveal_url` is filled whenever `pan` is not.\n- `GET /v1/cards/{id}`: `card_pan_url` is filled again for cards with a hosted card page.\n\n**What you need to do:** When `pan` is `null`, open `reveal_url` for the cardholder. Do not store it: it expires in ten minutes.\n\n## 8.0.3 — USDC on TRON reads as not swappable on the price sheet\n\n**03 Nov 2026, 1:56 PM IST**\n\nThe public `GET /v1/pricing` sheet showed `swappable: true` for every asset, including USDC on TRON, which no route carries. It now reads `false` for USDC, as `GET /v1/assets` always did. A swap or an exchange that names USDC on TRON is refused with `400 invalid_pair` before any route is asked, as the fees page says.\n\n- `swappable` on the public `GET /v1/pricing` sheet is `false` for `usdc_trc20`.\n- A swap quote, swap or exchange naming `usdc_trc20` answers `400 invalid_pair`.\n\n**What you need to do:** Leave any asset with `swappable: false` out of your swap picker, whichever endpoint you read it from.\n\n## 8.0.2 — Card gas is priced for the send a load really makes\n\n**01 Nov 2026, 4:55 PM IST** · :breaking:\n\n`gas` on a card load or issue is the network's price for the on-chain send behind it. It was priced as if the card's funding address already held the token. It almost never does, and a send to an address that holds none takes about twice the energy. `gas` is now priced for that send, so it no longer comes in under what the send costs. At today's network price the figure is about the same as before; at quieter moments it is higher than it was.\n\n- `gas` on `GET /v1/cards/products`, the load and issue quotes and the public `GET /v1/pricing` sheet: the funding send is priced for a recipient that holds no token.\n- `gas_burning_trx` on the price sheet is the same send, burning TRX, to that recipient.\n\n**What you need to do:** If you show a load or an issue before the customer confirms, read `gas` and `total` from the quote when you show it, not from a figure saved earlier.\n\n## 8.0.1 — Card codes for Apple Pay and checkouts show in the dashboard\n\n**31 Oct 2026, 3:00 PM IST**\n\nA card's page in the dashboard now has a Verification code section. When the card is added to Apple Pay or Google Pay, or a checkout asks for a code, it appears there live. Your server and your dashboard each get one read of a code, so an integration that fetches codes no longer hides them from the dashboard.\n\n- GET /v1/cards/{id}/otp: one read for your API key and a separate one for the dashboard. A payment code now also carries `merchant_name`, `amount` and `currency` when the issuer sends them.\n- `kind` is now `wallet_provisioning` for the code that adds a card to Apple or Google Pay. It used to say `payment_3ds` for every code. The `card.wallet_otp` webhook's `otpKind` is corrected the same way.\n- GET /v1/cards/{id} adds `otp_waiting` while a code is waiting: its kind and, for a payment, the shop and amount. It never includes the code.\n\n## 8.0.0 — The INR buy rail is retired\n\n**26 Oct 2026, 8:00 PM IST** · :breaking:\n\nBuying USDT with rupees over UPI is gone: the buy quote, buy orders and their reads, and the UPI commission settings. The `/v1/buy/*` and `/v1/merchant/upi*` endpoints now answer `404`. Orders placed before today stay in your history and balances.\n\n- Removed: `POST /v1/buy/quote`, `POST /v1/buy/orders`, `GET /v1/buy/orders/{id}`, `POST /v1/buy/orders/{id}/cancel`, `GET /v1/merchant/upi`, `PUT /v1/merchant/upi/fee` and `GET /v1/merchant/upi/orders`.\n- Dashboard: the Fiat (UPI) page is gone. Earnings already made on past orders still show on the Profit page.\n\n**What you need to do:** Stop calling `/v1/buy/*` and `/v1/merchant/upi*`, and remove any \"buy with UPI\" option from your product. Nothing else changes.\n\n## 7.11.2 — KYC upload handles, a 400 for unknown assets, daily caps on paid calls\n\n**16 Oct 2026, 5:30 PM IST**\n\nPOST /v1/cards/kyc/upload now answers with a `kycfile_…` handle instead of a storage link; pass it to POST /v1/cards/kyc exactly as before. Starting identity verifications and uploading card documents are capped per account per day. An asset id we do not support answers 400 unknown_asset rather than 500.\n\n- file_url from POST /v1/cards/kyc/upload is a handle, valid 24 hours on your account; the KYC submit accepts it (or a URL you host) in id_url, id_back_url, file_url, face_url and selfie_url.\n- 429 daily_limit_reached on POST /v1/kyc/start (new sessions only) and POST /v1/cards/kyc/upload past the daily cap.\n- 400 unknown_asset for an asset id that is not on GET /v1/assets.\n\n**What you need to do:** If you displayed the uploaded document from file_url, keep your own copy instead — the handle is not a link.\n\n## 7.11.1 — Card close: the refund charge and timing on the price sheet\n\n**23 Sep 2026, 3:55 PM IST**\n\nGET /v1/pricing now carries close_refund on every card product: the card network's charge to release a closed card's balance (a percentage with a minimum, capped below a threshold) and how long the refund takes to arrive, typically and at most. The fees page's card table and close section show them; the close quote still does the exact sum for one card.\n\n- New on each card product in GET /v1/pricing: close_refund { release_fee_percent, release_fee_min, release_fee_max, release_fee_cap_below, settlement_max_days, settlement_typical }.\n- Fees page corrected: a close is always accepted (never refused for the issuer's cancellation charge), and our close fee is our own flat part plus the network figure.\n\n## 7.11.0 — USDT ⇄ TRX swaps run on-chain on TRON, from a dollar up\n\n**23 Sep 2026, 3:40 PM IST** · :breaking:\n\nA swap between USDT and TRX is now traded on the TRON network itself, signed by the user's own address, so small amounts swap at the market rate (a 5 USDT swap used to have no route). The swap fee is now 0.5% with a $0.50 minimum, plus the swap's own network cost in the sold coin — a small swap costs a little more than the old flat fee, a large one far less.\n\n- Swap fee for USDT and TRX: 0.5% (minimum $0.50) plus network_fee, all taken out of the sold amount. network_fee is a new field on POST /v1/swap/quote and on the order.\n- A USDT ⇄ TRX order goes pending → sent → completed, or failed with the held amount returned in full. There is no swapping state and no refund from an outside party.\n- receive_minimum is a hard floor: the trade reverts on-chain rather than pay less, and the order fails with nothing traded.\n- An exchange out of USDT or TRX (to a coin outside TRON) pays the same 0.5% and minimum plus its own send's network cost.\n- GET /v1/pricing: the swap block for USDT and TRX carries percent, min_fee, network_fee and total (the fee on a small swap).\n\n**What you need to do:** Show fee and network_fee from the quote rather than a fixed figure. If you display a swap as in progress while it is swapping, treat sent as in progress too.\n\n## 7.10.1 — The refund wallet’s surplus can be swept to the profit wallet\n\n**20 Sep 2026, 12:20 PM IST**\n\nInternal only: withdrawing a rail's float brings the close refunds and our own fees out together, so the fees land in the refund wallet. The admin can now send that surplus to the profit wallet in one action, capped at what the wallet holds beyond every holder it still owes.\n\n- New admin route POST /admin/cards/refund-wallet/send-profit.\n\n## 7.10.0 — A close is always accepted, and its refund is a state you can read\n\n**20 Sep 2026, 11:45 AM IST** · :breaking:\n\nDELETE /v1/cards/{id} no longer answers 409 card_refund_wallet_empty. The card's balance goes to the rail and the card terminates whatever the refund wallet holds; paying the holder on-chain is reported as close_refund.state on GET /v1/cards/{id} — waiting, approved, sending, paid, or held with a reason — so there is nothing for a customer to retry and nothing for you to translate into an error.\n\n- 409 card_refund_wallet_empty is retired.\n- New close_refund block on GET /v1/cards/{id}.\n\n## 7.9.0 — An empty card can be closed\n\n**20 Sep 2026, 11:05 AM IST** · :breaking:\n\nA card holding less than the issuer's cancellation charge — an empty one included — now closes like any other. 409 card_close_underfunded is no longer raised, and minimum_card_balance on the close quote is always 0 (the new issuer_fee field carries what the issuer keeps). The floor was ours, not the rail's: the rail terminates a zero-balance card at once, and refusing meant asking somebody to load a card they wanted to be rid of.\n\n- 409 card_close_underfunded is retired.\n- close-quote: minimum_card_balance is always 0; new issuer_fee / issuer_fee_base.\n\n## 7.8.2 — A close quote states the issuer’s settlement period\n\n**20 Sep 2026, 10:25 AM IST**\n\nThe close quote carries refund_eta_max_days (45) and refund_eta_typical (\"24–48 hours\"). Cancelling a card opens the issuer's settlement period and the balance is not released until it ends; the issuer states it can run to 45 days, though it has been far quicker. Quote your customer the issuer's limit.\n\n- New fields refund_eta_typical and refund_eta_max_days on the close quote.\n\n## 7.8.1 — The close fee is 5.00 plus the network figure\n\n**20 Sep 2026, 10:05 AM IST**\n\nclose_fee is now 5.00 and is our own charge only — the issuer's cancellation is kept on the card by the issuer, so it is no longer folded into our flat part (which double-billed it). The live network figure for the on-chain send is added as before, and an affiliate share is paid on the whole of our 5.00.\n\n- close_fee 4.00 -> 5.00, ours alone.\n\n## 7.8.0 — A close is paid from the balance it returns\n\n**20 Sep 2026, 9:40 AM IST** · :breaking:\n\nClosing a card no longer debits the holder's wallet balance. Our close fee, the network cost of the on-chain send, your own fee and the rail's release charge all come out of the balance the card returns, and the holder receives the rest — refund_quote on the close quote shows every line. A close therefore no longer fails because the wallet is empty, which is the normal state of a wallet whose owner is closing their card.\n\n- close-quote: charged_to is now \"refund\"; refund_quote adds close_fee, gas and merchant_fee.\n- A close refund is sent only once the card is fully closed, and pays the net.\n\n## 7.7.1 — The rail float sweep is planned and watched\n\n**20 Sep 2026, 9:10 AM IST**\n\nInternal only, no API change for merchants: a worker now prices what sits in the card rail's merchant float and the cheapest way to withdraw it (the rail's cap applies per withdrawal, so splitting below the threshold costs a fraction), mails the operator once a day above a threshold, and the admin records each withdrawal made by hand.\n\n- New worker rail-float-watch and admin routes GET/POST /admin/cards/float-sweep.\n\n## 7.7.0 — A close quotes the exact refund\n\n**20 Sep 2026, 8:40 AM IST**\n\nGET /v1/cards/{id}/close-quote now carries refund_quote — the card's live balance, the issuer's cancellation fee that stays on the card, the card rail's charge for releasing the balance back on-chain, and the refund the holder receives. That rail charge is new: it was always paid, silently, by us. It is now taken from the returned balance, booked as a rail cost, and shown before the close so a holder can be told the exact figure.\n\n- New refund_quote block on the close quote.\n- The card rail's release charge is deducted from a close refund and carries no commission.\n\n## 7.6.0 — Deposits name their sender\n\n**19 Sep 2026, 9:00 PM IST**\n\nEvery deposit record carries from_address and the deposit.credited webhook carries fromAddress. For USDT and USDC it is the from of the token Transfer event to the user's address — the real payer, not the operator address that signed an exchange's batch payout. For TRX it is the sender. A token moved by transferFrom() is now detected too.\n\n- New field from_address on GET /v1/users/{externalId}/deposits and GET /v1/deposits.\n- New field fromAddress on the deposit.credited webhook.\n- Deposits made with transferFrom() are credited.\n\n## 7.5.4 — Deposits sent by a contract are credited\n\n**19 Sep 2026, 7:50 PM IST**\n\nUSDT or USDC that reaches a user's address from inside another contract — a swap provider paying out, an exchange's batch withdrawal — is now detected and credited like any deposit, with the usual deposit.credited webhook. Before, only a direct token transfer was seen. Its output_index is numbered above 1,000,000,000.\n\n- Contract-sent token deposits are detected and credited.\n\n## 7.5.3 — Balances show the address alongside the ledger\n\n**19 Sep 2026, 3:00 PM IST**\n\nGET /v1/users/{externalId}/balances carries on_chain and on_chain_base per asset — what the user's own address holds right now, read live — next to the spendable amount. The two differ by design (fees settle to the profit wallet, earnings are credited from there); amount is what a payout may spend. null when the chain could not be read in time.\n\n- New fields on_chain / on_chain_base on every Balance.\n\n## 7.5.2 — Payouts to the user’s own address are refused\n\n**19 Sep 2026, 1:30 PM IST**\n\nPOST /v1/withdrawals answers 400 self_transfer when the recipient is the user's own deposit address. Such a send moved nothing and spent the network fee; the coins that came back were not a deposit. Nothing is held on a refused request.\n\n- New 400 error code self_transfer on POST /v1/withdrawals.\n\n## 7.5.1 — Partial card unload retired\n\n**14 Sep 2026, 9:05 PM IST** · :breaking:\n\nPOST /v1/cards/{id}/withdraw is retired and answers 410 card_unload_retired. An unload credited the holder's balance with coins that never reached their own address; the only way value leaves a card is the close, which returns the whole balance on-chain.\n\n- `POST /v1/cards/{id}/withdraw` → `410 card_unload_retired`, always.\n\n**What you need to do:** Remove any \"withdraw from card\" action from your product; offer \"close card\" instead.\n\n## 7.5.0 — A card load pays its own way on-chain, or waits\n\n**14 Sep 2026, 7:40 PM IST** · :breaking:\n\nA load is sent from the holder's own address, and only when the coins are there. A balance the ledger shows but the chain does not yet — a close refund or a deposit still on its way — no longer loads a card from our float: POST /v1/cards/{id}/topup answers 409 card_coins_not_on_chain and nothing is charged.\n\n- `409 card_coins_not_on_chain` on `POST /v1/cards/{id}/topup` and `POST /v1/cards`; `details.needed` says how much must be on-chain.\n\n**What you need to do:** Handle `409 card_coins_not_on_chain` on a load (and on an issue with an opening balance): show its message and let the holder try again once their coins have arrived.\n\n## 7.4.1 — A close is refused while its refund cannot be paid\n\n**14 Sep 2026, 7:10 PM IST**\n\nA card's balance goes back to the holder's own address on-chain within 7 working days of the close, from a wallet cryptoinfra funds by hand. When that wallet cannot pay the balance today, DELETE /v1/cards/{id} answers 409 card_refund_wallet_empty and the card is untouched. The close quote says the ETA (`refund_eta_working_days`).\n\n- `409 card_refund_wallet_empty` on `DELETE /v1/cards/{id}`; `refund_eta_working_days` on the close quote.\n\n**What you need to do:** Handle `409 card_refund_wallet_empty` on a close: show its message (\"Card refund wallet balance is empty, please contact cryptoinfra.\") and let the holder try again later.\n\n## 7.4.0 — Close fee: a flat part plus the live network figure\n\n**14 Sep 2026, 5:30 PM IST** · :breaking:\n\nThe close fee now has two parts: a flat part (our fee plus the issuer's cancellation charge billed to us) and the live network figure for returning the balance to the holder's own address on-chain. `GET /v1/cards/{id}/close-quote` shows both, `gas` with `dynamic`, and `total` is what the balance is debited. The cardholder terms sheet for the live BIN prices the 3-D Secure check as the issuer bills it.\n\n- `gas`, `gas_base` and `dynamic` on `GET /v1/cards/{id}/close-quote`; `total` includes it.\n- `close_fee` on the product and the price sheet is the flat part only.\n\n**What you need to do:** If you show a close before the holder confirms, read `total` from the close quote rather than the product's `close_fee` alone — the network part is priced when you ask.\n\n## 7.3.0 — A flat platform fee on every card load\n\n**13 Sep 2026, 2:20 PM IST** · :breaking:\n\nA card load now carries a flat platform fee beside its percentage, the way a payout carries one beside its network figure — so every movement of money except a deposit has the same flat part. The figure is on the product as `load_fee_flat`, on the public price sheet, and in the fee calculator; a load's charge rises by it.\n\n- `load_fee_flat` on every card product (`GET /v1/cards/products`, the public `GET /v1/pricing` sheet): a flat platform fee added to the percentage or its floor.\n- The price sheet also carries `network_fee_burning_trx` / `network_fee_burning_trx_fresh_recipient` on a withdrawal and `gas_burning_trx` on cards — what the same send would burn in a wallet without energy, for a \"you save\" comparison. The docs' new fee calculator shows it.\n- Docs: a live fee calculator at /fee-calculator — any amount, payout / card load / card issue / deposit, your own fee on top, every figure from the price sheet at load and re-read on every change.\n\n**What you need to do:** If you show a load's cost before the customer confirms, price it from `GET /v1/cards/products` (`load_fee_bps`, `load_fee_min`, `load_fee_flat`, `gas`) rather than from a percentage alone — the docs' fee calculator does the arithmetic.\n\n## 7.2.0 — Your affiliate commission has a public ledger page\n\n**12 Sep 2026, 10:36 PM IST**\n\nA shareable page of your affiliate commission — total per asset and one line per operation that earned some, with our fee, your share and what you got — readable by anyone holding the link, no login. The link is on your Profit page; replace it from there if it gets around.\n\n- `GET /v1/merchant/affiliate-ledger` returns the link (`/a/{token}` on the merchant origin); `POST /v1/merchant/affiliate-ledger/rotate` replaces it (dashboard owner, both codes). The page reads the public `GET /v1/a/{token}`.\n\n## 7.1.0 — A card order carries the hash of its on-chain send\n\n**12 Sep 2026, 9:29 PM IST**\n\n`txid` on every card order and on the `card.topup` / `card.status` webhooks — the hash of the send from the cardholder's address to the card rail — so a receipt for a load can link to a block explorer the way one for a deposit or a payout already does. Null until the send is broadcast; a failed order keeps the hash of the send that reverted.\n\n- `GET /v1/cards/orders/{id}` and the `orders[]` of `GET /v1/cards/orders` carry `txid`; `card.topup` and `card.status` payloads carry `txid`. Additive.\n\n## 7.0.1 — A card load whose send reverts is sent again before it fails\n\n**12 Sep 2026, 9:10 PM IST**\n\nWhen the on-chain send behind a load reverts, the load is now re-sent once instead of failing at once. The order stays `funding` a little longer; the cardholder's charge is untouched throughout. Only if the second send reverts too does the order turn `failed` and refund the whole charge, as before.\n\n- `GET /v1/cards/orders/{id}` may stay `funding` for a few minutes more than before while a send is re-issued; `error` then notes the first send and the new one. Nothing to handle — keep treating `202` as pending and `failed` as the refund.\n\n## 7.0.0 — Fees, simple: the total, one live flag, one time\n\n**12 Sep 2026, 4:48 PM IST** · :breaking:\n\nToday's fee responses had grown a breakdown nobody needs to charge a customer — the fallback figure, the bill behind the figure, the margin floor. They are gone again. A fee response is the amounts, `dynamic` (live or approx) and when it was priced. The guarantees behind the number (priced live, never below what a send cost yesterday) still hold; they are inside the figure, not beside it.\n\n- Removed from `GET /v1/fees`: `static_fallback`, `static_fallback_base`, `network_cost`, `recent_cost_floor`, `floored`. Kept: `priced_at`, `dynamic`.\n- Removed from `GET /v1/withdrawal-fee-quote`: `static_fallback`, `recent_cost_floor`, `floored`. Kept: `dynamic`, `quoted_at`, `network_cost`, `recipient_priced`.\n- Removed from both card quotes: `network_cost`, `recent_cost_floor`, `floored`. Kept: `dynamic`.\n- Removed from `GET /v1/merchant/card-fees`: `gas_static_fallback`, `gas_network_cost`, `gas_recent_cost_floor`, `gas_floored`. Kept: `gas`, `gas_dynamic`, `gas_priced_at`.\n- Removed from `GET /v1/pricing`: `recent_cost_floor`, `floored`, `gas_static_fallback`, `gas_recent_cost_floor`, `gas_floored`, `gas_priced_from`. Kept: everything that was there before today, plus `gas_dynamic`.\n- Docs: no fee example carries a real number any more — the field is shown, the value is read from the quote. A Quickstart of six copy-paste calls opens the docs.\n\n**What you need to do:** If you read `static_fallback`, `network_cost`, `recent_cost_floor` or `floored` on `GET /v1/fees` or the card quotes, or `gas_static_fallback`, `gas_network_cost`, `gas_recent_cost_floor`, `gas_floored` or `gas_priced_from` on the card tariff, the products list or the price sheet, stop — they no longer exist (all were added earlier today). `network_cost` stays on `GET /v1/withdrawal-fee-quote` only, where it always was. Show `total_fee` (payouts) or `total` (cards); label it from `dynamic`.\n\n## 6.3.0 — A network fee is never below what a send cost yesterday\n\n**12 Sep 2026, 3:57 PM IST**\n\nEvery network figure — the payout quote, the card gas line, `GET /v1/fees`, the public price sheet and the charge itself — is now lifted to the dearest confirmed send of the last 24 hours (energy rented plus TRX burned, priced and cushioned like a quote). The platform part stays a fixed 0.50 on top; the floor is what keeps it 0.50 on a day the market moves faster than the cushion.\n\n- `recent_cost_floor` and `floored` on `GET /v1/withdrawal-fee-quote`, both card quotes, `GET /v1/fees`, `GET /v1/pricing` and (as `gas_recent_cost_floor` / `gas_floored`) on `GET /v1/merchant/card-fees`.\n- A first-time sender's activation is charged to that sender only and never raises the floor for anyone else.\n\n## 6.2.0 — Every fee says whether it is live, everywhere\n\n**12 Sep 2026, 3:26 PM IST**\n\nWhat 6.1.0 did for the card network fee now holds for every network figure the platform publishes: it carries whether it was priced live or is the static fallback, the fallback itself, the bill it was built from, and when it was priced. Nothing you mirror from us needs to be shown as a fixed charge again.\n\n- `GET /v1/fees`: `priced_at` on the response; `withdrawal.static_fallback` and `withdrawal.network_cost`; `deposit.dynamic` and `deposit.network_cost`.\n- `GET /v1/withdrawal-fee-quote`: `static_fallback` and `quoted_at` beside `dynamic` and `network_cost`.\n- `POST /v1/swap/quote`, `POST /v1/exchange/quote` and `POST /v1/buy/quote` carry `quoted_at`.\n- `GET /v1/cards/products` carries the `gas` inside every `issue_total`, with `gas_dynamic` and `gas_priced_at`.\n- Dashboard: the profit-withdrawal quote labels the network fee live or approx. The fees guide has one table of these fields and where each appears.\n\n## 6.1.0 — The card network fee says whether it is live\n\n**12 Sep 2026, 3:14 PM IST**\n\n`ours.load.gas` on the card tariff has always been priced live — the cost of the load's two TRON transfers plus 0.50, computed when you ask, exactly like a payout's `network_fee` — but nothing said so, and a merchant mirroring the figure showed it as a fixed charge. Now the tariff, both card quotes and the public price sheet carry whether the figure is live or the static fallback, and the bill it was built from.\n\n- `GET /v1/merchant/card-fees`: `ours.load` gains `gas_dynamic`, `gas_priced_at`, `gas_static_fallback` and `gas_network_cost`. Label the figure \"live\" when `gas_dynamic` is true and \"approx\" otherwise; re-read it rather than printing it.\n- `GET /v1/cards/topup-quote` and `GET /v1/cards/issue-quote` carry `dynamic` and `network_cost`, the same shape as `GET /v1/withdrawal-fee-quote`.\n- `GET /v1/pricing` `cards` carries `gas_dynamic`, `gas_static_fallback` and `gas_priced_from`.\n- Documented: `gas` on a card order and on the `card.*` webhooks is the figure actually charged, fixed when the order was created — reconcile a quote against it.\n- Dashboard: Profit → Our rates and both card review screens label the network line live or approx.\n\n## 6.0.1 — Payouts have no minimum amount\n\n**12 Sep 2026, 1:34 PM IST**\n\nA payout no longer has a per-asset minimum. How much you send is your decision; the one condition is that the balance covers the amount plus the fee. Only a zero or negative amount is refused.\n\n- `POST /v1/withdrawals` accepts any positive amount. `amount_below_minimum` now means zero or negative only; `min_withdrawal` on `GET /v1/assets` is no longer a payout rule.\n\n## 6.0.0 — Invoices are removed\n\n**12 Sep 2026, 1:33 PM IST** · :breaking:\n\nThe invoice product is gone: creating, listing and reading invoices, the invoice settings, the hosted invoice page a customer paid on, and the Invoices page of the dashboard. Bill an order from a deposit address instead — a user is free and permanent, so an order can have its own — and credit it from `deposit.credited`.\n\n- Removed: `/v1/invoices`, `/v1/invoices/{uuid}`, `/v1/invoices/by-order/{orderId}`, `/v1/invoices/{uuid}/payments`, `/v1/invoice-settings`, and the public `/v1/i/{uuid}`, `/payments` and `/logo` pages.\n- Removed: the `invoice.paid`, `invoice.expired` and `invoice.late_payment` webhook events. Nine events remain and the contract lists all of them.\n- Dashboard: the Invoices page is gone; Deposits shows the day's incoming money.\n\n**What you need to do:** Anything calling `POST /v1/invoices`, `GET /v1/invoices/*` or `/v1/invoice-settings` gets a `404`; stop calling them. Handlers for `invoice.paid`, `invoice.expired` and `invoice.late_payment` can be removed — the events are never sent, and the test-fire no longer offers them. Money a customer still sends to an old invoice address is credited and moved to your own balance like any other deposit; nothing is lost.\n\n## 5.1.0 — Swaps work again, and a coin's picture comes from us\n\n**2026-09-06**\n\nTwo fixes on the exchange. Creating and listing swap orders had been failing since 4 September on a database column the code expected and the schema had dropped; both work again, and nothing was lost — no order was created while it was broken. And the picture of a payout coin now comes back from this API instead of the router's own storage, so it loads and it says nothing about who the router is.\n\n- `POST /v1/swap/orders` and `GET /v1/swap/orders` answer again (they returned `500` from 2026-09-04 to 2026-09-06).\n- New: `GET /v1/exchange/assets/{identifier}/logo` — the coin's picture, served from here.\n\n**What you need to do:** If you render `logo_url` from `GET /v1/exchange/assets`, it is now a path on this API (`/v1/exchange/assets/{identifier}/logo`) rather than an outside URL, so fetch it with your API key like any other call. It answers an image and is cacheable for a week. The old value had been redacted on the way out and did not load, so nothing that worked before stops working.\n\n## 5.0.0 — One network, one way to move money, one place for every job\n\n**2026-09-06** · :breaking:\n\nThe platform is TRON only, and the parts nobody used are gone. Internal transfers, payment links, the fee-in-TRX payout option, the mobile second factor, verification tiers and the approval step are removed. A signup is live at once and waits only on its email; every payout is an on-chain withdrawal; invoices are how you bill an order; the dashboard shows each thing in one place. The endpoints that stay are unchanged.\n\n- Removed from the contract: the Transfers tag (`POST /v1/transfers` and its reads); `/v1/payment-links/*`, the hosted `/pay` page and the `payment.received` event; `fee_mode` as a choice, with `trx_fee_option`, `fee_balance_insufficient`, `fee_mode_unavailable` and `invalid_fee_mode`; `GET /v1/merchant/verification`, `POST /v1/merchant/verification/start`, `verification_tier`, and `tier`, `tier1_daily_auto_usd` and the new-merchant rule under `rules` on `GET /v1/merchant/profit`; `account_pending` and `account_rejected`; `/merchant/account/mobile/{start,confirm}`.\n- `Chain` is `tron` and nothing else: `POST /v1/users` returns one address, and `GET /v1/users/{externalId}/balances` no longer lists an empty balance on a retired chain. Every network fee is charged in the sent asset; `fee_asset_id` stays on the withdrawal, read-only, and names that asset.\n- Two factors everywhere: the authenticator and a code emailed to the login. The password-reset flow is email only.\n- Dashboard: Swap carries both destinations (to my balance, to an address); Settings → API keys is the one developer page (key, webhooks, API log); the cards Overview is the programme page; Invoices is a top-level entry; the Assets page and the currency tabs on Fiat are gone. Approval thresholds are set under Settings → Security and parked payouts are decided on Payouts.\n- The dashboard's webhook page lists all twelve events, and the test fire can rehearse any of them.\n\n**What you need to do:** Anything still calling `POST /v1/transfers` must call `POST /v1/withdrawals` instead (a transfer is now a `404`). Anything creating payment links must create invoices (`POST /v1/invoices`); `/v1/payment-links/*` is gone and `payment.received` is never sent. Stop sending `fee_mode` — it is accepted with any value and ignored. Stop handling `account_pending` and `account_rejected`: an account is `active`, `paused` or `suspended`, and a key whose owner login never verified its email answers `403 email_unverified`. `/merchant/account/mobile/*` and the mobile fields on `/merchant-auth/me` are gone. Treat `chain` as `tron` everywhere. Exchange endpoints are unchanged; on the dashboard the Exchange page is now the \"to an address\" choice on Swap, and `/exchange` redirects there.\n\n## 4.10.0 — Verification tiers: approval opens the API, verification the full limits\n\n**2026-09-05**\n\nEvery account now has a tier. Tier 1 comes with approval and lets profit withdrawals go on their own up to a small daily figure; tier 2 — the business verified in the browser on our verification rail, or by our team by hand — lifts them to the ordinary limits. Settings → Verification shows where you stand and what each step opens.\n\n- `GET /v1/merchant/verification` and `POST /v1/merchant/verification/start` (`503 verification_unavailable` while the flow is not open); `verification_tier` on `GET /merchant-auth/me`; `tier` and `tier1_daily_auto_usd` under `rules` on `GET /v1/merchant/profit`.\n- A held profit withdrawal from an unverified business says so in its `desk_note`.\n\n## 4.9.0 — Signup guarded; linked accounts and out-of-line days are flagged for a look\n\n**2026-09-05**\n\nSignup refuses disposable mailboxes and, when configured, asks for a bot check. Accounts that share a network, a Telegram username, a company e-mail domain, a payout address or their customers with another merchant — or a day of earnings far above their own history — are flagged for us to look at. A flag holds a profit withdrawal for a look, with the reason on the request; it never closes an account on its own.\n\n- `400 email_disposable`, `400 bot_check_failed` and `503 bot_check_unavailable` from signup; `turnstile_token` accepted when the page showed the check.\n- A held profit withdrawal may now carry `risk flag: …` in its `desk_note`.\n\n## 4.8.0 — Profit matures before it is withdrawable; reversed operations claw back\n\n**2026-09-05** · :breaking:\n\nAn earning still lands in your balance the moment the customer pays, but counts as profit available to withdraw only after a wait — a short one for what was earned on-chain, a longer one for UPI and cards, which can still be refunded or disputed. If an operation comes back, the earning on it comes back too: what your balance holds is taken at once, the rest is netted from your next earnings, and no profit withdrawal goes until it is settled.\n\n- `profit_pending` (with the moment it matures), `profit_owed`, `reversals` and `maturity` on `GET /v1/merchant/profit`; `available_at`, `matured` and `reversed` on each earning row.\n- `409 profit_not_matured` and `409 profit_owed` from `POST /v1/merchant/profit/withdraw`.\n- A swap that fails or is refunded by the exchange reverses your fee and your share on its own; a UPI reversal or a rail refund is reversed by cryptoinfra with the reason on the row.\n\n**What you need to do:** Profit earned in the last day (on-chain) or three (UPI, cards) shows as pending and cannot be withdrawn yet; `profit_available` on `GET /v1/merchant/profit` is smaller than before by that much. Nothing else changes.\n\n## 4.7.0 — Profit withdrawals: the rules that decide on their own\n\n**2026-09-05**\n\nA profit withdrawal now goes on its own when it is within the limits, and waits for a look when it is large, from a merchant approved only days ago, to an address never paid before, or past the day's cap. The dialog shows the figures that apply, the Profit page shows why one waits, and owner and finance logins get a mail at each turn.\n\n- `409 profit_below_minimum`, `profit_too_many_open` and `profit_cooldown` from `POST /v1/merchant/profit/withdraw`; `rules` and per-request `desk_note` on `GET /v1/merchant/profit`.\n- An address never paid before waits a fixed number of hours, then the request goes on its own if the other rules still pass.\n\n## 4.6.0 — Profit withdrawals are requests, listed on your Profit page\n\n**2026-09-04**\n\nA profit withdrawal is now a request to cryptoinfra, approved there — automatically as a rule — before it leaves our profit wallet. Your Profit page lists every request with where it stands, and shows what is on its way out.\n\n- `withdrawals` and `in_flight` on `GET /v1/merchant/profit`; a request that is refused returns the whole hold to your profit, with the reason on it.\n- A request waits as `awaiting_approval`; your own payout approvals never list it.\n\n## 4.5.0 — Withdraw your profit from the Profit page\n\n**2026-09-04**\n\nProfit → Withdraw profit sends what you have earned — commission and your own fees, less what you already withdrew — to any address, from our profit wallet in real time, for our ordinary withdrawal fee. The Profit page now shows the profit still available rather than your whole balance.\n\n- Your own fee on a customer's payout, deposit or swap now travels on-chain to our profit wallet with ours as the operation settles, so the wallet always holds what it pays you out.\n- `profit_available` and `withdrawn` on `GET /v1/merchant/profit`; a profit withdrawal is a withdrawal of kind `merchant_profit` and refuses past the earnings with `409 profit_insufficient`.\n\n## 4.4.0 — Affiliate commission: a share of our fee, on every operation, nothing to set\n\n**2026-09-04**\n\nYou now earn two ways, together or alone. A share of OUR platform fee on every payout, swap and card your customers make is paid to your balance the moment our fee settles — automatically, with nothing to set. Your own fee stays what it was: a price above ours, yours entirely. The Profit page shows both.\n\n- `affiliate` on `GET /v1/fees` — `enabled`, `share_bps`, `share_percent` — and on the public price sheet.\n- A commission is a journal of its own; on the Profit page it reads as source `affiliate` with the operation it came from as its reference.\n- The share is of our fee, never of a cost: network bills, the rail's take and card close fees carry none; deposits are free and carry none.\n\n## 4.3.1 — Four new integration guides, and a menu organised by what you are building\n\n**2026-09-04**\n\nThe documentation now walks every money flow once, in order: Payouts, Swaps and exchange, Payment links and invoices, and Buy with UPI join Users and deposits and Cards under one Integrate section. The menu reads Start here, Integrate, Conventions, Pricing, Resources, and the API reference is grouped by product. The front page's quickstart shows the request bodies the API actually accepts.\n\n- New guides: Payouts, Swaps and exchange, Payment links and invoices, Buy with UPI.\n- Stale wording removed: a user's coins sit at their own address (nothing is swept), and a payout's `fee` is the live network cost, not one flat price.\n\n## 4.3.0 — A public price sheet, and docs that read it\n\n**2026-09-04**\n\n`GET /v1/pricing` needs no key and returns every price we charge, live: the platform fee and the network figures per asset, and every card product's terms. The fees documentation now renders its tables from it at page load, so a price on a docs page is the price of that minute, never a number typed into a page.\n\n- New `GET /v1/pricing` (no authentication, cached for a minute). Your own fees stay on `GET /v1/fees` with your key; the exact charge for one send stays on `GET /v1/withdrawal-fee-quote`.\n- The Fees and Cards pages show live tables instead of typed numbers. Text readers of the docs see the endpoint to call instead.\n\n## 4.2.0 — Deposits are free\n\n**2026-09-04**\n\nA deposit to an address we issue is now credited in full. The platform's deposit fee — the flat part and the network part that came with it — is gone on every asset. Nothing changes in the API's shape: `deposit.total` on `GET /v1/fees` reads 0, and a deposit's `fee` and `network_fee` are 0. Your own fee on deposits, if you set one, is unchanged.\n\n- `GET /v1/fees`: `deposit.total`, `deposit.flat` and `deposit.network_fee` are 0 for every asset.\n- A deposit's `amount_credited` equals `amount` unless you charge your own `merchant_fee`. `min_deposit` (2 USDT) still applies as a dust floor.\n- Payment links and invoices receive on the same terms. An invoice's fee split now has nothing to split.\n\n## 4.1.0 — Your own fee on deposits, payouts and swaps; a Profit page\n\n**2026-09-04**\n\nYou can now charge your own fee — a percentage, a flat amount, or both, with no ceiling — on deposits, payouts and swaps, as you already could on cards and UPI. It is charged to your customer and credited to your own balance in the same movement. The dashboard's new Profit page shows everything you earn, by source, with a Withdraw button; Your fees are set there in one place.\n\n- `merchant_fee` (and `merchant_fee_base`) on deposits, on the withdrawal quote and the withdrawal, and on swap quotes and orders — 0 unless you set one. A deposit's `amount_credited` and a quote's `total_fee` include it.\n- GET /v1/fees carries `merchant_fee` (your percent and flat) beside ours for deposit, withdrawal and swap.\n- The `deposit.credited` webhook carries `fee`, `merchantFee` and `amountCredited`.\n- Dashboard: Profit page (earned all time, last 30 days, available to withdraw, by source, every earning) and Your fees; `GET /v1/merchant/profit`, `GET/PUT/DELETE /v1/merchant/fees/{operation}`.\n- The ceilings on card and UPI fees are removed.\n\n## 4.0.0 — Fees are now the live network cost plus a flat 0.50\n\n**2026-09-04** · :breaking:\n\nOne rule for every fee. The network part is what the send actually costs us on-chain, priced at the moment you ask; the platform part is a flat 0.50. A payout to an address that already holds USDT quotes about 1.45–2.10 today, one to an address that does not 2.10–3.05, and a user's first send from a new address adds about 0.80 once. Deposits are 0.50 plus the cost of taking it (about 1.05–1.30). Swaps are 2.00 flat.\n\n- GET /v1/withdrawal-fee-quote prices the exact send: `network_fee` is live (`dynamic: true`), takes an optional `user_id` so a first send's activation is included, and returns `network_cost` (energy, bandwidth, activation in TRX, the TRX price, the number of transfers) so you can explain the figure.\n- GET /v1/fees: `withdrawal.network_fee` is today's figure for a recipient that holds the token; `network_fee_fresh_recipient`, `first_send_activation_fee` and `total` were added; `deposit.network_fee` and `deposit.total` were added.\n- Deposits: the fee is the 0.50 platform part plus the network cost of taking it; deposit rows carry `network_fee`.\n- Swaps and exchanges: 2.00 flat (0.50 + a fixed 1.50 network figure).\n- When a live price cannot be read, a payout is charged the old flat 1.50 and the quote says `dynamic: false`; a deposit's network part falls back to 0.50.\n\n**What you need to do:** Never carry a fee figure in your own code. Call GET /v1/withdrawal-fee-quote with `to_address` and `user_id` before every payout and show the customer `total_fee`; the figure it returns is the one charged. Read deposit fees from the `deposit.credited` webhook or the deposit row (`fee`, and `network_fee` for the network part). GET /v1/fees now carries today's figures, not a constant.\n\n## 3.0.1 — A card still being issued answers 409 card_issuing\n\n**2026-09-03**\n\nFor the minute or two after POST /v1/cards, the balance and reveal calls used to surface the rail's own refusal — a 409 with a message nobody could act on, or a 200 holding neither pan nor reveal_url. They now answer 409 card_issuing until the card is active.\n\n- GET /v1/cards/{id}/balance and POST /v1/cards/{id}/reveal answer 409 card_issuing while the card's status_normalized is issuing; details.card_id names the card. Poll GET /v1/cards/{id} or wait for the card.status webhook, then retry.\n\n## 3.0.0 — Card pricing: fees on top of the amount, $2.00 to issue, $1.50 to close\n\n**2026-09-03** · :breaking:\n\nEvery card fee is charged on top of the amount: a $100 load puts $100.00 on the card and debits $102.90; the opening balance on an issue lands in full. Our issue fee on a virtual card is $2.00 and our close fee is $1.50 — the issuer's own $2.00 stays on the card, so closing costs the cardholder $3.50 all in.\n\n- POST /v1/cards/{id}/topup puts exactly `amount` on the card and debits amount + fee + gas + merchant_fee from the cardholder.\n- On GET /v1/cards/topup-quote, `total` is that debit and `credited` equals `amount`.\n- On GET /v1/cards/issue-quote, `total` is our issue fee + yours + initial_amount + its load fee and gas, and `opening_credited` equals initial_amount.\n- Our issue fee on Core-1 BIN 493 Virtual is $2.00. Issuing at the $5.00 minimum costs the cardholder $8.57. The physical issue fee and the load rate are unchanged.\n- Our close fee is $1.50 on every BIN, from the cardholder's balance when the card terminates; the close_fee order on DELETE /v1/cards/{id} carries it. A card holding exactly the issuer's $2.00 needs $1.50 in the balance to close.\n- GET /v1/cards/products returns close_fee and close_fee_base per product, beside issue_fee and load_fee_bps.\n\n**What you need to do:** Read `total` from GET /v1/cards/topup-quote, GET /v1/cards/issue-quote and GET /v1/cards/{id}/close-quote before every operation — it is the cardholder's debit, and a balance short of it is refused with 409 insufficient_balance. `credited` on a top-up quote equals `amount`. Never carry a fee amount in your own code: GET /v1/cards/products has issue_fee, issue_total, load_fee_bps and close_fee per product.\n\n## 2.1.0 — A payout leaves your customer's own deposit address\n\n**2026-09-03**\n\nThe address a payout is sent FROM has changed. Money used to leave a shared platform wallet; it now leaves the deposit address of the customer whose balance is being spent, when that address holds enough, and the shared wallet otherwise. Nothing about the API, the amounts or the fees changes — only the sender you see on-chain.\n\n- A withdrawal, and the send leg of a swap, an exchange or a card load, leaves the deposit address of the customer whose balance pays for it when that address holds enough; otherwise it leaves the shared wallet as before. The txid, the amounts and every fee are unchanged.\n\n**What you need to do:** If you reconcile by the sending address, or if anyone downstream allow-lists one, stop expecting a single fixed sender. Match on the txid you already get back from GET /v1/withdrawals instead. The same applies to the on-chain leg of a swap, an exchange and a card load.\n\n## 1.9.0 — One flat fee, and no percentages\n\n**2026-09-03** · :breaking:\n\nEvery fee is a flat amount per asset — no percentages anywhere. Deposits and swaps carry one flat fee each; the per-asset swap minimums are gone with them.\n\n- Deposit fee is a flat 1.00 USDT / 1.00 USDC, charged out of the incoming amount: a 100 USDT deposit credits 99.00. TRX and BTC carry their own flat amounts — GET /v1/fees.\n- Swap and exchange fees are flat amounts per asset; the per-asset minimums (1.6 USDT and friends) are removed.\n- min_deposit is 2 USDT / 2 USDC / 2 TRX / 2000 sat. Below it a deposit is ignored_dust and never credited.\n\n**What you need to do:** Read the effective schedule from GET /v1/fees rather than computing a percentage. A deposit below min_deposit is ignored_dust and never credited.\n\n## 1.8.1 — A card must hold its own close fee\n\n**2026-09-03** · :breaking:\n\nClosing a card that holds less than the issuer's cancellation fee is refused with 409 card_close_underfunded instead of closing anyway. The close quote now says the least the card must hold.\n\n- DELETE /v1/cards/{id} answers 409 card_close_underfunded when the card holds less than the issuer's cancellation fee. No card cost is paid by anyone but the cardholder.\n- GET /v1/cards/{id}/close-quote carries minimum_card_balance and minimum_card_balance_base.\n\n**What you need to do:** Before DELETE /v1/cards/{id}, read minimum_card_balance from GET /v1/cards/{id}/close-quote and load the card to it if it is short. Handle 409 card_close_underfunded — details carry minimum_card_balance and card_balance.\n\n## 1.8.0 — Every movement of money is on-chain\n\n**2026-09-03** · :breaking:\n\nInternal transfers are retired. POST /v1/transfers answers 410 transfers_removed to every call; the transfers you made before today are still readable. Pay out with POST /v1/withdrawals — to one of your own users or to any address — priced with our fee and the network's gas. The card programme read no longer carries a deposit address, and reading it no longer opens a programme.\n\n- POST /v1/transfers answers 410 transfers_removed and creates nothing. GET /v1/transfers and GET /v1/transfers/{id} still return the transfers made before 2026-09-03. The dashboard's Transfers page is gone.\n- GET /v1/merchant/card-program no longer returns address and no longer opens a programme as a side effect; it carries a boolean opened, and describes an unopened programme with a zero balance. Opening one is POST /v1/merchant/card-programs, as before.\n- The inbound chain-data webhook is no longer listed in the API reference; it was never a merchant endpoint.\n\n**What you need to do:** Replace every POST /v1/transfers with a POST /v1/withdrawals to the recipient's deposit address, and budget the withdrawal fee where a transfer used to be free. If you read address from GET /v1/merchant/card-program, stop — it is gone, and nothing needs it.\n\n## 1.7.2 — Which assets can be swapped, a pausable buy rail, and straighter answers\n\n**2026-09-02** · :breaking:\n\nGET /v1/assets now says which assets can be swapped or exchanged, and a swap or exchange that names one that cannot is refused before anything is charged — USDC on TRON is the one today. The operator can pause the INR buy rail; while it is paused a new quote or order answers 403 buy_disabled and an order already paid still settles. A reused idempotency_key with a different payload on a swap or exchange order is a 409 now, GET /v1/users says when its count was cut off, and setting your UPI fee from the dashboard asks for both codes like every other change there.\n\n- GET /v1/assets carries a boolean swappable. POST /v1/swap/quote, /v1/swap/orders, /v1/exchange/quote and /v1/exchange/orders answer 400 invalid_pair for an asset marked false, or one that is disabled; usdc_trc20 is false today because no route carries it. Deposits and withdrawals of it are unchanged.\n- The INR buy rail can be paused by the operator. POST /v1/buy/quote and POST /v1/buy/orders answer 403 buy_disabled while it is paused; an order already paid is settled regardless.\n- Replaying an idempotency_key on POST /v1/swap/orders or /v1/exchange/orders with a different payload answers 409 idempotency_conflict. The same payload still returns the existing order.\n- GET /v1/users returns a boolean truncated. The list is scanned in one pass of at most 2000 users; when truncated is true, total is that cap, not your count.\n- PUT /v1/merchant/upi/fee from a dashboard session takes totp and email_code, like every other dashboard change. With an API key it sets the fee directly, as before.\n- When the operator rotates your API key, every owner login is emailed. The previous key stops working at that moment.\n\n**What you need to do:** Drop usdc_trc20 from any swap or exchange picker — better, read swappable from GET /v1/assets and offer only what is true. Treat 403 buy_disabled as a pause, not a broken integration: back off and try again later. Handle 409 idempotency_conflict on POST /v1/swap/orders and /v1/exchange/orders the way you already do on withdrawals. If you show total from GET /v1/users, check truncated first.\n\n## 1.7.1 — A user ID is a number\n\n**2026-09-02** · :breaking:\n\nPOST /v1/users now takes a numeric external_user_id of 5 to 10 digits and refuses anything else with 400 validation_error. Users you already created keep their ids and keep working; this is the rule for new ones.\n\n- external_user_id on POST /v1/users must match ^[0-9]{5,10}$.\n\n**What you need to do:** If your integration sends letters or symbols in external_user_id, change it to a 5 to 10 digit number before creating new users.\n\n## 1.7.0 — Accounts are approved before they go live\n\n**2026-09-02**\n\nA new account signs up with an email, a password and a Telegram username, verifies the email, and can then sign in, add webhooks and generate its API key from the dashboard — signup never shows one. Teammates can be invited once the account is approved. The API and every money feature open once our team has approved the account; until then an API key answers `403 account_pending`. An operator can also pause, reject or suspend an account; each decision is emailed to the owner. Password reset sends its code to your verified email address, and changes made on the dashboard ask for an emailed code alongside the authenticator.\n\n- Every request on an API key of an account that is not yet approved answers 403 account_pending; an account on hold answers 403 account_paused, one that was not approved 403 account_rejected. Do not retry; the dashboard shows the state.\n- A suspended account's key answers 403 account_suspended. It was 401 unauthorized.\n- Signup asks for a Telegram username and verifies the email before the first sign-in. No API key is shown at signup — an owner or developer login generates it from the dashboard, with the authenticator code and the emailed code.\n- Password reset sends its code to your verified email address. SMS is used only if email is switched off platform-wide.\n\n## 1.6.2 — A rail that cannot pay says so first, and a close takes a moment\n\n**2026-09-02**\n\nA card issue or load the rail cannot fund is refused with 503 rail_unfunded before anything is charged. The minimum opening balance is quoted per product, with the product's own activation minimum folded in. Closing a card is its own order, close_fee, and DELETE /v1/cards/{id} answers 202 card_close_in_progress while it runs.\n\n- POST /v1/cards and POST /v1/cards/{id}/topup answer 503 rail_unfunded when the rail has no funding wallet on file or the hot wallet's balance is not known. Nothing is charged; it is an operator matter, not a request to correct.\n- GET /v1/cards/products and GET /v1/cards/issue-quote return min_opening_balance per product — a physical card's activation minimum differs from a virtual one's — and the figure is the one the charge path uses.\n- A card close creates an order of kind close_fee. DELETE /v1/cards/{id} answers 202 card_close_in_progress with card_id and order_id while the residual is being returned; call it again once the order settles.\n\n**What you need to do:** Handle 503 rail_unfunded like any 503 — nothing was charged, come back later — and treat 202 card_close_in_progress on DELETE /v1/cards/{id} as pending, not failure.\n\n## 1.6.1 — A card is issued for a user, from what that user holds\n\n**2026-09-02**\n\nGET /v1/cards/issue-quote previews what issuing a card costs the cardholder, priced by the same code that charges. A card is issued FOR one of your users and only from that user's own balance; when it is short, POST /v1/cards says so before anything is charged. The dashboard's Issue a card checks the cardholder's balance at every step, and the \"Top up\" a programme used to have is gone from the Card Issuing overview.\n\n- GET /v1/cards/issue-quote returns initial_amount, fee, gas, merchant_fee and total. Read total.\n- 409 insufficient_balance now carries a message saying whose balance is short.\n\n## 1.6.0 — The cardholder pays, and every load goes on-chain\n\n**2026-09-02** · :breaking:\n\nCard issue, every load and the close now come out of the cardholder's own balance. A load is sent on-chain to the card rail and the card is funded once that send is confirmed — minutes, not seconds. You can add your own fee on top of ours; it is charged to the cardholder and lands in your own balance.\n\n- payer_user_id is gone from POST /v1/cards, /topup and /withdraw. Sending it is a 400 payer_removed. The holder always pays.\n- Orders have a new first stage, funding, while the money is on its way to the rail on-chain; then requested, sent, confirmed as before. Expect minutes between 202 and confirmed.\n- GET /v1/cards/topup-quote returns fee, gas, merchant_fee and total; total is the cardholder's debit. Pass card_id and user_id to price a specific card.\n- GET /v1/cards/products returns issue_total (everything the holder pays to issue at the minimum opening balance) and merchant_issue_fee.\n- New GET /v1/cards/{id}/close-quote, and GET/PUT/DELETE /v1/merchant/card-fees/{issue|load|close} to set your own percent and/or flat fee on top of ours.\n- card.* webhooks carry gas, merchantFee and total; card orders carry gas, merchant_fee, total and sent_on_chain.\n- POST /v1/merchant/card-program/fund is gone; card programme balances are no longer drawn on. What was prepaid is returned to your own balance.\n\n**What you need to do:** Stop sending payer_user_id and stop moving money from your customer to your treasury before a load — charge nothing yourself; mirror the card.topup / card.status webhooks, which carry the full breakdown. Treat funding like sent: pending, never a failure. Read total from the quotes.\n\n## 1.5.0 — Merchant markups are gone; UPI keeps its commission\n\n**2026-09-02** · :breaking:\n\nYou price your own customers on your side; cryptoinfra charges only its own fee. The per-operation markups, the Pricing page, the Profit page and its payout were removed. Your UPI buy commission now lands straight in your own balance.\n\n- GET/PUT/DELETE /v1/merchant/pricing and GET/POST /v1/merchant/profit* are gone (404).\n- The merchant_fee field is gone from deposit.credited, from card.* webhook payloads and card orders, from GET /v1/withdrawal-fee-quote and from GET /v1/cards/topup-quote. merchant_issue_fee is gone from GET /v1/cards/products. Swap orders no longer carry merchant_fee_base.\n- amount_credited on a deposit is amount minus our fee again, nothing else.\n- UPI buy keeps its markup, but the commission is credited to your own balance at settlement instead of a separate earnings account. A tenant with a markup and no own-balance account is refused at order time (409 no_own_account) rather than having the money land anywhere else.\n\n**What you need to do:** If you parse any of the removed fields, drop them. If you set a markup, it no longer applies — your customers pay exactly the rates on the fees page. Everything else is unchanged.\n\n## 1.2.0 — One flat withdrawal fee on TRON — 1.50, gas included\n\n**2026-09-01** · :breaking:\n\nThe TRON withdrawal fee is now a flat 1.50 USDT/USDC and it covers the network gas. It was 3.50, priced as a worst-case gas estimate.\n\n- The fee deducted from a TRC20 withdrawal is 1.50 USDT or 1.50 USDC.\n- It includes the TRON energy and bandwidth the transfer consumes. There is nothing to add on top and no TRX to hold.\n- It does not vary with the recipient. A first-time recipient costs us about twice the energy of an existing holder; that difference is absorbed rather than passed on.\n- There is no prepaid gas pot and nothing to switch on: gas is inside the fee.\n\n**What you need to do:** If you hard-coded 3.50 as the withdrawal fee, change it, or better, read withdraw_fee from GET /v1/assets at runtime. If you used the Gas Saver endpoints under /v1/merchant/gas, they now return 404 — you no longer need them.\n\n## 1.1.0 — A merchant can hold more than one card programme\n\n**2026-09-01**\n\nCard programmes (\"cores\") let one merchant run several card balances, each with its own funding, pricing and cards.\n\n- Every card carries core_id and bin. Read them as a PAIR — a BIN number is unique only inside its issuer.\n- POST /v1/cards accepts core_id. With one programme open it is optional; with two, omitting it is refused with card_core_required rather than guessed.\n- GET /v1/cards/products accepts ?core_id= and returns each row priced for that programme.\n\n**What you need to do:** Nothing today if you hold one programme. Send core_id on POST /v1/cards before you open a second one.\n",
      "sections": [
        {
          "anchor": "8-1-0-card-close-one-call-is-enough",
          "heading": "8.1.0 — Card close: one call is enough",
          "text": "07 Nov 2026, 4:40 PM IST\n\nClosing a card that still holds a balance used to take two DELETE /v1/cards/{id} calls: the first returned the balance and answered 202, and only a second call, made after the return settled, actually closed the card. A client that read the 202 as success left the card open and the holder's refund waiting. We now finish the close ourselves within about a minute of the return settling. Until then GET /v1/cards/{id} carries close_in_progress: true.\n\nA close whose balance return has settled is finished by us — close fee, terminate, refund queue — without a second call.\nGET /v1/cards/{id} carries close_in_progress: true while a close has started and the card is not yet terminated.\nDELETE /v1/cards/{id} on a card closed through us answers 200 with the card.\n\nWhat you need to do: Nothing to change if you already call close twice: the second call still works, and a DELETE on a card we already closed now answers 200 with the card instead of 409 card_state. If your close screen treats the first response as done, make it wait for status_normalized: closed or the card.status webhook instead."
        },
        {
          "anchor": "8-0-7-card-transactions-every-payment-by-webhook-codes-documented",
          "heading": "8.0.7 — Card transactions: every payment by webhook, codes documented",
          "text": "05 Nov 2026, 4:30 PM IST\n\nSome card payment (170) card.transaction webhooks were not delivered until today; that is fixed on our side, and every payment, refund, card fee, ATM withdrawal and pre-authorization now arrives. The cards guide, section 7, lists every type and status code: 170 payment, 171 refund (with originalTransactionId), 172 card fee, 173 ATM, 174 pre-authorization; status 1 processing, 2 success, 3 failed.\n\ncard.transaction is now delivered for card payments (transType 170); before today some were not.\nCards guide, section 7, and the API reference: every type / transType and status / transStatus code with its meaning.\n\nWhat you need to do: Handle card.transaction for type 170 as well as 171 if you only saw refunds before. Payments made before today that never reached you by webhook are in GET /v1/cards/{id}/transactions."
        },
        {
          "anchor": "8-0-6-card-details-one-iframe-and-we-draw-the-card",
          "heading": "8.0.6 — Card details: one iframe, and we draw the card",
          "text": "04 Nov 2026, 4:48 PM IST\n\nreveal_url and card_pan_url now open our card page. Put the link in one iframe and it does the rest: a card in your company name and logo while it loads, then the card itself fitted to the frame, with a pointer to the eye that shows the number. There is nothing to style or size on your side.\n\nreveal_url (POST /v1/cards/{id}/reveal) and card_pan_url (GET /v1/cards/{id}) open our card page.\nCards guide, section 5: a single iframe snippet; opening the card in a new tab is no longer documented.\n\nWhat you need to do: Use the one iframe in the cards guide, section 5, and remove any box or styling you added around the old page. If your page sends a Content-Security-Policy, frame-src https: covers the new page."
        },
        {
          "anchor": "8-0-5-card-details-are-shown-on-an-embedded-card-page",
          "heading": "8.0.5 — Card details are shown on an embedded card page",
          "text": "04 Nov 2026, 2:52 PM IST\n\nThe card issuer's security policy changed for current cards (BINs 493 and 404): the card number, CVV and expiry can no longer be returned by any API. They are shown on a secure card page that you embed in an iframe — card_pan_url on GET /v1/cards/{id}, or reveal_url on POST /v1/cards/{id}/reveal. The cards guide now has a copy-paste iframe, a card-shaped version that hides the page's empty space, and the checklist the page needs.\n\nCards guide, section 5: embed snippet, card-shaped snippet and checklist (HTTPS, no sandbox, frame-src https: when you send a CSP, no Cross-Origin-Embedder-Policy: require-corp, WebView in mobile apps).\nPOST /v1/cards/{id}/reveal for these cards: reveal_url filled, pan, cvv and expiry null.\n\nWhat you need to do: Show the card by embedding card_pan_url (or reveal_url) in an iframe on an HTTPS page, with a fresh link each time. Follow the checklist in the cards guide, section 5."
        },
        {
          "anchor": "8-0-4-card-reveal-gives-its-hosted-card-page-again",
          "heading": "8.0.4 — Card reveal gives its hosted card page again",
          "text": "04 Nov 2026, 12:17 PM IST\n\nSince 3 November, POST /v1/cards/{id}/reveal could answer 200 with neither the card details nor a reveal_url, and card_pan_url on GET /v1/cards/{id} came back null. The card issuer changed how its card page hands over the details, so the raw details could not be read, and the hosted-page link a reveal falls back to was not being built on our side. The link is back: a reveal now always carries one of the two forms, as the API reference says. For these cards the issuer now shows the details only on its embedded card page (see 8.0.5).\n\nPOST /v1/cards/{id}/reveal: reveal_url is filled whenever pan is not.\nGET /v1/cards/{id}: card_pan_url is filled again for cards with a hosted card page.\n\nWhat you need to do: When pan is null, open reveal_url for the cardholder. Do not store it: it expires in ten minutes."
        },
        {
          "anchor": "8-0-3-usdc-on-tron-reads-as-not-swappable-on-the-price-sheet",
          "heading": "8.0.3 — USDC on TRON reads as not swappable on the price sheet",
          "text": "03 Nov 2026, 1:56 PM IST\n\nThe public GET /v1/pricing sheet showed swappable: true for every asset, including USDC on TRON, which no route carries. It now reads false for USDC, as GET /v1/assets always did. A swap or an exchange that names USDC on TRON is refused with 400 invalid_pair before any route is asked, as the fees page says.\n\nswappable on the public GET /v1/pricing sheet is false for usdc_trc20.\nA swap quote, swap or exchange naming usdc_trc20 answers 400 invalid_pair.\n\nWhat you need to do: Leave any asset with swappable: false out of your swap picker, whichever endpoint you read it from."
        },
        {
          "anchor": "8-0-2-card-gas-is-priced-for-the-send-a-load-really-makes",
          "heading": "8.0.2 — Card gas is priced for the send a load really makes",
          "text": "01 Nov 2026, 4:55 PM IST · :breaking:\n\ngas on a card load or issue is the network's price for the on-chain send behind it. It was priced as if the card's funding address already held the token. It almost never does, and a send to an address that holds none takes about twice the energy. gas is now priced for that send, so it no longer comes in under what the send costs. At today's network price the figure is about the same as before; at quieter moments it is higher than it was.\n\ngas on GET /v1/cards/products, the load and issue quotes and the public GET /v1/pricing sheet: the funding send is priced for a recipient that holds no token.\ngas_burning_trx on the price sheet is the same send, burning TRX, to that recipient.\n\nWhat you need to do: If you show a load or an issue before the customer confirms, read gas and total from the quote when you show it, not from a figure saved earlier."
        },
        {
          "anchor": "8-0-1-card-codes-for-apple-pay-and-checkouts-show-in-the-dashboard",
          "heading": "8.0.1 — Card codes for Apple Pay and checkouts show in the dashboard",
          "text": "31 Oct 2026, 3:00 PM IST\n\nA card's page in the dashboard now has a Verification code section. When the card is added to Apple Pay or Google Pay, or a checkout asks for a code, it appears there live. Your server and your dashboard each get one read of a code, so an integration that fetches codes no longer hides them from the dashboard.\n\nGET /v1/cards/{id}/otp: one read for your API key and a separate one for the dashboard. A payment code now also carries merchant_name, amount and currency when the issuer sends them.\nkind is now wallet_provisioning for the code that adds a card to Apple or Google Pay. It used to say payment_3ds for every code. The card.wallet_otp webhook's otpKind is corrected the same way.\nGET /v1/cards/{id} adds otp_waiting while a code is waiting: its kind and, for a payment, the shop and amount. It never includes the code."
        },
        {
          "anchor": "8-0-0-the-inr-buy-rail-is-retired",
          "heading": "8.0.0 — The INR buy rail is retired",
          "text": "26 Oct 2026, 8:00 PM IST · :breaking:\n\nBuying USDT with rupees over UPI is gone: the buy quote, buy orders and their reads, and the UPI commission settings. The /v1/buy/ and /v1/merchant/upi endpoints now answer 404. Orders placed before today stay in your history and balances.\n\nRemoved: POST /v1/buy/quote, POST /v1/buy/orders, GET /v1/buy/orders/{id}, POST /v1/buy/orders/{id}/cancel, GET /v1/merchant/upi, PUT /v1/merchant/upi/fee and GET /v1/merchant/upi/orders.\nDashboard: the Fiat (UPI) page is gone. Earnings already made on past orders still show on the Profit page.\n\nWhat you need to do: Stop calling /v1/buy/ and /v1/merchant/upi, and remove any \"buy with UPI\" option from your product. Nothing else changes."
        },
        {
          "anchor": "7-11-2-kyc-upload-handles-a-400-for-unknown-assets-daily-caps-on-paid-calls",
          "heading": "7.11.2 — KYC upload handles, a 400 for unknown assets, daily caps on paid calls",
          "text": "16 Oct 2026, 5:30 PM IST\n\nPOST /v1/cards/kyc/upload now answers with a kycfile_… handle instead of a storage link; pass it to POST /v1/cards/kyc exactly as before. Starting identity verifications and uploading card documents are capped per account per day. An asset id we do not support answers 400 unknown_asset rather than 500.\n\nfile_url from POST /v1/cards/kyc/upload is a handle, valid 24 hours on your account; the KYC submit accepts it (or a URL you host) in id_url, id_back_url, file_url, face_url and selfie_url.\n429 daily_limit_reached on POST /v1/kyc/start (new sessions only) and POST /v1/cards/kyc/upload past the daily cap.\n400 unknown_asset for an asset id that is not on GET /v1/assets.\n\nWhat you need to do: If you displayed the uploaded document from file_url, keep your own copy instead — the handle is not a link."
        },
        {
          "anchor": "7-11-1-card-close-the-refund-charge-and-timing-on-the-price-sheet",
          "heading": "7.11.1 — Card close: the refund charge and timing on the price sheet",
          "text": "23 Sep 2026, 3:55 PM IST\n\nGET /v1/pricing now carries close_refund on every card product: the card network's charge to release a closed card's balance (a percentage with a minimum, capped below a threshold) and how long the refund takes to arrive, typically and at most. The fees page's card table and close section show them; the close quote still does the exact sum for one card.\n\nNew on each card product in GET /v1/pricing: close_refund { release_fee_percent, release_fee_min, release_fee_max, release_fee_cap_below, settlement_max_days, settlement_typical }.\nFees page corrected: a close is always accepted (never refused for the issuer's cancellation charge), and our close fee is our own flat part plus the network figure."
        },
        {
          "anchor": "7-11-0-usdt-trx-swaps-run-on-chain-on-tron-from-a-dollar-up",
          "heading": "7.11.0 — USDT ⇄ TRX swaps run on-chain on TRON, from a dollar up",
          "text": "23 Sep 2026, 3:40 PM IST · :breaking:\n\nA swap between USDT and TRX is now traded on the TRON network itself, signed by the user's own address, so small amounts swap at the market rate (a 5 USDT swap used to have no route). The swap fee is now 0.5% with a $0.50 minimum, plus the swap's own network cost in the sold coin — a small swap costs a little more than the old flat fee, a large one far less.\n\nSwap fee for USDT and TRX: 0.5% (minimum $0.50) plus network_fee, all taken out of the sold amount. network_fee is a new field on POST /v1/swap/quote and on the order.\nA USDT ⇄ TRX order goes pending → sent → completed, or failed with the held amount returned in full. There is no swapping state and no refund from an outside party.\nreceive_minimum is a hard floor: the trade reverts on-chain rather than pay less, and the order fails with nothing traded.\nAn exchange out of USDT or TRX (to a coin outside TRON) pays the same 0.5% and minimum plus its own send's network cost.\nGET /v1/pricing: the swap block for USDT and TRX carries percent, min_fee, network_fee and total (the fee on a small swap).\n\nWhat you need to do: Show fee and network_fee from the quote rather than a fixed figure. If you display a swap as in progress while it is swapping, treat sent as in progress too."
        },
        {
          "anchor": "7-10-1-the-refund-wallets-surplus-can-be-swept-to-the-profit-wallet",
          "heading": "7.10.1 — The refund wallet’s surplus can be swept to the profit wallet",
          "text": "20 Sep 2026, 12:20 PM IST\n\nInternal only: withdrawing a rail's float brings the close refunds and our own fees out together, so the fees land in the refund wallet. The admin can now send that surplus to the profit wallet in one action, capped at what the wallet holds beyond every holder it still owes.\n\nNew admin route POST /admin/cards/refund-wallet/send-profit."
        },
        {
          "anchor": "7-10-0-a-close-is-always-accepted-and-its-refund-is-a-state-you-can-read",
          "heading": "7.10.0 — A close is always accepted, and its refund is a state you can read",
          "text": "20 Sep 2026, 11:45 AM IST · :breaking:\n\nDELETE /v1/cards/{id} no longer answers 409 card_refund_wallet_empty. The card's balance goes to the rail and the card terminates whatever the refund wallet holds; paying the holder on-chain is reported as close_refund.state on GET /v1/cards/{id} — waiting, approved, sending, paid, or held with a reason — so there is nothing for a customer to retry and nothing for you to translate into an error.\n\n409 card_refund_wallet_empty is retired.\nNew close_refund block on GET /v1/cards/{id}."
        },
        {
          "anchor": "7-9-0-an-empty-card-can-be-closed",
          "heading": "7.9.0 — An empty card can be closed",
          "text": "20 Sep 2026, 11:05 AM IST · :breaking:\n\nA card holding less than the issuer's cancellation charge — an empty one included — now closes like any other. 409 card_close_underfunded is no longer raised, and minimum_card_balance on the close quote is always 0 (the new issuer_fee field carries what the issuer keeps). The floor was ours, not the rail's: the rail terminates a zero-balance card at once, and refusing meant asking somebody to load a card they wanted to be rid of.\n\n409 card_close_underfunded is retired.\nclose-quote: minimum_card_balance is always 0; new issuer_fee / issuer_fee_base."
        },
        {
          "anchor": "7-8-2-a-close-quote-states-the-issuers-settlement-period",
          "heading": "7.8.2 — A close quote states the issuer’s settlement period",
          "text": "20 Sep 2026, 10:25 AM IST\n\nThe close quote carries refund_eta_max_days (45) and refund_eta_typical (\"24–48 hours\"). Cancelling a card opens the issuer's settlement period and the balance is not released until it ends; the issuer states it can run to 45 days, though it has been far quicker. Quote your customer the issuer's limit.\n\nNew fields refund_eta_typical and refund_eta_max_days on the close quote."
        },
        {
          "anchor": "7-8-1-the-close-fee-is-5-00-plus-the-network-figure",
          "heading": "7.8.1 — The close fee is 5.00 plus the network figure",
          "text": "20 Sep 2026, 10:05 AM IST\n\nclose_fee is now 5.00 and is our own charge only — the issuer's cancellation is kept on the card by the issuer, so it is no longer folded into our flat part (which double-billed it). The live network figure for the on-chain send is added as before, and an affiliate share is paid on the whole of our 5.00.\n\nclose_fee 4.00 -> 5.00, ours alone."
        },
        {
          "anchor": "7-8-0-a-close-is-paid-from-the-balance-it-returns",
          "heading": "7.8.0 — A close is paid from the balance it returns",
          "text": "20 Sep 2026, 9:40 AM IST · :breaking:\n\nClosing a card no longer debits the holder's wallet balance. Our close fee, the network cost of the on-chain send, your own fee and the rail's release charge all come out of the balance the card returns, and the holder receives the rest — refund_quote on the close quote shows every line. A close therefore no longer fails because the wallet is empty, which is the normal state of a wallet whose owner is closing their card.\n\nclose-quote: charged_to is now \"refund\"; refund_quote adds close_fee, gas and merchant_fee.\nA close refund is sent only once the card is fully closed, and pays the net."
        },
        {
          "anchor": "7-7-1-the-rail-float-sweep-is-planned-and-watched",
          "heading": "7.7.1 — The rail float sweep is planned and watched",
          "text": "20 Sep 2026, 9:10 AM IST\n\nInternal only, no API change for merchants: a worker now prices what sits in the card rail's merchant float and the cheapest way to withdraw it (the rail's cap applies per withdrawal, so splitting below the threshold costs a fraction), mails the operator once a day above a threshold, and the admin records each withdrawal made by hand.\n\nNew worker rail-float-watch and admin routes GET/POST /admin/cards/float-sweep."
        },
        {
          "anchor": "7-7-0-a-close-quotes-the-exact-refund",
          "heading": "7.7.0 — A close quotes the exact refund",
          "text": "20 Sep 2026, 8:40 AM IST\n\nGET /v1/cards/{id}/close-quote now carries refund_quote — the card's live balance, the issuer's cancellation fee that stays on the card, the card rail's charge for releasing the balance back on-chain, and the refund the holder receives. That rail charge is new: it was always paid, silently, by us. It is now taken from the returned balance, booked as a rail cost, and shown before the close so a holder can be told the exact figure.\n\nNew refund_quote block on the close quote.\nThe card rail's release charge is deducted from a close refund and carries no commission."
        },
        {
          "anchor": "7-6-0-deposits-name-their-sender",
          "heading": "7.6.0 — Deposits name their sender",
          "text": "19 Sep 2026, 9:00 PM IST\n\nEvery deposit record carries from_address and the deposit.credited webhook carries fromAddress. For USDT and USDC it is the from of the token Transfer event to the user's address — the real payer, not the operator address that signed an exchange's batch payout. For TRX it is the sender. A token moved by transferFrom() is now detected too.\n\nNew field from_address on GET /v1/users/{externalId}/deposits and GET /v1/deposits.\nNew field fromAddress on the deposit.credited webhook.\nDeposits made with transferFrom() are credited."
        },
        {
          "anchor": "7-5-4-deposits-sent-by-a-contract-are-credited",
          "heading": "7.5.4 — Deposits sent by a contract are credited",
          "text": "19 Sep 2026, 7:50 PM IST\n\nUSDT or USDC that reaches a user's address from inside another contract — a swap provider paying out, an exchange's batch withdrawal — is now detected and credited like any deposit, with the usual deposit.credited webhook. Before, only a direct token transfer was seen. Its output_index is numbered above 1,000,000,000.\n\nContract-sent token deposits are detected and credited."
        },
        {
          "anchor": "7-5-3-balances-show-the-address-alongside-the-ledger",
          "heading": "7.5.3 — Balances show the address alongside the ledger",
          "text": "19 Sep 2026, 3:00 PM IST\n\nGET /v1/users/{externalId}/balances carries on_chain and on_chain_base per asset — what the user's own address holds right now, read live — next to the spendable amount. The two differ by design (fees settle to the profit wallet, earnings are credited from there); amount is what a payout may spend. null when the chain could not be read in time.\n\nNew fields on_chain / on_chain_base on every Balance."
        },
        {
          "anchor": "7-5-2-payouts-to-the-users-own-address-are-refused",
          "heading": "7.5.2 — Payouts to the user’s own address are refused",
          "text": "19 Sep 2026, 1:30 PM IST\n\nPOST /v1/withdrawals answers 400 self_transfer when the recipient is the user's own deposit address. Such a send moved nothing and spent the network fee; the coins that came back were not a deposit. Nothing is held on a refused request.\n\nNew 400 error code self_transfer on POST /v1/withdrawals."
        },
        {
          "anchor": "7-5-1-partial-card-unload-retired",
          "heading": "7.5.1 — Partial card unload retired",
          "text": "14 Sep 2026, 9:05 PM IST · :breaking:\n\nPOST /v1/cards/{id}/withdraw is retired and answers 410 card_unload_retired. An unload credited the holder's balance with coins that never reached their own address; the only way value leaves a card is the close, which returns the whole balance on-chain.\n\nPOST /v1/cards/{id}/withdraw → 410 card_unload_retired, always.\n\nWhat you need to do: Remove any \"withdraw from card\" action from your product; offer \"close card\" instead."
        },
        {
          "anchor": "7-5-0-a-card-load-pays-its-own-way-on-chain-or-waits",
          "heading": "7.5.0 — A card load pays its own way on-chain, or waits",
          "text": "14 Sep 2026, 7:40 PM IST · :breaking:\n\nA load is sent from the holder's own address, and only when the coins are there. A balance the ledger shows but the chain does not yet — a close refund or a deposit still on its way — no longer loads a card from our float: POST /v1/cards/{id}/topup answers 409 card_coins_not_on_chain and nothing is charged.\n\n409 card_coins_not_on_chain on POST /v1/cards/{id}/topup and POST /v1/cards; details.needed says how much must be on-chain.\n\nWhat you need to do: Handle 409 card_coins_not_on_chain on a load (and on an issue with an opening balance): show its message and let the holder try again once their coins have arrived."
        },
        {
          "anchor": "7-4-1-a-close-is-refused-while-its-refund-cannot-be-paid",
          "heading": "7.4.1 — A close is refused while its refund cannot be paid",
          "text": "14 Sep 2026, 7:10 PM IST\n\nA card's balance goes back to the holder's own address on-chain within 7 working days of the close, from a wallet cryptoinfra funds by hand. When that wallet cannot pay the balance today, DELETE /v1/cards/{id} answers 409 card_refund_wallet_empty and the card is untouched. The close quote says the ETA (refund_eta_working_days).\n\n409 card_refund_wallet_empty on DELETE /v1/cards/{id}; refund_eta_working_days on the close quote.\n\nWhat you need to do: Handle 409 card_refund_wallet_empty on a close: show its message (\"Card refund wallet balance is empty, please contact cryptoinfra.\") and let the holder try again later."
        },
        {
          "anchor": "7-4-0-close-fee-a-flat-part-plus-the-live-network-figure",
          "heading": "7.4.0 — Close fee: a flat part plus the live network figure",
          "text": "14 Sep 2026, 5:30 PM IST · :breaking:\n\nThe close fee now has two parts: a flat part (our fee plus the issuer's cancellation charge billed to us) and the live network figure for returning the balance to the holder's own address on-chain. GET /v1/cards/{id}/close-quote shows both, gas with dynamic, and total is what the balance is debited. The cardholder terms sheet for the live BIN prices the 3-D Secure check as the issuer bills it.\n\ngas, gas_base and dynamic on GET /v1/cards/{id}/close-quote; total includes it.\nclose_fee on the product and the price sheet is the flat part only.\n\nWhat you need to do: If you show a close before the holder confirms, read total from the close quote rather than the product's close_fee alone — the network part is priced when you ask."
        },
        {
          "anchor": "7-3-0-a-flat-platform-fee-on-every-card-load",
          "heading": "7.3.0 — A flat platform fee on every card load",
          "text": "13 Sep 2026, 2:20 PM IST · :breaking:\n\nA card load now carries a flat platform fee beside its percentage, the way a payout carries one beside its network figure — so every movement of money except a deposit has the same flat part. The figure is on the product as load_fee_flat, on the public price sheet, and in the fee calculator; a load's charge rises by it.\n\nload_fee_flat on every card product (GET /v1/cards/products, the public GET /v1/pricing sheet): a flat platform fee added to the percentage or its floor.\nThe price sheet also carries network_fee_burning_trx / network_fee_burning_trx_fresh_recipient on a withdrawal and gas_burning_trx on cards — what the same send would burn in a wallet without energy, for a \"you save\" comparison. The docs' new fee calculator shows it.\nDocs: a live fee calculator at /fee-calculator — any amount, payout / card load / card issue / deposit, your own fee on top, every figure from the price sheet at load and re-read on every change.\n\nWhat you need to do: If you show a load's cost before the customer confirms, price it from GET /v1/cards/products (load_fee_bps, load_fee_min, load_fee_flat, gas) rather than from a percentage alone — the docs' fee calculator does the arithmetic."
        },
        {
          "anchor": "7-2-0-your-affiliate-commission-has-a-public-ledger-page",
          "heading": "7.2.0 — Your affiliate commission has a public ledger page",
          "text": "12 Sep 2026, 10:36 PM IST\n\nA shareable page of your affiliate commission — total per asset and one line per operation that earned some, with our fee, your share and what you got — readable by anyone holding the link, no login. The link is on your Profit page; replace it from there if it gets around.\n\nGET /v1/merchant/affiliate-ledger returns the link (/a/{token} on the merchant origin); POST /v1/merchant/affiliate-ledger/rotate replaces it (dashboard owner, both codes). The page reads the public GET /v1/a/{token}."
        },
        {
          "anchor": "7-1-0-a-card-order-carries-the-hash-of-its-on-chain-send",
          "heading": "7.1.0 — A card order carries the hash of its on-chain send",
          "text": "12 Sep 2026, 9:29 PM IST\n\ntxid on every card order and on the card.topup / card.status webhooks — the hash of the send from the cardholder's address to the card rail — so a receipt for a load can link to a block explorer the way one for a deposit or a payout already does. Null until the send is broadcast; a failed order keeps the hash of the send that reverted.\n\nGET /v1/cards/orders/{id} and the orders[] of GET /v1/cards/orders carry txid; card.topup and card.status payloads carry txid. Additive."
        },
        {
          "anchor": "7-0-1-a-card-load-whose-send-reverts-is-sent-again-before-it-fails",
          "heading": "7.0.1 — A card load whose send reverts is sent again before it fails",
          "text": "12 Sep 2026, 9:10 PM IST\n\nWhen the on-chain send behind a load reverts, the load is now re-sent once instead of failing at once. The order stays funding a little longer; the cardholder's charge is untouched throughout. Only if the second send reverts too does the order turn failed and refund the whole charge, as before.\n\nGET /v1/cards/orders/{id} may stay funding for a few minutes more than before while a send is re-issued; error then notes the first send and the new one. Nothing to handle — keep treating 202 as pending and failed as the refund."
        },
        {
          "anchor": "7-0-0-fees-simple-the-total-one-live-flag-one-time",
          "heading": "7.0.0 — Fees, simple: the total, one live flag, one time",
          "text": "12 Sep 2026, 4:48 PM IST · :breaking:\n\nToday's fee responses had grown a breakdown nobody needs to charge a customer — the fallback figure, the bill behind the figure, the margin floor. They are gone again. A fee response is the amounts, dynamic (live or approx) and when it was priced. The guarantees behind the number (priced live, never below what a send cost yesterday) still hold; they are inside the figure, not beside it.\n\nRemoved from GET /v1/fees: static_fallback, static_fallback_base, network_cost, recent_cost_floor, floored. Kept: priced_at, dynamic.\nRemoved from GET /v1/withdrawal-fee-quote: static_fallback, recent_cost_floor, floored. Kept: dynamic, quoted_at, network_cost, recipient_priced.\nRemoved from both card quotes: network_cost, recent_cost_floor, floored. Kept: dynamic.\nRemoved from GET /v1/merchant/card-fees: gas_static_fallback, gas_network_cost, gas_recent_cost_floor, gas_floored. Kept: gas, gas_dynamic, gas_priced_at.\nRemoved from GET /v1/pricing: recent_cost_floor, floored, gas_static_fallback, gas_recent_cost_floor, gas_floored, gas_priced_from. Kept: everything that was there before today, plus gas_dynamic.\nDocs: no fee example carries a real number any more — the field is shown, the value is read from the quote. A Quickstart of six copy-paste calls opens the docs.\n\nWhat you need to do: If you read static_fallback, network_cost, recent_cost_floor or floored on GET /v1/fees or the card quotes, or gas_static_fallback, gas_network_cost, gas_recent_cost_floor, gas_floored or gas_priced_from on the card tariff, the products list or the price sheet, stop — they no longer exist (all were added earlier today). network_cost stays on GET /v1/withdrawal-fee-quote only, where it always was. Show total_fee (payouts) or total (cards); label it from dynamic."
        },
        {
          "anchor": "6-3-0-a-network-fee-is-never-below-what-a-send-cost-yesterday",
          "heading": "6.3.0 — A network fee is never below what a send cost yesterday",
          "text": "12 Sep 2026, 3:57 PM IST\n\nEvery network figure — the payout quote, the card gas line, GET /v1/fees, the public price sheet and the charge itself — is now lifted to the dearest confirmed send of the last 24 hours (energy rented plus TRX burned, priced and cushioned like a quote). The platform part stays a fixed 0.50 on top; the floor is what keeps it 0.50 on a day the market moves faster than the cushion.\n\nrecent_cost_floor and floored on GET /v1/withdrawal-fee-quote, both card quotes, GET /v1/fees, GET /v1/pricing and (as gas_recent_cost_floor / gas_floored) on GET /v1/merchant/card-fees.\nA first-time sender's activation is charged to that sender only and never raises the floor for anyone else."
        },
        {
          "anchor": "6-2-0-every-fee-says-whether-it-is-live-everywhere",
          "heading": "6.2.0 — Every fee says whether it is live, everywhere",
          "text": "12 Sep 2026, 3:26 PM IST\n\nWhat 6.1.0 did for the card network fee now holds for every network figure the platform publishes: it carries whether it was priced live or is the static fallback, the fallback itself, the bill it was built from, and when it was priced. Nothing you mirror from us needs to be shown as a fixed charge again.\n\nGET /v1/fees: priced_at on the response; withdrawal.static_fallback and withdrawal.network_cost; deposit.dynamic and deposit.network_cost.\nGET /v1/withdrawal-fee-quote: static_fallback and quoted_at beside dynamic and network_cost.\nPOST /v1/swap/quote, POST /v1/exchange/quote and POST /v1/buy/quote carry quoted_at.\nGET /v1/cards/products carries the gas inside every issue_total, with gas_dynamic and gas_priced_at.\nDashboard: the profit-withdrawal quote labels the network fee live or approx. The fees guide has one table of these fields and where each appears."
        },
        {
          "anchor": "6-1-0-the-card-network-fee-says-whether-it-is-live",
          "heading": "6.1.0 — The card network fee says whether it is live",
          "text": "12 Sep 2026, 3:14 PM IST\n\nours.load.gas on the card tariff has always been priced live — the cost of the load's two TRON transfers plus 0.50, computed when you ask, exactly like a payout's network_fee — but nothing said so, and a merchant mirroring the figure showed it as a fixed charge. Now the tariff, both card quotes and the public price sheet carry whether the figure is live or the static fallback, and the bill it was built from.\n\nGET /v1/merchant/card-fees: ours.load gains gas_dynamic, gas_priced_at, gas_static_fallback and gas_network_cost. Label the figure \"live\" when gas_dynamic is true and \"approx\" otherwise; re-read it rather than printing it.\nGET /v1/cards/topup-quote and GET /v1/cards/issue-quote carry dynamic and network_cost, the same shape as GET /v1/withdrawal-fee-quote.\nGET /v1/pricing cards carries gas_dynamic, gas_static_fallback and gas_priced_from.\nDocumented: gas on a card order and on the card.* webhooks is the figure actually charged, fixed when the order was created — reconcile a quote against it.\nDashboard: Profit → Our rates and both card review screens label the network line live or approx."
        },
        {
          "anchor": "6-0-1-payouts-have-no-minimum-amount",
          "heading": "6.0.1 — Payouts have no minimum amount",
          "text": "12 Sep 2026, 1:34 PM IST\n\nA payout no longer has a per-asset minimum. How much you send is your decision; the one condition is that the balance covers the amount plus the fee. Only a zero or negative amount is refused.\n\nPOST /v1/withdrawals accepts any positive amount. amount_below_minimum now means zero or negative only; min_withdrawal on GET /v1/assets is no longer a payout rule."
        },
        {
          "anchor": "6-0-0-invoices-are-removed",
          "heading": "6.0.0 — Invoices are removed",
          "text": "12 Sep 2026, 1:33 PM IST · :breaking:\n\nThe invoice product is gone: creating, listing and reading invoices, the invoice settings, the hosted invoice page a customer paid on, and the Invoices page of the dashboard. Bill an order from a deposit address instead — a user is free and permanent, so an order can have its own — and credit it from deposit.credited.\n\nRemoved: /v1/invoices, /v1/invoices/{uuid}, /v1/invoices/by-order/{orderId}, /v1/invoices/{uuid}/payments, /v1/invoice-settings, and the public /v1/i/{uuid}, /payments and /logo pages.\nRemoved: the invoice.paid, invoice.expired and invoice.late_payment webhook events. Nine events remain and the contract lists all of them.\nDashboard: the Invoices page is gone; Deposits shows the day's incoming money.\n\nWhat you need to do: Anything calling POST /v1/invoices, GET /v1/invoices/* or /v1/invoice-settings gets a 404; stop calling them. Handlers for invoice.paid, invoice.expired and invoice.late_payment can be removed — the events are never sent, and the test-fire no longer offers them. Money a customer still sends to an old invoice address is credited and moved to your own balance like any other deposit; nothing is lost."
        },
        {
          "anchor": "5-1-0-swaps-work-again-and-a-coins-picture-comes-from-us",
          "heading": "5.1.0 — Swaps work again, and a coin's picture comes from us",
          "text": "2026-09-06\n\nTwo fixes on the exchange. Creating and listing swap orders had been failing since 4 September on a database column the code expected and the schema had dropped; both work again, and nothing was lost — no order was created while it was broken. And the picture of a payout coin now comes back from this API instead of the router's own storage, so it loads and it says nothing about who the router is.\n\nPOST /v1/swap/orders and GET /v1/swap/orders answer again (they returned 500 from 2026-09-04 to 2026-09-06).\nNew: GET /v1/exchange/assets/{identifier}/logo — the coin's picture, served from here.\n\nWhat you need to do: If you render logo_url from GET /v1/exchange/assets, it is now a path on this API (/v1/exchange/assets/{identifier}/logo) rather than an outside URL, so fetch it with your API key like any other call. It answers an image and is cacheable for a week. The old value had been redacted on the way out and did not load, so nothing that worked before stops working."
        },
        {
          "anchor": "5-0-0-one-network-one-way-to-move-money-one-place-for-every-job",
          "heading": "5.0.0 — One network, one way to move money, one place for every job",
          "text": "2026-09-06 · :breaking:\n\nThe platform is TRON only, and the parts nobody used are gone. Internal transfers, payment links, the fee-in-TRX payout option, the mobile second factor, verification tiers and the approval step are removed. A signup is live at once and waits only on its email; every payout is an on-chain withdrawal; invoices are how you bill an order; the dashboard shows each thing in one place. The endpoints that stay are unchanged.\n\nRemoved from the contract: the Transfers tag (POST /v1/transfers and its reads); /v1/payment-links/*, the hosted /pay page and the payment.received event; fee_mode as a choice, with trx_fee_option, fee_balance_insufficient, fee_mode_unavailable and invalid_fee_mode; GET /v1/merchant/verification, POST /v1/merchant/verification/start, verification_tier, and tier, tier1_daily_auto_usd and the new-merchant rule under rules on GET /v1/merchant/profit; account_pending and account_rejected; /merchant/account/mobile/{start,confirm}.\nChain is tron and nothing else: POST /v1/users returns one address, and GET /v1/users/{externalId}/balances no longer lists an empty balance on a retired chain. Every network fee is charged in the sent asset; fee_asset_id stays on the withdrawal, read-only, and names that asset.\nTwo factors everywhere: the authenticator and a code emailed to the login. The password-reset flow is email only.\nDashboard: Swap carries both destinations (to my balance, to an address); Settings → API keys is the one developer page (key, webhooks, API log); the cards Overview is the programme page; Invoices is a top-level entry; the Assets page and the currency tabs on Fiat are gone. Approval thresholds are set under Settings → Security and parked payouts are decided on Payouts.\nThe dashboard's webhook page lists all twelve events, and the test fire can rehearse any of them.\n\nWhat you need to do: Anything still calling POST /v1/transfers must call POST /v1/withdrawals instead (a transfer is now a 404). Anything creating payment links must create invoices (POST /v1/invoices); /v1/payment-links/ is gone and payment.received is never sent. Stop sending fee_mode — it is accepted with any value and ignored. Stop handling account_pending and account_rejected: an account is active, paused or suspended, and a key whose owner login never verified its email answers 403 email_unverified. /merchant/account/mobile/ and the mobile fields on /merchant-auth/me are gone. Treat chain as tron everywhere. Exchange endpoints are unchanged; on the dashboard the Exchange page is now the \"to an address\" choice on Swap, and /exchange redirects there."
        },
        {
          "anchor": "4-10-0-verification-tiers-approval-opens-the-api-verification-the-full-limits",
          "heading": "4.10.0 — Verification tiers: approval opens the API, verification the full limits",
          "text": "2026-09-05\n\nEvery account now has a tier. Tier 1 comes with approval and lets profit withdrawals go on their own up to a small daily figure; tier 2 — the business verified in the browser on our verification rail, or by our team by hand — lifts them to the ordinary limits. Settings → Verification shows where you stand and what each step opens.\n\nGET /v1/merchant/verification and POST /v1/merchant/verification/start (503 verification_unavailable while the flow is not open); verification_tier on GET /merchant-auth/me; tier and tier1_daily_auto_usd under rules on GET /v1/merchant/profit.\nA held profit withdrawal from an unverified business says so in its desk_note."
        },
        {
          "anchor": "4-9-0-signup-guarded-linked-accounts-and-out-of-line-days-are-flagged-for-a-look",
          "heading": "4.9.0 — Signup guarded; linked accounts and out-of-line days are flagged for a look",
          "text": "2026-09-05\n\nSignup refuses disposable mailboxes and, when configured, asks for a bot check. Accounts that share a network, a Telegram username, a company e-mail domain, a payout address or their customers with another merchant — or a day of earnings far above their own history — are flagged for us to look at. A flag holds a profit withdrawal for a look, with the reason on the request; it never closes an account on its own.\n\n400 email_disposable, 400 bot_check_failed and 503 bot_check_unavailable from signup; turnstile_token accepted when the page showed the check.\nA held profit withdrawal may now carry risk flag: … in its desk_note."
        },
        {
          "anchor": "4-8-0-profit-matures-before-it-is-withdrawable-reversed-operations-claw-back",
          "heading": "4.8.0 — Profit matures before it is withdrawable; reversed operations claw back",
          "text": "2026-09-05 · :breaking:\n\nAn earning still lands in your balance the moment the customer pays, but counts as profit available to withdraw only after a wait — a short one for what was earned on-chain, a longer one for UPI and cards, which can still be refunded or disputed. If an operation comes back, the earning on it comes back too: what your balance holds is taken at once, the rest is netted from your next earnings, and no profit withdrawal goes until it is settled.\n\nprofit_pending (with the moment it matures), profit_owed, reversals and maturity on GET /v1/merchant/profit; available_at, matured and reversed on each earning row.\n409 profit_not_matured and 409 profit_owed from POST /v1/merchant/profit/withdraw.\nA swap that fails or is refunded by the exchange reverses your fee and your share on its own; a UPI reversal or a rail refund is reversed by cryptoinfra with the reason on the row.\n\nWhat you need to do: Profit earned in the last day (on-chain) or three (UPI, cards) shows as pending and cannot be withdrawn yet; profit_available on GET /v1/merchant/profit is smaller than before by that much. Nothing else changes."
        },
        {
          "anchor": "4-7-0-profit-withdrawals-the-rules-that-decide-on-their-own",
          "heading": "4.7.0 — Profit withdrawals: the rules that decide on their own",
          "text": "2026-09-05\n\nA profit withdrawal now goes on its own when it is within the limits, and waits for a look when it is large, from a merchant approved only days ago, to an address never paid before, or past the day's cap. The dialog shows the figures that apply, the Profit page shows why one waits, and owner and finance logins get a mail at each turn.\n\n409 profit_below_minimum, profit_too_many_open and profit_cooldown from POST /v1/merchant/profit/withdraw; rules and per-request desk_note on GET /v1/merchant/profit.\nAn address never paid before waits a fixed number of hours, then the request goes on its own if the other rules still pass."
        },
        {
          "anchor": "4-6-0-profit-withdrawals-are-requests-listed-on-your-profit-page",
          "heading": "4.6.0 — Profit withdrawals are requests, listed on your Profit page",
          "text": "2026-09-04\n\nA profit withdrawal is now a request to cryptoinfra, approved there — automatically as a rule — before it leaves our profit wallet. Your Profit page lists every request with where it stands, and shows what is on its way out.\n\nwithdrawals and in_flight on GET /v1/merchant/profit; a request that is refused returns the whole hold to your profit, with the reason on it.\nA request waits as awaiting_approval; your own payout approvals never list it."
        },
        {
          "anchor": "4-5-0-withdraw-your-profit-from-the-profit-page",
          "heading": "4.5.0 — Withdraw your profit from the Profit page",
          "text": "2026-09-04\n\nProfit → Withdraw profit sends what you have earned — commission and your own fees, less what you already withdrew — to any address, from our profit wallet in real time, for our ordinary withdrawal fee. The Profit page now shows the profit still available rather than your whole balance.\n\nYour own fee on a customer's payout, deposit or swap now travels on-chain to our profit wallet with ours as the operation settles, so the wallet always holds what it pays you out.\nprofit_available and withdrawn on GET /v1/merchant/profit; a profit withdrawal is a withdrawal of kind merchant_profit and refuses past the earnings with 409 profit_insufficient."
        },
        {
          "anchor": "4-4-0-affiliate-commission-a-share-of-our-fee-on-every-operation-nothing-to-set",
          "heading": "4.4.0 — Affiliate commission: a share of our fee, on every operation, nothing to set",
          "text": "2026-09-04\n\nYou now earn two ways, together or alone. A share of OUR platform fee on every payout, swap and card your customers make is paid to your balance the moment our fee settles — automatically, with nothing to set. Your own fee stays what it was: a price above ours, yours entirely. The Profit page shows both.\n\naffiliate on GET /v1/fees — enabled, share_bps, share_percent — and on the public price sheet.\nA commission is a journal of its own; on the Profit page it reads as source affiliate with the operation it came from as its reference.\nThe share is of our fee, never of a cost: network bills, the rail's take and card close fees carry none; deposits are free and carry none."
        },
        {
          "anchor": "4-3-1-four-new-integration-guides-and-a-menu-organised-by-what-you-are-building",
          "heading": "4.3.1 — Four new integration guides, and a menu organised by what you are building",
          "text": "2026-09-04\n\nThe documentation now walks every money flow once, in order: Payouts, Swaps and exchange, Payment links and invoices, and Buy with UPI join Users and deposits and Cards under one Integrate section. The menu reads Start here, Integrate, Conventions, Pricing, Resources, and the API reference is grouped by product. The front page's quickstart shows the request bodies the API actually accepts.\n\nNew guides: Payouts, Swaps and exchange, Payment links and invoices, Buy with UPI.\nStale wording removed: a user's coins sit at their own address (nothing is swept), and a payout's fee is the live network cost, not one flat price."
        },
        {
          "anchor": "4-3-0-a-public-price-sheet-and-docs-that-read-it",
          "heading": "4.3.0 — A public price sheet, and docs that read it",
          "text": "2026-09-04\n\nGET /v1/pricing needs no key and returns every price we charge, live: the platform fee and the network figures per asset, and every card product's terms. The fees documentation now renders its tables from it at page load, so a price on a docs page is the price of that minute, never a number typed into a page.\n\nNew GET /v1/pricing (no authentication, cached for a minute). Your own fees stay on GET /v1/fees with your key; the exact charge for one send stays on GET /v1/withdrawal-fee-quote.\nThe Fees and Cards pages show live tables instead of typed numbers. Text readers of the docs see the endpoint to call instead."
        },
        {
          "anchor": "4-2-0-deposits-are-free",
          "heading": "4.2.0 — Deposits are free",
          "text": "2026-09-04\n\nA deposit to an address we issue is now credited in full. The platform's deposit fee — the flat part and the network part that came with it — is gone on every asset. Nothing changes in the API's shape: deposit.total on GET /v1/fees reads 0, and a deposit's fee and network_fee are 0. Your own fee on deposits, if you set one, is unchanged.\n\nGET /v1/fees: deposit.total, deposit.flat and deposit.network_fee are 0 for every asset.\nA deposit's amount_credited equals amount unless you charge your own merchant_fee. min_deposit (2 USDT) still applies as a dust floor.\nPayment links and invoices receive on the same terms. An invoice's fee split now has nothing to split."
        },
        {
          "anchor": "4-1-0-your-own-fee-on-deposits-payouts-and-swaps-a-profit-page",
          "heading": "4.1.0 — Your own fee on deposits, payouts and swaps; a Profit page",
          "text": "2026-09-04\n\nYou can now charge your own fee — a percentage, a flat amount, or both, with no ceiling — on deposits, payouts and swaps, as you already could on cards and UPI. It is charged to your customer and credited to your own balance in the same movement. The dashboard's new Profit page shows everything you earn, by source, with a Withdraw button; Your fees are set there in one place.\n\nmerchant_fee (and merchant_fee_base) on deposits, on the withdrawal quote and the withdrawal, and on swap quotes and orders — 0 unless you set one. A deposit's amount_credited and a quote's total_fee include it.\nGET /v1/fees carries merchant_fee (your percent and flat) beside ours for deposit, withdrawal and swap.\nThe deposit.credited webhook carries fee, merchantFee and amountCredited.\nDashboard: Profit page (earned all time, last 30 days, available to withdraw, by source, every earning) and Your fees; GET /v1/merchant/profit, GET/PUT/DELETE /v1/merchant/fees/{operation}.\nThe ceilings on card and UPI fees are removed."
        },
        {
          "anchor": "4-0-0-fees-are-now-the-live-network-cost-plus-a-flat-0-50",
          "heading": "4.0.0 — Fees are now the live network cost plus a flat 0.50",
          "text": "2026-09-04 · :breaking:\n\nOne rule for every fee. The network part is what the send actually costs us on-chain, priced at the moment you ask; the platform part is a flat 0.50. A payout to an address that already holds USDT quotes about 1.45–2.10 today, one to an address that does not 2.10–3.05, and a user's first send from a new address adds about 0.80 once. Deposits are 0.50 plus the cost of taking it (about 1.05–1.30). Swaps are 2.00 flat.\n\nGET /v1/withdrawal-fee-quote prices the exact send: network_fee is live (dynamic: true), takes an optional user_id so a first send's activation is included, and returns network_cost (energy, bandwidth, activation in TRX, the TRX price, the number of transfers) so you can explain the figure.\nGET /v1/fees: withdrawal.network_fee is today's figure for a recipient that holds the token; network_fee_fresh_recipient, first_send_activation_fee and total were added; deposit.network_fee and deposit.total were added.\nDeposits: the fee is the 0.50 platform part plus the network cost of taking it; deposit rows carry network_fee.\nSwaps and exchanges: 2.00 flat (0.50 + a fixed 1.50 network figure).\nWhen a live price cannot be read, a payout is charged the old flat 1.50 and the quote says dynamic: false; a deposit's network part falls back to 0.50.\n\nWhat you need to do: Never carry a fee figure in your own code. Call GET /v1/withdrawal-fee-quote with to_address and user_id before every payout and show the customer total_fee; the figure it returns is the one charged. Read deposit fees from the deposit.credited webhook or the deposit row (fee, and network_fee for the network part). GET /v1/fees now carries today's figures, not a constant."
        },
        {
          "anchor": "3-0-1-a-card-still-being-issued-answers-409-card-issuing",
          "heading": "3.0.1 — A card still being issued answers 409 card_issuing",
          "text": "2026-09-03\n\nFor the minute or two after POST /v1/cards, the balance and reveal calls used to surface the rail's own refusal — a 409 with a message nobody could act on, or a 200 holding neither pan nor reveal_url. They now answer 409 card_issuing until the card is active.\n\nGET /v1/cards/{id}/balance and POST /v1/cards/{id}/reveal answer 409 card_issuing while the card's status_normalized is issuing; details.card_id names the card. Poll GET /v1/cards/{id} or wait for the card.status webhook, then retry."
        },
        {
          "anchor": "3-0-0-card-pricing-fees-on-top-of-the-amount-2-00-to-issue-1-50-to-close",
          "heading": "3.0.0 — Card pricing: fees on top of the amount, $2.00 to issue, $1.50 to close",
          "text": "2026-09-03 · :breaking:\n\nEvery card fee is charged on top of the amount: a $100 load puts $100.00 on the card and debits $102.90; the opening balance on an issue lands in full. Our issue fee on a virtual card is $2.00 and our close fee is $1.50 — the issuer's own $2.00 stays on the card, so closing costs the cardholder $3.50 all in.\n\nPOST /v1/cards/{id}/topup puts exactly amount on the card and debits amount + fee + gas + merchant_fee from the cardholder.\nOn GET /v1/cards/topup-quote, total is that debit and credited equals amount.\nOn GET /v1/cards/issue-quote, total is our issue fee + yours + initial_amount + its load fee and gas, and opening_credited equals initial_amount.\nOur issue fee on Core-1 BIN 493 Virtual is $2.00. Issuing at the $5.00 minimum costs the cardholder $8.57. The physical issue fee and the load rate are unchanged.\nOur close fee is $1.50 on every BIN, from the cardholder's balance when the card terminates; the close_fee order on DELETE /v1/cards/{id} carries it. A card holding exactly the issuer's $2.00 needs $1.50 in the balance to close.\nGET /v1/cards/products returns close_fee and close_fee_base per product, beside issue_fee and load_fee_bps.\n\nWhat you need to do: Read total from GET /v1/cards/topup-quote, GET /v1/cards/issue-quote and GET /v1/cards/{id}/close-quote before every operation — it is the cardholder's debit, and a balance short of it is refused with 409 insufficient_balance. credited on a top-up quote equals amount. Never carry a fee amount in your own code: GET /v1/cards/products has issue_fee, issue_total, load_fee_bps and close_fee per product."
        },
        {
          "anchor": "2-1-0-a-payout-leaves-your-customers-own-deposit-address",
          "heading": "2.1.0 — A payout leaves your customer's own deposit address",
          "text": "2026-09-03\n\nThe address a payout is sent FROM has changed. Money used to leave a shared platform wallet; it now leaves the deposit address of the customer whose balance is being spent, when that address holds enough, and the shared wallet otherwise. Nothing about the API, the amounts or the fees changes — only the sender you see on-chain.\n\nA withdrawal, and the send leg of a swap, an exchange or a card load, leaves the deposit address of the customer whose balance pays for it when that address holds enough; otherwise it leaves the shared wallet as before. The txid, the amounts and every fee are unchanged.\n\nWhat you need to do: If you reconcile by the sending address, or if anyone downstream allow-lists one, stop expecting a single fixed sender. Match on the txid you already get back from GET /v1/withdrawals instead. The same applies to the on-chain leg of a swap, an exchange and a card load."
        },
        {
          "anchor": "1-9-0-one-flat-fee-and-no-percentages",
          "heading": "1.9.0 — One flat fee, and no percentages",
          "text": "2026-09-03 · :breaking:\n\nEvery fee is a flat amount per asset — no percentages anywhere. Deposits and swaps carry one flat fee each; the per-asset swap minimums are gone with them.\n\nDeposit fee is a flat 1.00 USDT / 1.00 USDC, charged out of the incoming amount: a 100 USDT deposit credits 99.00. TRX and BTC carry their own flat amounts — GET /v1/fees.\nSwap and exchange fees are flat amounts per asset; the per-asset minimums (1.6 USDT and friends) are removed.\nmin_deposit is 2 USDT / 2 USDC / 2 TRX / 2000 sat. Below it a deposit is ignored_dust and never credited.\n\nWhat you need to do: Read the effective schedule from GET /v1/fees rather than computing a percentage. A deposit below min_deposit is ignored_dust and never credited."
        },
        {
          "anchor": "1-8-1-a-card-must-hold-its-own-close-fee",
          "heading": "1.8.1 — A card must hold its own close fee",
          "text": "2026-09-03 · :breaking:\n\nClosing a card that holds less than the issuer's cancellation fee is refused with 409 card_close_underfunded instead of closing anyway. The close quote now says the least the card must hold.\n\nDELETE /v1/cards/{id} answers 409 card_close_underfunded when the card holds less than the issuer's cancellation fee. No card cost is paid by anyone but the cardholder.\nGET /v1/cards/{id}/close-quote carries minimum_card_balance and minimum_card_balance_base.\n\nWhat you need to do: Before DELETE /v1/cards/{id}, read minimum_card_balance from GET /v1/cards/{id}/close-quote and load the card to it if it is short. Handle 409 card_close_underfunded — details carry minimum_card_balance and card_balance."
        },
        {
          "anchor": "1-8-0-every-movement-of-money-is-on-chain",
          "heading": "1.8.0 — Every movement of money is on-chain",
          "text": "2026-09-03 · :breaking:\n\nInternal transfers are retired. POST /v1/transfers answers 410 transfers_removed to every call; the transfers you made before today are still readable. Pay out with POST /v1/withdrawals — to one of your own users or to any address — priced with our fee and the network's gas. The card programme read no longer carries a deposit address, and reading it no longer opens a programme.\n\nPOST /v1/transfers answers 410 transfers_removed and creates nothing. GET /v1/transfers and GET /v1/transfers/{id} still return the transfers made before 2026-09-03. The dashboard's Transfers page is gone.\nGET /v1/merchant/card-program no longer returns address and no longer opens a programme as a side effect; it carries a boolean opened, and describes an unopened programme with a zero balance. Opening one is POST /v1/merchant/card-programs, as before.\nThe inbound chain-data webhook is no longer listed in the API reference; it was never a merchant endpoint.\n\nWhat you need to do: Replace every POST /v1/transfers with a POST /v1/withdrawals to the recipient's deposit address, and budget the withdrawal fee where a transfer used to be free. If you read address from GET /v1/merchant/card-program, stop — it is gone, and nothing needs it."
        },
        {
          "anchor": "1-7-2-which-assets-can-be-swapped-a-pausable-buy-rail-and-straighter-answers",
          "heading": "1.7.2 — Which assets can be swapped, a pausable buy rail, and straighter answers",
          "text": "2026-09-02 · :breaking:\n\nGET /v1/assets now says which assets can be swapped or exchanged, and a swap or exchange that names one that cannot is refused before anything is charged — USDC on TRON is the one today. The operator can pause the INR buy rail; while it is paused a new quote or order answers 403 buy_disabled and an order already paid still settles. A reused idempotency_key with a different payload on a swap or exchange order is a 409 now, GET /v1/users says when its count was cut off, and setting your UPI fee from the dashboard asks for both codes like every other change there.\n\nGET /v1/assets carries a boolean swappable. POST /v1/swap/quote, /v1/swap/orders, /v1/exchange/quote and /v1/exchange/orders answer 400 invalid_pair for an asset marked false, or one that is disabled; usdc_trc20 is false today because no route carries it. Deposits and withdrawals of it are unchanged.\nThe INR buy rail can be paused by the operator. POST /v1/buy/quote and POST /v1/buy/orders answer 403 buy_disabled while it is paused; an order already paid is settled regardless.\nReplaying an idempotency_key on POST /v1/swap/orders or /v1/exchange/orders with a different payload answers 409 idempotency_conflict. The same payload still returns the existing order.\nGET /v1/users returns a boolean truncated. The list is scanned in one pass of at most 2000 users; when truncated is true, total is that cap, not your count.\nPUT /v1/merchant/upi/fee from a dashboard session takes totp and email_code, like every other dashboard change. With an API key it sets the fee directly, as before.\nWhen the operator rotates your API key, every owner login is emailed. The previous key stops working at that moment.\n\nWhat you need to do: Drop usdc_trc20 from any swap or exchange picker — better, read swappable from GET /v1/assets and offer only what is true. Treat 403 buy_disabled as a pause, not a broken integration: back off and try again later. Handle 409 idempotency_conflict on POST /v1/swap/orders and /v1/exchange/orders the way you already do on withdrawals. If you show total from GET /v1/users, check truncated first."
        },
        {
          "anchor": "1-7-1-a-user-id-is-a-number",
          "heading": "1.7.1 — A user ID is a number",
          "text": "2026-09-02 · :breaking:\n\nPOST /v1/users now takes a numeric external_user_id of 5 to 10 digits and refuses anything else with 400 validation_error. Users you already created keep their ids and keep working; this is the rule for new ones.\n\nexternal_user_id on POST /v1/users must match ^[0-9]{5,10}$.\n\nWhat you need to do: If your integration sends letters or symbols in external_user_id, change it to a 5 to 10 digit number before creating new users."
        },
        {
          "anchor": "1-7-0-accounts-are-approved-before-they-go-live",
          "heading": "1.7.0 — Accounts are approved before they go live",
          "text": "2026-09-02\n\nA new account signs up with an email, a password and a Telegram username, verifies the email, and can then sign in, add webhooks and generate its API key from the dashboard — signup never shows one. Teammates can be invited once the account is approved. The API and every money feature open once our team has approved the account; until then an API key answers 403 account_pending. An operator can also pause, reject or suspend an account; each decision is emailed to the owner. Password reset sends its code to your verified email address, and changes made on the dashboard ask for an emailed code alongside the authenticator.\n\nEvery request on an API key of an account that is not yet approved answers 403 account_pending; an account on hold answers 403 account_paused, one that was not approved 403 account_rejected. Do not retry; the dashboard shows the state.\nA suspended account's key answers 403 account_suspended. It was 401 unauthorized.\nSignup asks for a Telegram username and verifies the email before the first sign-in. No API key is shown at signup — an owner or developer login generates it from the dashboard, with the authenticator code and the emailed code.\nPassword reset sends its code to your verified email address. SMS is used only if email is switched off platform-wide."
        },
        {
          "anchor": "1-6-2-a-rail-that-cannot-pay-says-so-first-and-a-close-takes-a-moment",
          "heading": "1.6.2 — A rail that cannot pay says so first, and a close takes a moment",
          "text": "2026-09-02\n\nA card issue or load the rail cannot fund is refused with 503 rail_unfunded before anything is charged. The minimum opening balance is quoted per product, with the product's own activation minimum folded in. Closing a card is its own order, close_fee, and DELETE /v1/cards/{id} answers 202 card_close_in_progress while it runs.\n\nPOST /v1/cards and POST /v1/cards/{id}/topup answer 503 rail_unfunded when the rail has no funding wallet on file or the hot wallet's balance is not known. Nothing is charged; it is an operator matter, not a request to correct.\nGET /v1/cards/products and GET /v1/cards/issue-quote return min_opening_balance per product — a physical card's activation minimum differs from a virtual one's — and the figure is the one the charge path uses.\nA card close creates an order of kind close_fee. DELETE /v1/cards/{id} answers 202 card_close_in_progress with card_id and order_id while the residual is being returned; call it again once the order settles.\n\nWhat you need to do: Handle 503 rail_unfunded like any 503 — nothing was charged, come back later — and treat 202 card_close_in_progress on DELETE /v1/cards/{id} as pending, not failure."
        },
        {
          "anchor": "1-6-1-a-card-is-issued-for-a-user-from-what-that-user-holds",
          "heading": "1.6.1 — A card is issued for a user, from what that user holds",
          "text": "2026-09-02\n\nGET /v1/cards/issue-quote previews what issuing a card costs the cardholder, priced by the same code that charges. A card is issued FOR one of your users and only from that user's own balance; when it is short, POST /v1/cards says so before anything is charged. The dashboard's Issue a card checks the cardholder's balance at every step, and the \"Top up\" a programme used to have is gone from the Card Issuing overview.\n\nGET /v1/cards/issue-quote returns initial_amount, fee, gas, merchant_fee and total. Read total.\n409 insufficient_balance now carries a message saying whose balance is short."
        },
        {
          "anchor": "1-6-0-the-cardholder-pays-and-every-load-goes-on-chain",
          "heading": "1.6.0 — The cardholder pays, and every load goes on-chain",
          "text": "2026-09-02 · :breaking:\n\nCard issue, every load and the close now come out of the cardholder's own balance. A load is sent on-chain to the card rail and the card is funded once that send is confirmed — minutes, not seconds. You can add your own fee on top of ours; it is charged to the cardholder and lands in your own balance.\n\npayer_user_id is gone from POST /v1/cards, /topup and /withdraw. Sending it is a 400 payer_removed. The holder always pays.\nOrders have a new first stage, funding, while the money is on its way to the rail on-chain; then requested, sent, confirmed as before. Expect minutes between 202 and confirmed.\nGET /v1/cards/topup-quote returns fee, gas, merchant_fee and total; total is the cardholder's debit. Pass card_id and user_id to price a specific card.\nGET /v1/cards/products returns issue_total (everything the holder pays to issue at the minimum opening balance) and merchant_issue_fee.\nNew GET /v1/cards/{id}/close-quote, and GET/PUT/DELETE /v1/merchant/card-fees/{issue · load · close} to set your own percent and/or flat fee on top of ours.\ncard.* webhooks carry gas, merchantFee and total; card orders carry gas, merchant_fee, total and sent_on_chain.\nPOST /v1/merchant/card-program/fund is gone; card programme balances are no longer drawn on. What was prepaid is returned to your own balance.\n\nWhat you need to do: Stop sending payer_user_id and stop moving money from your customer to your treasury before a load — charge nothing yourself; mirror the card.topup / card.status webhooks, which carry the full breakdown. Treat funding like sent: pending, never a failure. Read total from the quotes."
        },
        {
          "anchor": "1-5-0-merchant-markups-are-gone-upi-keeps-its-commission",
          "heading": "1.5.0 — Merchant markups are gone; UPI keeps its commission",
          "text": "2026-09-02 · :breaking:\n\nYou price your own customers on your side; cryptoinfra charges only its own fee. The per-operation markups, the Pricing page, the Profit page and its payout were removed. Your UPI buy commission now lands straight in your own balance.\n\nGET/PUT/DELETE /v1/merchant/pricing and GET/POST /v1/merchant/profit* are gone (404).\nThe merchant_fee field is gone from deposit.credited, from card.* webhook payloads and card orders, from GET /v1/withdrawal-fee-quote and from GET /v1/cards/topup-quote. merchant_issue_fee is gone from GET /v1/cards/products. Swap orders no longer carry merchant_fee_base.\namount_credited on a deposit is amount minus our fee again, nothing else.\nUPI buy keeps its markup, but the commission is credited to your own balance at settlement instead of a separate earnings account. A tenant with a markup and no own-balance account is refused at order time (409 no_own_account) rather than having the money land anywhere else.\n\nWhat you need to do: If you parse any of the removed fields, drop them. If you set a markup, it no longer applies — your customers pay exactly the rates on the fees page. Everything else is unchanged."
        },
        {
          "anchor": "1-2-0-one-flat-withdrawal-fee-on-tron-1-50-gas-included",
          "heading": "1.2.0 — One flat withdrawal fee on TRON — 1.50, gas included",
          "text": "2026-09-01 · :breaking:\n\nThe TRON withdrawal fee is now a flat 1.50 USDT/USDC and it covers the network gas. It was 3.50, priced as a worst-case gas estimate.\n\nThe fee deducted from a TRC20 withdrawal is 1.50 USDT or 1.50 USDC.\nIt includes the TRON energy and bandwidth the transfer consumes. There is nothing to add on top and no TRX to hold.\nIt does not vary with the recipient. A first-time recipient costs us about twice the energy of an existing holder; that difference is absorbed rather than passed on.\nThere is no prepaid gas pot and nothing to switch on: gas is inside the fee.\n\nWhat you need to do: If you hard-coded 3.50 as the withdrawal fee, change it, or better, read withdraw_fee from GET /v1/assets at runtime. If you used the Gas Saver endpoints under /v1/merchant/gas, they now return 404 — you no longer need them."
        },
        {
          "anchor": "1-1-0-a-merchant-can-hold-more-than-one-card-programme",
          "heading": "1.1.0 — A merchant can hold more than one card programme",
          "text": "2026-09-01\n\nCard programmes (\"cores\") let one merchant run several card balances, each with its own funding, pricing and cards.\n\nEvery card carries core_id and bin. Read them as a PAIR — a BIN number is unique only inside its issuer.\nPOST /v1/cards accepts core_id. With one programme open it is optional; with two, omitting it is refused with card_core_required rather than guessed.\nGET /v1/cards/products accepts ?core_id= and returns each row priced for that programme.\n\nWhat you need to do: Nothing today if you hold one programme. Send core_id on POST /v1/cards before you open a second one."
        }
      ]
    },
    {
      "slug": "users",
      "title": "Users",
      "summary": "End-users and their Static Wallet Address — one permanent address per user, reused for every payment",
      "kind": "reference",
      "markdown": "# Users\n\nEnd-users and their Static Wallet Address — one permanent address per user, reused for every payment\n\n3 endpoints. Generated from `docs/openapi.yaml` v1.0.0.\n\n| Endpoint | Summary |\n| --- | --- |\n| [`GET /v1/users`](#get-v1-users) | List your users |\n| [`POST /v1/users`](#post-v1-users) | Create a user and derive its address |\n| [`GET /v1/users/{externalId}/addresses`](#get-v1-users-externalid-addresses) | Read a user's Static Wallet Address |\n\n## GET /v1/users\n\n**List your users**  ·  _Auth: bearer API key_  ·  `operationId: listUsers`\n\nYour users, newest first (by creation time). Scoped to your tenant — nobody\nelse's users are ever visible.\n\n`total` is counted through the SAME filter as the rows, so it is the size of\nthe filtered set, not of your whole user base: page with `offset` until you\nhave `total` rows. The page is ordered on figures the database cannot compute\n(a USD total needs the price feed), so at most 2000 matching users are read;\n`total` counts no further than that, and `truncated: true` says the cap was\nhit — narrow the filter.\n\n`balance_usd`, `balances[].usd` and `card_balance` are price-feed estimates\nfor display, with no `_base` twin; read exact balances from\nGET /v1/users/{externalId}/balances.\n\nReserved internal accounts (the card-programme float pot) are excluded,\nso this list agrees with the user counts your dashboard shows.\n\n`from`/`to` accept either a bare IST calendar day (`YYYY-MM-DD`, the whole day\nINCLUDED on both ends) or a full RFC 3339 instant with an offset. Anything else\nis a `400` rather than a silently ignored filter.\n\n#### Parameters\n\n| Name | In | Type | Required | Description |\n| --- | --- | --- | --- | --- |\n| `search` | query | `string` | no | Case-insensitive substring match on `external_user_id`. |\n| `from` | query | `string` | no | Only users created on/after this IST day (or instant). |\n| `to` | query | `string` | no | Only users created on/before this IST day (or before this instant). |\n| `limit` | query | `integer` | no | — _(default `50`; min `1`; max `200`)_ |\n| `offset` | query | `integer` | no | — _(default `0`; min `0`)_ |\n| `sort` | query | `string` | no | What orders the page — when the user was created, what they hold (USD estimate), or when they last moved money. A user who never moved money sorts last either way. _(one of `created`, `balance`, `last_active`; default `\"created\"`)_ |\n| `dir` | query | `string` | no | — _(one of `asc`, `desc`; default `\"desc\"`)_ |\n\n#### Responses\n\n| Status | Body | Meaning |\n| --- | --- | --- |\n| `200` | `object` | One page of users, plus the total matching the same filter. |\n| `400` | `Error` | Body/params failed validation, or a domain rule was violated. `error` is one of: validation_error, unknown_asset, asset_disabled, invalid_amount, amount_below_minimum, bad_amount, invalid_address, invalid_request. |\n| `401` | `Error` | Missing, malformed or unknown API key. |\n\n**`200` response body**\n\n| Field | Type | Description |\n| --- | --- | --- |\n| `users` **required** | `object[]` | — |\n| `users[].user_id` **required** | `string` | Your `external_user_id` for this user. |\n| `users[].account_kind` **required** | `string` | `end_user` is one of YOUR customers; `merchant_own` is an account that holds your own money (e.g. your treasury). _(one of `end_user`, `merchant_own`)_ |\n| `users[].created_at` **required** | `string<date-time>` | — |\n| `users[].balance_usd` **required** | `string` | USD estimate of everything the user holds, two decimals. Display only. |\n| `users[].balances` **required** | `object[]` | Per-asset holdings, largest USD value first. |\n| `users[].balances[].asset_id` **required** | `string` | — |\n| `users[].balances[].symbol` **required** | `string` | — |\n| `users[].balances[].amount` **required** | `string` | A monetary value as a decimal string. Always paired with a `*_base` field that carries the exact integer base-unit value; compute with the `_base` string. |\n| `users[].balances[].usd` **required** | `string` | Price-feed estimate; no `_base` twin. |\n| `users[].card_balance` **required** | `string | null` | Sum on the user's cards (available + pending); null when the user has no card. |\n| `users[].last_active_at` **required** | `string<date-time> | null` | The user's most recent ledger movement; null when none. |\n| `total` **required** | `integer` | Users matching the filter, ignoring `limit`/`offset` — counted no further than 2000. |\n| `truncated` **required** | `boolean` | true when more than 2000 users matched and `total` stopped there. Narrow the filter. |\n| `limit` **required** | `integer` | — |\n| `offset` **required** | `integer` | — |\n\n```json\n{\n  \"users\": [\n    {\n      \"user_id\": \"user-42\",\n      \"account_kind\": \"end_user\",\n      \"created_at\": \"2026-08-31T09:15:00.000Z\",\n      \"balance_usd\": \"125.40\",\n      \"balances\": [\n        {\n          \"asset_id\": \"usdt_trc20\",\n          \"symbol\": \"USDT\",\n          \"amount\": \"1.5\",\n          \"usd\": null\n        }\n      ],\n      \"card_balance\": \"string\",\n      \"last_active_at\": \"2026-08-31T09:15:00.000Z\"\n    }\n  ],\n  \"total\": 1,\n  \"truncated\": true,\n  \"limit\": 1,\n  \"offset\": 1\n}\n```\n\n#### Example request\n\n```bash\ncurl -X GET 'https://api.cryptoinfra.build/v1/users?from=2026-08-01&to=2026-08-31' \\\n  -H 'Authorization: Bearer ck_live_your_key'\n```\n\n## POST /v1/users\n\n**Create a user and derive its address**  ·  _Auth: bearer API key_  ·  `operationId: createUser`\n\nCreates a user under your tenant and derives their Static Wallet Address — one permanent TRON address.\nEvery asset in GET /v1/assets lands on that one address; `addresses` is a list\nso a client reads it the same way whatever it holds. Idempotent on\n`external_user_id`: replaying returns the existing user and address (still `201`).\n\n#### Request body\n\n`application/json` · required\n\n| Field | Type | Description |\n| --- | --- | --- |\n| `external_user_id` **required** | `string` | A number of 5 to 10 digits — the ID this user has in your own system. Letters are refused. _(minLength `5`; maxLength `10`; pattern `^[0-9]{5,10}$`)_ |\n\n#### Responses\n\n| Status | Body | Meaning |\n| --- | --- | --- |\n| `201` | `UserAddresses` | User created (or already existed). |\n| `400` | `Error` | Body/params failed validation, or a domain rule was violated. `error` is one of: validation_error, unknown_asset, asset_disabled, invalid_amount, amount_below_minimum, bad_amount, invalid_address, invalid_request. |\n| `401` | `Error` | Missing, malformed or unknown API key. |\n\n**`201` response body** — `UserAddresses`\n\n| Field | Type | Description |\n| --- | --- | --- |\n| `user_id` **required** | `string` | Your external_user_id. |\n| `addresses` **required** | `object[]` | — |\n| `addresses[].chain` **required** | `string` | The chain an address or asset is on. TRON is the only network the platform serves: `btc`, `eth`, `bsc` and `sol` were retired — historical addresses on them are never handed out again and nothing scans them. Read the value as an open string rather than a closed list, so a chain added later does not break your client. _(one of `tron`)_ |\n| `addresses[].address` **required** | `string` | The Static Wallet Address — permanent |\n\n```json\n{\n  \"user_id\": \"string\",\n  \"addresses\": [\n    {\n      \"chain\": \"tron\",\n      \"address\": \"string\"\n    }\n  ]\n}\n```\n\n#### Example request\n\n```bash\ncurl -X POST 'https://api.cryptoinfra.build/v1/users' \\\n  -H 'Authorization: Bearer ck_live_your_key' \\\n  -H 'Content-Type: application/json' \\\n  -d '{\n  \"external_user_id\": \"10482\"\n}'\n```\n\n## GET /v1/users/{externalId}/addresses\n\n**Read a user's Static Wallet Address**  ·  _Auth: bearer API key_  ·  `operationId: getUserAddresses`\n\n#### Parameters\n\n| Name | In | Type | Required | Description |\n| --- | --- | --- | --- | --- |\n| `externalId` | path | `string` | yes | Your `external_user_id` for the user. |\n\n#### Responses\n\n| Status | Body | Meaning |\n| --- | --- | --- |\n| `200` | `UserAddresses` | The user's Static Wallet Address, as a one-entry list keyed by chain. |\n| `401` | `Error` | Missing, malformed or unknown API key. |\n| `404` | `Error` | Unknown external_user_id for this tenant. |\n\n**`200` response body** — `UserAddresses`\n\n| Field | Type | Description |\n| --- | --- | --- |\n| `user_id` **required** | `string` | Your external_user_id. |\n| `addresses` **required** | `object[]` | — |\n| `addresses[].chain` **required** | `string` | The chain an address or asset is on. TRON is the only network the platform serves: `btc`, `eth`, `bsc` and `sol` were retired — historical addresses on them are never handed out again and nothing scans them. Read the value as an open string rather than a closed list, so a chain added later does not break your client. _(one of `tron`)_ |\n| `addresses[].address` **required** | `string` | The Static Wallet Address — permanent |\n\n```json\n{\n  \"user_id\": \"string\",\n  \"addresses\": [\n    {\n      \"chain\": \"tron\",\n      \"address\": \"string\"\n    }\n  ]\n}\n```\n\n#### Example request\n\n```bash\ncurl -X GET 'https://api.cryptoinfra.build/v1/users/string/addresses' \\\n  -H 'Authorization: Bearer ck_live_your_key'\n```\n",
      "sections": [
        {
          "anchor": "get-v1-users",
          "heading": "GET /v1/users",
          "text": "List your users  ·  Auth: bearer API key  ·  operationId: listUsers\n\nYour users, newest first (by creation time). Scoped to your tenant — nobody\nelse's users are ever visible.\n\ntotal is counted through the SAME filter as the rows, so it is the size of\nthe filtered set, not of your whole user base: page with offset until you\nhave total rows. The page is ordered on figures the database cannot compute\n(a USD total needs the price feed), so at most 2000 matching users are read;\ntotal counts no further than that, and truncated: true says the cap was\nhit — narrow the filter.\n\nbalance_usd, balances[].usd and card_balance are price-feed estimates\nfor display, with no _base twin; read exact balances from\nGET /v1/users/{externalId}/balances.\n\nReserved internal accounts (the card-programme float pot) are excluded,\nso this list agrees with the user counts your dashboard shows.\n\nfrom/to accept either a bare IST calendar day (YYYY-MM-DD, the whole day\nINCLUDED on both ends) or a full RFC 3339 instant with an offset. Anything else\nis a 400 rather than a silently ignored filter.\n\nParameters\n\nName · In · Type · Required · Description\nsearch · query · string · no · Case-insensitive substring match on external_user_id.\nfrom · query · string · no · Only users created on/after this IST day (or instant).\nto · query · string · no · Only users created on/before this IST day (or before this instant).\nlimit · query · integer · no · — (default 50; min 1; max 200)\noffset · query · integer · no · — (default 0; min 0)\nsort · query · string · no · What orders the page — when the user was created, what they hold (USD estimate), or when they last moved money. A user who never moved money sorts last either way. _(one of created, balance, last_active; default \"created\")_\ndir · query · string · no · — (one of asc, desc; default \"desc\")\n\nResponses\n\nStatus · Body · Meaning\n200 · object · One page of users, plus the total matching the same filter.\n400 · Error · Body/params failed validation, or a domain rule was violated. error is one of: validation_error, unknown_asset, asset_disabled, invalid_amount, amount_below_minimum, bad_amount, invalid_address, invalid_request.\n401 · Error · Missing, malformed or unknown API key.\n\n200 response body\n\nField · Type · Description\nusers required · object[] · —\nusers[].user_id required · string · Your external_user_id for this user.\nusers[].account_kind required · string · end_user is one of YOUR customers; merchant_own is an account that holds your own money (e.g. your treasury). _(one of end_user, merchant_own)_\nusers[].created_at required · string<date-time> · —\nusers[].balance_usd required · string · USD estimate of everything the user holds, two decimals. Display only.\nusers[].balances required · object[] · Per-asset holdings, largest USD value first.\nusers[].balances[].asset_id required · string · —\nusers[].balances[].symbol required · string · —\nusers[].balances[].amount required · string · A monetary value as a decimal string. Always paired with a *_base field that carries the exact integer base-unit value; compute with the _base string.\nusers[].balances[].usd required · string · Price-feed estimate; no _base twin.\nusers[].card_balance required · string · null · Sum on the user's cards (available + pending); null when the user has no card.\nusers[].last_active_at required · string<date-time> · null · The user's most recent ledger movement; null when none.\ntotal required · integer · Users matching the filter, ignoring limit/offset — counted no further than 2000.\ntruncated required · boolean · true when more than 2000 users matched and total stopped there. Narrow the filter.\nlimit required · integer · —\noffset required · integer · —\n\n{\n\"users\": [\n{\n\"user_id\": \"user-42\",\n\"account_kind\": \"end_user\",\n\"created_at\": \"2026-08-31T09:15:00.000Z\",\n\"balance_usd\": \"125.40\",\n\"balances\": [\n{\n\"asset_id\": \"usdt_trc20\",\n\"symbol\": \"USDT\",\n\"amount\": \"1.5\",\n\"usd\": null\n}\n],\n\"card_balance\": \"string\",\n\"last_active_at\": \"2026-08-31T09:15:00.000Z\"\n}\n],\n\"total\": 1,\n\"truncated\": true,\n\"limit\": 1,\n\"offset\": 1\n}\n\nExample request\n\ncurl -X GET 'https://api.cryptoinfra.build/v1/users?from=2026-08-01&to=2026-08-31' \\\n-H 'Authorization: Bearer ck_live_your_key'"
        },
        {
          "anchor": "post-v1-users",
          "heading": "POST /v1/users",
          "text": "Create a user and derive its address  ·  Auth: bearer API key  ·  operationId: createUser\n\nCreates a user under your tenant and derives their Static Wallet Address — one permanent TRON address.\nEvery asset in GET /v1/assets lands on that one address; addresses is a list\nso a client reads it the same way whatever it holds. Idempotent on\nexternal_user_id: replaying returns the existing user and address (still 201).\n\nRequest body\n\napplication/json · required\n\nField · Type · Description\nexternal_user_id required · string · A number of 5 to 10 digits — the ID this user has in your own system. Letters are refused. (minLength 5; maxLength 10; pattern ^[0-9]{5,10}$)\n\nResponses\n\nStatus · Body · Meaning\n201 · UserAddresses · User created (or already existed).\n400 · Error · Body/params failed validation, or a domain rule was violated. error is one of: validation_error, unknown_asset, asset_disabled, invalid_amount, amount_below_minimum, bad_amount, invalid_address, invalid_request.\n401 · Error · Missing, malformed or unknown API key.\n\n201 response body — UserAddresses\n\nField · Type · Description\nuser_id required · string · Your external_user_id.\naddresses required · object[] · —\naddresses[].chain required · string · The chain an address or asset is on. TRON is the only network the platform serves: btc, eth, bsc and sol were retired — historical addresses on them are never handed out again and nothing scans them. Read the value as an open string rather than a closed list, so a chain added later does not break your client. (one of tron)\naddresses[].address required · string · The Static Wallet Address — permanent\n\n{\n\"user_id\": \"string\",\n\"addresses\": [\n{\n\"chain\": \"tron\",\n\"address\": \"string\"\n}\n]\n}\n\nExample request\n\ncurl -X POST 'https://api.cryptoinfra.build/v1/users' \\\n-H 'Authorization: Bearer ck_live_your_key' \\\n-H 'Content-Type: application/json' \\\n-d '{\n\"external_user_id\": \"10482\"\n}'"
        },
        {
          "anchor": "get-v1-users-externalid-addresses",
          "heading": "GET /v1/users/{externalId}/addresses",
          "text": "Read a user's Static Wallet Address  ·  Auth: bearer API key  ·  operationId: getUserAddresses\n\nParameters\n\nName · In · Type · Required · Description\nexternalId · path · string · yes · Your external_user_id for the user.\n\nResponses\n\nStatus · Body · Meaning\n200 · UserAddresses · The user's Static Wallet Address, as a one-entry list keyed by chain.\n401 · Error · Missing, malformed or unknown API key.\n404 · Error · Unknown external_user_id for this tenant.\n\n200 response body — UserAddresses\n\nField · Type · Description\nuser_id required · string · Your external_user_id.\naddresses required · object[] · —\naddresses[].chain required · string · The chain an address or asset is on. TRON is the only network the platform serves: btc, eth, bsc and sol were retired — historical addresses on them are never handed out again and nothing scans them. Read the value as an open string rather than a closed list, so a chain added later does not break your client. (one of tron)\naddresses[].address required · string · The Static Wallet Address — permanent\n\n{\n\"user_id\": \"string\",\n\"addresses\": [\n{\n\"chain\": \"tron\",\n\"address\": \"string\"\n}\n]\n}\n\nExample request\n\ncurl -X GET 'https://api.cryptoinfra.build/v1/users/string/addresses' \\\n-H 'Authorization: Bearer ck_live_your_key'"
        }
      ]
    },
    {
      "slug": "balances-deposits",
      "title": "Balances & deposits",
      "summary": "Read spendable balances and incoming deposit history",
      "kind": "reference",
      "markdown": "# Balances & deposits\n\nRead spendable balances and incoming deposit history\n\n2 endpoints. Generated from `docs/openapi.yaml` v1.0.0.\n\n| Endpoint | Summary |\n| --- | --- |\n| [`GET /v1/users/{externalId}/balances`](#get-v1-users-externalid-balances) | Get a user's spendable balances |\n| [`GET /v1/users/{externalId}/deposits`](#get-v1-users-externalid-deposits) | List a user's deposits |\n\n## GET /v1/users/{externalId}/balances\n\n**Get a user's spendable balances**  ·  _Auth: bearer API key_  ·  `operationId: getUserBalances`\n\n**Spendable, always net of holds.** A pending withdrawal places\nits hold at REQUEST time — the held amount (plus fees) leaves the user's\nbalance immediately and sits in a pending account until the operation\nconfirms or is refunded.\n\nThe guarantee runs in one direction and is safe to rely on: while an\noperation is in flight this endpoint reads **lower** than the user's\neventual balance, **never higher**. It cannot report a surplus that a\nqueued payout is about to consume. If the operation fails, the hold is\nreversed and the balance returns.\n\nAssets with no activity are absent from the response. So is an empty\naccount on a chain the platform no longer serves (`Chain`) — a user from\nbefore a chain was retired keeps such rows, and they are listed only\nwhile they hold a balance.\n\n`on_chain` is what the user's own address holds at this moment, for\ncomparison with a block explorer. It is not the spendable figure and\nthe two differ by design (see the `Balance` schema).\n\n#### Parameters\n\n| Name | In | Type | Required | Description |\n| --- | --- | --- | --- | --- |\n| `externalId` | path | `string` | yes | Your `external_user_id` for the user. |\n\n#### Responses\n\n| Status | Body | Meaning |\n| --- | --- | --- |\n| `200` | `object[]` | Per-asset balances. |\n| `401` | `Error` | Missing, malformed or unknown API key. |\n| `404` | `Error` | Unknown external_user_id for this tenant. |\n\n**`200` response body**\n\n| Field | Type | Description |\n| --- | --- | --- |\n| `[].asset_id` **required** | `string` | — |\n| `[].symbol` **required** | `string` | — |\n| `[].chain` **required** | `string` | The chain an address or asset is on. TRON is the only network the platform serves: `btc`, `eth`, `bsc` and `sol` were retired — historical addresses on them are never handed out again and nothing scans them. Read the value as an open string rather than a closed list, so a chain added later does not break your client. _(one of `tron`)_ |\n| `[].amount` **required** | `string` | A monetary value as a decimal string. Always paired with a `*_base` field that carries the exact integer base-unit value; compute with the `_base` string. |\n| `[].amount_base` **required** | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |\n| `[].on_chain` **required** | `string` | What the user's own deposit address holds right now, read live from the chain. Informational: `amount` is the balance the platform enforces. The two differ by design — fees leave the address for the platform's profit wallet as they settle, earnings (commission, a merchant's own fees) are credited from there, and a balance from before 4 September 2026 lives in the platform's wallets. `null` when the address could not be read in time. |\n| `[].on_chain_base` **required** | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |\n\n```json\n[\n  {\n    \"asset_id\": \"usdt_trc20\",\n    \"symbol\": \"USDT\",\n    \"chain\": \"tron\",\n    \"amount\": \"1.5\",\n    \"amount_base\": \"1500000000\",\n    \"on_chain\": \"1.5\",\n    \"on_chain_base\": \"1500000000\"\n  }\n]\n```\n\n#### Example request\n\n```bash\ncurl -X GET 'https://api.cryptoinfra.build/v1/users/string/balances' \\\n  -H 'Authorization: Bearer ck_live_your_key'\n```\n\n## GET /v1/users/{externalId}/deposits\n\n**List a user's deposits**  ·  _Auth: bearer API key_  ·  `operationId: getUserDeposits`\n\nMost recent deposits first.\n\nThe optional range filters on when the deposit was FIRST SEEN on-chain, not on\nwhen it was credited — so a still-pending deposit cannot fall out of a window\nthat covers it. `from`/`to` accept either a bare IST calendar day\n(`YYYY-MM-DD`, both ends included) or a full RFC 3339 instant with an offset.\n\n#### Parameters\n\n| Name | In | Type | Required | Description |\n| --- | --- | --- | --- | --- |\n| `externalId` | path | `string` | yes | Your `external_user_id` for the user. |\n| `limit` | query | `integer` | no | — _(default `50`; min `1`; max `200`)_ |\n| `offset` | query | `integer` | no | — _(default `0`; min `0`)_ |\n| `from` | query | `string` | no | Only deposits first seen on/after this IST day (or instant). |\n| `to` | query | `string` | no | Only deposits first seen on/before this IST day (or before this instant). |\n\n#### Responses\n\n| Status | Body | Meaning |\n| --- | --- | --- |\n| `200` | `object[]` | Deposit history. |\n| `400` | `Error` | Body/params failed validation, or a domain rule was violated. `error` is one of: validation_error, unknown_asset, asset_disabled, invalid_amount, amount_below_minimum, bad_amount, invalid_address, invalid_request. |\n| `401` | `Error` | Missing, malformed or unknown API key. |\n| `404` | `Error` | Unknown external_user_id for this tenant. |\n\n**`200` response body**\n\n| Field | Type | Description |\n| --- | --- | --- |\n| `[].id` **required** | `integer` | — |\n| `[].chain` **required** | `string` | The chain an address or asset is on. TRON is the only network the platform serves: `btc`, `eth`, `bsc` and `sol` were retired — historical addresses on them are never handed out again and nothing scans them. Read the value as an open string rather than a closed list, so a chain added later does not break your client. _(one of `tron`)_ |\n| `[].asset_id` **required** | `string` | — |\n| `[].symbol` **required** | `string` | — |\n| `[].address` **required** | `string` | — |\n| `[].from_address` | `string` | Who paid it. For a token, the `from` of the Transfer event to `address`, not the transaction's signer; for TRX, the sender. `null` only for an old deposit whose sender could not be read. |\n| `[].txid` **required** | `string` | — |\n| `[].output_index` **required** | `integer` | Position of the transfer inside its transaction (the log index of a TRC20 transfer; 0 for a native TRX transfer). |\n| `[].amount` **required** | `string` | Gross on-chain amount. |\n| `[].amount_base` **required** | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |\n| `[].fee` **required** | `string` | Platform deposit fee. Deposits are free, so this is `0` on every deposit since 2026-09-04. |\n| `[].fee_base` **required** | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |\n| `[].amount_credited` **required** | `string` | Net credited to the user (amount - fee). |\n| `[].amount_credited_base` **required** | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |\n| `[].status` **required** | `string` |  _(one of `seen`, `confirming`, `credited`, `orphaned`, `ignored_dust`)_ |\n| `[].block_height` | `integer | null` | — |\n| `[].confirmations` **required** | `integer` | — |\n| `[].first_seen_at` **required** | `string<date-time>` | — |\n| `[].credited_at` | `string<date-time> | null` | — |\n\n```json\n[\n  {\n    \"id\": 1,\n    \"chain\": \"tron\",\n    \"asset_id\": \"string\",\n    \"symbol\": \"string\",\n    \"address\": \"string\",\n    \"from_address\": \"string\",\n    \"txid\": \"string\",\n    \"output_index\": 1,\n    \"amount\": \"1.5\",\n    \"amount_base\": \"1500000000\",\n    \"fee\": \"1.5\",\n    \"fee_base\": \"1500000000\",\n    \"amount_credited\": \"1.5\",\n    \"amount_credited_base\": \"1500000000\",\n    \"status\": \"seen\",\n    \"block_height\": 1,\n    \"confirmations\": 1,\n    \"first_seen_at\": \"2026-08-31T09:15:00.000Z\",\n    \"credited_at\": \"2026-08-31T09:15:00.000Z\"\n  }\n]\n```\n\n#### Example request\n\n```bash\ncurl -X GET 'https://api.cryptoinfra.build/v1/users/string/deposits?from=2026-08-01&to=2026-08-31' \\\n  -H 'Authorization: Bearer ck_live_your_key'\n```\n",
      "sections": [
        {
          "anchor": "get-v1-users-externalid-balances",
          "heading": "GET /v1/users/{externalId}/balances",
          "text": "Get a user's spendable balances  ·  Auth: bearer API key  ·  operationId: getUserBalances\n\nSpendable, always net of holds. A pending withdrawal places\nits hold at REQUEST time — the held amount (plus fees) leaves the user's\nbalance immediately and sits in a pending account until the operation\nconfirms or is refunded.\n\nThe guarantee runs in one direction and is safe to rely on: while an\noperation is in flight this endpoint reads lower than the user's\neventual balance, never higher. It cannot report a surplus that a\nqueued payout is about to consume. If the operation fails, the hold is\nreversed and the balance returns.\n\nAssets with no activity are absent from the response. So is an empty\naccount on a chain the platform no longer serves (Chain) — a user from\nbefore a chain was retired keeps such rows, and they are listed only\nwhile they hold a balance.\n\non_chain is what the user's own address holds at this moment, for\ncomparison with a block explorer. It is not the spendable figure and\nthe two differ by design (see the Balance schema).\n\nParameters\n\nName · In · Type · Required · Description\nexternalId · path · string · yes · Your external_user_id for the user.\n\nResponses\n\nStatus · Body · Meaning\n200 · object[] · Per-asset balances.\n401 · Error · Missing, malformed or unknown API key.\n404 · Error · Unknown external_user_id for this tenant.\n\n200 response body\n\nField · Type · Description\n[].asset_id required · string · —\n[].symbol required · string · —\n[].chain required · string · The chain an address or asset is on. TRON is the only network the platform serves: btc, eth, bsc and sol were retired — historical addresses on them are never handed out again and nothing scans them. Read the value as an open string rather than a closed list, so a chain added later does not break your client. (one of tron)\n[].amount required · string · A monetary value as a decimal string. Always paired with a *_base field that carries the exact integer base-unit value; compute with the _base string.\n[].amount_base required · string · Exact integer amount in the asset's smallest unit. (pattern ^[0-9]+$)\n[].on_chain required · string · What the user's own deposit address holds right now, read live from the chain. Informational: amount is the balance the platform enforces. The two differ by design — fees leave the address for the platform's profit wallet as they settle, earnings (commission, a merchant's own fees) are credited from there, and a balance from before 4 September 2026 lives in the platform's wallets. null when the address could not be read in time.\n[].on_chain_base required · string · Exact integer amount in the asset's smallest unit. (pattern ^[0-9]+$)\n\n[\n{\n\"asset_id\": \"usdt_trc20\",\n\"symbol\": \"USDT\",\n\"chain\": \"tron\",\n\"amount\": \"1.5\",\n\"amount_base\": \"1500000000\",\n\"on_chain\": \"1.5\",\n\"on_chain_base\": \"1500000000\"\n}\n]\n\nExample request\n\ncurl -X GET 'https://api.cryptoinfra.build/v1/users/string/balances' \\\n-H 'Authorization: Bearer ck_live_your_key'"
        },
        {
          "anchor": "get-v1-users-externalid-deposits",
          "heading": "GET /v1/users/{externalId}/deposits",
          "text": "List a user's deposits  ·  Auth: bearer API key  ·  operationId: getUserDeposits\n\nMost recent deposits first.\n\nThe optional range filters on when the deposit was FIRST SEEN on-chain, not on\nwhen it was credited — so a still-pending deposit cannot fall out of a window\nthat covers it. from/to accept either a bare IST calendar day\n(YYYY-MM-DD, both ends included) or a full RFC 3339 instant with an offset.\n\nParameters\n\nName · In · Type · Required · Description\nexternalId · path · string · yes · Your external_user_id for the user.\nlimit · query · integer · no · — (default 50; min 1; max 200)\noffset · query · integer · no · — (default 0; min 0)\nfrom · query · string · no · Only deposits first seen on/after this IST day (or instant).\nto · query · string · no · Only deposits first seen on/before this IST day (or before this instant).\n\nResponses\n\nStatus · Body · Meaning\n200 · object[] · Deposit history.\n400 · Error · Body/params failed validation, or a domain rule was violated. error is one of: validation_error, unknown_asset, asset_disabled, invalid_amount, amount_below_minimum, bad_amount, invalid_address, invalid_request.\n401 · Error · Missing, malformed or unknown API key.\n404 · Error · Unknown external_user_id for this tenant.\n\n200 response body\n\nField · Type · Description\n[].id required · integer · —\n[].chain required · string · The chain an address or asset is on. TRON is the only network the platform serves: btc, eth, bsc and sol were retired — historical addresses on them are never handed out again and nothing scans them. Read the value as an open string rather than a closed list, so a chain added later does not break your client. (one of tron)\n[].asset_id required · string · —\n[].symbol required · string · —\n[].address required · string · —\n[].from_address · string · Who paid it. For a token, the from of the Transfer event to address, not the transaction's signer; for TRX, the sender. null only for an old deposit whose sender could not be read.\n[].txid required · string · —\n[].output_index required · integer · Position of the transfer inside its transaction (the log index of a TRC20 transfer; 0 for a native TRX transfer).\n[].amount required · string · Gross on-chain amount.\n[].amount_base required · string · Exact integer amount in the asset's smallest unit. (pattern ^[0-9]+$)\n[].fee required · string · Platform deposit fee. Deposits are free, so this is 0 on every deposit since 2026-09-04.\n[].fee_base required · string · Exact integer amount in the asset's smallest unit. (pattern ^[0-9]+$)\n[].amount_credited required · string · Net credited to the user (amount - fee).\n[].amount_credited_base required · string · Exact integer amount in the asset's smallest unit. (pattern ^[0-9]+$)\n[].status required · string · _(one of seen, confirming, credited, orphaned, ignored_dust)_\n[].block_height · integer · null · —\n[].confirmations required · integer · —\n[].first_seen_at required · string<date-time> · —\n[].credited_at · string<date-time> · null · —\n\n[\n{\n\"id\": 1,\n\"chain\": \"tron\",\n\"asset_id\": \"string\",\n\"symbol\": \"string\",\n\"address\": \"string\",\n\"from_address\": \"string\",\n\"txid\": \"string\",\n\"output_index\": 1,\n\"amount\": \"1.5\",\n\"amount_base\": \"1500000000\",\n\"fee\": \"1.5\",\n\"fee_base\": \"1500000000\",\n\"amount_credited\": \"1.5\",\n\"amount_credited_base\": \"1500000000\",\n\"status\": \"seen\",\n\"block_height\": 1,\n\"confirmations\": 1,\n\"first_seen_at\": \"2026-08-31T09:15:00.000Z\",\n\"credited_at\": \"2026-08-31T09:15:00.000Z\"\n}\n]\n\nExample request\n\ncurl -X GET 'https://api.cryptoinfra.build/v1/users/string/deposits?from=2026-08-01&to=2026-08-31' \\\n-H 'Authorization: Bearer ck_live_your_key'"
        }
      ]
    },
    {
      "slug": "withdrawals",
      "title": "Withdrawals",
      "summary": "Pay out from a user's balance",
      "kind": "reference",
      "markdown": "# Withdrawals\n\nPay out from a user's balance\n\n5 endpoints. Generated from `docs/openapi.yaml` v1.0.0.\n\n| Endpoint | Summary |\n| --- | --- |\n| [`GET /v1/withdrawal-fee-quote`](#get-v1-withdrawal-fee-quote) | Preview a send's exact fees before confirming |\n| [`GET /v1/withdrawals`](#get-v1-withdrawals) | List withdrawals, or look one up by idempotency key |\n| [`POST /v1/withdrawals`](#post-v1-withdrawals) | Request a payout |\n| [`GET /v1/withdrawals/{id}`](#get-v1-withdrawals-id) | Get a withdrawal |\n| [`POST /v1/withdrawals/batch`](#post-v1-withdrawals-batch) | Batch payouts — up to 50 sends in one call |\n\n## GET /v1/withdrawal-fee-quote\n\n**Preview a send's exact fees before confirming**  ·  _Auth: bearer API key_  ·  `operationId: withdrawalFeeQuote`\n\nRead-only. **Our live network cost plus a flat platform fee.** `network_fee`\nis what this send costs us on-chain, priced now — the energy we rent for\nthe payout and for our fee's own transfer, the bandwidth, and (with\n`user_id`) the one-time activation of a sender address that has never held\nTRX — converted at the live TRX price with a cushion and rounded up to the cent.\n`platform_fee` is your account's flat schedule from GET /v1/fees (the\nplatform's own figure is public on GET /v1/pricing); `total_fee` is their sum and is what POST\n/v1/withdrawals holds on top of `amount`. Show the user `total_fee`;\nthe figure returned here is the one charged.\n\nPass `to_address` so the recipient is priced exactly (an address that\nholds none of the token costs about twice one that does — `recipient_priced`\nsays it was probed) and `user_id` so a first send's activation is\nincluded. `network_cost` says what the figure was built from.\n\n`dynamic: false` means the live TRX price could not be read and the\nasset's static fallback (`static_fallback` on GET /v1/pricing) was\ncharged instead. When only the energy market is unreadable the energy\npart is priced at a fixed fallback rate and the quote stays\n`dynamic: true`.\n\n#### Parameters\n\n| Name | In | Type | Required | Description |\n| --- | --- | --- | --- | --- |\n| `asset_id` | query | `string` | yes | — |\n| `amount` | query | `string` | no | Optional decimal amount; only affects percentage-based platform fees. |\n| `to_address` | query | `string` | no | Optional recipient. Validated for the asset's chain (400 invalid_address otherwise) and priced exactly: an address holding none of the token costs about twice one that does. Without it the worst case is quoted. |\n| `user_id` | query | `string` | no | Optional sender (your user id). The send leaves this user's own address; if it has never held TRX its one-time activation (about 2.5 TRX) is part of the quote. |\n\n#### Responses\n\n| Status | Body | Meaning |\n| --- | --- | --- |\n| `200` | `object` | Itemized fee preview. |\n| `401` | `Error` | Missing, malformed or unknown API key. |\n\n**`200` response body**\n\n| Field | Type | Description |\n| --- | --- | --- |\n| `asset_id` | `string` | — |\n| `network_fee` | `string` | A monetary value as a decimal string. Always paired with a `*_base` field that carries the exact integer base-unit value; compute with the `_base` string. |\n| `network_fee_base` | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |\n| `platform_fee` | `string` | A monetary value as a decimal string. Always paired with a `*_base` field that carries the exact integer base-unit value; compute with the `_base` string. |\n| `platform_fee_base` | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |\n| `merchant_fee` | `string` | Your own fee on this send (Profit → Your fees); 0 unless you set one, and 0 for your own balance. |\n| `merchant_fee_base` | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |\n| `total_fee` | `string` | A monetary value as a decimal string. Always paired with a `*_base` field that carries the exact integer base-unit value; compute with the `_base` string. |\n| `total_fee_base` | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |\n| `dynamic` | `boolean` | true when the network fee was priced live (the normal case); false means the static fallback was charged. |\n| `quoted_at` | `string<date-time>` | When this quote was priced. Every figure here is computed for this response, never stored. |\n| `recipient_priced` | `boolean` | true when `to_address` was given and probed, so the figure is exact for that recipient. |\n| `network_cost` | `object` | What the live network figure was built from; null on the static fallback. |\n| `network_cost.energy_trx` **required** | `string` | TRX for the energy the transfers use (rented |\n| `network_cost.bandwidth_trx` **required** | `string` | TRX for the bandwidth the transfers burn. |\n| `network_cost.activation_trx` **required** | `string` | TRX to create the sender's account on its first send; \"0\" otherwise. |\n| `network_cost.trx_usd` **required** | `number` | The TRX price the bill was converted at. |\n| `network_cost.transfers` **required** | `integer` | On-chain transfers in the operation (a payout or load and its fee leg = 2). |\n| `network_cost.recipient` **required** | `string` | Whether the recipient was priced as holding the token already. _(one of `fresh`, `existing`, `unknown`)_ |\n| `network_cost.first_send` **required** | `boolean` | Whether the sender's activation was included. |\n\n```json\n{\n  \"asset_id\": \"string\",\n  \"network_fee\": \"1.5\",\n  \"network_fee_base\": \"1500000000\",\n  \"platform_fee\": \"1.5\",\n  \"platform_fee_base\": \"1500000000\",\n  \"merchant_fee\": \"1.5\",\n  \"merchant_fee_base\": \"1500000000\",\n  \"total_fee\": \"1.5\",\n  \"total_fee_base\": \"1500000000\",\n  \"dynamic\": true,\n  \"quoted_at\": \"2026-08-31T09:15:00.000Z\",\n  \"recipient_priced\": true,\n  \"network_cost\": {\n    \"energy_trx\": \"string\",\n    \"bandwidth_trx\": \"string\",\n    \"activation_trx\": \"string\",\n    \"trx_usd\": 0,\n    \"transfers\": 1,\n    \"recipient\": \"fresh\",\n    \"first_send\": true\n  }\n}\n```\n\n#### Example request\n\n```bash\ncurl -X GET 'https://api.cryptoinfra.build/v1/withdrawal-fee-quote?asset_id=string' \\\n  -H 'Authorization: Bearer ck_live_your_key'\n```\n\n## GET /v1/withdrawals\n\n**List withdrawals, or look one up by idempotency key**  ·  _Auth: bearer API key_  ·  `operationId: listWithdrawals`\n\nNewest-first, scoped to your merchant. The `idempotency_key` lookup is the\nresolution path for an INDETERMINATE create (timeout, dropped connection):\nit tells you whether the payout landed, so you never have to re-send one\nto find out. Returns 404 when that key has no withdrawal.\n\nEvery filter is additive; with none supplied the list is the one it has\nalways been. `total` is counted through the SAME filter as the rows, so\npage with `offset` until you have `total` rows. `from`/`to` accept either a\nbare IST calendar day (`YYYY-MM-DD`, both ends included) or a full RFC 3339\ninstant with an offset, and filter on when the payout was requested.\n\n#### Parameters\n\n| Name | In | Type | Required | Description |\n| --- | --- | --- | --- | --- |\n| `idempotency_key` | query | `string` | no | Exact-match lookup. `withdrawals` is unique on (tenant, key). |\n| `from` | query | `string` | no | Only withdrawals requested on/after this IST day (or instant). |\n| `to` | query | `string` | no | Only withdrawals requested on/before this IST day (or before this instant). |\n| `status` | query | `string` | no | Exact status match. _(one of `requested`, `queued`, `awaiting_approval`, `signing`, `broadcast`, `confirming`, `confirmed`, `failed`, `reversed`)_ |\n| `search` | query | `string` | no | Case-insensitive substring match on the destination address or the payer's `external_user_id`. |\n| `limit` | query | `integer` | no | — _(default `50`; min `1`; max `200`)_ |\n| `offset` | query | `integer` | no | — _(default `0`; min `0`)_ |\n| `sort` | query | `string` | no | — _(one of `id`, `created_at`, `amount`, `status`, `chain`, `fee`; default `\"id\"`)_ |\n| `dir` | query | `string` | no | — _(one of `asc`, `desc`; default `\"desc\"`)_ |\n\n#### Responses\n\n| Status | Body | Meaning |\n| --- | --- | --- |\n| `200` | `object` | Matching withdrawals, plus the total matching the same filter. |\n| `401` | `Error` | Missing, malformed or unknown API key. |\n| `404` | — | No withdrawal for that idempotency_key. |\n\n**`200` response body**\n\n| Field | Type | Description |\n| --- | --- | --- |\n| `total` **required** | `integer` | Withdrawals matching the filter, ignoring `limit`/`offset`. |\n| `limit` **required** | `integer` | — |\n| `offset` **required** | `integer` | — |\n| `withdrawals` **required** | `object[]` | — |\n| `withdrawals[].id` **required** | `integer` | — |\n| `withdrawals[].status` **required** | `string` | Lifecycle. `awaiting_approval` = parked by your own maker-checker threshold (set from the dashboard under Settings → Security): the hold is placed and nothing is signed until an owner or finance member approves it from the dashboard's Payouts page (→ `queued`); a rejection refunds the hold and arrives as `withdrawal.failed` (→ `failed`). `failed` = never broadcast (hold refunded); `reversed` = broadcast then definitively failed on-chain (hold refunded, operator reconciliation). _(one of `requested`, `queued`, `awaiting_approval`, `signing`, `broadcast`, `confirming`, `confirmed`, `failed`, `reversed`)_ |\n| `withdrawals[].chain` **required** | `string` | The chain an address or asset is on. TRON is the only network the platform serves: `btc`, `eth`, `bsc` and `sol` were retired — historical addresses on them are never handed out again and nothing scans them. Read the value as an open string rather than a closed list, so a chain added later does not break your client. _(one of `tron`)_ |\n| `withdrawals[].asset_id` **required** | `string` | — |\n| `withdrawals[].to_address` **required** | `string` | — |\n| `withdrawals[].amount` **required** | `string` | A monetary value as a decimal string. Always paired with a `*_base` field that carries the exact integer base-unit value; compute with the `_base` string. |\n| `withdrawals[].amount_base` **required** | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |\n| `withdrawals[].fee` **required** | `string` | The network fee, gas included, charged on top in the sent asset. |\n| `withdrawals[].fee_base` **required** | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |\n| `withdrawals[].fee_asset_id` **required** | `string` | The asset `fee` is charged in. Equals `asset_id` on every payout since 2026-09-05. A payout created before that date may say `trx`: an option, since retired, let the network fee be paid from the customer's TRX balance, and such a fee is reported in the coin it was actually paid in. |\n| `withdrawals[].platform_fee` **required** | `string` | The flat platform fee from your schedule (GET /v1/fees; the platform's own figure is public on GET /v1/pricing). `fee` is the live network cost of the send. Total held = amount + fee + platform_fee. |\n| `withdrawals[].platform_fee_base` **required** | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |\n| `withdrawals[].txid` | `string | null` | — |\n| `withdrawals[].confirmations` **required** | `integer` | — |\n| `withdrawals[].error` | `string | null` | — |\n| `withdrawals[].idempotency_key` **required** | `string` | — |\n| `withdrawals[].created_at` | `string<date-time>` | Present on GET. |\n| `withdrawals[].updated_at` | `string<date-time>` | Present on GET. |\n\n```json\n{\n  \"total\": 1,\n  \"limit\": 1,\n  \"offset\": 1,\n  \"withdrawals\": [\n    {\n      \"id\": 1,\n      \"status\": \"requested\",\n      \"chain\": \"tron\",\n      \"asset_id\": \"string\",\n      \"to_address\": \"string\",\n      \"amount\": \"1.5\",\n      \"amount_base\": \"1500000000\",\n      \"fee\": \"1.5\",\n      \"fee_base\": \"1500000000\",\n      \"fee_asset_id\": \"string\",\n      \"platform_fee\": \"1.5\",\n      \"platform_fee_base\": \"1500000000\",\n      \"txid\": \"string\",\n      \"confirmations\": 1,\n      \"error\": \"string\",\n      \"idempotency_key\": \"string\",\n      \"created_at\": \"2026-08-31T09:15:00.000Z\",\n      \"updated_at\": \"2026-08-31T09:15:00.000Z\"\n    }\n  ]\n}\n```\n\n#### Example request\n\n```bash\ncurl -X GET 'https://api.cryptoinfra.build/v1/withdrawals?from=2026-08-01&to=2026-08-31' \\\n  -H 'Authorization: Bearer ck_live_your_key'\n```\n\n## POST /v1/withdrawals\n\n**Request a payout**  ·  _Auth: bearer API key_  ·  `operationId: createWithdrawal`\n\nHolds `amount + fee + platform_fee` from the user's balance and queues the\npayout. `amount` is what the recipient receives. Idempotent on `idempotency_key`.\nPoll `GET /v1/withdrawals/{id}` or consume the `withdrawal.confirmed` /\n`withdrawal.failed` webhooks.\n\n**Approval thresholds.** If you have set an approval threshold for this asset\n(dashboard → Settings → Security; owner or finance login), an API-key send at or\nabove it answers `202` with `status: awaiting_approval`: the hold is placed, nothing\nis signed, and it is not queued until an owner or finance member approves it\nfrom the dashboard (→ `queued`). A rejection refunds the hold and arrives as\n`withdrawal.failed` with `error: \"rejected from the dashboard\"`.\n\n#### Request body\n\n`application/json` · required · schema `CreateWithdrawal`\n\n| Field | Type | Description |\n| --- | --- | --- |\n| `external_user_id` **required** | `string` |  _(minLength `1`; maxLength `255`)_ |\n| `asset_id` **required** | `string` | An id from GET /v1/assets. |\n| `to_address` **required** | `string` | Destination address (validated for the asset's chain). |\n| `amount` **required** | `string` | Decimal amount the recipient receives (fees are charged on top). _(pattern `^\\d+(\\.\\d+)?$`)_ |\n| `idempotency_key` **required** | `string` |  _(minLength `1`; maxLength `255`)_ |\n| `fee_mode` | `string` | Deprecated and ignored. Until 2026-09-05 this chose which balance paid the network fee (`auto` / `asset` / `trx`); every network fee is now charged in the sent asset. The field is still accepted, with any value, so an older client keeps working — it changes nothing. |\n\n#### Responses\n\n| Status | Body | Meaning |\n| --- | --- | --- |\n| `202` | `Withdrawal` | Payout accepted — `queued`, or `awaiting_approval` when your own threshold parked it for a human. |\n| `400` | `Error` | Refused before anything was held. `error` is one of: validation_error, unknown_asset, asset_disabled, invalid_amount, amount_below_minimum (a zero or negative amount — a payout has no other minimum), invalid_address, self_transfer (the recipient is the user's own deposit address — nothing would move but the network fee), invalid_request. |\n| `401` | `Error` | Missing, malformed or unknown API key. |\n| `404` | `Error` | Unknown external_user_id for this tenant. |\n| `409` | `object` | Two different conditions share this status, and they need different handling — branch on `error`, never on the code alone: - `insufficient_balance` — the user cannot cover amount + fee + platform_fee. Do not retry. - `idempotency_conflict` — this `idempotency_key` was already used with DIFFERENT parameters. Your replay is not the request you think it is. |\n| `503` | `Error` | No chain adapter configured for the asset's chain. |\n\n**`202` response body** — `Withdrawal`\n\n| Field | Type | Description |\n| --- | --- | --- |\n| `id` **required** | `integer` | — |\n| `status` **required** | `string` | Lifecycle. `awaiting_approval` = parked by your own maker-checker threshold (set from the dashboard under Settings → Security): the hold is placed and nothing is signed until an owner or finance member approves it from the dashboard's Payouts page (→ `queued`); a rejection refunds the hold and arrives as `withdrawal.failed` (→ `failed`). `failed` = never broadcast (hold refunded); `reversed` = broadcast then definitively failed on-chain (hold refunded, operator reconciliation). _(one of `requested`, `queued`, `awaiting_approval`, `signing`, `broadcast`, `confirming`, `confirmed`, `failed`, `reversed`)_ |\n| `chain` **required** | `string` | The chain an address or asset is on. TRON is the only network the platform serves: `btc`, `eth`, `bsc` and `sol` were retired — historical addresses on them are never handed out again and nothing scans them. Read the value as an open string rather than a closed list, so a chain added later does not break your client. _(one of `tron`)_ |\n| `asset_id` **required** | `string` | — |\n| `to_address` **required** | `string` | — |\n| `amount` **required** | `string` | A monetary value as a decimal string. Always paired with a `*_base` field that carries the exact integer base-unit value; compute with the `_base` string. |\n| `amount_base` **required** | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |\n| `fee` **required** | `string` | The network fee, gas included, charged on top in the sent asset. |\n| `fee_base` **required** | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |\n| `fee_asset_id` **required** | `string` | The asset `fee` is charged in. Equals `asset_id` on every payout since 2026-09-05. A payout created before that date may say `trx`: an option, since retired, let the network fee be paid from the customer's TRX balance, and such a fee is reported in the coin it was actually paid in. |\n| `platform_fee` **required** | `string` | The flat platform fee from your schedule (GET /v1/fees; the platform's own figure is public on GET /v1/pricing). `fee` is the live network cost of the send. Total held = amount + fee + platform_fee. |\n| `platform_fee_base` **required** | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |\n| `txid` | `string | null` | — |\n| `confirmations` **required** | `integer` | — |\n| `error` | `string | null` | — |\n| `idempotency_key` **required** | `string` | — |\n| `created_at` | `string<date-time>` | Present on GET. |\n| `updated_at` | `string<date-time>` | Present on GET. |\n\n```json\n{\n  \"id\": 1,\n  \"status\": \"requested\",\n  \"chain\": \"tron\",\n  \"asset_id\": \"string\",\n  \"to_address\": \"string\",\n  \"amount\": \"1.5\",\n  \"amount_base\": \"1500000000\",\n  \"fee\": \"1.5\",\n  \"fee_base\": \"1500000000\",\n  \"fee_asset_id\": \"string\",\n  \"platform_fee\": \"1.5\",\n  \"platform_fee_base\": \"1500000000\",\n  \"txid\": \"string\",\n  \"confirmations\": 1,\n  \"error\": \"string\",\n  \"idempotency_key\": \"string\",\n  \"created_at\": \"2026-08-31T09:15:00.000Z\",\n  \"updated_at\": \"2026-08-31T09:15:00.000Z\"\n}\n```\n\n#### Example request\n\n```bash\ncurl -X POST 'https://api.cryptoinfra.build/v1/withdrawals' \\\n  -H 'Authorization: Bearer ck_live_your_key' \\\n  -H 'Content-Type: application/json' \\\n  -d '{\n  \"external_user_id\": \"string\",\n  \"asset_id\": \"string\",\n  \"to_address\": \"string\",\n  \"amount\": \"25.5\",\n  \"idempotency_key\": \"string\"\n}'\n```\n\n## GET /v1/withdrawals/{id}\n\n**Get a withdrawal**  ·  _Auth: bearer API key_  ·  `operationId: getWithdrawal`\n\n#### Parameters\n\n| Name | In | Type | Required | Description |\n| --- | --- | --- | --- | --- |\n| `id` | path | `integer` | yes | — _(min `1`)_ |\n\n#### Responses\n\n| Status | Body | Meaning |\n| --- | --- | --- |\n| `200` | `Withdrawal` | The withdrawal (includes timestamps). |\n| `401` | `Error` | Missing, malformed or unknown API key. |\n| `404` | `Error` | Record not found (or belongs to another tenant). |\n\n**`200` response body** — `Withdrawal`\n\n| Field | Type | Description |\n| --- | --- | --- |\n| `id` **required** | `integer` | — |\n| `status` **required** | `string` | Lifecycle. `awaiting_approval` = parked by your own maker-checker threshold (set from the dashboard under Settings → Security): the hold is placed and nothing is signed until an owner or finance member approves it from the dashboard's Payouts page (→ `queued`); a rejection refunds the hold and arrives as `withdrawal.failed` (→ `failed`). `failed` = never broadcast (hold refunded); `reversed` = broadcast then definitively failed on-chain (hold refunded, operator reconciliation). _(one of `requested`, `queued`, `awaiting_approval`, `signing`, `broadcast`, `confirming`, `confirmed`, `failed`, `reversed`)_ |\n| `chain` **required** | `string` | The chain an address or asset is on. TRON is the only network the platform serves: `btc`, `eth`, `bsc` and `sol` were retired — historical addresses on them are never handed out again and nothing scans them. Read the value as an open string rather than a closed list, so a chain added later does not break your client. _(one of `tron`)_ |\n| `asset_id` **required** | `string` | — |\n| `to_address` **required** | `string` | — |\n| `amount` **required** | `string` | A monetary value as a decimal string. Always paired with a `*_base` field that carries the exact integer base-unit value; compute with the `_base` string. |\n| `amount_base` **required** | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |\n| `fee` **required** | `string` | The network fee, gas included, charged on top in the sent asset. |\n| `fee_base` **required** | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |\n| `fee_asset_id` **required** | `string` | The asset `fee` is charged in. Equals `asset_id` on every payout since 2026-09-05. A payout created before that date may say `trx`: an option, since retired, let the network fee be paid from the customer's TRX balance, and such a fee is reported in the coin it was actually paid in. |\n| `platform_fee` **required** | `string` | The flat platform fee from your schedule (GET /v1/fees; the platform's own figure is public on GET /v1/pricing). `fee` is the live network cost of the send. Total held = amount + fee + platform_fee. |\n| `platform_fee_base` **required** | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |\n| `txid` | `string | null` | — |\n| `confirmations` **required** | `integer` | — |\n| `error` | `string | null` | — |\n| `idempotency_key` **required** | `string` | — |\n| `created_at` | `string<date-time>` | Present on GET. |\n| `updated_at` | `string<date-time>` | Present on GET. |\n\n```json\n{\n  \"id\": 1,\n  \"status\": \"requested\",\n  \"chain\": \"tron\",\n  \"asset_id\": \"string\",\n  \"to_address\": \"string\",\n  \"amount\": \"1.5\",\n  \"amount_base\": \"1500000000\",\n  \"fee\": \"1.5\",\n  \"fee_base\": \"1500000000\",\n  \"fee_asset_id\": \"string\",\n  \"platform_fee\": \"1.5\",\n  \"platform_fee_base\": \"1500000000\",\n  \"txid\": \"string\",\n  \"confirmations\": 1,\n  \"error\": \"string\",\n  \"idempotency_key\": \"string\",\n  \"created_at\": \"2026-08-31T09:15:00.000Z\",\n  \"updated_at\": \"2026-08-31T09:15:00.000Z\"\n}\n```\n\n#### Example request\n\n```bash\ncurl -X GET 'https://api.cryptoinfra.build/v1/withdrawals/1' \\\n  -H 'Authorization: Bearer ck_live_your_key'\n```\n\n## POST /v1/withdrawals/batch\n\n**Batch payouts — up to 50 sends in one call**  ·  _Auth: bearer API key_\n\nEach item runs through the same path as a single withdrawal (fees and\napproval thresholds apply per item) and carries its own\nidempotency key, so re-POSTing a batch after a timeout re-creates\nnothing. Items are independent — one bad address never sinks the rest.\nAn infrastructure failure stops the march mid-batch; retry the whole\nbatch, the keys de-duplicate.\n\n#### Request body\n\n`application/json` · required\n\n| Field | Type | Description |\n| --- | --- | --- |\n| `items` **required** | `object[]` | — |\n| `items[].external_user_id` **required** | `string` |  _(minLength `1`; maxLength `255`)_ |\n| `items[].asset_id` **required** | `string` | An id from GET /v1/assets. |\n| `items[].to_address` **required** | `string` | Destination address (validated for the asset's chain). |\n| `items[].amount` **required** | `string` | Decimal amount the recipient receives (fees are charged on top). _(pattern `^\\d+(\\.\\d+)?$`)_ |\n| `items[].idempotency_key` **required** | `string` |  _(minLength `1`; maxLength `255`)_ |\n| `items[].fee_mode` | `string` | Deprecated and ignored. Until 2026-09-05 this chose which balance paid the network fee (`auto` / `asset` / `trx`); every network fee is now charged in the sent asset. The field is still accepted, with any value, so an older client keeps working — it changes nothing. |\n\n#### Responses\n\n| Status | Body | Meaning |\n| --- | --- | --- |\n| `202` | `object` | Per-item outcomes, in order. |\n\n**`202` response body**\n\n| Field | Type | Description |\n| --- | --- | --- |\n| `total` | `integer` | — |\n| `accepted` | `integer` | — |\n| `failed` | `integer` | — |\n| `results` | `object[]` | — |\n| `results[].idempotency_key` | `string` | — |\n| `results[].ok` | `boolean` | — |\n| `results[].withdrawal` | `object` | — |\n| `results[].withdrawal.id` **required** | `integer` | — |\n| `results[].withdrawal.status` **required** | `string` | Lifecycle. `awaiting_approval` = parked by your own maker-checker threshold (set from the dashboard under Settings → Security): the hold is placed and nothing is signed until an owner or finance member approves it from the dashboard's Payouts page (→ `queued`); a rejection refunds the hold and arrives as `withdrawal.failed` (→ `failed`). `failed` = never broadcast (hold refunded); `reversed` = broadcast then definitively failed on-chain (hold refunded, operator reconciliation). _(one of `requested`, `queued`, `awaiting_approval`, `signing`, `broadcast`, `confirming`, `confirmed`, `failed`, `reversed`)_ |\n| `results[].withdrawal.chain` **required** | `string` | The chain an address or asset is on. TRON is the only network the platform serves: `btc`, `eth`, `bsc` and `sol` were retired — historical addresses on them are never handed out again and nothing scans them. Read the value as an open string rather than a closed list, so a chain added later does not break your client. _(one of `tron`)_ |\n| `results[].withdrawal.asset_id` **required** | `string` | — |\n| `results[].withdrawal.to_address` **required** | `string` | — |\n| `results[].withdrawal.amount` **required** | `string` | A monetary value as a decimal string. Always paired with a `*_base` field that carries the exact integer base-unit value; compute with the `_base` string. |\n| `results[].withdrawal.amount_base` **required** | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |\n| `results[].withdrawal.fee` **required** | `string` | The network fee, gas included, charged on top in the sent asset. |\n| `results[].withdrawal.fee_base` **required** | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |\n| `results[].withdrawal.fee_asset_id` **required** | `string` | The asset `fee` is charged in. Equals `asset_id` on every payout since 2026-09-05. A payout created before that date may say `trx`: an option, since retired, let the network fee be paid from the customer's TRX balance, and such a fee is reported in the coin it was actually paid in. |\n| `results[].withdrawal.platform_fee` **required** | `string` | The flat platform fee from your schedule (GET /v1/fees; the platform's own figure is public on GET /v1/pricing). `fee` is the live network cost of the send. Total held = amount + fee + platform_fee. |\n| `results[].withdrawal.platform_fee_base` **required** | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |\n| `results[].withdrawal.txid` | `string | null` | — |\n| `results[].withdrawal.confirmations` **required** | `integer` | — |\n| `results[].withdrawal.error` | `string | null` | — |\n| `results[].withdrawal.idempotency_key` **required** | `string` | — |\n| `results[].withdrawal.created_at` | `string<date-time>` | Present on GET. |\n| `results[].withdrawal.updated_at` | `string<date-time>` | Present on GET. |\n| `results[].error` | `object` | — |\n| `results[].error.code` | `string` | — |\n| `results[].error.message` | `string` | — |\n\n```json\n{\n  \"total\": 1,\n  \"accepted\": 1,\n  \"failed\": 1,\n  \"results\": [\n    {\n      \"idempotency_key\": \"string\",\n      \"ok\": true,\n      \"withdrawal\": {\n        \"id\": 1,\n        \"status\": \"requested\",\n        \"chain\": \"tron\",\n        \"asset_id\": \"string\",\n        \"to_address\": \"string\",\n        \"amount\": \"1.5\",\n        \"amount_base\": \"1500000000\",\n        \"fee\": \"1.5\",\n        \"fee_base\": \"1500000000\",\n        \"fee_asset_id\": \"string\",\n        \"platform_fee\": \"1.5\",\n        \"platform_fee_base\": \"1500000000\",\n        \"txid\": \"string\",\n        \"confirmations\": 1,\n        \"error\": \"string\",\n        \"idempotency_key\": \"string\",\n        \"created_at\": \"2026-08-31T09:15:00.000Z\",\n        \"updated_at\": \"2026-08-31T09:15:00.000Z\"\n      },\n      \"error\": {\n        \"code\": \"string\",\n        \"message\": \"string\"\n      }\n    }\n  ]\n}\n```\n\n#### Example request\n\n```bash\ncurl -X POST 'https://api.cryptoinfra.build/v1/withdrawals/batch' \\\n  -H 'Authorization: Bearer ck_live_your_key' \\\n  -H 'Content-Type: application/json' \\\n  -d '{\n  \"items\": [\n    {\n      \"external_user_id\": \"string\",\n      \"asset_id\": \"string\",\n      \"to_address\": \"string\",\n      \"amount\": \"25.5\",\n      \"idempotency_key\": \"string\"\n    }\n  ]\n}'\n```\n",
      "sections": [
        {
          "anchor": "get-v1-withdrawal-fee-quote",
          "heading": "GET /v1/withdrawal-fee-quote",
          "text": "Preview a send's exact fees before confirming  ·  Auth: bearer API key  ·  operationId: withdrawalFeeQuote\n\nRead-only. Our live network cost plus a flat platform fee. network_fee\nis what this send costs us on-chain, priced now — the energy we rent for\nthe payout and for our fee's own transfer, the bandwidth, and (with\nuser_id) the one-time activation of a sender address that has never held\nTRX — converted at the live TRX price with a cushion and rounded up to the cent.\nplatform_fee is your account's flat schedule from GET /v1/fees (the\nplatform's own figure is public on GET /v1/pricing); total_fee is their sum and is what POST\n/v1/withdrawals holds on top of amount. Show the user total_fee;\nthe figure returned here is the one charged.\n\nPass to_address so the recipient is priced exactly (an address that\nholds none of the token costs about twice one that does — recipient_priced\nsays it was probed) and user_id so a first send's activation is\nincluded. network_cost says what the figure was built from.\n\ndynamic: false means the live TRX price could not be read and the\nasset's static fallback (static_fallback on GET /v1/pricing) was\ncharged instead. When only the energy market is unreadable the energy\npart is priced at a fixed fallback rate and the quote stays\ndynamic: true.\n\nParameters\n\nName · In · Type · Required · Description\nasset_id · query · string · yes · —\namount · query · string · no · Optional decimal amount; only affects percentage-based platform fees.\nto_address · query · string · no · Optional recipient. Validated for the asset's chain (400 invalid_address otherwise) and priced exactly: an address holding none of the token costs about twice one that does. Without it the worst case is quoted.\nuser_id · query · string · no · Optional sender (your user id). The send leaves this user's own address; if it has never held TRX its one-time activation (about 2.5 TRX) is part of the quote.\n\nResponses\n\nStatus · Body · Meaning\n200 · object · Itemized fee preview.\n401 · Error · Missing, malformed or unknown API key.\n\n200 response body\n\nField · Type · Description\nasset_id · string · —\nnetwork_fee · string · A monetary value as a decimal string. Always paired with a *_base field that carries the exact integer base-unit value; compute with the _base string.\nnetwork_fee_base · string · Exact integer amount in the asset's smallest unit. (pattern ^[0-9]+$)\nplatform_fee · string · A monetary value as a decimal string. Always paired with a *_base field that carries the exact integer base-unit value; compute with the _base string.\nplatform_fee_base · string · Exact integer amount in the asset's smallest unit. (pattern ^[0-9]+$)\nmerchant_fee · string · Your own fee on this send (Profit → Your fees); 0 unless you set one, and 0 for your own balance.\nmerchant_fee_base · string · Exact integer amount in the asset's smallest unit. (pattern ^[0-9]+$)\ntotal_fee · string · A monetary value as a decimal string. Always paired with a *_base field that carries the exact integer base-unit value; compute with the _base string.\ntotal_fee_base · string · Exact integer amount in the asset's smallest unit. (pattern ^[0-9]+$)\ndynamic · boolean · true when the network fee was priced live (the normal case); false means the static fallback was charged.\nquoted_at · string<date-time> · When this quote was priced. Every figure here is computed for this response, never stored.\nrecipient_priced · boolean · true when to_address was given and probed, so the figure is exact for that recipient.\nnetwork_cost · object · What the live network figure was built from; null on the static fallback.\nnetwork_cost.energy_trx required · string · TRX for the energy the transfers use (rented\nnetwork_cost.bandwidth_trx required · string · TRX for the bandwidth the transfers burn.\nnetwork_cost.activation_trx required · string · TRX to create the sender's account on its first send; \"0\" otherwise.\nnetwork_cost.trx_usd required · number · The TRX price the bill was converted at.\nnetwork_cost.transfers required · integer · On-chain transfers in the operation (a payout or load and its fee leg = 2).\nnetwork_cost.recipient required · string · Whether the recipient was priced as holding the token already. (one of fresh, existing, unknown)\nnetwork_cost.first_send required · boolean · Whether the sender's activation was included.\n\n{\n\"asset_id\": \"string\",\n\"network_fee\": \"1.5\",\n\"network_fee_base\": \"1500000000\",\n\"platform_fee\": \"1.5\",\n\"platform_fee_base\": \"1500000000\",\n\"merchant_fee\": \"1.5\",\n\"merchant_fee_base\": \"1500000000\",\n\"total_fee\": \"1.5\",\n\"total_fee_base\": \"1500000000\",\n\"dynamic\": true,\n\"quoted_at\": \"2026-08-31T09:15:00.000Z\",\n\"recipient_priced\": true,\n\"network_cost\": {\n\"energy_trx\": \"string\",\n\"bandwidth_trx\": \"string\",\n\"activation_trx\": \"string\",\n\"trx_usd\": 0,\n\"transfers\": 1,\n\"recipient\": \"fresh\",\n\"first_send\": true\n}\n}\n\nExample request\n\ncurl -X GET 'https://api.cryptoinfra.build/v1/withdrawal-fee-quote?asset_id=string' \\\n-H 'Authorization: Bearer ck_live_your_key'"
        },
        {
          "anchor": "get-v1-withdrawals",
          "heading": "GET /v1/withdrawals",
          "text": "List withdrawals, or look one up by idempotency key  ·  Auth: bearer API key  ·  operationId: listWithdrawals\n\nNewest-first, scoped to your merchant. The idempotency_key lookup is the\nresolution path for an INDETERMINATE create (timeout, dropped connection):\nit tells you whether the payout landed, so you never have to re-send one\nto find out. Returns 404 when that key has no withdrawal.\n\nEvery filter is additive; with none supplied the list is the one it has\nalways been. total is counted through the SAME filter as the rows, so\npage with offset until you have total rows. from/to accept either a\nbare IST calendar day (YYYY-MM-DD, both ends included) or a full RFC 3339\ninstant with an offset, and filter on when the payout was requested.\n\nParameters\n\nName · In · Type · Required · Description\nidempotency_key · query · string · no · Exact-match lookup. withdrawals is unique on (tenant, key).\nfrom · query · string · no · Only withdrawals requested on/after this IST day (or instant).\nto · query · string · no · Only withdrawals requested on/before this IST day (or before this instant).\nstatus · query · string · no · Exact status match. _(one of requested, queued, awaiting_approval, signing, broadcast, confirming, confirmed, failed, reversed)_\nsearch · query · string · no · Case-insensitive substring match on the destination address or the payer's external_user_id.\nlimit · query · integer · no · — (default 50; min 1; max 200)\noffset · query · integer · no · — (default 0; min 0)\nsort · query · string · no · — _(one of id, created_at, amount, status, chain, fee; default \"id\")_\ndir · query · string · no · — (one of asc, desc; default \"desc\")\n\nResponses\n\nStatus · Body · Meaning\n200 · object · Matching withdrawals, plus the total matching the same filter.\n401 · Error · Missing, malformed or unknown API key.\n404 · — · No withdrawal for that idempotency_key.\n\n200 response body\n\nField · Type · Description\ntotal required · integer · Withdrawals matching the filter, ignoring limit/offset.\nlimit required · integer · —\noffset required · integer · —\nwithdrawals required · object[] · —\nwithdrawals[].id required · integer · —\nwithdrawals[].status required · string · Lifecycle. awaiting_approval = parked by your own maker-checker threshold (set from the dashboard under Settings → Security): the hold is placed and nothing is signed until an owner or finance member approves it from the dashboard's Payouts page (→ queued); a rejection refunds the hold and arrives as withdrawal.failed (→ failed). failed = never broadcast (hold refunded); reversed = broadcast then definitively failed on-chain (hold refunded, operator reconciliation). _(one of requested, queued, awaiting_approval, signing, broadcast, confirming, confirmed, failed, reversed)_\nwithdrawals[].chain required · string · The chain an address or asset is on. TRON is the only network the platform serves: btc, eth, bsc and sol were retired — historical addresses on them are never handed out again and nothing scans them. Read the value as an open string rather than a closed list, so a chain added later does not break your client. (one of tron)\nwithdrawals[].asset_id required · string · —\nwithdrawals[].to_address required · string · —\nwithdrawals[].amount required · string · A monetary value as a decimal string. Always paired with a *_base field that carries the exact integer base-unit value; compute with the _base string.\nwithdrawals[].amount_base required · string · Exact integer amount in the asset's smallest unit. (pattern ^[0-9]+$)\nwithdrawals[].fee required · string · The network fee, gas included, charged on top in the sent asset.\nwithdrawals[].fee_base required · string · Exact integer amount in the asset's smallest unit. (pattern ^[0-9]+$)\nwithdrawals[].fee_asset_id required · string · The asset fee is charged in. Equals asset_id on every payout since 2026-09-05. A payout created before that date may say trx: an option, since retired, let the network fee be paid from the customer's TRX balance, and such a fee is reported in the coin it was actually paid in.\nwithdrawals[].platform_fee required · string · The flat platform fee from your schedule (GET /v1/fees; the platform's own figure is public on GET /v1/pricing). fee is the live network cost of the send. Total held = amount + fee + platform_fee.\nwithdrawals[].platform_fee_base required · string · Exact integer amount in the asset's smallest unit. (pattern ^[0-9]+$)\nwithdrawals[].txid · string · null · —\nwithdrawals[].confirmations required · integer · —\nwithdrawals[].error · string · null · —\nwithdrawals[].idempotency_key required · string · —\nwithdrawals[].created_at · string<date-time> · Present on GET.\nwithdrawals[].updated_at · string<date-time> · Present on GET.\n\n{\n\"total\": 1,\n\"limit\": 1,\n\"offset\": 1,\n\"withdrawals\": [\n{\n\"id\": 1,\n\"status\": \"requested\",\n\"chain\": \"tron\",\n\"asset_id\": \"string\",\n\"to_address\": \"string\",\n\"amount\": \"1.5\",\n\"amount_base\": \"1500000000\",\n\"fee\": \"1.5\",\n\"fee_base\": \"1500000000\",\n\"fee_asset_id\": \"string\",\n\"platform_fee\": \"1.5\",\n\"platform_fee_base\": \"1500000000\",\n\"txid\": \"string\",\n\"confirmations\": 1,\n\"error\": \"string\",\n\"idempotency_key\": \"string\",\n\"created_at\": \"2026-08-31T09:15:00.000Z\",\n\"updated_at\": \"2026-08-31T09:15:00.000Z\"\n}\n]\n}\n\nExample request\n\ncurl -X GET 'https://api.cryptoinfra.build/v1/withdrawals?from=2026-08-01&to=2026-08-31' \\\n-H 'Authorization: Bearer ck_live_your_key'"
        },
        {
          "anchor": "post-v1-withdrawals",
          "heading": "POST /v1/withdrawals",
          "text": "Request a payout  ·  Auth: bearer API key  ·  operationId: createWithdrawal\n\nHolds amount + fee + platform_fee from the user's balance and queues the\npayout. amount is what the recipient receives. Idempotent on idempotency_key.\nPoll GET /v1/withdrawals/{id} or consume the withdrawal.confirmed /\nwithdrawal.failed webhooks.\n\nApproval thresholds. If you have set an approval threshold for this asset\n(dashboard → Settings → Security; owner or finance login), an API-key send at or\nabove it answers 202 with status: awaiting_approval: the hold is placed, nothing\nis signed, and it is not queued until an owner or finance member approves it\nfrom the dashboard (→ queued). A rejection refunds the hold and arrives as\nwithdrawal.failed with error: \"rejected from the dashboard\".\n\nRequest body\n\napplication/json · required · schema CreateWithdrawal\n\nField · Type · Description\nexternal_user_id required · string · (minLength 1; maxLength 255)\nasset_id required · string · An id from GET /v1/assets.\nto_address required · string · Destination address (validated for the asset's chain).\namount required · string · Decimal amount the recipient receives (fees are charged on top). (pattern ^\\d+(\\.\\d+)?$)\nidempotency_key required · string · (minLength 1; maxLength 255)\nfee_mode · string · Deprecated and ignored. Until 2026-09-05 this chose which balance paid the network fee (auto / asset / trx); every network fee is now charged in the sent asset. The field is still accepted, with any value, so an older client keeps working — it changes nothing.\n\nResponses\n\nStatus · Body · Meaning\n202 · Withdrawal · Payout accepted — queued, or awaiting_approval when your own threshold parked it for a human.\n400 · Error · Refused before anything was held. error is one of: validation_error, unknown_asset, asset_disabled, invalid_amount, amount_below_minimum (a zero or negative amount — a payout has no other minimum), invalid_address, self_transfer (the recipient is the user's own deposit address — nothing would move but the network fee), invalid_request.\n401 · Error · Missing, malformed or unknown API key.\n404 · Error · Unknown external_user_id for this tenant.\n409 · object · Two different conditions share this status, and they need different handling — branch on error, never on the code alone: - insufficient_balance — the user cannot cover amount + fee + platform_fee. Do not retry. - idempotency_conflict — this idempotency_key was already used with DIFFERENT parameters. Your replay is not the request you think it is.\n503 · Error · No chain adapter configured for the asset's chain.\n\n202 response body — Withdrawal\n\nField · Type · Description\nid required · integer · —\nstatus required · string · Lifecycle. awaiting_approval = parked by your own maker-checker threshold (set from the dashboard under Settings → Security): the hold is placed and nothing is signed until an owner or finance member approves it from the dashboard's Payouts page (→ queued); a rejection refunds the hold and arrives as withdrawal.failed (→ failed). failed = never broadcast (hold refunded); reversed = broadcast then definitively failed on-chain (hold refunded, operator reconciliation). _(one of requested, queued, awaiting_approval, signing, broadcast, confirming, confirmed, failed, reversed)_\nchain required · string · The chain an address or asset is on. TRON is the only network the platform serves: btc, eth, bsc and sol were retired — historical addresses on them are never handed out again and nothing scans them. Read the value as an open string rather than a closed list, so a chain added later does not break your client. (one of tron)\nasset_id required · string · —\nto_address required · string · —\namount required · string · A monetary value as a decimal string. Always paired with a *_base field that carries the exact integer base-unit value; compute with the _base string.\namount_base required · string · Exact integer amount in the asset's smallest unit. (pattern ^[0-9]+$)\nfee required · string · The network fee, gas included, charged on top in the sent asset.\nfee_base required · string · Exact integer amount in the asset's smallest unit. (pattern ^[0-9]+$)\nfee_asset_id required · string · The asset fee is charged in. Equals asset_id on every payout since 2026-09-05. A payout created before that date may say trx: an option, since retired, let the network fee be paid from the customer's TRX balance, and such a fee is reported in the coin it was actually paid in.\nplatform_fee required · string · The flat platform fee from your schedule (GET /v1/fees; the platform's own figure is public on GET /v1/pricing). fee is the live network cost of the send. Total held = amount + fee + platform_fee.\nplatform_fee_base required · string · Exact integer amount in the asset's smallest unit. (pattern ^[0-9]+$)\ntxid · string · null · —\nconfirmations required · integer · —\nerror · string · null · —\nidempotency_key required · string · —\ncreated_at · string<date-time> · Present on GET.\nupdated_at · string<date-time> · Present on GET.\n\n{\n\"id\": 1,\n\"status\": \"requested\",\n\"chain\": \"tron\",\n\"asset_id\": \"string\",\n\"to_address\": \"string\",\n\"amount\": \"1.5\",\n\"amount_base\": \"1500000000\",\n\"fee\": \"1.5\",\n\"fee_base\": \"1500000000\",\n\"fee_asset_id\": \"string\",\n\"platform_fee\": \"1.5\",\n\"platform_fee_base\": \"1500000000\",\n\"txid\": \"string\",\n\"confirmations\": 1,\n\"error\": \"string\",\n\"idempotency_key\": \"string\",\n\"created_at\": \"2026-08-31T09:15:00.000Z\",\n\"updated_at\": \"2026-08-31T09:15:00.000Z\"\n}\n\nExample request\n\ncurl -X POST 'https://api.cryptoinfra.build/v1/withdrawals' \\\n-H 'Authorization: Bearer ck_live_your_key' \\\n-H 'Content-Type: application/json' \\\n-d '{\n\"external_user_id\": \"string\",\n\"asset_id\": \"string\",\n\"to_address\": \"string\",\n\"amount\": \"25.5\",\n\"idempotency_key\": \"string\"\n}'"
        },
        {
          "anchor": "get-v1-withdrawals-id",
          "heading": "GET /v1/withdrawals/{id}",
          "text": "Get a withdrawal  ·  Auth: bearer API key  ·  operationId: getWithdrawal\n\nParameters\n\nName · In · Type · Required · Description\nid · path · integer · yes · — (min 1)\n\nResponses\n\nStatus · Body · Meaning\n200 · Withdrawal · The withdrawal (includes timestamps).\n401 · Error · Missing, malformed or unknown API key.\n404 · Error · Record not found (or belongs to another tenant).\n\n200 response body — Withdrawal\n\nField · Type · Description\nid required · integer · —\nstatus required · string · Lifecycle. awaiting_approval = parked by your own maker-checker threshold (set from the dashboard under Settings → Security): the hold is placed and nothing is signed until an owner or finance member approves it from the dashboard's Payouts page (→ queued); a rejection refunds the hold and arrives as withdrawal.failed (→ failed). failed = never broadcast (hold refunded); reversed = broadcast then definitively failed on-chain (hold refunded, operator reconciliation). _(one of requested, queued, awaiting_approval, signing, broadcast, confirming, confirmed, failed, reversed)_\nchain required · string · The chain an address or asset is on. TRON is the only network the platform serves: btc, eth, bsc and sol were retired — historical addresses on them are never handed out again and nothing scans them. Read the value as an open string rather than a closed list, so a chain added later does not break your client. (one of tron)\nasset_id required · string · —\nto_address required · string · —\namount required · string · A monetary value as a decimal string. Always paired with a *_base field that carries the exact integer base-unit value; compute with the _base string.\namount_base required · string · Exact integer amount in the asset's smallest unit. (pattern ^[0-9]+$)\nfee required · string · The network fee, gas included, charged on top in the sent asset.\nfee_base required · string · Exact integer amount in the asset's smallest unit. (pattern ^[0-9]+$)\nfee_asset_id required · string · The asset fee is charged in. Equals asset_id on every payout since 2026-09-05. A payout created before that date may say trx: an option, since retired, let the network fee be paid from the customer's TRX balance, and such a fee is reported in the coin it was actually paid in.\nplatform_fee required · string · The flat platform fee from your schedule (GET /v1/fees; the platform's own figure is public on GET /v1/pricing). fee is the live network cost of the send. Total held = amount + fee + platform_fee.\nplatform_fee_base required · string · Exact integer amount in the asset's smallest unit. (pattern ^[0-9]+$)\ntxid · string · null · —\nconfirmations required · integer · —\nerror · string · null · —\nidempotency_key required · string · —\ncreated_at · string<date-time> · Present on GET.\nupdated_at · string<date-time> · Present on GET.\n\n{\n\"id\": 1,\n\"status\": \"requested\",\n\"chain\": \"tron\",\n\"asset_id\": \"string\",\n\"to_address\": \"string\",\n\"amount\": \"1.5\",\n\"amount_base\": \"1500000000\",\n\"fee\": \"1.5\",\n\"fee_base\": \"1500000000\",\n\"fee_asset_id\": \"string\",\n\"platform_fee\": \"1.5\",\n\"platform_fee_base\": \"1500000000\",\n\"txid\": \"string\",\n\"confirmations\": 1,\n\"error\": \"string\",\n\"idempotency_key\": \"string\",\n\"created_at\": \"2026-08-31T09:15:00.000Z\",\n\"updated_at\": \"2026-08-31T09:15:00.000Z\"\n}\n\nExample request\n\ncurl -X GET 'https://api.cryptoinfra.build/v1/withdrawals/1' \\\n-H 'Authorization: Bearer ck_live_your_key'"
        },
        {
          "anchor": "post-v1-withdrawals-batch",
          "heading": "POST /v1/withdrawals/batch",
          "text": "Batch payouts — up to 50 sends in one call  ·  Auth: bearer API key\n\nEach item runs through the same path as a single withdrawal (fees and\napproval thresholds apply per item) and carries its own\nidempotency key, so re-POSTing a batch after a timeout re-creates\nnothing. Items are independent — one bad address never sinks the rest.\nAn infrastructure failure stops the march mid-batch; retry the whole\nbatch, the keys de-duplicate.\n\nRequest body\n\napplication/json · required\n\nField · Type · Description\nitems required · object[] · —\nitems[].external_user_id required · string · (minLength 1; maxLength 255)\nitems[].asset_id required · string · An id from GET /v1/assets.\nitems[].to_address required · string · Destination address (validated for the asset's chain).\nitems[].amount required · string · Decimal amount the recipient receives (fees are charged on top). (pattern ^\\d+(\\.\\d+)?$)\nitems[].idempotency_key required · string · (minLength 1; maxLength 255)\nitems[].fee_mode · string · Deprecated and ignored. Until 2026-09-05 this chose which balance paid the network fee (auto / asset / trx); every network fee is now charged in the sent asset. The field is still accepted, with any value, so an older client keeps working — it changes nothing.\n\nResponses\n\nStatus · Body · Meaning\n202 · object · Per-item outcomes, in order.\n\n202 response body\n\nField · Type · Description\ntotal · integer · —\naccepted · integer · —\nfailed · integer · —\nresults · object[] · —\nresults[].idempotency_key · string · —\nresults[].ok · boolean · —\nresults[].withdrawal · object · —\nresults[].withdrawal.id required · integer · —\nresults[].withdrawal.status required · string · Lifecycle. awaiting_approval = parked by your own maker-checker threshold (set from the dashboard under Settings → Security): the hold is placed and nothing is signed until an owner or finance member approves it from the dashboard's Payouts page (→ queued); a rejection refunds the hold and arrives as withdrawal.failed (→ failed). failed = never broadcast (hold refunded); reversed = broadcast then definitively failed on-chain (hold refunded, operator reconciliation). _(one of requested, queued, awaiting_approval, signing, broadcast, confirming, confirmed, failed, reversed)_\nresults[].withdrawal.chain required · string · The chain an address or asset is on. TRON is the only network the platform serves: btc, eth, bsc and sol were retired — historical addresses on them are never handed out again and nothing scans them. Read the value as an open string rather than a closed list, so a chain added later does not break your client. (one of tron)\nresults[].withdrawal.asset_id required · string · —\nresults[].withdrawal.to_address required · string · —\nresults[].withdrawal.amount required · string · A monetary value as a decimal string. Always paired with a *_base field that carries the exact integer base-unit value; compute with the _base string.\nresults[].withdrawal.amount_base required · string · Exact integer amount in the asset's smallest unit. (pattern ^[0-9]+$)\nresults[].withdrawal.fee required · string · The network fee, gas included, charged on top in the sent asset.\nresults[].withdrawal.fee_base required · string · Exact integer amount in the asset's smallest unit. (pattern ^[0-9]+$)\nresults[].withdrawal.fee_asset_id required · string · The asset fee is charged in. Equals asset_id on every payout since 2026-09-05. A payout created before that date may say trx: an option, since retired, let the network fee be paid from the customer's TRX balance, and such a fee is reported in the coin it was actually paid in.\nresults[].withdrawal.platform_fee required · string · The flat platform fee from your schedule (GET /v1/fees; the platform's own figure is public on GET /v1/pricing). fee is the live network cost of the send. Total held = amount + fee + platform_fee.\nresults[].withdrawal.platform_fee_base required · string · Exact integer amount in the asset's smallest unit. (pattern ^[0-9]+$)\nresults[].withdrawal.txid · string · null · —\nresults[].withdrawal.confirmations required · integer · —\nresults[].withdrawal.error · string · null · —\nresults[].withdrawal.idempotency_key required · string · —\nresults[].withdrawal.created_at · string<date-time> · Present on GET.\nresults[].withdrawal.updated_at · string<date-time> · Present on GET.\nresults[].error · object · —\nresults[].error.code · string · —\nresults[].error.message · string · —\n\n{\n\"total\": 1,\n\"accepted\": 1,\n\"failed\": 1,\n\"results\": [\n{\n\"idempotency_key\": \"string\",\n\"ok\": true,\n\"withdrawal\": {\n\"id\": 1,\n\"status\": \"requested\",\n\"chain\": \"tron\",\n\"asset_id\": \"string\",\n\"to_address\": \"string\",\n\"amount\": \"1.5\",\n\"amount_base\": \"1500000000\",\n\"fee\": \"1.5\",\n\"fee_base\": \"1500000000\",\n\"fee_asset_id\": \"string\",\n\"platform_fee\": \"1.5\",\n\"platform_fee_base\": \"1500000000\",\n\"txid\": \"string\",\n\"confirmations\": 1,\n\"error\": \"string\",\n\"idempotency_key\": \"string\",\n\"created_at\": \"2026-08-31T09:15:00.000Z\",\n\"updated_at\": \"2026-08-31T09:15:00.000Z\"\n},\n\"error\": {\n\"code\": \"string\",\n\"message\": \"string\"\n}\n}\n]\n}\n\nExample request\n\ncurl -X POST 'https://api.cryptoinfra.build/v1/withdrawals/batch' \\\n-H 'Authorization: Bearer ck_live_your_key' \\\n-H 'Content-Type: application/json' \\\n-d '{\n\"items\": [\n{\n\"external_user_id\": \"string\",\n\"asset_id\": \"string\",\n\"to_address\": \"string\",\n\"amount\": \"25.5\",\n\"idempotency_key\": \"string\"\n}\n]\n}'"
        }
      ]
    },
    {
      "slug": "swaps",
      "title": "Swaps",
      "summary": "Trade between two coins you hold — the output is credited to the user's balance",
      "kind": "reference",
      "markdown": "# Swaps\n\nTrade between two coins you hold — the output is credited to the user's balance\n\n4 endpoints. Generated from `docs/openapi.yaml` v1.0.0.\n\n| Endpoint | Summary |\n| --- | --- |\n| [`POST /v1/swap/quote`](#post-v1-swap-quote) | Price a swap between two coins you hold |\n| [`POST /v1/swap/orders`](#post-v1-swap-orders) | Start a swap — sell one coin the user holds, credit them another |\n| [`GET /v1/swap/orders/{id}`](#get-v1-swap-orders-id) | Fetch one order |\n| [`GET /v1/swap/orders/{id}/detail`](#get-v1-swap-orders-id-detail) | Fetch one order in full — addresses, send leg, origin and timeline |\n\n## POST /v1/swap/quote\n\n**Price a swap between two coins you hold**  ·  _Auth: bearer API key_  ·  `operationId: quoteSwap`\n\nA SWAP trades between assets WE custody, named by OUR `asset_id` on both sides\n(GET /v1/assets) — unlike an exchange, which names its buy side by the\nexchange's own identifier. There is no destination address to supply and none\nto validate: the output lands on the user's own static wallet address and is credited\nto their balance.\n\nIndicative only. A quote confers no reservation and holds no balance, and the\nroute is re-quoted at create time — treat `expires_at` as the moment to ask\nagain, not as a price lock.\n\nOur fee is charged on the SELL side, taken OUT of `amount` rather than added on\ntop, and `send_amount` is what reaches the exchange — published so the numbers\nadd up rather than a fee appearing from nowhere between quote and settlement.\n\n`receive_estimate` is an estimate; `receive_minimum` is the floor this route\nguarantees. The user is credited what ACTUALLY ARRIVES, which is why both are\npublished: the pair is the promise.\n\nBoth sides are assets of ours, so unlike an exchange quote every amount here\ncarries its exact `*_base` twin.\n\nA quote is good for at most 60 seconds, or less when the route itself expires\nsooner — `expires_at` carries the earlier of the two.\n\n#### Request body\n\n`application/json` · required · schema `SwapQuoteRequest`\n\n| Field | Type | Description |\n| --- | --- | --- |\n| `sell_asset_id` **required** | `string` | One of OUR assets (GET /v1/assets) — what is sold out of the user's balance. _(minLength `1`; maxLength `64`)_ |\n| `buy_asset_id` **required** | `string` | One of OUR assets again — a swap trades between coins we custody, so this is an `asset_id`, NOT the exchange identifier an exchange takes. Must differ from `sell_asset_id`, and both must be enabled. _(minLength `1`; maxLength `64`)_ |\n| `amount` **required** | `string | number` | Decimal amount of `sell_asset_id`, fee INCLUSIVE: our fee comes out of it and `send_amount` is what reaches the exchange. |\n\n#### Responses\n\n| Status | Body | Meaning |\n| --- | --- | --- |\n| `200` | `SwapQuote` | An indicative price, with the moment it stops being valid. |\n| `400` | `Error` | The request was refused before anything was charged. `error` is one of: validation_error, invalid_amount, invalid_pair (both sides name the same asset, an asset is not enabled, an asset no exchange can route — `swappable: false` on GET /v1/assets, the message names it — the `user_id` is unknown, or the user has no address on the buy asset's chain), no_route (that pair has no liquidity path at this size — nothing is broken, try another amount or asset). |\n| `401` | `Error` | Missing, malformed or unknown API key. |\n| `403` | `Error` | Swaps and exchanges are turned off, or the exchange integration is not configured. |\n| `502` | `Error` | The exchange failed or was unreachable (your request was valid). |\n| `503` | `Error` | Our side is not ready for this chain. The request was fine — retry rather than change it. |\n\n**`200` response body** — `SwapQuote`\n\n| Field | Type | Description |\n| --- | --- | --- |\n| `sell_asset_id` **required** | `string` | — |\n| `buy_asset_id` **required** | `string` | — |\n| `sell_amount` **required** | `string` | A monetary value as a decimal string. Always paired with a `*_base` field that carries the exact integer base-unit value; compute with the `_base` string. |\n| `sell_amount_base` **required** | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |\n| `fee` **required** | `string` | Our fee, taken OUT of `sell_amount` — not charged on top. a percentage with a dollar minimum (see GET /v1/pricing), plus `network_fee`. |\n| `fee_base` **required** | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |\n| `network_fee` | `string` | The network part of `fee`, in the sold asset — a USDT ⇄ TRX trade's own gas on TRON, or an exchange's send. Already inside `fee`. |\n| `network_fee_base` | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |\n| `send_amount` **required** | `string` | `sell_amount` minus `fee` — what reaches the exchange, published so the numbers add up. |\n| `send_amount_base` **required** | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |\n| `receive_estimate` **required** | `string` | Estimated output, in the buy asset's major units. Indicative only. |\n| `receive_estimate_base` **required** | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |\n| `receive_minimum` **required** | `string` | The floor this route guarantees. The user is credited what ARRIVES, not this — treat it as the worst case you may quote them. |\n| `receive_minimum_base` **required** | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |\n| `providers` **required** | `string[]` | The route's legs, in order. |\n| `estimated_seconds` **required** | `integer | null` | — |\n| `expires_at` **required** | `string<date-time>` | ISO-8601. When the quote stops being valid — the moment to re-quote, not a price lock. Same shape as the exchange quote's. |\n\n```json\n{\n  \"sell_asset_id\": \"usdt_trc20\",\n  \"buy_asset_id\": \"trx\",\n  \"sell_amount\": \"1.5\",\n  \"sell_amount_base\": \"1500000000\",\n  \"fee\": \"1.5\",\n  \"fee_base\": \"1500000000\",\n  \"network_fee\": \"1.5\",\n  \"network_fee_base\": \"1500000000\",\n  \"send_amount\": \"1.5\",\n  \"send_amount_base\": \"1500000000\",\n  \"receive_estimate\": \"1.5\",\n  \"receive_estimate_base\": \"1500000000\",\n  \"receive_minimum\": \"1.5\",\n  \"receive_minimum_base\": \"1500000000\",\n  \"providers\": [\n    \"string\"\n  ],\n  \"estimated_seconds\": 1,\n  \"expires_at\": \"2026-08-31T04:21:00.000Z\"\n}\n```\n\n#### Example request\n\n```bash\ncurl -X POST 'https://api.cryptoinfra.build/v1/swap/quote' \\\n  -H 'Authorization: Bearer ck_live_your_key' \\\n  -H 'Content-Type: application/json' \\\n  -d '{\n  \"sell_asset_id\": \"usdt_trc20\",\n  \"buy_asset_id\": \"trx\",\n  \"amount\": \"100\"\n}'\n```\n\n## POST /v1/swap/orders\n\n**Start a swap — sell one coin the user holds, credit them another**  ·  _Auth: bearer API key_  ·  `operationId: createSwapOrder`\n\n`201`, and the user's balance is ALREADY debited: the sell leg is an ordinary\nwithdrawal, priced, signed and broadcast by the same code as any other payout.\nOur fee comes out of the amount, and the withdrawal fee is NOT charged on top.\n\nThe output is NOT credited yet. It is paid to the user's OWN static wallet address on\nthe buy asset's chain and credited by the ordinary deposit path once it\nconfirms — so poll GET /v1/swap/orders/{id} (or watch the user's balance)\nrather than reading `201` as completion. The user is credited what ARRIVES, not\n`receive_estimate`; `receive_minimum` is the floor the route guaranteed.\n\n`pending` until the sell leg is on-chain, then `sent`/`swapping` while the\nexchange works, and `completed` once the output is reported.\n\nThe route is re-quoted at execution time rather than carried over from\nPOST /v1/swap/quote, so an order's numbers are the ones actually executed and\nmay differ slightly from the quote you showed. Quote first anyway: it is the\ncheap way to find out that a pair has no route at this size.\n\nIdempotent on `idempotency_key` — replaying a key returns the ORIGINAL order\ninstead of starting a second swap; the same key with a DIFFERENT payload is\nrefused with `409 idempotency_conflict`. Always retry a failed or timed-out\ncreate with the SAME key.\n\n#### Request body\n\n`application/json` · required · schema `SwapOrderRequest`\n\n| Field | Type | Description |\n| --- | --- | --- |\n| `sell_asset_id` **required** | `string` | One of OUR assets (GET /v1/assets) — what is sold out of the user's balance. _(minLength `1`; maxLength `64`)_ |\n| `buy_asset_id` **required** | `string` | One of OUR assets again — a swap trades between coins we custody, so this is an `asset_id`, NOT the exchange identifier an exchange takes. Must differ from `sell_asset_id`, and both must be enabled. _(minLength `1`; maxLength `64`)_ |\n| `amount` **required** | `string | number` | Decimal amount of `sell_asset_id`, fee INCLUSIVE: our fee comes out of it and `send_amount` is what reaches the exchange. |\n| `user_id` **required** | `string` | Your `external_user_id` — whose balance is sold, and whose deposit address on the buy asset's chain receives the output. The user must already have an address on that chain. _(minLength `1`)_ |\n| `idempotency_key` | `string` | Replaying a key returns the ORIGINAL order instead of starting a second swap; the same key with a different payload is 409 idempotency_conflict. Use a fresh key per logical swap; always retry with the same one. _(minLength `1`; maxLength `255`)_ |\n\n#### Responses\n\n| Status | Body | Meaning |\n| --- | --- | --- |\n| `201` | `SwapOrder` | Swap started; the sell leg is queued as a withdrawal. |\n| `400` | `Error` | The request was refused before anything was charged. `error` is one of: validation_error, invalid_amount, invalid_pair (both sides name the same asset, an asset is not enabled, an asset no exchange can route — `swappable: false` on GET /v1/assets, the message names it — the `user_id` is unknown, or the user has no address on the buy asset's chain), no_route (that pair has no liquidity path at this size — nothing is broken, try another amount or asset). |\n| `401` | `Error` | Missing, malformed or unknown API key. |\n| `403` | `Error` | Swaps and exchanges are turned off, or the exchange integration is not configured. |\n| `404` | `Error` | Unknown external_user_id for this tenant. |\n| `409` | `Error` | `error` is one of: insufficient_balance (the sell amount would overdraw the user's balance), idempotency_conflict (this `idempotency_key` was already used with a DIFFERENT payload — your replay is not the request you think it is). |\n| `502` | `Error` | The exchange failed or was unreachable (your request was valid). |\n| `503` | `Error` | Our side is not ready for this chain. The request was fine — retry rather than change it. |\n\n**`201` response body** — `SwapOrder`\n\n| Field | Type | Description |\n| --- | --- | --- |\n| `order_id` **required** | `string<uuid>` | — |\n| `user_id` | `string` | Your `external_user_id`. Present on create; absent when listing or fetching. |\n| `kind` **required** | `string` | `swap` buys one of OUR assets: the output lands on the user's own deposit address and is credited to their balance. `exchange` buys a coin we do not custody: the output is paid to `destination_address` and never enters our system. _(one of `swap`, `exchange`)_ |\n| `sell_asset_id` **required** | `string` | — |\n| `buy_asset_id` **required** | `string | null` | One of our assets on a swap; null on an exchange (we have no asset row for it). |\n| `buy_asset_external` **required** | `string | null` | The exchange's identifier on an exchange (e.g. `SOL.SOL`); null on a swap. |\n| `buy_symbol` **required** | `string | null` | Display ticker of the bought coin on an exchange, so a listing needs no catalogue lookup. Null on a swap. |\n| `buy_chain` **required** | `string | null` | Chain of the bought coin on an exchange, as the exchange names it. Null on a swap. |\n| `destination_address` | `string` | Where the bought coin was paid. Present on EXCHANGE rows ONLY — echoed back so you can confirm it — and absent on a swap, whose output goes to the user's own static wallet address. This payout is irreversible and outside our custody. |\n| `sell_amount` **required** | `string` | A monetary value as a decimal string. Always paired with a `*_base` field that carries the exact integer base-unit value; compute with the `_base` string. |\n| `sell_amount_base` **required** | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |\n| `fee` **required** | `string` | Our fee, taken out of `sell_amount` — a percentage with a dollar minimum (see GET /v1/pricing), plus `network_fee`. |\n| `fee_base` **required** | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |\n| `network_fee` | `string` | The network part of `fee`, in the sold asset — a USDT ⇄ TRX trade's own gas on TRON, or an exchange's send. Already inside `fee`. |\n| `network_fee_base` | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |\n| `receive_estimate` | `string | null` | What the route estimated. A swap's is in the buy asset's major units; an exchange's is the exchange's decimal string. |\n| `receive_estimate_base` | `string | null` | Exact base units on a SWAP. Always null on an exchange. |\n| `receive_minimum` | `string | null` | The floor the route guaranteed. The user is credited what arrives, not this. |\n| `receive_minimum_base` | `string | null` | Exact base units on a SWAP. Always null on an exchange. |\n| `received` | `string | null` | What ACTUALLY arrived. Null until the exchange settles. |\n| `received_base` | `string | null` | Exact base units on a SWAP. Always null on an exchange. |\n| `providers` **required** | `string[]` | — |\n| `status` **required** | `string` | `pending` until the sell leg is on-chain, then `sent` and `swapping` while the exchange works; `completed` when it reports the payout. _(one of `pending`, `sent`, `swapping`, `completed`, `refunded`, `failed`)_ |\n| `tracking_status` | `string | null` | The exchange's own last word on the route, verbatim. Informational. |\n| `txid` | `string | null` | The on-chain SELL payment that started the order — the handle to show a customer, and the one the exchange's tracker keys on. Null until it is sent. |\n| `created_at` **required** | `string<date-time>` | — |\n| `updated_at` **required** | `string<date-time>` | — |\n\n```json\n{\n  \"order_id\": \"string\",\n  \"user_id\": \"string\",\n  \"kind\": \"swap\",\n  \"sell_asset_id\": \"string\",\n  \"buy_asset_id\": \"string\",\n  \"buy_asset_external\": \"string\",\n  \"buy_symbol\": \"string\",\n  \"buy_chain\": \"string\",\n  \"destination_address\": \"string\",\n  \"sell_amount\": \"1.5\",\n  \"sell_amount_base\": \"1500000000\",\n  \"fee\": \"1.5\",\n  \"fee_base\": \"1500000000\",\n  \"network_fee\": \"1.5\",\n  \"network_fee_base\": \"1500000000\",\n  \"receive_estimate\": \"string\",\n  \"receive_estimate_base\": \"string\",\n  \"receive_minimum\": \"string\",\n  \"receive_minimum_base\": \"string\",\n  \"received\": \"string\",\n  \"received_base\": \"string\",\n  \"providers\": [\n    \"string\"\n  ],\n  \"status\": \"pending\",\n  \"tracking_status\": \"string\",\n  \"txid\": \"string\",\n  \"created_at\": \"2026-08-31T09:15:00.000Z\",\n  \"updated_at\": \"2026-08-31T09:15:00.000Z\"\n}\n```\n\n#### Example request\n\n```bash\ncurl -X POST 'https://api.cryptoinfra.build/v1/swap/orders' \\\n  -H 'Authorization: Bearer ck_live_your_key' \\\n  -H 'Content-Type: application/json' \\\n  -d '{\n  \"sell_asset_id\": \"usdt_trc20\",\n  \"buy_asset_id\": \"trx\",\n  \"amount\": \"100\",\n  \"user_id\": \"string\"\n}'\n```\n\n## GET /v1/swap/orders/{id}\n\n**Fetch one order**  ·  _Auth: bearer API key_  ·  `operationId: getSwapOrder`\n\nThe authoritative read, for either kind — another merchant's order is simply\n`404`. Poll it while `status` is `pending`, `sent` or `swapping`.\n\nOn a SWAP, `received` is what actually arrived and was credited to the user's\nbalance; it stays null until the output confirms, so a `completed` status with\na populated `received` is the signal to tell your customer it is done.\n\n`user_id` is not echoed here — it is returned on create, and by\nGET /v1/swap/orders/{id}/detail.\n\n#### Parameters\n\n| Name | In | Type | Required | Description |\n| --- | --- | --- | --- | --- |\n| `id` | path | `string<uuid>` | yes | The `order_id` from create or the list. |\n\n#### Responses\n\n| Status | Body | Meaning |\n| --- | --- | --- |\n| `200` | `SwapOrder` | The order. |\n| `401` | `Error` | Missing, malformed or unknown API key. |\n| `404` | `Error` | Unknown order, or one belonging to another merchant — the two are indistinguishable by design. |\n\n**`200` response body** — `SwapOrder`\n\n| Field | Type | Description |\n| --- | --- | --- |\n| `order_id` **required** | `string<uuid>` | — |\n| `user_id` | `string` | Your `external_user_id`. Present on create; absent when listing or fetching. |\n| `kind` **required** | `string` | `swap` buys one of OUR assets: the output lands on the user's own deposit address and is credited to their balance. `exchange` buys a coin we do not custody: the output is paid to `destination_address` and never enters our system. _(one of `swap`, `exchange`)_ |\n| `sell_asset_id` **required** | `string` | — |\n| `buy_asset_id` **required** | `string | null` | One of our assets on a swap; null on an exchange (we have no asset row for it). |\n| `buy_asset_external` **required** | `string | null` | The exchange's identifier on an exchange (e.g. `SOL.SOL`); null on a swap. |\n| `buy_symbol` **required** | `string | null` | Display ticker of the bought coin on an exchange, so a listing needs no catalogue lookup. Null on a swap. |\n| `buy_chain` **required** | `string | null` | Chain of the bought coin on an exchange, as the exchange names it. Null on a swap. |\n| `destination_address` | `string` | Where the bought coin was paid. Present on EXCHANGE rows ONLY — echoed back so you can confirm it — and absent on a swap, whose output goes to the user's own static wallet address. This payout is irreversible and outside our custody. |\n| `sell_amount` **required** | `string` | A monetary value as a decimal string. Always paired with a `*_base` field that carries the exact integer base-unit value; compute with the `_base` string. |\n| `sell_amount_base` **required** | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |\n| `fee` **required** | `string` | Our fee, taken out of `sell_amount` — a percentage with a dollar minimum (see GET /v1/pricing), plus `network_fee`. |\n| `fee_base` **required** | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |\n| `network_fee` | `string` | The network part of `fee`, in the sold asset — a USDT ⇄ TRX trade's own gas on TRON, or an exchange's send. Already inside `fee`. |\n| `network_fee_base` | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |\n| `receive_estimate` | `string | null` | What the route estimated. A swap's is in the buy asset's major units; an exchange's is the exchange's decimal string. |\n| `receive_estimate_base` | `string | null` | Exact base units on a SWAP. Always null on an exchange. |\n| `receive_minimum` | `string | null` | The floor the route guaranteed. The user is credited what arrives, not this. |\n| `receive_minimum_base` | `string | null` | Exact base units on a SWAP. Always null on an exchange. |\n| `received` | `string | null` | What ACTUALLY arrived. Null until the exchange settles. |\n| `received_base` | `string | null` | Exact base units on a SWAP. Always null on an exchange. |\n| `providers` **required** | `string[]` | — |\n| `status` **required** | `string` | `pending` until the sell leg is on-chain, then `sent` and `swapping` while the exchange works; `completed` when it reports the payout. _(one of `pending`, `sent`, `swapping`, `completed`, `refunded`, `failed`)_ |\n| `tracking_status` | `string | null` | The exchange's own last word on the route, verbatim. Informational. |\n| `txid` | `string | null` | The on-chain SELL payment that started the order — the handle to show a customer, and the one the exchange's tracker keys on. Null until it is sent. |\n| `created_at` **required** | `string<date-time>` | — |\n| `updated_at` **required** | `string<date-time>` | — |\n\n```json\n{\n  \"order_id\": \"string\",\n  \"user_id\": \"string\",\n  \"kind\": \"swap\",\n  \"sell_asset_id\": \"string\",\n  \"buy_asset_id\": \"string\",\n  \"buy_asset_external\": \"string\",\n  \"buy_symbol\": \"string\",\n  \"buy_chain\": \"string\",\n  \"destination_address\": \"string\",\n  \"sell_amount\": \"1.5\",\n  \"sell_amount_base\": \"1500000000\",\n  \"fee\": \"1.5\",\n  \"fee_base\": \"1500000000\",\n  \"network_fee\": \"1.5\",\n  \"network_fee_base\": \"1500000000\",\n  \"receive_estimate\": \"string\",\n  \"receive_estimate_base\": \"string\",\n  \"receive_minimum\": \"string\",\n  \"receive_minimum_base\": \"string\",\n  \"received\": \"string\",\n  \"received_base\": \"string\",\n  \"providers\": [\n    \"string\"\n  ],\n  \"status\": \"pending\",\n  \"tracking_status\": \"string\",\n  \"txid\": \"string\",\n  \"created_at\": \"2026-08-31T09:15:00.000Z\",\n  \"updated_at\": \"2026-08-31T09:15:00.000Z\"\n}\n```\n\n#### Example request\n\n```bash\ncurl -X GET 'https://api.cryptoinfra.build/v1/swap/orders/string' \\\n  -H 'Authorization: Bearer ck_live_your_key'\n```\n\n## GET /v1/swap/orders/{id}/detail\n\n**Fetch one order in full — addresses, send leg, origin and timeline**  ·  _Auth: bearer API key_  ·  `operationId: getSwapOrderDetail`\n\nEverything GET /v1/swap/orders/{id} returns, plus every hop: the exchange's\none-time deposit address, the address the output went to, the on-chain send\nwith its confirmations, who asked for the order and from where, and the story\nin order. Enough to answer \"where is my money\" from a block explorer without\nopening a ticket.\n\nDeliberately narrower than the operator's view of the same order. You see YOUR\nfacts — your team member, your IP, your addresses, your money — not what the\nsend cost us in gas or the ledger account codes behind it.\n\n`received_deposit` is LABELLED, never asserted: nothing on-chain ties a swap to\nthe deposit that credited it, so this is our best match by asset, amount and\ntime. `confidence` says how far to trust it (`ambiguous` means a second deposit\ncould equally be this swap's output) and a page should present it as likely,\nnot as fact. It is always null on an exchange, whose output never reaches us.\n\n#### Parameters\n\n| Name | In | Type | Required | Description |\n| --- | --- | --- | --- | --- |\n| `id` | path | `string<uuid>` | yes | The `order_id` from create or the list. |\n\n#### Responses\n\n| Status | Body | Meaning |\n| --- | --- | --- |\n| `200` | `SwapOrderDetail` | The order in full. |\n| `401` | `Error` | Missing, malformed or unknown API key. |\n| `404` | `Error` | Unknown order, or one belonging to another merchant — the two are indistinguishable by design. |\n\n**`200` response body** — `SwapOrderDetail`\n\n| Field | Type | Description |\n| --- | --- | --- |\n| `order_id` **required** | `string<uuid>` | — |\n| `user_id` **required** | `string` | Your `external_user_id`. Always present here, unlike the summary shape. |\n| `kind` **required** | `string` | `swap` buys one of OUR assets: the output lands on the user's own deposit address and is credited to their balance. `exchange` buys a coin we do not custody: the output is paid to `destination_address` and never enters our system. _(one of `swap`, `exchange`)_ |\n| `sell_asset_id` **required** | `string` | — |\n| `buy_asset_id` **required** | `string | null` | One of our assets on a swap; null on an exchange (we have no asset row for it). |\n| `buy_asset_external` **required** | `string | null` | The exchange's identifier on an exchange (e.g. `SOL.SOL`); null on a swap. |\n| `buy_symbol` **required** | `string | null` | Display ticker of the bought coin on an exchange, so a listing needs no catalogue lookup. Null on a swap. |\n| `buy_chain` **required** | `string | null` | Chain of the bought coin on an exchange, as the exchange names it. Null on a swap. |\n| `destination_address` **required** | `string` | Where the output was sent. Always present here for BOTH kinds: on a swap it is the user's own static wallet address on the buy asset's chain (the summary shape omits it there), and on an exchange it is the address you supplied. |\n| `sell_amount` **required** | `string` | A monetary value as a decimal string. Always paired with a `*_base` field that carries the exact integer base-unit value; compute with the `_base` string. |\n| `sell_amount_base` **required** | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |\n| `fee` **required** | `string` | Our fee, taken out of `sell_amount` — a percentage with a dollar minimum (see GET /v1/pricing), plus `network_fee`. |\n| `fee_base` **required** | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |\n| `network_fee` | `string` | The network part of `fee`, in the sold asset — a USDT ⇄ TRX trade's own gas on TRON, or an exchange's send. Already inside `fee`. |\n| `network_fee_base` | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |\n| `receive_estimate` | `string | null` | What the route estimated. A swap's is in the buy asset's major units; an exchange's is the exchange's decimal string. |\n| `receive_estimate_base` | `string | null` | Exact base units on a SWAP. Always null on an exchange. |\n| `receive_minimum` | `string | null` | The floor the route guaranteed. The user is credited what arrives, not this. |\n| `receive_minimum_base` | `string | null` | Exact base units on a SWAP. Always null on an exchange. |\n| `received` | `string | null` | What ACTUALLY arrived. Null until the exchange settles. |\n| `received_base` | `string | null` | Exact base units on a SWAP. Always null on an exchange. |\n| `providers` **required** | `string[]` | — |\n| `status` **required** | `string` | `pending` until the sell leg is on-chain, then `sent` and `swapping` while the exchange works; `completed` when it reports the payout. _(one of `pending`, `sent`, `swapping`, `completed`, `refunded`, `failed`)_ |\n| `tracking_status` | `string | null` | The exchange's own last word on the route, verbatim. Informational. |\n| `txid` | `string | null` | The on-chain SELL payment that started the order — the handle to show a customer, and the one the exchange's tracker keys on. Null until it is sent. |\n| `created_at` **required** | `string<date-time>` | — |\n| `updated_at` **required** | `string<date-time>` | — |\n| `send_amount` **required** | `string` | `sell_amount` minus `fee` — what was actually paid to the exchange. |\n| `send_amount_base` **required** | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |\n| `exchange_address` **required** | `string` | The exchange's one-time deposit address for THIS order — where the sell payment went. The first hop to follow on a block explorer. |\n| `swap_id` **required** | `string | null` | The exchange's own id for the route, for a support conversation with them. |\n| `sent_at` **required** | `string<date-time> | null` | When the sell payment reached the chain. Null before that. |\n| `settled_at` **required** | `string<date-time> | null` | When the exchange reported a terminal state. Null while still moving. |\n| `error` **required** | `string | null` | Why the order failed, when it did. |\n| `send` **required** | `object | null` | The withdrawal that paid the exchange. Null until it exists. |\n| `send.status` **required** | `string` | — |\n| `send.txid` **required** | `string | null` | — |\n| `send.confirmations` **required** | `integer` | — |\n| `send.block_height` **required** | `integer | null` | — |\n| `send.error` **required** | `string | null` | — |\n| `origin` **required** | `object | null` | Who asked for this order and from where. Null when nothing was recorded. |\n| `origin.actor_email` **required** | `string | null` | The dashboard login that asked. Null when an API key did — there is no person. |\n| `origin.ip` **required** | `string | null` | — |\n| `origin.ip_geo` **required** | `object | null` | Geolocation of `ip`, or null when lookup is off, failed, or the address is private. |\n| `origin.user_agent` **required** | `string | null` | — |\n| `origin.via` **required** | `string | null` |  _(one of `dashboard_session`, `api_key`, `null`)_ |\n| `origin.at` **required** | `string<date-time>` | — |\n| `received_deposit` **required** | `object | null` | Our best MATCH for the deposit that credited this swap's output — a label, not an assertion, because nothing on-chain ties the two together. Null on an exchange (its output never reaches us) and until a candidate appears. |\n| `received_deposit.amount` **required** | `string` | A monetary value as a decimal string. Always paired with a `*_base` field that carries the exact integer base-unit value; compute with the `_base` string. |\n| `received_deposit.amount_base` **required** | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |\n| `received_deposit.txid` **required** | `string` | — |\n| `received_deposit.status` **required** | `string` | — |\n| `received_deposit.credited_at` **required** | `string<date-time> | null` | — |\n| `received_deposit.confidence` **required** | `string` | `unconfirmed` is a single plausible match; `ambiguous` means more than one deposit could be this output — show the warning. _(one of `unconfirmed`, `ambiguous`)_ |\n| `timeline` **required** | `object[]` | What happened, oldest first — e.g. `Swap requested`, `Fee charged`, `Balance held`, `Payment signed`, `Payment on chain`, `Payment confirmed`, `Output seen on chain`, `Credited to balance`. Human-readable and additive; new steps may appear, so render the list rather than switching on it. |\n| `timeline[].at` **required** | `string<date-time>` | — |\n| `timeline[].event` **required** | `string` | — |\n| `timeline[].detail` **required** | `string | null` | The step's one supporting fact (often a txid). Null when there is none. |\n\n```json\n{\n  \"order_id\": \"string\",\n  \"user_id\": \"string\",\n  \"kind\": \"swap\",\n  \"sell_asset_id\": \"string\",\n  \"buy_asset_id\": \"string\",\n  \"buy_asset_external\": \"string\",\n  \"buy_symbol\": \"string\",\n  \"buy_chain\": \"string\",\n  \"destination_address\": \"string\",\n  \"sell_amount\": \"1.5\",\n  \"sell_amount_base\": \"1500000000\",\n  \"fee\": \"1.5\",\n  \"fee_base\": \"1500000000\",\n  \"network_fee\": \"1.5\",\n  \"network_fee_base\": \"1500000000\",\n  \"receive_estimate\": \"string\",\n  \"receive_estimate_base\": \"string\",\n  \"receive_minimum\": \"string\",\n  \"receive_minimum_base\": \"string\",\n  \"received\": \"string\",\n  \"received_base\": \"string\",\n  \"providers\": [\n    \"string\"\n  ],\n  \"status\": \"pending\",\n  \"tracking_status\": \"string\",\n  \"txid\": \"string\",\n  \"created_at\": \"2026-08-31T09:15:00.000Z\",\n  \"updated_at\": \"2026-08-31T09:15:00.000Z\",\n  \"send_amount\": \"1.5\",\n  \"send_amount_base\": \"1500000000\",\n  \"exchange_address\": \"string\",\n  \"swap_id\": \"string\",\n  \"sent_at\": \"2026-08-31T09:15:00.000Z\",\n  \"settled_at\": \"2026-08-31T09:15:00.000Z\",\n  \"error\": \"string\",\n  \"send\": {\n    \"status\": \"string\",\n    \"txid\": \"string\",\n    \"confirmations\": 1,\n    \"block_height\": 1,\n    \"error\": \"string\"\n  },\n  \"origin\": {\n    \"actor_email\": \"string\",\n    \"ip\": \"string\",\n    \"ip_geo\": {},\n    \"user_agent\": \"string\",\n    \"via\": \"dashboard_session\",\n    \"at\": \"2026-08-31T09:15:00.000Z\"\n  },\n  \"received_deposit\": {\n    \"amount\": \"1.5\",\n    \"amount_base\": \"1500000000\",\n    \"txid\": \"string\",\n    \"status\": \"string\",\n    \"credited_at\": \"2026-08-31T09:15:00.000Z\",\n    \"confidence\": \"unconfirmed\"\n  },\n  \"timeline\": [\n    {\n      \"at\": \"2026-08-31T09:15:00.000Z\",\n      \"event\": \"Payment on chain\",\n      \"detail\": \"string\"\n    }\n  ]\n}\n```\n\n#### Example request\n\n```bash\ncurl -X GET 'https://api.cryptoinfra.build/v1/swap/orders/string/detail' \\\n  -H 'Authorization: Bearer ck_live_your_key'\n```\n",
      "sections": [
        {
          "anchor": "post-v1-swap-quote",
          "heading": "POST /v1/swap/quote",
          "text": "Price a swap between two coins you hold  ·  Auth: bearer API key  ·  operationId: quoteSwap\n\nA SWAP trades between assets WE custody, named by OUR asset_id on both sides\n(GET /v1/assets) — unlike an exchange, which names its buy side by the\nexchange's own identifier. There is no destination address to supply and none\nto validate: the output lands on the user's own static wallet address and is credited\nto their balance.\n\nIndicative only. A quote confers no reservation and holds no balance, and the\nroute is re-quoted at create time — treat expires_at as the moment to ask\nagain, not as a price lock.\n\nOur fee is charged on the SELL side, taken OUT of amount rather than added on\ntop, and send_amount is what reaches the exchange — published so the numbers\nadd up rather than a fee appearing from nowhere between quote and settlement.\n\nreceive_estimate is an estimate; receive_minimum is the floor this route\nguarantees. The user is credited what ACTUALLY ARRIVES, which is why both are\npublished: the pair is the promise.\n\nBoth sides are assets of ours, so unlike an exchange quote every amount here\ncarries its exact *_base twin.\n\nA quote is good for at most 60 seconds, or less when the route itself expires\nsooner — expires_at carries the earlier of the two.\n\nRequest body\n\napplication/json · required · schema SwapQuoteRequest\n\nField · Type · Description\nsell_asset_id required · string · One of OUR assets (GET /v1/assets) — what is sold out of the user's balance. (minLength 1; maxLength 64)\nbuy_asset_id required · string · One of OUR assets again — a swap trades between coins we custody, so this is an asset_id, NOT the exchange identifier an exchange takes. Must differ from sell_asset_id, and both must be enabled. (minLength 1; maxLength 64)\namount required · string · number · Decimal amount of sell_asset_id, fee INCLUSIVE: our fee comes out of it and send_amount is what reaches the exchange.\n\nResponses\n\nStatus · Body · Meaning\n200 · SwapQuote · An indicative price, with the moment it stops being valid.\n400 · Error · The request was refused before anything was charged. error is one of: validation_error, invalid_amount, invalid_pair (both sides name the same asset, an asset is not enabled, an asset no exchange can route — swappable: false on GET /v1/assets, the message names it — the user_id is unknown, or the user has no address on the buy asset's chain), no_route (that pair has no liquidity path at this size — nothing is broken, try another amount or asset).\n401 · Error · Missing, malformed or unknown API key.\n403 · Error · Swaps and exchanges are turned off, or the exchange integration is not configured.\n502 · Error · The exchange failed or was unreachable (your request was valid).\n503 · Error · Our side is not ready for this chain. The request was fine — retry rather than change it.\n\n200 response body — SwapQuote\n\nField · Type · Description\nsell_asset_id required · string · —\nbuy_asset_id required · string · —\nsell_amount required · string · A monetary value as a decimal string. Always paired with a *_base field that carries the exact integer base-unit value; compute with the _base string.\nsell_amount_base required · string · Exact integer amount in the asset's smallest unit. (pattern ^[0-9]+$)\nfee required · string · Our fee, taken OUT of sell_amount — not charged on top. a percentage with a dollar minimum (see GET /v1/pricing), plus network_fee.\nfee_base required · string · Exact integer amount in the asset's smallest unit. (pattern ^[0-9]+$)\nnetwork_fee · string · The network part of fee, in the sold asset — a USDT ⇄ TRX trade's own gas on TRON, or an exchange's send. Already inside fee.\nnetwork_fee_base · string · Exact integer amount in the asset's smallest unit. (pattern ^[0-9]+$)\nsend_amount required · string · sell_amount minus fee — what reaches the exchange, published so the numbers add up.\nsend_amount_base required · string · Exact integer amount in the asset's smallest unit. (pattern ^[0-9]+$)\nreceive_estimate required · string · Estimated output, in the buy asset's major units. Indicative only.\nreceive_estimate_base required · string · Exact integer amount in the asset's smallest unit. (pattern ^[0-9]+$)\nreceive_minimum required · string · The floor this route guarantees. The user is credited what ARRIVES, not this — treat it as the worst case you may quote them.\nreceive_minimum_base required · string · Exact integer amount in the asset's smallest unit. (pattern ^[0-9]+$)\nproviders required · string[] · The route's legs, in order.\nestimated_seconds required · integer · null · —\nexpires_at required · string<date-time> · ISO-8601. When the quote stops being valid — the moment to re-quote, not a price lock. Same shape as the exchange quote's.\n\n{\n\"sell_asset_id\": \"usdt_trc20\",\n\"buy_asset_id\": \"trx\",\n\"sell_amount\": \"1.5\",\n\"sell_amount_base\": \"1500000000\",\n\"fee\": \"1.5\",\n\"fee_base\": \"1500000000\",\n\"network_fee\": \"1.5\",\n\"network_fee_base\": \"1500000000\",\n\"send_amount\": \"1.5\",\n\"send_amount_base\": \"1500000000\",\n\"receive_estimate\": \"1.5\",\n\"receive_estimate_base\": \"1500000000\",\n\"receive_minimum\": \"1.5\",\n\"receive_minimum_base\": \"1500000000\",\n\"providers\": [\n\"string\"\n],\n\"estimated_seconds\": 1,\n\"expires_at\": \"2026-08-31T04:21:00.000Z\"\n}\n\nExample request\n\ncurl -X POST 'https://api.cryptoinfra.build/v1/swap/quote' \\\n-H 'Authorization: Bearer ck_live_your_key' \\\n-H 'Content-Type: application/json' \\\n-d '{\n\"sell_asset_id\": \"usdt_trc20\",\n\"buy_asset_id\": \"trx\",\n\"amount\": \"100\"\n}'"
        },
        {
          "anchor": "post-v1-swap-orders",
          "heading": "POST /v1/swap/orders",
          "text": "Start a swap — sell one coin the user holds, credit them another  ·  Auth: bearer API key  ·  operationId: createSwapOrder\n\n201, and the user's balance is ALREADY debited: the sell leg is an ordinary\nwithdrawal, priced, signed and broadcast by the same code as any other payout.\nOur fee comes out of the amount, and the withdrawal fee is NOT charged on top.\n\nThe output is NOT credited yet. It is paid to the user's OWN static wallet address on\nthe buy asset's chain and credited by the ordinary deposit path once it\nconfirms — so poll GET /v1/swap/orders/{id} (or watch the user's balance)\nrather than reading 201 as completion. The user is credited what ARRIVES, not\nreceive_estimate; receive_minimum is the floor the route guaranteed.\n\npending until the sell leg is on-chain, then sent/swapping while the\nexchange works, and completed once the output is reported.\n\nThe route is re-quoted at execution time rather than carried over from\nPOST /v1/swap/quote, so an order's numbers are the ones actually executed and\nmay differ slightly from the quote you showed. Quote first anyway: it is the\ncheap way to find out that a pair has no route at this size.\n\nIdempotent on idempotency_key — replaying a key returns the ORIGINAL order\ninstead of starting a second swap; the same key with a DIFFERENT payload is\nrefused with 409 idempotency_conflict. Always retry a failed or timed-out\ncreate with the SAME key.\n\nRequest body\n\napplication/json · required · schema SwapOrderRequest\n\nField · Type · Description\nsell_asset_id required · string · One of OUR assets (GET /v1/assets) — what is sold out of the user's balance. (minLength 1; maxLength 64)\nbuy_asset_id required · string · One of OUR assets again — a swap trades between coins we custody, so this is an asset_id, NOT the exchange identifier an exchange takes. Must differ from sell_asset_id, and both must be enabled. (minLength 1; maxLength 64)\namount required · string · number · Decimal amount of sell_asset_id, fee INCLUSIVE: our fee comes out of it and send_amount is what reaches the exchange.\nuser_id required · string · Your external_user_id — whose balance is sold, and whose deposit address on the buy asset's chain receives the output. The user must already have an address on that chain. (minLength 1)\nidempotency_key · string · Replaying a key returns the ORIGINAL order instead of starting a second swap; the same key with a different payload is 409 idempotency_conflict. Use a fresh key per logical swap; always retry with the same one. (minLength 1; maxLength 255)\n\nResponses\n\nStatus · Body · Meaning\n201 · SwapOrder · Swap started; the sell leg is queued as a withdrawal.\n400 · Error · The request was refused before anything was charged. error is one of: validation_error, invalid_amount, invalid_pair (both sides name the same asset, an asset is not enabled, an asset no exchange can route — swappable: false on GET /v1/assets, the message names it — the user_id is unknown, or the user has no address on the buy asset's chain), no_route (that pair has no liquidity path at this size — nothing is broken, try another amount or asset).\n401 · Error · Missing, malformed or unknown API key.\n403 · Error · Swaps and exchanges are turned off, or the exchange integration is not configured.\n404 · Error · Unknown external_user_id for this tenant.\n409 · Error · error is one of: insufficient_balance (the sell amount would overdraw the user's balance), idempotency_conflict (this idempotency_key was already used with a DIFFERENT payload — your replay is not the request you think it is).\n502 · Error · The exchange failed or was unreachable (your request was valid).\n503 · Error · Our side is not ready for this chain. The request was fine — retry rather than change it.\n\n201 response body — SwapOrder\n\nField · Type · Description\norder_id required · string<uuid> · —\nuser_id · string · Your external_user_id. Present on create; absent when listing or fetching.\nkind required · string · swap buys one of OUR assets: the output lands on the user's own deposit address and is credited to their balance. exchange buys a coin we do not custody: the output is paid to destination_address and never enters our system. (one of swap, exchange)\nsell_asset_id required · string · —\nbuy_asset_id required · string · null · One of our assets on a swap; null on an exchange (we have no asset row for it).\nbuy_asset_external required · string · null · The exchange's identifier on an exchange (e.g. SOL.SOL); null on a swap.\nbuy_symbol required · string · null · Display ticker of the bought coin on an exchange, so a listing needs no catalogue lookup. Null on a swap.\nbuy_chain required · string · null · Chain of the bought coin on an exchange, as the exchange names it. Null on a swap.\ndestination_address · string · Where the bought coin was paid. Present on EXCHANGE rows ONLY — echoed back so you can confirm it — and absent on a swap, whose output goes to the user's own static wallet address. This payout is irreversible and outside our custody.\nsell_amount required · string · A monetary value as a decimal string. Always paired with a *_base field that carries the exact integer base-unit value; compute with the _base string.\nsell_amount_base required · string · Exact integer amount in the asset's smallest unit. (pattern ^[0-9]+$)\nfee required · string · Our fee, taken out of sell_amount — a percentage with a dollar minimum (see GET /v1/pricing), plus network_fee.\nfee_base required · string · Exact integer amount in the asset's smallest unit. (pattern ^[0-9]+$)\nnetwork_fee · string · The network part of fee, in the sold asset — a USDT ⇄ TRX trade's own gas on TRON, or an exchange's send. Already inside fee.\nnetwork_fee_base · string · Exact integer amount in the asset's smallest unit. (pattern ^[0-9]+$)\nreceive_estimate · string · null · What the route estimated. A swap's is in the buy asset's major units; an exchange's is the exchange's decimal string.\nreceive_estimate_base · string · null · Exact base units on a SWAP. Always null on an exchange.\nreceive_minimum · string · null · The floor the route guaranteed. The user is credited what arrives, not this.\nreceive_minimum_base · string · null · Exact base units on a SWAP. Always null on an exchange.\nreceived · string · null · What ACTUALLY arrived. Null until the exchange settles.\nreceived_base · string · null · Exact base units on a SWAP. Always null on an exchange.\nproviders required · string[] · —\nstatus required · string · pending until the sell leg is on-chain, then sent and swapping while the exchange works; completed when it reports the payout. (one of pending, sent, swapping, completed, refunded, failed)\ntracking_status · string · null · The exchange's own last word on the route, verbatim. Informational.\ntxid · string · null · The on-chain SELL payment that started the order — the handle to show a customer, and the one the exchange's tracker keys on. Null until it is sent.\ncreated_at required · string<date-time> · —\nupdated_at required · string<date-time> · —\n\n{\n\"order_id\": \"string\",\n\"user_id\": \"string\",\n\"kind\": \"swap\",\n\"sell_asset_id\": \"string\",\n\"buy_asset_id\": \"string\",\n\"buy_asset_external\": \"string\",\n\"buy_symbol\": \"string\",\n\"buy_chain\": \"string\",\n\"destination_address\": \"string\",\n\"sell_amount\": \"1.5\",\n\"sell_amount_base\": \"1500000000\",\n\"fee\": \"1.5\",\n\"fee_base\": \"1500000000\",\n\"network_fee\": \"1.5\",\n\"network_fee_base\": \"1500000000\",\n\"receive_estimate\": \"string\",\n\"receive_estimate_base\": \"string\",\n\"receive_minimum\": \"string\",\n\"receive_minimum_base\": \"string\",\n\"received\": \"string\",\n\"received_base\": \"string\",\n\"providers\": [\n\"string\"\n],\n\"status\": \"pending\",\n\"tracking_status\": \"string\",\n\"txid\": \"string\",\n\"created_at\": \"2026-08-31T09:15:00.000Z\",\n\"updated_at\": \"2026-08-31T09:15:00.000Z\"\n}\n\nExample request\n\ncurl -X POST 'https://api.cryptoinfra.build/v1/swap/orders' \\\n-H 'Authorization: Bearer ck_live_your_key' \\\n-H 'Content-Type: application/json' \\\n-d '{\n\"sell_asset_id\": \"usdt_trc20\",\n\"buy_asset_id\": \"trx\",\n\"amount\": \"100\",\n\"user_id\": \"string\"\n}'"
        },
        {
          "anchor": "get-v1-swap-orders-id",
          "heading": "GET /v1/swap/orders/{id}",
          "text": "Fetch one order  ·  Auth: bearer API key  ·  operationId: getSwapOrder\n\nThe authoritative read, for either kind — another merchant's order is simply\n404. Poll it while status is pending, sent or swapping.\n\nOn a SWAP, received is what actually arrived and was credited to the user's\nbalance; it stays null until the output confirms, so a completed status with\na populated received is the signal to tell your customer it is done.\n\nuser_id is not echoed here — it is returned on create, and by\nGET /v1/swap/orders/{id}/detail.\n\nParameters\n\nName · In · Type · Required · Description\nid · path · string<uuid> · yes · The order_id from create or the list.\n\nResponses\n\nStatus · Body · Meaning\n200 · SwapOrder · The order.\n401 · Error · Missing, malformed or unknown API key.\n404 · Error · Unknown order, or one belonging to another merchant — the two are indistinguishable by design.\n\n200 response body — SwapOrder\n\nField · Type · Description\norder_id required · string<uuid> · —\nuser_id · string · Your external_user_id. Present on create; absent when listing or fetching.\nkind required · string · swap buys one of OUR assets: the output lands on the user's own deposit address and is credited to their balance. exchange buys a coin we do not custody: the output is paid to destination_address and never enters our system. (one of swap, exchange)\nsell_asset_id required · string · —\nbuy_asset_id required · string · null · One of our assets on a swap; null on an exchange (we have no asset row for it).\nbuy_asset_external required · string · null · The exchange's identifier on an exchange (e.g. SOL.SOL); null on a swap.\nbuy_symbol required · string · null · Display ticker of the bought coin on an exchange, so a listing needs no catalogue lookup. Null on a swap.\nbuy_chain required · string · null · Chain of the bought coin on an exchange, as the exchange names it. Null on a swap.\ndestination_address · string · Where the bought coin was paid. Present on EXCHANGE rows ONLY — echoed back so you can confirm it — and absent on a swap, whose output goes to the user's own static wallet address. This payout is irreversible and outside our custody.\nsell_amount required · string · A monetary value as a decimal string. Always paired with a *_base field that carries the exact integer base-unit value; compute with the _base string.\nsell_amount_base required · string · Exact integer amount in the asset's smallest unit. (pattern ^[0-9]+$)\nfee required · string · Our fee, taken out of sell_amount — a percentage with a dollar minimum (see GET /v1/pricing), plus network_fee.\nfee_base required · string · Exact integer amount in the asset's smallest unit. (pattern ^[0-9]+$)\nnetwork_fee · string · The network part of fee, in the sold asset — a USDT ⇄ TRX trade's own gas on TRON, or an exchange's send. Already inside fee.\nnetwork_fee_base · string · Exact integer amount in the asset's smallest unit. (pattern ^[0-9]+$)\nreceive_estimate · string · null · What the route estimated. A swap's is in the buy asset's major units; an exchange's is the exchange's decimal string.\nreceive_estimate_base · string · null · Exact base units on a SWAP. Always null on an exchange.\nreceive_minimum · string · null · The floor the route guaranteed. The user is credited what arrives, not this.\nreceive_minimum_base · string · null · Exact base units on a SWAP. Always null on an exchange.\nreceived · string · null · What ACTUALLY arrived. Null until the exchange settles.\nreceived_base · string · null · Exact base units on a SWAP. Always null on an exchange.\nproviders required · string[] · —\nstatus required · string · pending until the sell leg is on-chain, then sent and swapping while the exchange works; completed when it reports the payout. (one of pending, sent, swapping, completed, refunded, failed)\ntracking_status · string · null · The exchange's own last word on the route, verbatim. Informational.\ntxid · string · null · The on-chain SELL payment that started the order — the handle to show a customer, and the one the exchange's tracker keys on. Null until it is sent.\ncreated_at required · string<date-time> · —\nupdated_at required · string<date-time> · —\n\n{\n\"order_id\": \"string\",\n\"user_id\": \"string\",\n\"kind\": \"swap\",\n\"sell_asset_id\": \"string\",\n\"buy_asset_id\": \"string\",\n\"buy_asset_external\": \"string\",\n\"buy_symbol\": \"string\",\n\"buy_chain\": \"string\",\n\"destination_address\": \"string\",\n\"sell_amount\": \"1.5\",\n\"sell_amount_base\": \"1500000000\",\n\"fee\": \"1.5\",\n\"fee_base\": \"1500000000\",\n\"network_fee\": \"1.5\",\n\"network_fee_base\": \"1500000000\",\n\"receive_estimate\": \"string\",\n\"receive_estimate_base\": \"string\",\n\"receive_minimum\": \"string\",\n\"receive_minimum_base\": \"string\",\n\"received\": \"string\",\n\"received_base\": \"string\",\n\"providers\": [\n\"string\"\n],\n\"status\": \"pending\",\n\"tracking_status\": \"string\",\n\"txid\": \"string\",\n\"created_at\": \"2026-08-31T09:15:00.000Z\",\n\"updated_at\": \"2026-08-31T09:15:00.000Z\"\n}\n\nExample request\n\ncurl -X GET 'https://api.cryptoinfra.build/v1/swap/orders/string' \\\n-H 'Authorization: Bearer ck_live_your_key'"
        },
        {
          "anchor": "get-v1-swap-orders-id-detail",
          "heading": "GET /v1/swap/orders/{id}/detail",
          "text": "Fetch one order in full — addresses, send leg, origin and timeline  ·  Auth: bearer API key  ·  operationId: getSwapOrderDetail\n\nEverything GET /v1/swap/orders/{id} returns, plus every hop: the exchange's\none-time deposit address, the address the output went to, the on-chain send\nwith its confirmations, who asked for the order and from where, and the story\nin order. Enough to answer \"where is my money\" from a block explorer without\nopening a ticket.\n\nDeliberately narrower than the operator's view of the same order. You see YOUR\nfacts — your team member, your IP, your addresses, your money — not what the\nsend cost us in gas or the ledger account codes behind it.\n\nreceived_deposit is LABELLED, never asserted: nothing on-chain ties a swap to\nthe deposit that credited it, so this is our best match by asset, amount and\ntime. confidence says how far to trust it (ambiguous means a second deposit\ncould equally be this swap's output) and a page should present it as likely,\nnot as fact. It is always null on an exchange, whose output never reaches us.\n\nParameters\n\nName · In · Type · Required · Description\nid · path · string<uuid> · yes · The order_id from create or the list.\n\nResponses\n\nStatus · Body · Meaning\n200 · SwapOrderDetail · The order in full.\n401 · Error · Missing, malformed or unknown API key.\n404 · Error · Unknown order, or one belonging to another merchant — the two are indistinguishable by design.\n\n200 response body — SwapOrderDetail\n\nField · Type · Description\norder_id required · string<uuid> · —\nuser_id required · string · Your external_user_id. Always present here, unlike the summary shape.\nkind required · string · swap buys one of OUR assets: the output lands on the user's own deposit address and is credited to their balance. exchange buys a coin we do not custody: the output is paid to destination_address and never enters our system. (one of swap, exchange)\nsell_asset_id required · string · —\nbuy_asset_id required · string · null · One of our assets on a swap; null on an exchange (we have no asset row for it).\nbuy_asset_external required · string · null · The exchange's identifier on an exchange (e.g. SOL.SOL); null on a swap.\nbuy_symbol required · string · null · Display ticker of the bought coin on an exchange, so a listing needs no catalogue lookup. Null on a swap.\nbuy_chain required · string · null · Chain of the bought coin on an exchange, as the exchange names it. Null on a swap.\ndestination_address required · string · Where the output was sent. Always present here for BOTH kinds: on a swap it is the user's own static wallet address on the buy asset's chain (the summary shape omits it there), and on an exchange it is the address you supplied.\nsell_amount required · string · A monetary value as a decimal string. Always paired with a *_base field that carries the exact integer base-unit value; compute with the _base string.\nsell_amount_base required · string · Exact integer amount in the asset's smallest unit. (pattern ^[0-9]+$)\nfee required · string · Our fee, taken out of sell_amount — a percentage with a dollar minimum (see GET /v1/pricing), plus network_fee.\nfee_base required · string · Exact integer amount in the asset's smallest unit. (pattern ^[0-9]+$)\nnetwork_fee · string · The network part of fee, in the sold asset — a USDT ⇄ TRX trade's own gas on TRON, or an exchange's send. Already inside fee.\nnetwork_fee_base · string · Exact integer amount in the asset's smallest unit. (pattern ^[0-9]+$)\nreceive_estimate · string · null · What the route estimated. A swap's is in the buy asset's major units; an exchange's is the exchange's decimal string.\nreceive_estimate_base · string · null · Exact base units on a SWAP. Always null on an exchange.\nreceive_minimum · string · null · The floor the route guaranteed. The user is credited what arrives, not this.\nreceive_minimum_base · string · null · Exact base units on a SWAP. Always null on an exchange.\nreceived · string · null · What ACTUALLY arrived. Null until the exchange settles.\nreceived_base · string · null · Exact base units on a SWAP. Always null on an exchange.\nproviders required · string[] · —\nstatus required · string · pending until the sell leg is on-chain, then sent and swapping while the exchange works; completed when it reports the payout. (one of pending, sent, swapping, completed, refunded, failed)\ntracking_status · string · null · The exchange's own last word on the route, verbatim. Informational.\ntxid · string · null · The on-chain SELL payment that started the order — the handle to show a customer, and the one the exchange's tracker keys on. Null until it is sent.\ncreated_at required · string<date-time> · —\nupdated_at required · string<date-time> · —\nsend_amount required · string · sell_amount minus fee — what was actually paid to the exchange.\nsend_amount_base required · string · Exact integer amount in the asset's smallest unit. (pattern ^[0-9]+$)\nexchange_address required · string · The exchange's one-time deposit address for THIS order — where the sell payment went. The first hop to follow on a block explorer.\nswap_id required · string · null · The exchange's own id for the route, for a support conversation with them.\nsent_at required · string<date-time> · null · When the sell payment reached the chain. Null before that.\nsettled_at required · string<date-time> · null · When the exchange reported a terminal state. Null while still moving.\nerror required · string · null · Why the order failed, when it did.\nsend required · object · null · The withdrawal that paid the exchange. Null until it exists.\nsend.status required · string · —\nsend.txid required · string · null · —\nsend.confirmations required · integer · —\nsend.block_height required · integer · null · —\nsend.error required · string · null · —\norigin required · object · null · Who asked for this order and from where. Null when nothing was recorded.\norigin.actor_email required · string · null · The dashboard login that asked. Null when an API key did — there is no person.\norigin.ip required · string · null · —\norigin.ip_geo required · object · null · Geolocation of ip, or null when lookup is off, failed, or the address is private.\norigin.user_agent required · string · null · —\norigin.via required · string · null · _(one of dashboard_session, api_key, null)_\norigin.at required · string<date-time> · —\nreceived_deposit required · object · null · Our best MATCH for the deposit that credited this swap's output — a label, not an assertion, because nothing on-chain ties the two together. Null on an exchange (its output never reaches us) and until a candidate appears.\nreceived_deposit.amount required · string · A monetary value as a decimal string. Always paired with a *_base field that carries the exact integer base-unit value; compute with the _base string.\nreceived_deposit.amount_base required · string · Exact integer amount in the asset's smallest unit. (pattern ^[0-9]+$)\nreceived_deposit.txid required · string · —\nreceived_deposit.status required · string · —\nreceived_deposit.credited_at required · string<date-time> · null · —\nreceived_deposit.confidence required · string · unconfirmed is a single plausible match; ambiguous means more than one deposit could be this output — show the warning. (one of unconfirmed, ambiguous)\ntimeline required · object[] · What happened, oldest first — e.g. Swap requested, Fee charged, Balance held, Payment signed, Payment on chain, Payment confirmed, Output seen on chain, Credited to balance. Human-readable and additive; new steps may appear, so render the list rather than switching on it.\ntimeline[].at required · string<date-time> · —\ntimeline[].event required · string · —\ntimeline[].detail required · string · null · The step's one supporting fact (often a txid). Null when there is none.\n\n{\n\"order_id\": \"string\",\n\"user_id\": \"string\",\n\"kind\": \"swap\",\n\"sell_asset_id\": \"string\",\n\"buy_asset_id\": \"string\",\n\"buy_asset_external\": \"string\",\n\"buy_symbol\": \"string\",\n\"buy_chain\": \"string\",\n\"destination_address\": \"string\",\n\"sell_amount\": \"1.5\",\n\"sell_amount_base\": \"1500000000\",\n\"fee\": \"1.5\",\n\"fee_base\": \"1500000000\",\n\"network_fee\": \"1.5\",\n\"network_fee_base\": \"1500000000\",\n\"receive_estimate\": \"string\",\n\"receive_estimate_base\": \"string\",\n\"receive_minimum\": \"string\",\n\"receive_minimum_base\": \"string\",\n\"received\": \"string\",\n\"received_base\": \"string\",\n\"providers\": [\n\"string\"\n],\n\"status\": \"pending\",\n\"tracking_status\": \"string\",\n\"txid\": \"string\",\n\"created_at\": \"2026-08-31T09:15:00.000Z\",\n\"updated_at\": \"2026-08-31T09:15:00.000Z\",\n\"send_amount\": \"1.5\",\n\"send_amount_base\": \"1500000000\",\n\"exchange_address\": \"string\",\n\"swap_id\": \"string\",\n\"sent_at\": \"2026-08-31T09:15:00.000Z\",\n\"settled_at\": \"2026-08-31T09:15:00.000Z\",\n\"error\": \"string\",\n\"send\": {\n\"status\": \"string\",\n\"txid\": \"string\",\n\"confirmations\": 1,\n\"block_height\": 1,\n\"error\": \"string\"\n},\n\"origin\": {\n\"actor_email\": \"string\",\n\"ip\": \"string\",\n\"ip_geo\": {},\n\"user_agent\": \"string\",\n\"via\": \"dashboard_session\",\n\"at\": \"2026-08-31T09:15:00.000Z\"\n},\n\"received_deposit\": {\n\"amount\": \"1.5\",\n\"amount_base\": \"1500000000\",\n\"txid\": \"string\",\n\"status\": \"string\",\n\"credited_at\": \"2026-08-31T09:15:00.000Z\",\n\"confidence\": \"unconfirmed\"\n},\n\"timeline\": [\n{\n\"at\": \"2026-08-31T09:15:00.000Z\",\n\"event\": \"Payment on chain\",\n\"detail\": \"string\"\n}\n]\n}\n\nExample request\n\ncurl -X GET 'https://api.cryptoinfra.build/v1/swap/orders/string/detail' \\\n-H 'Authorization: Bearer ck_live_your_key'"
        }
      ]
    },
    {
      "slug": "exchange",
      "title": "Exchange",
      "summary": "Sell a coin you hold and pay any of several thousand others out to an address",
      "kind": "reference",
      "markdown": "# Exchange\n\nSell a coin you hold and pay any of several thousand others out to an address\n\n5 endpoints. Generated from `docs/openapi.yaml` v1.0.0.\n\n| Endpoint | Summary |\n| --- | --- |\n| [`GET /v1/exchange/assets`](#get-v1-exchange-assets) | List the coins an exchange can buy |\n| [`GET /v1/exchange/chains`](#get-v1-exchange-chains) | List the chains an exchange can pay out to |\n| [`POST /v1/exchange/quote`](#post-v1-exchange-quote) | Price an exchange, and validate the destination address |\n| [`POST /v1/exchange/orders`](#post-v1-exchange-orders) | Start an exchange — sell a held coin, pay another one out |\n| [`GET /v1/swap/orders`](#get-v1-swap-orders) | List swap and exchange orders |\n\n## GET /v1/exchange/assets\n\n**List the coins an exchange can buy**  ·  _Auth: bearer API key_  ·  `operationId: listExchangeAssets`\n\nThe payout catalogue — thousands of coins across dozens of chains, refreshed\nhourly from the router; `total` on this route is the live count. Far wider\nthan GET /v1/assets (the handful a user can HOLD with us). Nothing here is\ncustodial: these are payout destinations only.\n\n`asset` is the EXCHANGE's own identifier (e.g. `SOL.SOL`,\n`ETH.USDC-0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48`) and is exactly what\n`buy_asset` expects on POST /v1/exchange/quote and POST /v1/exchange/orders.\nIt is NOT one of our `asset_id` values — take it from here rather than\nconstructing it, because a value we cannot route is rejected outright.\n\n`total` counts every match; `assets` is capped at `limit` (default 100), so\n`total` greater than the array's length means \"narrow the search\", not\n\"there are only this many\".\n\n#### Parameters\n\n| Name | In | Type | Required | Description |\n| --- | --- | --- | --- | --- |\n| `search` | query | `string` | no | Case-insensitive substring match on ticker, name or identifier. |\n| `chain` | query | `string` | no | Exact chain, case-insensitive (e.g. `SOL`). Values from GET /v1/exchange/chains. |\n| `limit` | query | `integer` | no | — _(default `100`; min `1`; max `500`)_ |\n\n#### Responses\n\n| Status | Body | Meaning |\n| --- | --- | --- |\n| `200` | `object` | Matching payout assets. |\n| `401` | `Error` | Missing, malformed or unknown API key. |\n| `403` | `Error` | Swaps and exchanges are turned off, or the exchange integration is not configured. |\n| `502` | `Error` | The exchange failed or was unreachable (your request was valid). |\n\n**`200` response body**\n\n| Field | Type | Description |\n| --- | --- | --- |\n| `total` **required** | `integer` | Matches BEFORE `limit` was applied. |\n| `assets` **required** | `object[]` | — |\n| `assets[].asset` **required** | `string` | The EXCHANGE's identifier — what `buy_asset` expects. NOT one of our `asset_id` values, and not derivable from one. |\n| `assets[].chain` **required** | `string` | Chain as the exchange names it — a wider set than our `Chain` enum. |\n| `assets[].symbol` **required** | `string` | — |\n| `assets[].name` **required** | `string` | — |\n| `assets[].decimals` **required** | `integer` | The coin's own decimals, on a chain we do not model. Informational: we publish no base units for it, so amounts stay decimal strings. |\n| `assets[].contract_address` **required** | `string | null` | Token contract, or null for a chain's native coin. |\n| `assets[].logo_url` **required** | `string | null` | A path on this API that serves the coin's picture (`/v1/exchange/assets/{identifier}/logo`). Null when the coin has none. Fetch it with your API key like any other call; it is cacheable for a week. |\n\n```json\n{\n  \"total\": 1,\n  \"assets\": [\n    {\n      \"asset\": \"ETH.USDC-0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48\",\n      \"chain\": \"ETH\",\n      \"symbol\": \"USDC\",\n      \"name\": \"USD Coin\",\n      \"decimals\": 1,\n      \"contract_address\": \"string\",\n      \"logo_url\": \"string\"\n    }\n  ]\n}\n```\n\n#### Example request\n\n```bash\ncurl -X GET 'https://api.cryptoinfra.build/v1/exchange/assets' \\\n  -H 'Authorization: Bearer ck_live_your_key'\n```\n\n## GET /v1/exchange/chains\n\n**List the chains an exchange can pay out to**  ·  _Auth: bearer API key_  ·  `operationId: listExchangeChains`\n\nThe distinct chains present in the catalogue, sorted — for a picker's chain filter. Feed a value back as the `chain` query on GET /v1/exchange/assets.\n\n#### Responses\n\n| Status | Body | Meaning |\n| --- | --- | --- |\n| `200` | `object` | Payout chains. |\n| `401` | `Error` | Missing, malformed or unknown API key. |\n| `403` | `Error` | Swaps and exchanges are turned off, or the exchange integration is not configured. |\n| `502` | `Error` | The exchange failed or was unreachable (your request was valid). |\n\n**`200` response body**\n\n| Field | Type | Description |\n| --- | --- | --- |\n| `chains` **required** | `string[]` | — |\n\n```json\n{\n  \"chains\": [\n    \"SOL\"\n  ]\n}\n```\n\n#### Example request\n\n```bash\ncurl -X GET 'https://api.cryptoinfra.build/v1/exchange/chains' \\\n  -H 'Authorization: Bearer ck_live_your_key'\n```\n\n## POST /v1/exchange/quote\n\n**Price an exchange, and validate the destination address**  ·  _Auth: bearer API key_  ·  `operationId: quoteExchange`\n\nIndicative: confers no reservation and holds no balance. But it DOES validate\n`destination_address`, and that is the reason to always call it first. We have\nno address library for most of these chains, so the exchange is asked at quote\ntime; a bad address comes back as `400 invalid_pair` before anything is\ncharged. Quote before you create, and show the rejection to your customer.\n\nThe SELL side carries the usual `*_base` twin. The BUY side does not: the\ncoin's decimals belong to a chain we do not model, so `receive_estimate` and\n`receive_minimum` are the exchange's own decimal strings. Render them as\ngiven — never scale them by an asset of ours.\n\n`receive_estimate` is an estimate; `receive_minimum` is the floor this route\nguarantees. The customer is paid what ACTUALLY ARRIVES, which is why both are\npublished: the pair is the promise.\n\nA quote is good for at most 60 seconds, or less when the route itself expires\nsooner — `expires_at` carries the earlier of the two.\n\n`destination_address` is optional here — omit it to price alone, send it and\nit is validated too. It is required on POST /v1/exchange/orders.\n\n#### Request body\n\n`application/json` · required · schema `ExchangeQuoteRequest`\n\n| Field | Type | Description |\n| --- | --- | --- |\n| `sell_asset_id` **required** | `string` | One of OUR assets (GET /v1/assets) — what is sold out of the user's balance. A disabled asset, or one with `swappable: false`, is refused with 400 invalid_pair (the message names it), exactly as on a swap. _(minLength `1`; maxLength `64`)_ |\n| `buy_asset` **required** | `string` | The coin to buy, as the EXCHANGE's identifier — the `asset` field of GET /v1/exchange/assets, e.g. `SOL.SOL` or `ETH.USDC-0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48`. This is NOT one of our asset ids; anything we cannot route is rejected with 400 invalid_pair. _(minLength `1`; maxLength `128`)_ |\n| `amount` **required** | `string | number` | Decimal amount of `sell_asset_id`, fee INCLUSIVE: our fee comes out of it and `send_amount` is what reaches the exchange. |\n| `destination_address` | `string` | Where the bought coin is paid out. Must be valid on `buy_asset`'s chain. Optional on a quote — omit it to price alone, send it and the exchange validates it here too. Required on POST /v1/exchange/orders. We have no address library for most of these chains, so the exchange is asked at quote time and an invalid address is refused with 400 invalid_pair — before anything is charged. Once an order is created this payout is IRREVERSIBLE: the coin never enters our custody. _(minLength `1`; maxLength `256`)_ |\n\n#### Responses\n\n| Status | Body | Meaning |\n| --- | --- | --- |\n| `200` | `ExchangeQuote` | An indicative price, with the moment it stops being valid. |\n| `400` | `Error` | The request was refused before anything was charged. `error` is one of: validation_error, invalid_amount, invalid_pair (`buy_asset` is not routable, the sell asset is disabled or no exchange can route it — `swappable: false` on GET /v1/assets, the message names it — both sides are the same coin, or `destination_address` is not valid for `buy_asset`'s chain), no_route (that pair has no liquidity path at this size — nothing is broken, try another amount or coin). |\n| `401` | `Error` | Missing, malformed or unknown API key. |\n| `403` | `Error` | Swaps and exchanges are turned off, or the exchange integration is not configured. |\n| `502` | `Error` | The exchange failed or was unreachable (your request was valid). |\n| `503` | `Error` | Our side is not ready for this chain. The request was fine — retry rather than change it. |\n\n**`200` response body** — `ExchangeQuote`\n\n| Field | Type | Description |\n| --- | --- | --- |\n| `sell_asset_id` **required** | `string` | — |\n| `buy_asset` **required** | `string` | The exchange identifier you asked for, echoed back. |\n| `buy_symbol` **required** | `string` | — |\n| `buy_chain` **required** | `string` | — |\n| `sell_amount` **required** | `string` | A monetary value as a decimal string. Always paired with a `*_base` field that carries the exact integer base-unit value; compute with the `_base` string. |\n| `sell_amount_base` **required** | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |\n| `fee` **required** | `string` | Our fee, taken OUT of `sell_amount` — not charged on top. |\n| `fee_base` **required** | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |\n| `send_amount` **required** | `string` | `sell_amount` minus `fee` — what reaches the exchange, published so the numbers add up. No `_base` twin here. |\n| `receive_estimate` **required** | `string` | Estimated payout, as the EXCHANGE's decimal string on its own chain. Render as given; there is no `receive_estimate_base` for an exchange. |\n| `receive_minimum` **required** | `string` | The floor this route guarantees, same encoding. The customer is credited what ARRIVES, not this — treat it as the worst case you may quote them. |\n| `quoted_at` | `string<date-time>` | When this quote was priced; `expires_at` is how long the route stands. |\n| `providers` **required** | `string[]` | The route's legs, in order. |\n| `estimated_seconds` **required** | `integer | null` | — |\n| `expires_at` **required** | `string<date-time>` | When the quote stops being valid. Same shape as the swap quote's. |\n\n```json\n{\n  \"sell_asset_id\": \"string\",\n  \"buy_asset\": \"SOL.SOL\",\n  \"buy_symbol\": \"SOL\",\n  \"buy_chain\": \"SOL\",\n  \"sell_amount\": \"1.5\",\n  \"sell_amount_base\": \"1500000000\",\n  \"fee\": \"1.5\",\n  \"fee_base\": \"1500000000\",\n  \"send_amount\": \"1.5\",\n  \"receive_estimate\": \"0.4193\",\n  \"receive_minimum\": \"0.4109\",\n  \"quoted_at\": \"2026-08-31T09:15:00.000Z\",\n  \"providers\": [\n    \"string\"\n  ],\n  \"estimated_seconds\": 1,\n  \"expires_at\": \"2026-08-31T04:21:00.000Z\"\n}\n```\n\n#### Example request\n\n```bash\ncurl -X POST 'https://api.cryptoinfra.build/v1/exchange/quote' \\\n  -H 'Authorization: Bearer ck_live_your_key' \\\n  -H 'Content-Type: application/json' \\\n  -d '{\n  \"sell_asset_id\": \"usdt_trc20\",\n  \"buy_asset\": \"SOL.SOL\",\n  \"amount\": \"100\"\n}'\n```\n\n## POST /v1/exchange/orders\n\n**Start an exchange — sell a held coin, pay another one out**  ·  _Auth: bearer API key_  ·  `operationId: createExchangeOrder`\n\n`201`, and the user's balance is ALREADY debited: the sell leg is an ordinary\nwithdrawal, priced, signed and broadcast by the same code as any other payout.\nOur fee comes out of the amount, and the withdrawal fee is NOT charged on top.\n\nThe bought coin is paid to `destination_address` and NEVER enters our custody.\nIt cannot be reversed and it cannot be recovered — the same finality as a\nwithdrawal, which is exactly what the sell leg is. POST /v1/exchange/quote is\nthe only validation that address gets, and it costs nothing: quote first.\n\nIdempotent on `idempotency_key` — replaying returns the existing order instead\nof starting a second one; the same key with a DIFFERENT payload is refused with\n`409 idempotency_conflict`. Then poll GET /v1/swap/orders/{id}: `pending` until\nthe sell leg is on-chain, `sent`/`swapping` while the exchange works, and\n`completed` once it reports the payout. Nothing is ever credited to the user's\nbalance; `received` is what landed at `destination_address`.\n\nBuy-side amounts (`receive_estimate`, `receive_minimum`, `received`) are the\nexchange's decimal strings and their `*_base` twins are null.\n\n#### Request body\n\n`application/json` · required · schema `ExchangeOrderRequest`\n\n| Field | Type | Description |\n| --- | --- | --- |\n| `sell_asset_id` **required** | `string` | One of OUR assets (GET /v1/assets) — what is sold out of the user's balance. A disabled asset, or one with `swappable: false`, is refused with 400 invalid_pair (the message names it), exactly as on a swap. _(minLength `1`; maxLength `64`)_ |\n| `buy_asset` **required** | `string` | The coin to buy, as the EXCHANGE's identifier — the `asset` field of GET /v1/exchange/assets, e.g. `SOL.SOL` or `ETH.USDC-0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48`. This is NOT one of our asset ids; anything we cannot route is rejected with 400 invalid_pair. _(minLength `1`; maxLength `128`)_ |\n| `amount` **required** | `string | number` | Decimal amount of `sell_asset_id`, fee INCLUSIVE: our fee comes out of it and `send_amount` is what reaches the exchange. |\n| `destination_address` **required** | `string` | Where the bought coin is paid out. Must be valid on `buy_asset`'s chain. Optional on a quote — omit it to price alone, send it and the exchange validates it here too. Required on POST /v1/exchange/orders. We have no address library for most of these chains, so the exchange is asked at quote time and an invalid address is refused with 400 invalid_pair — before anything is charged. Once an order is created this payout is IRREVERSIBLE: the coin never enters our custody. _(minLength `1`; maxLength `256`)_ |\n| `user_id` **required** | `string` | Your `external_user_id` — whose balance is sold. _(minLength `1`)_ |\n| `idempotency_key` | `string` | Replaying a key returns the existing order instead of starting a second one; the same key with a different payload is 409 idempotency_conflict. _(minLength `1`; maxLength `255`)_ |\n\n#### Responses\n\n| Status | Body | Meaning |\n| --- | --- | --- |\n| `201` | `SwapOrder` | Exchange started; the sell leg is queued as a withdrawal. |\n| `400` | `Error` | The request was refused before anything was charged. `error` is one of: validation_error, invalid_amount, invalid_pair (`buy_asset` is not routable, the sell asset is disabled or no exchange can route it — `swappable: false` on GET /v1/assets, the message names it — both sides are the same coin, or `destination_address` is not valid for `buy_asset`'s chain), no_route (that pair has no liquidity path at this size — nothing is broken, try another amount or coin). |\n| `401` | `Error` | Missing, malformed or unknown API key. |\n| `403` | `Error` | Swaps and exchanges are turned off, or the exchange integration is not configured. |\n| `404` | `Error` | Unknown external_user_id for this tenant. |\n| `409` | `Error` | `error` is one of: insufficient_balance (the sell amount would overdraw the user's balance), idempotency_conflict (this `idempotency_key` was already used with a DIFFERENT payload — your replay is not the request you think it is). |\n| `502` | `Error` | The exchange failed or was unreachable (your request was valid). |\n| `503` | `Error` | Our side is not ready for this chain. The request was fine — retry rather than change it. |\n\n**`201` response body** — `SwapOrder`\n\n| Field | Type | Description |\n| --- | --- | --- |\n| `order_id` **required** | `string<uuid>` | — |\n| `user_id` | `string` | Your `external_user_id`. Present on create; absent when listing or fetching. |\n| `kind` **required** | `string` | `swap` buys one of OUR assets: the output lands on the user's own deposit address and is credited to their balance. `exchange` buys a coin we do not custody: the output is paid to `destination_address` and never enters our system. _(one of `swap`, `exchange`)_ |\n| `sell_asset_id` **required** | `string` | — |\n| `buy_asset_id` **required** | `string | null` | One of our assets on a swap; null on an exchange (we have no asset row for it). |\n| `buy_asset_external` **required** | `string | null` | The exchange's identifier on an exchange (e.g. `SOL.SOL`); null on a swap. |\n| `buy_symbol` **required** | `string | null` | Display ticker of the bought coin on an exchange, so a listing needs no catalogue lookup. Null on a swap. |\n| `buy_chain` **required** | `string | null` | Chain of the bought coin on an exchange, as the exchange names it. Null on a swap. |\n| `destination_address` | `string` | Where the bought coin was paid. Present on EXCHANGE rows ONLY — echoed back so you can confirm it — and absent on a swap, whose output goes to the user's own static wallet address. This payout is irreversible and outside our custody. |\n| `sell_amount` **required** | `string` | A monetary value as a decimal string. Always paired with a `*_base` field that carries the exact integer base-unit value; compute with the `_base` string. |\n| `sell_amount_base` **required** | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |\n| `fee` **required** | `string` | Our fee, taken out of `sell_amount` — a percentage with a dollar minimum (see GET /v1/pricing), plus `network_fee`. |\n| `fee_base` **required** | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |\n| `network_fee` | `string` | The network part of `fee`, in the sold asset — a USDT ⇄ TRX trade's own gas on TRON, or an exchange's send. Already inside `fee`. |\n| `network_fee_base` | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |\n| `receive_estimate` | `string | null` | What the route estimated. A swap's is in the buy asset's major units; an exchange's is the exchange's decimal string. |\n| `receive_estimate_base` | `string | null` | Exact base units on a SWAP. Always null on an exchange. |\n| `receive_minimum` | `string | null` | The floor the route guaranteed. The user is credited what arrives, not this. |\n| `receive_minimum_base` | `string | null` | Exact base units on a SWAP. Always null on an exchange. |\n| `received` | `string | null` | What ACTUALLY arrived. Null until the exchange settles. |\n| `received_base` | `string | null` | Exact base units on a SWAP. Always null on an exchange. |\n| `providers` **required** | `string[]` | — |\n| `status` **required** | `string` | `pending` until the sell leg is on-chain, then `sent` and `swapping` while the exchange works; `completed` when it reports the payout. _(one of `pending`, `sent`, `swapping`, `completed`, `refunded`, `failed`)_ |\n| `tracking_status` | `string | null` | The exchange's own last word on the route, verbatim. Informational. |\n| `txid` | `string | null` | The on-chain SELL payment that started the order — the handle to show a customer, and the one the exchange's tracker keys on. Null until it is sent. |\n| `created_at` **required** | `string<date-time>` | — |\n| `updated_at` **required** | `string<date-time>` | — |\n\n```json\n{\n  \"order_id\": \"string\",\n  \"user_id\": \"string\",\n  \"kind\": \"swap\",\n  \"sell_asset_id\": \"string\",\n  \"buy_asset_id\": \"string\",\n  \"buy_asset_external\": \"string\",\n  \"buy_symbol\": \"string\",\n  \"buy_chain\": \"string\",\n  \"destination_address\": \"string\",\n  \"sell_amount\": \"1.5\",\n  \"sell_amount_base\": \"1500000000\",\n  \"fee\": \"1.5\",\n  \"fee_base\": \"1500000000\",\n  \"network_fee\": \"1.5\",\n  \"network_fee_base\": \"1500000000\",\n  \"receive_estimate\": \"string\",\n  \"receive_estimate_base\": \"string\",\n  \"receive_minimum\": \"string\",\n  \"receive_minimum_base\": \"string\",\n  \"received\": \"string\",\n  \"received_base\": \"string\",\n  \"providers\": [\n    \"string\"\n  ],\n  \"status\": \"pending\",\n  \"tracking_status\": \"string\",\n  \"txid\": \"string\",\n  \"created_at\": \"2026-08-31T09:15:00.000Z\",\n  \"updated_at\": \"2026-08-31T09:15:00.000Z\"\n}\n```\n\n#### Example request\n\n```bash\ncurl -X POST 'https://api.cryptoinfra.build/v1/exchange/orders' \\\n  -H 'Authorization: Bearer ck_live_your_key' \\\n  -H 'Content-Type: application/json' \\\n  -d '{\n  \"sell_asset_id\": \"usdt_trc20\",\n  \"buy_asset\": \"SOL.SOL\",\n  \"amount\": \"100\",\n  \"destination_address\": \"string\",\n  \"user_id\": \"string\"\n}'\n```\n\n## GET /v1/swap/orders\n\n**List swap and exchange orders**  ·  _Auth: bearer API key_  ·  `operationId: listSwapOrders`\n\nNewest-first, scoped to your merchant. Swaps and exchanges share ONE order\nlist and one order shape; `kind` filters between them.\n\nA `swap` names one of our assets on both sides and credits the user's balance.\nAn `exchange` names its buy side by the exchange's identifier\n(`buy_asset_external`, with `buy_symbol` and `buy_chain` so a listing needs no\ncatalogue lookup) and pays out to `destination_address`, which is echoed on\nexchange rows only. On an exchange `buy_asset_id` is null, and every buy-side\n`*_base` field is null with it — those decimals belong to a chain we do not\nmodel, so there are no base units of ours to publish.\n\n#### Parameters\n\n| Name | In | Type | Required | Description |\n| --- | --- | --- | --- | --- |\n| `kind` | query | `string` | no | Return only swaps, or only exchanges. Omit for both. _(one of `swap`, `exchange`)_ |\n| `status` | query | `string` | no | — _(one of `pending`, `sent`, `swapping`, `completed`, `refunded`, `failed`)_ |\n| `limit` | query | `integer` | no | — _(default `50`; min `1`; max `200`)_ |\n| `offset` | query | `integer` | no | — _(default `0`; min `0`)_ |\n\n#### Responses\n\n| Status | Body | Meaning |\n| --- | --- | --- |\n| `200` | `object` | Matching orders. |\n| `401` | `Error` | Missing, malformed or unknown API key. |\n\n**`200` response body**\n\n| Field | Type | Description |\n| --- | --- | --- |\n| `orders` **required** | `object[]` | — |\n| `orders[].order_id` **required** | `string<uuid>` | — |\n| `orders[].user_id` | `string` | Your `external_user_id`. Present on create; absent when listing or fetching. |\n| `orders[].kind` **required** | `string` | `swap` buys one of OUR assets: the output lands on the user's own deposit address and is credited to their balance. `exchange` buys a coin we do not custody: the output is paid to `destination_address` and never enters our system. _(one of `swap`, `exchange`)_ |\n| `orders[].sell_asset_id` **required** | `string` | — |\n| `orders[].buy_asset_id` **required** | `string | null` | One of our assets on a swap; null on an exchange (we have no asset row for it). |\n| `orders[].buy_asset_external` **required** | `string | null` | The exchange's identifier on an exchange (e.g. `SOL.SOL`); null on a swap. |\n| `orders[].buy_symbol` **required** | `string | null` | Display ticker of the bought coin on an exchange, so a listing needs no catalogue lookup. Null on a swap. |\n| `orders[].buy_chain` **required** | `string | null` | Chain of the bought coin on an exchange, as the exchange names it. Null on a swap. |\n| `orders[].destination_address` | `string` | Where the bought coin was paid. Present on EXCHANGE rows ONLY — echoed back so you can confirm it — and absent on a swap, whose output goes to the user's own static wallet address. This payout is irreversible and outside our custody. |\n| `orders[].sell_amount` **required** | `string` | A monetary value as a decimal string. Always paired with a `*_base` field that carries the exact integer base-unit value; compute with the `_base` string. |\n| `orders[].sell_amount_base` **required** | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |\n| `orders[].fee` **required** | `string` | Our fee, taken out of `sell_amount` — a percentage with a dollar minimum (see GET /v1/pricing), plus `network_fee`. |\n| `orders[].fee_base` **required** | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |\n| `orders[].network_fee` | `string` | The network part of `fee`, in the sold asset — a USDT ⇄ TRX trade's own gas on TRON, or an exchange's send. Already inside `fee`. |\n| `orders[].network_fee_base` | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |\n| `orders[].receive_estimate` | `string | null` | What the route estimated. A swap's is in the buy asset's major units; an exchange's is the exchange's decimal string. |\n| `orders[].receive_estimate_base` | `string | null` | Exact base units on a SWAP. Always null on an exchange. |\n| `orders[].receive_minimum` | `string | null` | The floor the route guaranteed. The user is credited what arrives, not this. |\n| `orders[].receive_minimum_base` | `string | null` | Exact base units on a SWAP. Always null on an exchange. |\n| `orders[].received` | `string | null` | What ACTUALLY arrived. Null until the exchange settles. |\n| `orders[].received_base` | `string | null` | Exact base units on a SWAP. Always null on an exchange. |\n| `orders[].providers` **required** | `string[]` | — |\n| `orders[].status` **required** | `string` | `pending` until the sell leg is on-chain, then `sent` and `swapping` while the exchange works; `completed` when it reports the payout. _(one of `pending`, `sent`, `swapping`, `completed`, `refunded`, `failed`)_ |\n| `orders[].tracking_status` | `string | null` | The exchange's own last word on the route, verbatim. Informational. |\n| `orders[].txid` | `string | null` | The on-chain SELL payment that started the order — the handle to show a customer, and the one the exchange's tracker keys on. Null until it is sent. |\n| `orders[].created_at` **required** | `string<date-time>` | — |\n| `orders[].updated_at` **required** | `string<date-time>` | — |\n\n```json\n{\n  \"orders\": [\n    {\n      \"order_id\": \"string\",\n      \"user_id\": \"string\",\n      \"kind\": \"swap\",\n      \"sell_asset_id\": \"string\",\n      \"buy_asset_id\": \"string\",\n      \"buy_asset_external\": \"string\",\n      \"buy_symbol\": \"string\",\n      \"buy_chain\": \"string\",\n      \"destination_address\": \"string\",\n      \"sell_amount\": \"1.5\",\n      \"sell_amount_base\": \"1500000000\",\n      \"fee\": \"1.5\",\n      \"fee_base\": \"1500000000\",\n      \"network_fee\": \"1.5\",\n      \"network_fee_base\": \"1500000000\",\n      \"receive_estimate\": \"string\",\n      \"receive_estimate_base\": \"string\",\n      \"receive_minimum\": \"string\",\n      \"receive_minimum_base\": \"string\",\n      \"received\": \"string\",\n      \"received_base\": \"string\",\n      \"providers\": [\n        \"string\"\n      ],\n      \"status\": \"pending\",\n      \"tracking_status\": \"string\",\n      \"txid\": \"string\",\n      \"created_at\": \"2026-08-31T09:15:00.000Z\",\n      \"updated_at\": \"2026-08-31T09:15:00.000Z\"\n    }\n  ]\n}\n```\n\n#### Example request\n\n```bash\ncurl -X GET 'https://api.cryptoinfra.build/v1/swap/orders' \\\n  -H 'Authorization: Bearer ck_live_your_key'\n```\n",
      "sections": [
        {
          "anchor": "get-v1-exchange-assets",
          "heading": "GET /v1/exchange/assets",
          "text": "List the coins an exchange can buy  ·  Auth: bearer API key  ·  operationId: listExchangeAssets\n\nThe payout catalogue — thousands of coins across dozens of chains, refreshed\nhourly from the router; total on this route is the live count. Far wider\nthan GET /v1/assets (the handful a user can HOLD with us). Nothing here is\ncustodial: these are payout destinations only.\n\nasset is the EXCHANGE's own identifier (e.g. SOL.SOL,\nETH.USDC-0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48) and is exactly what\nbuy_asset expects on POST /v1/exchange/quote and POST /v1/exchange/orders.\nIt is NOT one of our asset_id values — take it from here rather than\nconstructing it, because a value we cannot route is rejected outright.\n\ntotal counts every match; assets is capped at limit (default 100), so\ntotal greater than the array's length means \"narrow the search\", not\n\"there are only this many\".\n\nParameters\n\nName · In · Type · Required · Description\nsearch · query · string · no · Case-insensitive substring match on ticker, name or identifier.\nchain · query · string · no · Exact chain, case-insensitive (e.g. SOL). Values from GET /v1/exchange/chains.\nlimit · query · integer · no · — (default 100; min 1; max 500)\n\nResponses\n\nStatus · Body · Meaning\n200 · object · Matching payout assets.\n401 · Error · Missing, malformed or unknown API key.\n403 · Error · Swaps and exchanges are turned off, or the exchange integration is not configured.\n502 · Error · The exchange failed or was unreachable (your request was valid).\n\n200 response body\n\nField · Type · Description\ntotal required · integer · Matches BEFORE limit was applied.\nassets required · object[] · —\nassets[].asset required · string · The EXCHANGE's identifier — what buy_asset expects. NOT one of our asset_id values, and not derivable from one.\nassets[].chain required · string · Chain as the exchange names it — a wider set than our Chain enum.\nassets[].symbol required · string · —\nassets[].name required · string · —\nassets[].decimals required · integer · The coin's own decimals, on a chain we do not model. Informational: we publish no base units for it, so amounts stay decimal strings.\nassets[].contract_address required · string · null · Token contract, or null for a chain's native coin.\nassets[].logo_url required · string · null · A path on this API that serves the coin's picture (/v1/exchange/assets/{identifier}/logo). Null when the coin has none. Fetch it with your API key like any other call; it is cacheable for a week.\n\n{\n\"total\": 1,\n\"assets\": [\n{\n\"asset\": \"ETH.USDC-0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48\",\n\"chain\": \"ETH\",\n\"symbol\": \"USDC\",\n\"name\": \"USD Coin\",\n\"decimals\": 1,\n\"contract_address\": \"string\",\n\"logo_url\": \"string\"\n}\n]\n}\n\nExample request\n\ncurl -X GET 'https://api.cryptoinfra.build/v1/exchange/assets' \\\n-H 'Authorization: Bearer ck_live_your_key'"
        },
        {
          "anchor": "get-v1-exchange-chains",
          "heading": "GET /v1/exchange/chains",
          "text": "List the chains an exchange can pay out to  ·  Auth: bearer API key  ·  operationId: listExchangeChains\n\nThe distinct chains present in the catalogue, sorted — for a picker's chain filter. Feed a value back as the chain query on GET /v1/exchange/assets.\n\nResponses\n\nStatus · Body · Meaning\n200 · object · Payout chains.\n401 · Error · Missing, malformed or unknown API key.\n403 · Error · Swaps and exchanges are turned off, or the exchange integration is not configured.\n502 · Error · The exchange failed or was unreachable (your request was valid).\n\n200 response body\n\nField · Type · Description\nchains required · string[] · —\n\n{\n\"chains\": [\n\"SOL\"\n]\n}\n\nExample request\n\ncurl -X GET 'https://api.cryptoinfra.build/v1/exchange/chains' \\\n-H 'Authorization: Bearer ck_live_your_key'"
        },
        {
          "anchor": "post-v1-exchange-quote",
          "heading": "POST /v1/exchange/quote",
          "text": "Price an exchange, and validate the destination address  ·  Auth: bearer API key  ·  operationId: quoteExchange\n\nIndicative: confers no reservation and holds no balance. But it DOES validate\ndestination_address, and that is the reason to always call it first. We have\nno address library for most of these chains, so the exchange is asked at quote\ntime; a bad address comes back as 400 invalid_pair before anything is\ncharged. Quote before you create, and show the rejection to your customer.\n\nThe SELL side carries the usual *_base twin. The BUY side does not: the\ncoin's decimals belong to a chain we do not model, so receive_estimate and\nreceive_minimum are the exchange's own decimal strings. Render them as\ngiven — never scale them by an asset of ours.\n\nreceive_estimate is an estimate; receive_minimum is the floor this route\nguarantees. The customer is paid what ACTUALLY ARRIVES, which is why both are\npublished: the pair is the promise.\n\nA quote is good for at most 60 seconds, or less when the route itself expires\nsooner — expires_at carries the earlier of the two.\n\ndestination_address is optional here — omit it to price alone, send it and\nit is validated too. It is required on POST /v1/exchange/orders.\n\nRequest body\n\napplication/json · required · schema ExchangeQuoteRequest\n\nField · Type · Description\nsell_asset_id required · string · One of OUR assets (GET /v1/assets) — what is sold out of the user's balance. A disabled asset, or one with swappable: false, is refused with 400 invalid_pair (the message names it), exactly as on a swap. (minLength 1; maxLength 64)\nbuy_asset required · string · The coin to buy, as the EXCHANGE's identifier — the asset field of GET /v1/exchange/assets, e.g. SOL.SOL or ETH.USDC-0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48. This is NOT one of our asset ids; anything we cannot route is rejected with 400 invalid_pair. (minLength 1; maxLength 128)\namount required · string · number · Decimal amount of sell_asset_id, fee INCLUSIVE: our fee comes out of it and send_amount is what reaches the exchange.\ndestination_address · string · Where the bought coin is paid out. Must be valid on buy_asset's chain. Optional on a quote — omit it to price alone, send it and the exchange validates it here too. Required on POST /v1/exchange/orders. We have no address library for most of these chains, so the exchange is asked at quote time and an invalid address is refused with 400 invalid_pair — before anything is charged. Once an order is created this payout is IRREVERSIBLE: the coin never enters our custody. (minLength 1; maxLength 256)\n\nResponses\n\nStatus · Body · Meaning\n200 · ExchangeQuote · An indicative price, with the moment it stops being valid.\n400 · Error · The request was refused before anything was charged. error is one of: validation_error, invalid_amount, invalid_pair (buy_asset is not routable, the sell asset is disabled or no exchange can route it — swappable: false on GET /v1/assets, the message names it — both sides are the same coin, or destination_address is not valid for buy_asset's chain), no_route (that pair has no liquidity path at this size — nothing is broken, try another amount or coin).\n401 · Error · Missing, malformed or unknown API key.\n403 · Error · Swaps and exchanges are turned off, or the exchange integration is not configured.\n502 · Error · The exchange failed or was unreachable (your request was valid).\n503 · Error · Our side is not ready for this chain. The request was fine — retry rather than change it.\n\n200 response body — ExchangeQuote\n\nField · Type · Description\nsell_asset_id required · string · —\nbuy_asset required · string · The exchange identifier you asked for, echoed back.\nbuy_symbol required · string · —\nbuy_chain required · string · —\nsell_amount required · string · A monetary value as a decimal string. Always paired with a *_base field that carries the exact integer base-unit value; compute with the _base string.\nsell_amount_base required · string · Exact integer amount in the asset's smallest unit. (pattern ^[0-9]+$)\nfee required · string · Our fee, taken OUT of sell_amount — not charged on top.\nfee_base required · string · Exact integer amount in the asset's smallest unit. (pattern ^[0-9]+$)\nsend_amount required · string · sell_amount minus fee — what reaches the exchange, published so the numbers add up. No _base twin here.\nreceive_estimate required · string · Estimated payout, as the EXCHANGE's decimal string on its own chain. Render as given; there is no receive_estimate_base for an exchange.\nreceive_minimum required · string · The floor this route guarantees, same encoding. The customer is credited what ARRIVES, not this — treat it as the worst case you may quote them.\nquoted_at · string<date-time> · When this quote was priced; expires_at is how long the route stands.\nproviders required · string[] · The route's legs, in order.\nestimated_seconds required · integer · null · —\nexpires_at required · string<date-time> · When the quote stops being valid. Same shape as the swap quote's.\n\n{\n\"sell_asset_id\": \"string\",\n\"buy_asset\": \"SOL.SOL\",\n\"buy_symbol\": \"SOL\",\n\"buy_chain\": \"SOL\",\n\"sell_amount\": \"1.5\",\n\"sell_amount_base\": \"1500000000\",\n\"fee\": \"1.5\",\n\"fee_base\": \"1500000000\",\n\"send_amount\": \"1.5\",\n\"receive_estimate\": \"0.4193\",\n\"receive_minimum\": \"0.4109\",\n\"quoted_at\": \"2026-08-31T09:15:00.000Z\",\n\"providers\": [\n\"string\"\n],\n\"estimated_seconds\": 1,\n\"expires_at\": \"2026-08-31T04:21:00.000Z\"\n}\n\nExample request\n\ncurl -X POST 'https://api.cryptoinfra.build/v1/exchange/quote' \\\n-H 'Authorization: Bearer ck_live_your_key' \\\n-H 'Content-Type: application/json' \\\n-d '{\n\"sell_asset_id\": \"usdt_trc20\",\n\"buy_asset\": \"SOL.SOL\",\n\"amount\": \"100\"\n}'"
        },
        {
          "anchor": "post-v1-exchange-orders",
          "heading": "POST /v1/exchange/orders",
          "text": "Start an exchange — sell a held coin, pay another one out  ·  Auth: bearer API key  ·  operationId: createExchangeOrder\n\n201, and the user's balance is ALREADY debited: the sell leg is an ordinary\nwithdrawal, priced, signed and broadcast by the same code as any other payout.\nOur fee comes out of the amount, and the withdrawal fee is NOT charged on top.\n\nThe bought coin is paid to destination_address and NEVER enters our custody.\nIt cannot be reversed and it cannot be recovered — the same finality as a\nwithdrawal, which is exactly what the sell leg is. POST /v1/exchange/quote is\nthe only validation that address gets, and it costs nothing: quote first.\n\nIdempotent on idempotency_key — replaying returns the existing order instead\nof starting a second one; the same key with a DIFFERENT payload is refused with\n409 idempotency_conflict. Then poll GET /v1/swap/orders/{id}: pending until\nthe sell leg is on-chain, sent/swapping while the exchange works, and\ncompleted once it reports the payout. Nothing is ever credited to the user's\nbalance; received is what landed at destination_address.\n\nBuy-side amounts (receive_estimate, receive_minimum, received) are the\nexchange's decimal strings and their *_base twins are null.\n\nRequest body\n\napplication/json · required · schema ExchangeOrderRequest\n\nField · Type · Description\nsell_asset_id required · string · One of OUR assets (GET /v1/assets) — what is sold out of the user's balance. A disabled asset, or one with swappable: false, is refused with 400 invalid_pair (the message names it), exactly as on a swap. (minLength 1; maxLength 64)\nbuy_asset required · string · The coin to buy, as the EXCHANGE's identifier — the asset field of GET /v1/exchange/assets, e.g. SOL.SOL or ETH.USDC-0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48. This is NOT one of our asset ids; anything we cannot route is rejected with 400 invalid_pair. (minLength 1; maxLength 128)\namount required · string · number · Decimal amount of sell_asset_id, fee INCLUSIVE: our fee comes out of it and send_amount is what reaches the exchange.\ndestination_address required · string · Where the bought coin is paid out. Must be valid on buy_asset's chain. Optional on a quote — omit it to price alone, send it and the exchange validates it here too. Required on POST /v1/exchange/orders. We have no address library for most of these chains, so the exchange is asked at quote time and an invalid address is refused with 400 invalid_pair — before anything is charged. Once an order is created this payout is IRREVERSIBLE: the coin never enters our custody. (minLength 1; maxLength 256)\nuser_id required · string · Your external_user_id — whose balance is sold. (minLength 1)\nidempotency_key · string · Replaying a key returns the existing order instead of starting a second one; the same key with a different payload is 409 idempotency_conflict. (minLength 1; maxLength 255)\n\nResponses\n\nStatus · Body · Meaning\n201 · SwapOrder · Exchange started; the sell leg is queued as a withdrawal.\n400 · Error · The request was refused before anything was charged. error is one of: validation_error, invalid_amount, invalid_pair (buy_asset is not routable, the sell asset is disabled or no exchange can route it — swappable: false on GET /v1/assets, the message names it — both sides are the same coin, or destination_address is not valid for buy_asset's chain), no_route (that pair has no liquidity path at this size — nothing is broken, try another amount or coin).\n401 · Error · Missing, malformed or unknown API key.\n403 · Error · Swaps and exchanges are turned off, or the exchange integration is not configured.\n404 · Error · Unknown external_user_id for this tenant.\n409 · Error · error is one of: insufficient_balance (the sell amount would overdraw the user's balance), idempotency_conflict (this idempotency_key was already used with a DIFFERENT payload — your replay is not the request you think it is).\n502 · Error · The exchange failed or was unreachable (your request was valid).\n503 · Error · Our side is not ready for this chain. The request was fine — retry rather than change it.\n\n201 response body — SwapOrder\n\nField · Type · Description\norder_id required · string<uuid> · —\nuser_id · string · Your external_user_id. Present on create; absent when listing or fetching.\nkind required · string · swap buys one of OUR assets: the output lands on the user's own deposit address and is credited to their balance. exchange buys a coin we do not custody: the output is paid to destination_address and never enters our system. (one of swap, exchange)\nsell_asset_id required · string · —\nbuy_asset_id required · string · null · One of our assets on a swap; null on an exchange (we have no asset row for it).\nbuy_asset_external required · string · null · The exchange's identifier on an exchange (e.g. SOL.SOL); null on a swap.\nbuy_symbol required · string · null · Display ticker of the bought coin on an exchange, so a listing needs no catalogue lookup. Null on a swap.\nbuy_chain required · string · null · Chain of the bought coin on an exchange, as the exchange names it. Null on a swap.\ndestination_address · string · Where the bought coin was paid. Present on EXCHANGE rows ONLY — echoed back so you can confirm it — and absent on a swap, whose output goes to the user's own static wallet address. This payout is irreversible and outside our custody.\nsell_amount required · string · A monetary value as a decimal string. Always paired with a *_base field that carries the exact integer base-unit value; compute with the _base string.\nsell_amount_base required · string · Exact integer amount in the asset's smallest unit. (pattern ^[0-9]+$)\nfee required · string · Our fee, taken out of sell_amount — a percentage with a dollar minimum (see GET /v1/pricing), plus network_fee.\nfee_base required · string · Exact integer amount in the asset's smallest unit. (pattern ^[0-9]+$)\nnetwork_fee · string · The network part of fee, in the sold asset — a USDT ⇄ TRX trade's own gas on TRON, or an exchange's send. Already inside fee.\nnetwork_fee_base · string · Exact integer amount in the asset's smallest unit. (pattern ^[0-9]+$)\nreceive_estimate · string · null · What the route estimated. A swap's is in the buy asset's major units; an exchange's is the exchange's decimal string.\nreceive_estimate_base · string · null · Exact base units on a SWAP. Always null on an exchange.\nreceive_minimum · string · null · The floor the route guaranteed. The user is credited what arrives, not this.\nreceive_minimum_base · string · null · Exact base units on a SWAP. Always null on an exchange.\nreceived · string · null · What ACTUALLY arrived. Null until the exchange settles.\nreceived_base · string · null · Exact base units on a SWAP. Always null on an exchange.\nproviders required · string[] · —\nstatus required · string · pending until the sell leg is on-chain, then sent and swapping while the exchange works; completed when it reports the payout. (one of pending, sent, swapping, completed, refunded, failed)\ntracking_status · string · null · The exchange's own last word on the route, verbatim. Informational.\ntxid · string · null · The on-chain SELL payment that started the order — the handle to show a customer, and the one the exchange's tracker keys on. Null until it is sent.\ncreated_at required · string<date-time> · —\nupdated_at required · string<date-time> · —\n\n{\n\"order_id\": \"string\",\n\"user_id\": \"string\",\n\"kind\": \"swap\",\n\"sell_asset_id\": \"string\",\n\"buy_asset_id\": \"string\",\n\"buy_asset_external\": \"string\",\n\"buy_symbol\": \"string\",\n\"buy_chain\": \"string\",\n\"destination_address\": \"string\",\n\"sell_amount\": \"1.5\",\n\"sell_amount_base\": \"1500000000\",\n\"fee\": \"1.5\",\n\"fee_base\": \"1500000000\",\n\"network_fee\": \"1.5\",\n\"network_fee_base\": \"1500000000\",\n\"receive_estimate\": \"string\",\n\"receive_estimate_base\": \"string\",\n\"receive_minimum\": \"string\",\n\"receive_minimum_base\": \"string\",\n\"received\": \"string\",\n\"received_base\": \"string\",\n\"providers\": [\n\"string\"\n],\n\"status\": \"pending\",\n\"tracking_status\": \"string\",\n\"txid\": \"string\",\n\"created_at\": \"2026-08-31T09:15:00.000Z\",\n\"updated_at\": \"2026-08-31T09:15:00.000Z\"\n}\n\nExample request\n\ncurl -X POST 'https://api.cryptoinfra.build/v1/exchange/orders' \\\n-H 'Authorization: Bearer ck_live_your_key' \\\n-H 'Content-Type: application/json' \\\n-d '{\n\"sell_asset_id\": \"usdt_trc20\",\n\"buy_asset\": \"SOL.SOL\",\n\"amount\": \"100\",\n\"destination_address\": \"string\",\n\"user_id\": \"string\"\n}'"
        },
        {
          "anchor": "get-v1-swap-orders",
          "heading": "GET /v1/swap/orders",
          "text": "List swap and exchange orders  ·  Auth: bearer API key  ·  operationId: listSwapOrders\n\nNewest-first, scoped to your merchant. Swaps and exchanges share ONE order\nlist and one order shape; kind filters between them.\n\nA swap names one of our assets on both sides and credits the user's balance.\nAn exchange names its buy side by the exchange's identifier\n(buy_asset_external, with buy_symbol and buy_chain so a listing needs no\ncatalogue lookup) and pays out to destination_address, which is echoed on\nexchange rows only. On an exchange buy_asset_id is null, and every buy-side\n*_base field is null with it — those decimals belong to a chain we do not\nmodel, so there are no base units of ours to publish.\n\nParameters\n\nName · In · Type · Required · Description\nkind · query · string · no · Return only swaps, or only exchanges. Omit for both. (one of swap, exchange)\nstatus · query · string · no · — (one of pending, sent, swapping, completed, refunded, failed)\nlimit · query · integer · no · — (default 50; min 1; max 200)\noffset · query · integer · no · — (default 0; min 0)\n\nResponses\n\nStatus · Body · Meaning\n200 · object · Matching orders.\n401 · Error · Missing, malformed or unknown API key.\n\n200 response body\n\nField · Type · Description\norders required · object[] · —\norders[].order_id required · string<uuid> · —\norders[].user_id · string · Your external_user_id. Present on create; absent when listing or fetching.\norders[].kind required · string · swap buys one of OUR assets: the output lands on the user's own deposit address and is credited to their balance. exchange buys a coin we do not custody: the output is paid to destination_address and never enters our system. (one of swap, exchange)\norders[].sell_asset_id required · string · —\norders[].buy_asset_id required · string · null · One of our assets on a swap; null on an exchange (we have no asset row for it).\norders[].buy_asset_external required · string · null · The exchange's identifier on an exchange (e.g. SOL.SOL); null on a swap.\norders[].buy_symbol required · string · null · Display ticker of the bought coin on an exchange, so a listing needs no catalogue lookup. Null on a swap.\norders[].buy_chain required · string · null · Chain of the bought coin on an exchange, as the exchange names it. Null on a swap.\norders[].destination_address · string · Where the bought coin was paid. Present on EXCHANGE rows ONLY — echoed back so you can confirm it — and absent on a swap, whose output goes to the user's own static wallet address. This payout is irreversible and outside our custody.\norders[].sell_amount required · string · A monetary value as a decimal string. Always paired with a *_base field that carries the exact integer base-unit value; compute with the _base string.\norders[].sell_amount_base required · string · Exact integer amount in the asset's smallest unit. (pattern ^[0-9]+$)\norders[].fee required · string · Our fee, taken out of sell_amount — a percentage with a dollar minimum (see GET /v1/pricing), plus network_fee.\norders[].fee_base required · string · Exact integer amount in the asset's smallest unit. (pattern ^[0-9]+$)\norders[].network_fee · string · The network part of fee, in the sold asset — a USDT ⇄ TRX trade's own gas on TRON, or an exchange's send. Already inside fee.\norders[].network_fee_base · string · Exact integer amount in the asset's smallest unit. (pattern ^[0-9]+$)\norders[].receive_estimate · string · null · What the route estimated. A swap's is in the buy asset's major units; an exchange's is the exchange's decimal string.\norders[].receive_estimate_base · string · null · Exact base units on a SWAP. Always null on an exchange.\norders[].receive_minimum · string · null · The floor the route guaranteed. The user is credited what arrives, not this.\norders[].receive_minimum_base · string · null · Exact base units on a SWAP. Always null on an exchange.\norders[].received · string · null · What ACTUALLY arrived. Null until the exchange settles.\norders[].received_base · string · null · Exact base units on a SWAP. Always null on an exchange.\norders[].providers required · string[] · —\norders[].status required · string · pending until the sell leg is on-chain, then sent and swapping while the exchange works; completed when it reports the payout. (one of pending, sent, swapping, completed, refunded, failed)\norders[].tracking_status · string · null · The exchange's own last word on the route, verbatim. Informational.\norders[].txid · string · null · The on-chain SELL payment that started the order — the handle to show a customer, and the one the exchange's tracker keys on. Null until it is sent.\norders[].created_at required · string<date-time> · —\norders[].updated_at required · string<date-time> · —\n\n{\n\"orders\": [\n{\n\"order_id\": \"string\",\n\"user_id\": \"string\",\n\"kind\": \"swap\",\n\"sell_asset_id\": \"string\",\n\"buy_asset_id\": \"string\",\n\"buy_asset_external\": \"string\",\n\"buy_symbol\": \"string\",\n\"buy_chain\": \"string\",\n\"destination_address\": \"string\",\n\"sell_amount\": \"1.5\",\n\"sell_amount_base\": \"1500000000\",\n\"fee\": \"1.5\",\n\"fee_base\": \"1500000000\",\n\"network_fee\": \"1.5\",\n\"network_fee_base\": \"1500000000\",\n\"receive_estimate\": \"string\",\n\"receive_estimate_base\": \"string\",\n\"receive_minimum\": \"string\",\n\"receive_minimum_base\": \"string\",\n\"received\": \"string\",\n\"received_base\": \"string\",\n\"providers\": [\n\"string\"\n],\n\"status\": \"pending\",\n\"tracking_status\": \"string\",\n\"txid\": \"string\",\n\"created_at\": \"2026-08-31T09:15:00.000Z\",\n\"updated_at\": \"2026-08-31T09:15:00.000Z\"\n}\n]\n}\n\nExample request\n\ncurl -X GET 'https://api.cryptoinfra.build/v1/swap/orders' \\\n-H 'Authorization: Bearer ck_live_your_key'"
        }
      ]
    },
    {
      "slug": "cards",
      "title": "Cards",
      "summary": "Crypto debit cards — holders, KYC, issuance, top-up/withdraw, reveal, and delivery",
      "kind": "reference",
      "markdown": "# Cards\n\nCrypto debit cards — holders, KYC, issuance, top-up/withdraw, reveal, and delivery\n\n32 endpoints. Generated from `docs/openapi.yaml` v1.0.0.\n\n| Endpoint | Summary |\n| --- | --- |\n| [`GET /v1/cards/holders`](#get-v1-cards-holders) | List your card holders |\n| [`POST /v1/cards/holders`](#post-v1-cards-holders) | Register a card holder |\n| [`GET /v1/cards/summary`](#get-v1-cards-summary) | Card counts at a glance |\n| [`GET /v1/cards/orders`](#get-v1-cards-orders) | Card-order ledger |\n| [`GET /v1/cards/kyc/countries`](#get-v1-cards-kyc-countries) | KYC country catalogue |\n| [`POST /v1/cards/kyc/upload`](#post-v1-cards-kyc-upload) | Upload a KYC document |\n| [`POST /v1/cards/kyc`](#post-v1-cards-kyc) | Submit the KYC dossier |\n| [`GET /v1/cards/kyc/status`](#get-v1-cards-kyc-status) | Authoritative KYC status |\n| [`POST /v1/kyc/start`](#post-v1-kyc-start) | Start (or resume) identity verification |\n| [`GET /v1/kyc/status`](#get-v1-kyc-status) | Current KYC verdict (reconciled at read time) |\n| [`GET /v1/cards/products`](#get-v1-cards-products) | Card product catalogue |\n| [`GET /v1/cards`](#get-v1-cards) | List cards |\n| [`POST /v1/cards`](#post-v1-cards) | Issue a card |\n| [`GET /v1/cards/topup-quote`](#get-v1-cards-topup-quote) | Preview a top-up's fee and total |\n| [`GET /v1/cards/issue-quote`](#get-v1-cards-issue-quote) | Preview what issuing a card costs the cardholder |\n| [`GET /v1/cards/orders/{order_id}`](#get-v1-cards-orders-order-id) | Read a card order's status (the authoritative poll) |\n| [`GET /v1/cards/{id}`](#get-v1-cards-id) | Secure card details |\n| [`DELETE /v1/cards/{id}`](#delete-v1-cards-id) | Close (terminate) a card |\n| [`GET /v1/cards/{id}/balance`](#get-v1-cards-id-balance) | Live card balance |\n| [`POST /v1/cards/{id}/reveal`](#post-v1-cards-id-reveal) | Reveal the full card number |\n| [`GET /v1/cards/{id}/otp`](#get-v1-cards-id-otp) | Fetch the card's live one-time code |\n| [`POST /v1/cards/{id}/topup`](#post-v1-cards-id-topup) | Top up a card |\n| [`GET /v1/cards/{id}/close-quote`](#get-v1-cards-id-close-quote) | What closing this card would charge the cardholder |\n| [`GET /v1/merchant/card-fees`](#get-v1-merchant-card-fees) | Your own card fees, on top of ours |\n| [`PUT /v1/merchant/card-fees/{operation}`](#put-v1-merchant-card-fees-operation) | Set your fee on one card operation |\n| [`DELETE /v1/merchant/card-fees/{operation}`](#delete-v1-merchant-card-fees-operation) | Remove your fee on one card operation |\n| [`POST /v1/cards/{id}/withdraw`](#post-v1-cards-id-withdraw) | Retired — moving part of a card balance back to the wallet |\n| [`POST /v1/cards/{id}/freeze`](#post-v1-cards-id-freeze) | Freeze a card |\n| [`POST /v1/cards/{id}/unfreeze`](#post-v1-cards-id-unfreeze) | Unfreeze a card |\n| [`GET /v1/cards/{id}/transactions`](#get-v1-cards-id-transactions) | Card transaction history |\n| [`GET /v1/cards/delivery/regions`](#get-v1-cards-delivery-regions) | Physical-card delivery regions |\n| [`POST /v1/cards/delivery/address`](#post-v1-cards-delivery-address) | Add a physical-card delivery address |\n\n## GET /v1/cards/holders\n\n**List your card holders**  ·  _Auth: bearer API key_  ·  `operationId: listCardHolders`\n\nOne row per provider identity, newest first, with a card count and KYC state.\n\n#### Responses\n\n| Status | Body | Meaning |\n| --- | --- | --- |\n| `200` | `object` | Your card holders. |\n| `401` | `Error` | Missing, malformed or unknown API key. |\n| `503` | `Error` | Temporarily unable to serve this card call — your request was valid. Retry; nothing has been charged. `error` is one of: `provider_unavailable` — the rail did not answer, or answered unreadably. Transient. `card_rail_unavailable` — this card's own rail is not reachable (it may have been closed to new business). Other rails are unaffected, and the card itself is fine. `cards_disabled` — no card rail is enabled on this deployment at all. `rail_unfunded` — the platform cannot fund the on-chain send behind this load right now. Nothing was charged; retry later. `409 card_coins_not_on_chain` — the holder's coins are not at their own address yet (a close refund or a deposit still on its way): a load pays its own way on-chain from that address and waits for them. `details` carry `needed`. Nothing was charged; try again once they have arrived. |\n\n**`200` response body**\n\n| Field | Type | Description |\n| --- | --- | --- |\n| `holders` **required** | `object[]` | — |\n| `holders[].id` **required** | `integer` | — |\n| `holders[].user_id` **required** | `string` | Your external_user_id. |\n| `holders[].rail` **required** | `string` | Opaque, stable id of the card rail a product, holder or card sits on — and the value to pass as `provider` when a call needs disambiguating. It tells you WHICH rail, never who runs it: two cards on different rails are different arrangements of ours, and that is all you need to route a call correctly. Read it from the resource and echo it back; never hard-code one, and never parse it — the string is an identifier, not a description. |\n| `holders[].name` **required** | `string` | The holder's display name as registered. |\n| `holders[].program` **required** | `string` | — |\n| `holders[].email` **required** | `string` | The email the holder was registered with. |\n| `holders[].kyc_status` **required** | `string` |  _(one of `none`, `pending`, `approved`, `rejected`)_ |\n| `holders[].card_count` **required** | `integer` | — |\n| `holders[].created_at` **required** | `string<date-time>` | — |\n\n```json\n{\n  \"holders\": [\n    {\n      \"id\": 1,\n      \"user_id\": \"string\",\n      \"rail\": \"rail_a\",\n      \"name\": \"string\",\n      \"program\": \"string\",\n      \"email\": \"string\",\n      \"kyc_status\": \"none\",\n      \"card_count\": 1,\n      \"created_at\": \"2026-08-31T09:15:00.000Z\"\n    }\n  ]\n}\n```\n\n#### Example request\n\n```bash\ncurl -X GET 'https://api.cryptoinfra.build/v1/cards/holders' \\\n  -H 'Authorization: Bearer ck_live_your_key'\n```\n\n## POST /v1/cards/holders\n\n**Register a card holder**  ·  _Auth: bearer API key_  ·  `operationId: createCardHolder`\n\nThe first step of the card flow: registers your user at the card provider\nand persists the holder. Idempotent per (user, rail) — replaying\nreturns the existing holder untouched (still `201`). Pass `provider`\nonly when two enabled rails could both take the holder.\n\nThe same customer may hold a card at any number of merchants with the\nsame email address: the rail's account is opened under a tagged copy of\nit, so one merchant's holder never blocks another's. `409 email_taken`\n(with `details.email`) is answered only when no tag is free for that\naddress — send a different one; it is not a retryable failure.\n\n#### Request body\n\n`application/json` · required\n\n| Field | Type | Description |\n| --- | --- | --- |\n| `user_id` **required** | `string` | Your external_user_id. _(minLength `1`; maxLength `255`)_ |\n| `email` **required** | `string<email>` |  _(maxLength `255`)_ |\n| `provider` | `string` | Rail id (a product's `rail`), for the identity to register. Omit it unless two enabled rails could both take this holder. _(minLength `1`; maxLength `64`)_ |\n| `first_name` | `string` |  _(maxLength `64`)_ |\n| `last_name` | `string` |  _(maxLength `64`)_ |\n| `phone` | `string` | E.164 |\n| `date_of_birth` | `string<date>` | — |\n| `country` | `string` |  _(minLength `2`; maxLength `2`)_ |\n| `zip_code` | `string` |  _(maxLength `16`)_ |\n| `address` | `string` |  _(maxLength `128`)_ |\n| `city` | `string` |  _(maxLength `64`)_ |\n| `state` | `string` |  _(maxLength `64`)_ |\n\n#### Responses\n\n| Status | Body | Meaning |\n| --- | --- | --- |\n| `201` | `CardHolder` | Holder registered (or already existed). |\n| `400` | `Error` | Request validation failed or a domain rule was violated. `error` is one of: validation_error, invalid_request, invalid_amount, card_core_required, card_provider_required, unknown_provider, not_supported_by_provider. `card_core_required` carries `details.cores` — the card programmes this call will accept as `core_id`. It appears when you hold more than one programme that could serve the request. We refuse rather than pick, because picking would spend a balance that did not pay for the card and nothing in the response would say so. Send `core_id`. `card_provider_required` carries `details.rails` — the rail ids this call will accept as `provider`. It only ever appears when two enabled rails could both serve the request; naming a `core_id`, or having cards on just one rail, resolves it without your having to choose. |\n| `401` | `Error` | Missing, malformed or unknown API key. |\n| `404` | `Error` | Unknown external_user_id for this tenant. |\n| `409` | `Error` | A conflicting card state. `error` is one of: kyc_not_approved (details.kyc_status), card_state, card_issuing (the card is still being issued — under two minutes; `details.card_id`; poll GET /v1/cards/{id} until status_normalized is active, or wait for the card.status webhook, then retry), provider_rejected (the rail refused — DEFINITIVE, so retrying the identical call cannot help; `details.order_id` on a money call, where any charge is already reversed, and `details.provider_code` otherwise), email_taken (details.email — the rail already holds an account on that address; send a different one), idempotency_conflict, insufficient_balance, no_own_account (you have set a card fee of your own but your account has no balance of its own to credit it to — ask us to open one). |\n| `503` | `Error` | Temporarily unable to serve this card call — your request was valid. Retry; nothing has been charged. `error` is one of: `provider_unavailable` — the rail did not answer, or answered unreadably. Transient. `card_rail_unavailable` — this card's own rail is not reachable (it may have been closed to new business). Other rails are unaffected, and the card itself is fine. `cards_disabled` — no card rail is enabled on this deployment at all. `rail_unfunded` — the platform cannot fund the on-chain send behind this load right now. Nothing was charged; retry later. `409 card_coins_not_on_chain` — the holder's coins are not at their own address yet (a close refund or a deposit still on its way): a load pays its own way on-chain from that address and waits for them. `details` carry `needed`. Nothing was charged; try again once they have arrived. |\n\n**`201` response body** — `CardHolder`\n\n| Field | Type | Description |\n| --- | --- | --- |\n| `user_id` **required** | `string` | Your external_user_id. |\n| `rail` **required** | `string` | Opaque, stable id of the card rail a product, holder or card sits on — and the value to pass as `provider` when a call needs disambiguating. It tells you WHICH rail, never who runs it: two cards on different rails are different arrangements of ours, and that is all you need to route a call correctly. Read it from the resource and echo it back; never hard-code one, and never parse it — the string is an identifier, not a description. |\n| `email` **required** | `string` | — |\n| `kyc_status` **required** | `string` | Legacy issuer-KYC lifecycle on a card holder. Distinct from `KycStatus`, the cryptoinfra-owned verdict at /v1/kyc/*. _(one of `none`, `pending`, `approved`, `rejected`)_ |\n| `kyc_remark` **required** | `string | null` | Provider remark (e.g. a rejection reason); null when none. |\n\n```json\n{\n  \"user_id\": \"string\",\n  \"rail\": \"rail_a\",\n  \"email\": \"string\",\n  \"kyc_status\": \"none\",\n  \"kyc_remark\": \"string\"\n}\n```\n\n#### Example request\n\n```bash\ncurl -X POST 'https://api.cryptoinfra.build/v1/cards/holders' \\\n  -H 'Authorization: Bearer ck_live_your_key' \\\n  -H 'Content-Type: application/json' \\\n  -d '{\n  \"user_id\": \"string\",\n  \"email\": \"string\",\n  \"phone\": \"+6591234567\"\n}'\n```\n\n## GET /v1/cards/summary\n\n**Card counts at a glance**  ·  _Auth: bearer API key_  ·  `operationId: cardsSummary`\n\nTotals for a dashboard header — cards by normalized status, plus how many holders exist.\n\n#### Parameters\n\n| Name | In | Type | Required | Description |\n| --- | --- | --- | --- | --- |\n| `core_id` | query | `string` | no | Count one programme's cards only. |\n\n#### Responses\n\n| Status | Body | Meaning |\n| --- | --- | --- |\n| `200` | `object` | The counts. |\n| `401` | `Error` | Missing, malformed or unknown API key. |\n| `503` | `Error` | Temporarily unable to serve this card call — your request was valid. Retry; nothing has been charged. `error` is one of: `provider_unavailable` — the rail did not answer, or answered unreadably. Transient. `card_rail_unavailable` — this card's own rail is not reachable (it may have been closed to new business). Other rails are unaffected, and the card itself is fine. `cards_disabled` — no card rail is enabled on this deployment at all. `rail_unfunded` — the platform cannot fund the on-chain send behind this load right now. Nothing was charged; retry later. `409 card_coins_not_on_chain` — the holder's coins are not at their own address yet (a close refund or a deposit still on its way): a load pays its own way on-chain from that address and waits for them. `details` carry `needed`. Nothing was charged; try again once they have arrived. |\n\n**`200` response body**\n\n| Field | Type | Description |\n| --- | --- | --- |\n| `cards` **required** | `integer` | — |\n| `by_status` **required** | `object` | Keys are issuing/active/frozen/suspended/closed/expired. |\n| `holders` **required** | `integer` | — |\n| `on_cards` | `string` | Sum of live card balances (≤60s cache); null when none were readable. |\n| `processing` | `string` | Sum of pending holds. |\n| `balances_read` | `integer` | — |\n\n```json\n{\n  \"cards\": 1,\n  \"by_status\": {},\n  \"holders\": 1,\n  \"on_cards\": \"string\",\n  \"processing\": \"string\",\n  \"balances_read\": 1\n}\n```\n\n#### Example request\n\n```bash\ncurl -X GET 'https://api.cryptoinfra.build/v1/cards/summary' \\\n  -H 'Authorization: Bearer ck_live_your_key'\n```\n\n## GET /v1/cards/orders\n\n**Card-order ledger**  ·  _Auth: bearer API key_  ·  `operationId: listCardOrders`\n\nEvery issue, top-up, card withdrawal and close fee on your account, newest first. `card_id` filters to one card, `core_id` to one programme.\n\n#### Parameters\n\n| Name | In | Type | Required | Description |\n| --- | --- | --- | --- | --- |\n| `card_id` | query | `integer` | no | — _(min `1`)_ |\n| `core_id` | query | `string` | no | One programme's orders. |\n| `limit` | query | `integer` | no | — _(min `1`; max `500`)_ |\n\n#### Responses\n\n| Status | Body | Meaning |\n| --- | --- | --- |\n| `200` | `object` | The orders. |\n| `401` | `Error` | Missing, malformed or unknown API key. |\n| `503` | `Error` | Temporarily unable to serve this card call — your request was valid. Retry; nothing has been charged. `error` is one of: `provider_unavailable` — the rail did not answer, or answered unreadably. Transient. `card_rail_unavailable` — this card's own rail is not reachable (it may have been closed to new business). Other rails are unaffected, and the card itself is fine. `cards_disabled` — no card rail is enabled on this deployment at all. `rail_unfunded` — the platform cannot fund the on-chain send behind this load right now. Nothing was charged; retry later. `409 card_coins_not_on_chain` — the holder's coins are not at their own address yet (a close refund or a deposit still on its way): a load pays its own way on-chain from that address and waits for them. `details` carry `needed`. Nothing was charged; try again once they have arrived. |\n\n**`200` response body**\n\n| Field | Type | Description |\n| --- | --- | --- |\n| `orders` **required** | `object[]` | — |\n| `orders[].id` | `integer` | — |\n| `orders[].kind` | `string` | `close_fee` is the close fee charged to the cardholder on DELETE /v1/cards/{id}: `amount` is 0, `fee` is the charge. _(one of `topup`, `withdraw`, `issue_fee`, `close_fee`)_ |\n| `orders[].status` | `string` | — |\n| `orders[].amount` | `string` | — |\n| `orders[].fee` | `string` | — |\n| `orders[].amount_credited` | `string` | — |\n| `orders[].card_id` | `integer` | — |\n| `orders[].card_last4` | `string` | — |\n| `orders[].rail` | `string` | Opaque, stable id of the card rail a product, holder or card sits on — and the value to pass as `provider` when a call needs disambiguating. It tells you WHICH rail, never who runs it: two cards on different rails are different arrangements of ours, and that is all you need to route a call correctly. Read it from the resource and echo it back; never hard-code one, and never parse it — the string is an identifier, not a description. |\n| `orders[].user_id` | `string` | — |\n| `orders[].error` | `string` | — |\n| `orders[].txid` | `string` | The on-chain send's hash; null until broadcast or when the rail already held the money. |\n| `orders[].created_at` | `string<date-time>` | — |\n\n```json\n{\n  \"orders\": [\n    {\n      \"id\": 1,\n      \"kind\": \"topup\",\n      \"status\": \"string\",\n      \"amount\": \"string\",\n      \"fee\": \"string\",\n      \"amount_credited\": \"string\",\n      \"card_id\": 1,\n      \"card_last4\": \"string\",\n      \"rail\": \"rail_a\",\n      \"user_id\": \"string\",\n      \"error\": \"string\",\n      \"txid\": \"string\",\n      \"created_at\": \"2026-08-31T09:15:00.000Z\"\n    }\n  ]\n}\n```\n\n#### Example request\n\n```bash\ncurl -X GET 'https://api.cryptoinfra.build/v1/cards/orders' \\\n  -H 'Authorization: Bearer ck_live_your_key'\n```\n\n## GET /v1/cards/kyc/countries\n\n**KYC country catalogue**  ·  _Auth: bearer API key_  ·  `operationId: getCardKycCountries`\n\nThe provider's supported KYC countries. `value` is the ISO alpha-3 code\nto pass as `nation_code` / `country_code` in POST /v1/cards/kyc.\n\n#### Parameters\n\n| Name | In | Type | Required | Description |\n| --- | --- | --- | --- | --- |\n| `name` | query | `string` | no | Optional local case-insensitive filter on name/en_name. |\n| `provider` | query | `string` | no | Rail id (a product's `rail`); omit unless two enabled rails could both answer. |\n\n#### Responses\n\n| Status | Body | Meaning |\n| --- | --- | --- |\n| `200` | `object` | Supported countries. |\n| `400` | `Error` | Request validation failed or a domain rule was violated. `error` is one of: validation_error, invalid_request, invalid_amount, card_core_required, card_provider_required, unknown_provider, not_supported_by_provider. `card_core_required` carries `details.cores` — the card programmes this call will accept as `core_id`. It appears when you hold more than one programme that could serve the request. We refuse rather than pick, because picking would spend a balance that did not pay for the card and nothing in the response would say so. Send `core_id`. `card_provider_required` carries `details.rails` — the rail ids this call will accept as `provider`. It only ever appears when two enabled rails could both serve the request; naming a `core_id`, or having cards on just one rail, resolves it without your having to choose. |\n| `401` | `Error` | Missing, malformed or unknown API key. |\n| `503` | `Error` | Temporarily unable to serve this card call — your request was valid. Retry; nothing has been charged. `error` is one of: `provider_unavailable` — the rail did not answer, or answered unreadably. Transient. `card_rail_unavailable` — this card's own rail is not reachable (it may have been closed to new business). Other rails are unaffected, and the card itself is fine. `cards_disabled` — no card rail is enabled on this deployment at all. `rail_unfunded` — the platform cannot fund the on-chain send behind this load right now. Nothing was charged; retry later. `409 card_coins_not_on_chain` — the holder's coins are not at their own address yet (a close refund or a deposit still on its way): a load pays its own way on-chain from that address and waits for them. `details` carry `needed`. Nothing was charged; try again once they have arrived. |\n\n**`200` response body**\n\n| Field | Type | Description |\n| --- | --- | --- |\n| `countries` **required** | `object[]` | — |\n| `countries[].name` | `string` | — |\n| `countries[].en_name` | `string` | — |\n| `countries[].value` | `string` | ISO alpha-3 code. |\n| `countries[].prof_value` | `string` | — |\n| `countries[].code` | `string` | — |\n| `countries[].telephone_code` | `string` | — |\n| `countries[].need_certificate` | `boolean` | true → this country additionally needs file_type/file_url/face_url in POST /v1/cards/kyc. |\n\n```json\n{\n  \"countries\": [\n    {\n      \"name\": \"string\",\n      \"en_name\": \"string\",\n      \"value\": \"string\",\n      \"prof_value\": \"string\",\n      \"code\": \"string\",\n      \"telephone_code\": \"string\",\n      \"need_certificate\": true\n    }\n  ]\n}\n```\n\n#### Example request\n\n```bash\ncurl -X GET 'https://api.cryptoinfra.build/v1/cards/kyc/countries' \\\n  -H 'Authorization: Bearer ck_live_your_key'\n```\n\n## POST /v1/cards/kyc/upload\n\n**Upload a KYC document**  ·  _Auth: bearer API key_  ·  `operationId: uploadCardKycDocument`\n\nPasses a KYC document through to the provider and returns its hosted URL\n(feed that URL into POST /v1/cards/kyc). The bytes live only in this\nrequest — never stored on disk or in the DB. Max 5 MB decoded; the\nextension must be .png/.jpg/.jpeg/.pdf.\n\n#### Request body\n\n`application/json` · required\n\n| Field | Type | Description |\n| --- | --- | --- |\n| `user_id` **required** | `string` |  _(minLength `1`; maxLength `255`)_ |\n| `provider` | `string` | Opaque, stable id of the card rail a product, holder or card sits on — and the value to pass as `provider` when a call needs disambiguating. It tells you WHICH rail, never who runs it: two cards on different rails are different arrangements of ours, and that is all you need to route a call correctly. Read it from the resource and echo it back; never hard-code one, and never parse it — the string is an identifier, not a description. |\n| `filename` **required** | `string` |  _(minLength `1`; maxLength `255`)_ |\n| `content_base64` **required** | `string` | Base64 of the document (≤5 MB decoded). |\n\n#### Responses\n\n| Status | Body | Meaning |\n| --- | --- | --- |\n| `201` | `object` | Uploaded. |\n| `400` | `Error` | Request validation failed or a domain rule was violated. `error` is one of: validation_error, invalid_request, invalid_amount, card_core_required, card_provider_required, unknown_provider, not_supported_by_provider. `card_core_required` carries `details.cores` — the card programmes this call will accept as `core_id`. It appears when you hold more than one programme that could serve the request. We refuse rather than pick, because picking would spend a balance that did not pay for the card and nothing in the response would say so. Send `core_id`. `card_provider_required` carries `details.rails` — the rail ids this call will accept as `provider`. It only ever appears when two enabled rails could both serve the request; naming a `core_id`, or having cards on just one rail, resolves it without your having to choose. |\n| `401` | `Error` | Missing, malformed or unknown API key. |\n| `404` | `Error` | No matching card holder (`holder_not_found`), or an unknown user/payer (`user_not_found`), for this tenant. |\n| `503` | `Error` | Temporarily unable to serve this card call — your request was valid. Retry; nothing has been charged. `error` is one of: `provider_unavailable` — the rail did not answer, or answered unreadably. Transient. `card_rail_unavailable` — this card's own rail is not reachable (it may have been closed to new business). Other rails are unaffected, and the card itself is fine. `cards_disabled` — no card rail is enabled on this deployment at all. `rail_unfunded` — the platform cannot fund the on-chain send behind this load right now. Nothing was charged; retry later. `409 card_coins_not_on_chain` — the holder's coins are not at their own address yet (a close refund or a deposit still on its way): a load pays its own way on-chain from that address and waits for them. `details` carry `needed`. Nothing was charged; try again once they have arrived. |\n\n**`201` response body**\n\n| Field | Type | Description |\n| --- | --- | --- |\n| `file_url` **required** | `string` | An opaque handle for the uploaded document, valid for 24 hours and only on your account. Pass it as id_url / id_back_url / file_url / face_url / selfie_url in POST /v1/cards/kyc. It is not a link to open. _(pattern `^kycfile_[A-Za-z0-9_-]{16,64}$`)_ |\n\n```json\n{\n  \"file_url\": \"string\"\n}\n```\n\n#### Example request\n\n```bash\ncurl -X POST 'https://api.cryptoinfra.build/v1/cards/kyc/upload' \\\n  -H 'Authorization: Bearer ck_live_your_key' \\\n  -H 'Content-Type: application/json' \\\n  -d '{\n  \"user_id\": \"string\",\n  \"provider\": \"rail_a\",\n  \"filename\": \"string\",\n  \"content_base64\": \"string\"\n}'\n```\n\n## POST /v1/cards/kyc\n\n**Submit the KYC dossier**  ·  _Auth: bearer API key_  ·  `operationId: submitCardKyc`\n\nSubmits KYC to the provider and flips the holder to `pending`. We keep no\nPII — the fields go straight to the provider. Resubmission after a\nrejection is allowed; an already-approved holder is rejected\n(`400 invalid_request`). `id_back_url` is required for cert_type 1 (ID\ncard) and 3 (driver's licence).\n\n#### Request body\n\n`application/json` · required · schema `CardKycApply`\n\n| Field | Type | Description |\n| --- | --- | --- |\n| `user_id` **required** | `string` |  _(minLength `1`; maxLength `255`)_ |\n| `provider` | `string` | Opaque, stable id of the card rail a product, holder or card sits on — and the value to pass as `provider` when a call needs disambiguating. It tells you WHICH rail, never who runs it: two cards on different rails are different arrangements of ours, and that is all you need to route a call correctly. Read it from the resource and echo it back; never hard-code one, and never parse it — the string is an identifier, not a description. |\n| `first_name` **required** | `string` |  _(minLength `1`; maxLength `128`)_ |\n| `last_name` **required** | `string` |  _(minLength `1`; maxLength `128`)_ |\n| `id_no` **required** | `string` |  _(minLength `1`; maxLength `64`)_ |\n| `email` **required** | `string<email>` |  _(maxLength `255`)_ |\n| `nation_code` **required** | `string` | Nationality, ISO alpha-3 (the `value` from GET /v1/cards/kyc/countries). _(minLength `3`; maxLength `3`)_ |\n| `cert_type` **required** | `integer` | 1 = ID card, 2 = passport, 3 = driver's licence. _(one of `1`, `2`, `3`)_ |\n| `id_url` **required** | `string` | FRONT document photo — the `kycfile_…` handle from POST /v1/cards/kyc/upload, or a URL you host. _(maxLength `1024`)_ |\n| `birthday` **required** | `string` | YYYY-MM-DD. _(pattern `^\\d{4}-\\d{2}-\\d{2}$`)_ |\n| `country_code` **required** | `string` | Country of residence, ISO alpha-3. _(minLength `3`; maxLength `3`)_ |\n| `area_code` **required** | `string` | Dialing code without '+' (e.g. '86'). _(minLength `1`; maxLength `8`)_ |\n| `phone` **required** | `string` |  _(minLength `3`; maxLength `32`)_ |\n| `id_back_url` | `string` | BACK document photo (handle or URL, as id_url); REQUIRED for cert_type 1 (ID card) and 3 (driver's licence). _(maxLength `1024`)_ |\n| `file_type` | `integer` |  _(min `1`; max `100`)_ |\n| `file_url` | `string` | Handle from the upload, or a URL you host. _(maxLength `1024`)_ |\n| `face_url` | `string` | Handle from the upload, or a URL you host. _(maxLength `1024`)_ |\n| `selfie_url` | `string` | Handle from the upload, or a URL you host. _(maxLength `1024`)_ |\n| `reference_id` | `string` |  _(maxLength `128`)_ |\n| `reference_type` | `string` |  _(maxLength `64`)_ |\n\n#### Responses\n\n| Status | Body | Meaning |\n| --- | --- | --- |\n| `202` | `CardHolder` | KYC accepted; the holder's status is now pending. |\n| `400` | `Error` | Request validation failed or a domain rule was violated. `error` is one of: validation_error, invalid_request, invalid_amount, card_core_required, card_provider_required, unknown_provider, not_supported_by_provider. `card_core_required` carries `details.cores` — the card programmes this call will accept as `core_id`. It appears when you hold more than one programme that could serve the request. We refuse rather than pick, because picking would spend a balance that did not pay for the card and nothing in the response would say so. Send `core_id`. `card_provider_required` carries `details.rails` — the rail ids this call will accept as `provider`. It only ever appears when two enabled rails could both serve the request; naming a `core_id`, or having cards on just one rail, resolves it without your having to choose. |\n| `401` | `Error` | Missing, malformed or unknown API key. |\n| `404` | `Error` | No matching card holder (`holder_not_found`), or an unknown user/payer (`user_not_found`), for this tenant. |\n| `503` | `Error` | Temporarily unable to serve this card call — your request was valid. Retry; nothing has been charged. `error` is one of: `provider_unavailable` — the rail did not answer, or answered unreadably. Transient. `card_rail_unavailable` — this card's own rail is not reachable (it may have been closed to new business). Other rails are unaffected, and the card itself is fine. `cards_disabled` — no card rail is enabled on this deployment at all. `rail_unfunded` — the platform cannot fund the on-chain send behind this load right now. Nothing was charged; retry later. `409 card_coins_not_on_chain` — the holder's coins are not at their own address yet (a close refund or a deposit still on its way): a load pays its own way on-chain from that address and waits for them. `details` carry `needed`. Nothing was charged; try again once they have arrived. |\n\n**`202` response body** — `CardHolder`\n\n| Field | Type | Description |\n| --- | --- | --- |\n| `user_id` **required** | `string` | Your external_user_id. |\n| `rail` **required** | `string` | Opaque, stable id of the card rail a product, holder or card sits on — and the value to pass as `provider` when a call needs disambiguating. It tells you WHICH rail, never who runs it: two cards on different rails are different arrangements of ours, and that is all you need to route a call correctly. Read it from the resource and echo it back; never hard-code one, and never parse it — the string is an identifier, not a description. |\n| `email` **required** | `string` | — |\n| `kyc_status` **required** | `string` | Legacy issuer-KYC lifecycle on a card holder. Distinct from `KycStatus`, the cryptoinfra-owned verdict at /v1/kyc/*. _(one of `none`, `pending`, `approved`, `rejected`)_ |\n| `kyc_remark` **required** | `string | null` | Provider remark (e.g. a rejection reason); null when none. |\n\n```json\n{\n  \"user_id\": \"string\",\n  \"rail\": \"rail_a\",\n  \"email\": \"string\",\n  \"kyc_status\": \"none\",\n  \"kyc_remark\": \"string\"\n}\n```\n\n#### Example request\n\n```bash\ncurl -X POST 'https://api.cryptoinfra.build/v1/cards/kyc' \\\n  -H 'Authorization: Bearer ck_live_your_key' \\\n  -H 'Content-Type: application/json' \\\n  -d '{\n  \"user_id\": \"string\",\n  \"provider\": \"rail_a\",\n  \"first_name\": \"string\",\n  \"last_name\": \"string\",\n  \"id_no\": \"string\",\n  \"email\": \"string\",\n  \"nation_code\": \"string\",\n  \"cert_type\": 1,\n  \"id_url\": \"string\",\n  \"birthday\": \"string\",\n  \"country_code\": \"string\",\n  \"area_code\": \"string\",\n  \"phone\": \"string\"\n}'\n```\n\n## GET /v1/cards/kyc/status\n\n**Authoritative KYC status**  ·  _Auth: bearer API key_  ·  `operationId: getCardKycStatus`\n\nPolls the rail and syncs the holder row before answering. Pass `provider` only when the user holds LIVE identities on two rails at once.\n\n#### Parameters\n\n| Name | In | Type | Required | Description |\n| --- | --- | --- | --- | --- |\n| `user_id` | query | `string` | yes | — |\n| `provider` | query | `string` | no | Rail id (a holder's or product's `rail`). Omit it: we follow the rail this user's own identity is on, and only ask when the user has live identities on two rails at once. |\n\n#### Responses\n\n| Status | Body | Meaning |\n| --- | --- | --- |\n| `200` | `CardHolder` | The holder with its current KYC status. |\n| `400` | `Error` | Request validation failed or a domain rule was violated. `error` is one of: validation_error, invalid_request, invalid_amount, card_core_required, card_provider_required, unknown_provider, not_supported_by_provider. `card_core_required` carries `details.cores` — the card programmes this call will accept as `core_id`. It appears when you hold more than one programme that could serve the request. We refuse rather than pick, because picking would spend a balance that did not pay for the card and nothing in the response would say so. Send `core_id`. `card_provider_required` carries `details.rails` — the rail ids this call will accept as `provider`. It only ever appears when two enabled rails could both serve the request; naming a `core_id`, or having cards on just one rail, resolves it without your having to choose. |\n| `401` | `Error` | Missing, malformed or unknown API key. |\n| `404` | `Error` | No matching card holder (`holder_not_found`), or an unknown user/payer (`user_not_found`), for this tenant. |\n| `503` | `Error` | Temporarily unable to serve this card call — your request was valid. Retry; nothing has been charged. `error` is one of: `provider_unavailable` — the rail did not answer, or answered unreadably. Transient. `card_rail_unavailable` — this card's own rail is not reachable (it may have been closed to new business). Other rails are unaffected, and the card itself is fine. `cards_disabled` — no card rail is enabled on this deployment at all. `rail_unfunded` — the platform cannot fund the on-chain send behind this load right now. Nothing was charged; retry later. `409 card_coins_not_on_chain` — the holder's coins are not at their own address yet (a close refund or a deposit still on its way): a load pays its own way on-chain from that address and waits for them. `details` carry `needed`. Nothing was charged; try again once they have arrived. |\n\n**`200` response body** — `CardHolder`\n\n| Field | Type | Description |\n| --- | --- | --- |\n| `user_id` **required** | `string` | Your external_user_id. |\n| `rail` **required** | `string` | Opaque, stable id of the card rail a product, holder or card sits on — and the value to pass as `provider` when a call needs disambiguating. It tells you WHICH rail, never who runs it: two cards on different rails are different arrangements of ours, and that is all you need to route a call correctly. Read it from the resource and echo it back; never hard-code one, and never parse it — the string is an identifier, not a description. |\n| `email` **required** | `string` | — |\n| `kyc_status` **required** | `string` | Legacy issuer-KYC lifecycle on a card holder. Distinct from `KycStatus`, the cryptoinfra-owned verdict at /v1/kyc/*. _(one of `none`, `pending`, `approved`, `rejected`)_ |\n| `kyc_remark` **required** | `string | null` | Provider remark (e.g. a rejection reason); null when none. |\n\n```json\n{\n  \"user_id\": \"string\",\n  \"rail\": \"rail_a\",\n  \"email\": \"string\",\n  \"kyc_status\": \"none\",\n  \"kyc_remark\": \"string\"\n}\n```\n\n#### Example request\n\n```bash\ncurl -X GET 'https://api.cryptoinfra.build/v1/cards/kyc/status?user_id=string' \\\n  -H 'Authorization: Bearer ck_live_your_key'\n```\n\n## POST /v1/kyc/start\n\n**Start (or resume) identity verification**  ·  _Auth: bearer API key_  ·  `operationId: startKyc`\n\ncryptoinfra-owned KYC, independent of the card rail. Creates\n(or RESUMES) a verification session for `user_id` and returns the hosted\nURL to send the user to. `approved` is terminal — a verified user is\nnever sent around again (no `url`, `status: approved`). Call this when a\nproduct's `requires_kyc` is true before issuing it.\n\nServer-to-server only: authenticate with the tenant key and proxy from\nyour backend — never expose this to a browser (the tenant key operates\nevery card on the tenant). The verdict itself is always re-read from the\nprovider's decision endpoint; poll GET /v1/kyc/status to observe it.\n\n#### Request body\n\n`application/json` · required\n\n| Field | Type | Description |\n| --- | --- | --- |\n| `user_id` **required** | `string` | The tenant's user ref (same one that scopes card reads). _(minLength `1`; maxLength `255`)_ |\n| `language` | `string` | Optional ISO language hint for the hosted flow. _(minLength `2`; maxLength `8`)_ |\n\n#### Responses\n\n| Status | Body | Meaning |\n| --- | --- | --- |\n| `200` | `object` | Session created or resumed (or already approved). |\n| `400` | `Error` | Request validation failed or a domain rule was violated. `error` is one of: validation_error, invalid_request, invalid_amount, card_core_required, card_provider_required, unknown_provider, not_supported_by_provider. `card_core_required` carries `details.cores` — the card programmes this call will accept as `core_id`. It appears when you hold more than one programme that could serve the request. We refuse rather than pick, because picking would spend a balance that did not pay for the card and nothing in the response would say so. Send `core_id`. `card_provider_required` carries `details.rails` — the rail ids this call will accept as `provider`. It only ever appears when two enabled rails could both serve the request; naming a `core_id`, or having cards on just one rail, resolves it without your having to choose. |\n| `401` | `Error` | Missing, malformed or unknown API key. |\n| `404` | `Error` | Unknown external_user_id for this tenant. |\n| `503` | `Error` | KYC is disabled or not configured. |\n\n**`200` response body**\n\n| Field | Type | Description |\n| --- | --- | --- |\n| `status` **required** | `string` | cryptoinfra-owned identity verdict for /v1/kyc/*. Issuance of a `requires_kyc` product needs `approved`. `none` = never started. _(one of `none`, `pending`, `approved`, `declined`, `review`, `expired`)_ |\n| `url` | `string` | Hosted verification URL; absent when already approved. |\n\n```json\n{\n  \"status\": \"none\",\n  \"url\": \"string\"\n}\n```\n\n#### Example request\n\n```bash\ncurl -X POST 'https://api.cryptoinfra.build/v1/kyc/start' \\\n  -H 'Authorization: Bearer ck_live_your_key' \\\n  -H 'Content-Type: application/json' \\\n  -d '{\n  \"user_id\": \"string\"\n}'\n```\n\n## GET /v1/kyc/status\n\n**Current KYC verdict (reconciled at read time)**  ·  _Auth: bearer API key_  ·  `operationId: getKycStatus`\n\nThe user's verdict, re-read from the provider while it can still change\n(`pending`/`review`); terminal states are not re-fetched. `none` when the\nuser has never started verification.\n\n#### Parameters\n\n| Name | In | Type | Required | Description |\n| --- | --- | --- | --- | --- |\n| `user_id` | query | `string` | yes | — |\n\n#### Responses\n\n| Status | Body | Meaning |\n| --- | --- | --- |\n| `200` | `object` | The current verdict. |\n| `400` | `Error` | Request validation failed or a domain rule was violated. `error` is one of: validation_error, invalid_request, invalid_amount, card_core_required, card_provider_required, unknown_provider, not_supported_by_provider. `card_core_required` carries `details.cores` — the card programmes this call will accept as `core_id`. It appears when you hold more than one programme that could serve the request. We refuse rather than pick, because picking would spend a balance that did not pay for the card and nothing in the response would say so. Send `core_id`. `card_provider_required` carries `details.rails` — the rail ids this call will accept as `provider`. It only ever appears when two enabled rails could both serve the request; naming a `core_id`, or having cards on just one rail, resolves it without your having to choose. |\n| `401` | `Error` | Missing, malformed or unknown API key. |\n| `404` | `Error` | Unknown external_user_id for this tenant. |\n\n**`200` response body**\n\n| Field | Type | Description |\n| --- | --- | --- |\n| `status` **required** | `string` | cryptoinfra-owned identity verdict for /v1/kyc/*. Issuance of a `requires_kyc` product needs `approved`. `none` = never started. _(one of `none`, `pending`, `approved`, `declined`, `review`, `expired`)_ |\n\n```json\n{\n  \"status\": \"none\"\n}\n```\n\n#### Example request\n\n```bash\ncurl -X GET 'https://api.cryptoinfra.build/v1/kyc/status?user_id=string' \\\n  -H 'Authorization: Bearer ck_live_your_key'\n```\n\n## GET /v1/cards/products\n\n**Card product catalogue**  ·  _Auth: bearer API key_  ·  `operationId: listCardProducts`\n\nEvery issuable product, across every card programme you can issue from —\nthe source of truth for the `product_code` and `core_id` you pass to\nPOST /v1/cards. Read both from here; never hard-code either. A programme\nthat is closed to new cards simply does not appear, so an empty list\nmeans nothing is currently on sale rather than that the feature is\nbroken.\n\nEach row carries the programme it belongs to (`core_id`, `core_name`)\nand is PRICED FOR THAT PROGRAMME. The same BIN under two programmes can\ncost different amounts, so compare prices by (`core_id`, `card_bin`) —\nnever by BIN alone.\n\nEach row also carries what the user pays: `issue_fee` (our one-time\nissuance fee for THAT product, on THAT programme), `min_opening_balance`\n— the smallest `initial_amount` the issue will accept — and\n`issue_total`, everything the cardholder is debited to open the card at\nthat minimum: our issue fee, the opening balance priced as a load (our\nload fee + gas), and your own fees if set. Budget from `issue_total`\n(or `total` on GET /v1/cards/issue-quote for another opening balance);\nnever add `issue_fee` and `initial_amount` yourself.\n\nThe envelope's top-level `issue_fee` is deprecated (virtual-card fee off\nthe default row only) — use `products[].issue_fee`.\n\n#### Parameters\n\n| Name | In | Type | Required | Description |\n| --- | --- | --- | --- | --- |\n| `core_id` | query | `string` | no | Show one programme's shelf, priced for it. Omit to see every programme on offer. |\n\n#### Responses\n\n| Status | Body | Meaning |\n| --- | --- | --- |\n| `200` | `object` | Products. |\n| `401` | `Error` | Missing, malformed or unknown API key. |\n| `503` | `Error` | Temporarily unable to serve this card call — your request was valid. Retry; nothing has been charged. `error` is one of: `provider_unavailable` — the rail did not answer, or answered unreadably. Transient. `card_rail_unavailable` — this card's own rail is not reachable (it may have been closed to new business). Other rails are unaffected, and the card itself is fine. `cards_disabled` — no card rail is enabled on this deployment at all. `rail_unfunded` — the platform cannot fund the on-chain send behind this load right now. Nothing was charged; retry later. `409 card_coins_not_on_chain` — the holder's coins are not at their own address yet (a close refund or a deposit still on its way): a load pays its own way on-chain from that address and waits for them. `details` carry `needed`. Nothing was charged; try again once they have arrived. |\n\n**`200` response body**\n\n| Field | Type | Description |\n| --- | --- | --- |\n| `asset_id` **required** | `string` | — |\n| `gas` | `string` | The network line inside every `issue_total` below, priced for this response. |\n| `gas_dynamic` | `boolean` | true = priced live now; false = the static fallback is inside the totals. |\n| `gas_priced_at` | `string<date-time>` | — |\n| `issue_fee` | `string` | Deprecated — virtual-card fee. Use products[].issue_fee. |\n| `issue_fee_base` | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |\n| `products` **required** | `object[]` | — |\n| `products[].id` **required** | `integer | null` | Numeric id, kept for back-compat; null when the product code is not numeric. |\n| `products[].rail` **required** | `string` | Opaque, stable id of the card rail a product, holder or card sits on — and the value to pass as `provider` when a call needs disambiguating. It tells you WHICH rail, never who runs it: two cards on different rails are different arrangements of ours, and that is all you need to route a call correctly. Read it from the resource and echo it back; never hard-code one, and never parse it — the string is an identifier, not a description. |\n| `products[].core_id` | `string` | The card programme this row belongs to — pass it as `core_id` to POST /v1/cards. Every price on this row is resolved FOR this programme, so compare prices by (`core_id`, `card_bin`). |\n| `products[].core_name` | `string` | What this programme is called, for display. e.g. `Core-1`. |\n| `products[].code` **required** | `string` | Provider-scoped product code — pass as `product_code` to POST /v1/cards. |\n| `products[].card_nature` **required** | `string` |  _(one of `VIRTUAL`, `PHYSICAL`)_ |\n| `products[].ccy` **required** | `string` | Card currency (only USD is supported for money operations). |\n| `products[].region` | `string | null` | — |\n| `products[].card_bin` | `string | null` | — |\n| `products[].card_brand` | `string | null` | — |\n| `products[].card_mode` | `string | null` | — |\n| `products[].title` | `string | null` | — |\n| `products[].requires_kyc` | `boolean` | Whether getting this card requires an ID check (cryptoinfra-owned KYC via /v1/kyc/*). Per-BIN; `false` while KYC is disabled. Show it on the shelf (\"ID check required\") so a customer is not refused at issuance — a card whose `requires_kyc` is true rejects POST /v1/cards with 409 `kyc_not_approved` until the user's status is `approved`. |\n| `products[].active_min_limit` | `string | null` | Minimum FIRST top-up that activates a virtual card (decimal string); null when none. |\n| `products[].recharge_min_limit` | `string | null` | Minimum single top-up (decimal string); null when the provider imposes none. |\n| `products[].withdraw_min_limit` | `string | null` | Minimum single withdraw/unload the issuer will process (decimal string); null when the provider does not declare one. A residual balance below this cannot be withdrawn — surface it so a user can be warned before trying to take the last few dollars off a card. |\n| `products[].issue_fee` **required** | `string` | OUR issue fee for THIS product. |\n| `products[].issue_fee_base` **required** | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |\n| `products[].merchant_issue_fee` | `string` | Your own issue fee (PUT /v1/merchant/card-fees/issue). \"0\" until you set one. |\n| `products[].issue_total` | `string` | Everything the cardholder is debited to issue this card at `min_opening_balance`: our issue fee, the opening balance priced as a load (our load fee + gas), and your own fees. A card opened with more pays the load parts on more. |\n| `products[].issue_total_base` | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |\n| `products[].charged_to` | `string` |  _(one of `cardholder`)_ |\n| `products[].load_fee_bps` **required** | `integer` | Our load fee for cards of this product, basis points of the amount loaded. |\n| `products[].load_fee_min` **required** | `string` | The floor on that load fee, in USD. |\n| `products[].load_fee_flat` **required** | `string` | A flat platform fee on every load, in USD, on top of the percentage (or its floor). |\n| `products[].close_fee` | `string` | The flat part of OUR fee to close a card of this product — our own fee plus the issuer's cancellation fee billed to us — debited from the cardholder's balance when the card terminates. The live network figure for returning the balance on-chain is added on top; `GET /v1/cards/{id}/close-quote` prices a specific card, `gas` and all. The issuer's own cancellation charge is separate and stays on the card — see `cardholder_fees.cancellation`. |\n| `products[].close_fee_base` | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |\n| `products[].cardholder_fees` **required** | `object | null` | What the CARDHOLDER pays when the card is used, per BIN — separate from `issue_fee` / `load_fee_bps`, which are charged when you issue and load. Surface them, or a $3.86 purchase that took $4.11 becomes a support ticket. null for a BIN whose terms we have not transcribed. |\n| `products[].cardholder_fees.per_transaction` | `string` | USD |\n| `products[].cardholder_fees.three_d_secure` | `string` | USD |\n| `products[].cardholder_fees.foreign_currency_pct` | `string` | Percent |\n| `products[].cardholder_fees.atm_flat` | `string` | USD |\n| `products[].cardholder_fees.atm_pct` | `string` | Percent |\n| `products[].cardholder_fees.cancellation` | `string` | USD |\n| `products[].cardholder_fees.dispute` | `string` | USD |\n| `products[].cardholder_fees.monthly_service` | `string | null` | Recurring holding fee; null when there is none. |\n| `products[].limits` **required** | `object | null` | Ceilings the cardholder hits, per BIN. null with `cardholder_fees`. |\n| `products[].limits.per_transaction` | `string` | — |\n| `products[].limits.daily` | `string` | — |\n| `products[].limits.monthly` | `string` | — |\n| `products[].limits.atm_single` | `string` | — |\n| `products[].limits.atm_per_day` | `integer` | — |\n| `products[].limits.atm_monthly` | `string` | — |\n| `products[].limits.cards_per_user` | `integer` | How many of this card one rail account may hold. Not a ceiling on your customer — when it is reached |\n| `products[].limits.validity_years` | `integer` | Years from issue. |\n| `products[].min_opening_balance` | `string` | Smallest `initial_amount` this product's issue accepts, in USD. Read it rather than hard-coding a minimum: it tracks platform policy, the issuer's floor and the product's own activation minimum, whichever is highest. |\n| `products[].min_opening_balance_base` | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |\n\n```json\n{\n  \"asset_id\": \"usdt_trc20\",\n  \"gas\": \"1.5\",\n  \"gas_dynamic\": true,\n  \"gas_priced_at\": \"2026-08-31T09:15:00.000Z\",\n  \"issue_fee\": \"1.5\",\n  \"issue_fee_base\": \"1500000000\",\n  \"products\": [\n    {\n      \"id\": 1,\n      \"rail\": \"rail_a\",\n      \"core_id\": \"string\",\n      \"core_name\": \"string\",\n      \"code\": \"string\",\n      \"card_nature\": \"VIRTUAL\",\n      \"ccy\": \"string\",\n      \"region\": \"string\",\n      \"card_bin\": \"string\",\n      \"card_brand\": \"string\",\n      \"card_mode\": \"string\",\n      \"title\": \"string\",\n      \"requires_kyc\": true,\n      \"active_min_limit\": \"string\",\n      \"recharge_min_limit\": \"string\",\n      \"withdraw_min_limit\": \"string\",\n      \"issue_fee\": \"1.5\",\n      \"issue_fee_base\": \"1500000000\",\n      \"merchant_issue_fee\": \"1.5\",\n      \"issue_total\": \"1.5\",\n      \"issue_total_base\": \"1500000000\",\n      \"charged_to\": \"cardholder\",\n      \"load_fee_bps\": 1,\n      \"load_fee_min\": \"1.5\",\n      \"load_fee_flat\": \"1.5\",\n      \"close_fee\": \"1.5\",\n      \"close_fee_base\": \"1500000000\",\n      \"cardholder_fees\": {\n        \"per_transaction\": \"string\",\n        \"three_d_secure\": \"string\",\n        \"foreign_currency_pct\": \"string\",\n        \"atm_flat\": \"string\",\n        \"atm_pct\": \"string\",\n        \"cancellation\": \"string\",\n        \"dispute\": \"string\",\n        \"monthly_service\": \"string\"\n      },\n      \"limits\": {\n        \"per_transaction\": \"string\",\n        \"daily\": \"string\",\n        \"monthly\": \"string\",\n        \"atm_single\": \"string\",\n        \"atm_per_day\": 1,\n        \"atm_monthly\": \"string\",\n        \"cards_per_user\": 1,\n        \"validity_years\": 1\n      },\n      \"min_opening_balance\": \"5.000000\",\n      \"min_opening_balance_base\": \"1500000000\"\n    }\n  ]\n}\n```\n\n#### Example request\n\n```bash\ncurl -X GET 'https://api.cryptoinfra.build/v1/cards/products' \\\n  -H 'Authorization: Bearer ck_live_your_key'\n```\n\n## GET /v1/cards\n\n**List cards**  ·  _Auth: bearer API key_  ·  `operationId: listCards`\n\nWith `user_id`: that user's cards across all their provider identities\n(404 when the user has no card holder). Without it: every card on your\naccount, newest first — each row additionally carries `user_id`,\n`cardholder_name`, the holder's `program`, and a cached (≤60 s) live\n`balance` / `pending` (null when unreadable, never a fake zero), so any\nper-card endpoint can be called from a row. `core_id` narrows either\nform to one programme.\n\n#### Parameters\n\n| Name | In | Type | Required | Description |\n| --- | --- | --- | --- | --- |\n| `user_id` | query | `string` | no | — |\n| `core_id` | query | `string` | no | One programme's cards only. |\n\n#### Responses\n\n| Status | Body | Meaning |\n| --- | --- | --- |\n| `200` | `object` | The cards. |\n| `401` | `Error` | Missing, malformed or unknown API key. |\n| `404` | `Error` | No matching card holder (`holder_not_found`), or an unknown user/payer (`user_not_found`), for this tenant. |\n| `503` | `Error` | Temporarily unable to serve this card call — your request was valid. Retry; nothing has been charged. `error` is one of: `provider_unavailable` — the rail did not answer, or answered unreadably. Transient. `card_rail_unavailable` — this card's own rail is not reachable (it may have been closed to new business). Other rails are unaffected, and the card itself is fine. `cards_disabled` — no card rail is enabled on this deployment at all. `rail_unfunded` — the platform cannot fund the on-chain send behind this load right now. Nothing was charged; retry later. `409 card_coins_not_on_chain` — the holder's coins are not at their own address yet (a close refund or a deposit still on its way): a load pays its own way on-chain from that address and waits for them. `details` carry `needed`. Nothing was charged; try again once they have arrived. |\n\n**`200` response body**\n\n| Field | Type | Description |\n| --- | --- | --- |\n| `user_id` | `string` | Echoed only on the one-user form. |\n| `cards` **required** | `object[]` | — |\n| `cards[].id` **required** | `integer` | — |\n| `cards[].rail` **required** | `string` | Opaque, stable id of the card rail a product, holder or card sits on — and the value to pass as `provider` when a call needs disambiguating. It tells you WHICH rail, never who runs it: two cards on different rails are different arrangements of ours, and that is all you need to route a call correctly. Read it from the resource and echo it back; never hard-code one, and never parse it — the string is an identifier, not a description. |\n| `cards[].core_id` **required** | `string | null` | The programme this card was issued from — the pair (`core_id`, `bin`) is what prices its loads and close. A programme holds no money; the cardholder pays. null on cards issued before programmes existed. |\n| `cards[].bin` **required** | `string | null` | The card's BIN. Read it WITH `core_id`, never alone: a BIN number is unique only inside its issuer, so the same number under a different programme is a different card with different fees, limits and validity. |\n| `cards[].product_id` **required** | `string | null` | Provider-scoped product CODE (kept as an alias of product_code); null when the card was listed untagged. |\n| `cards[].product_code` **required** | `string | null` | Provider-scoped product code; null when the card was listed untagged. |\n| `cards[].card_nature` **required** | `string` |  _(one of `VIRTUAL`, `PHYSICAL`)_ |\n| `cards[].status` **required** | `integer` | The provider's RAW status code, verbatim — interpret via status_normalized. |\n| `cards[].status_normalized` **required** | `string` | Provider-neutral status. A just-issued card is `issuing`; it becomes `active` once the opening balance has landed at the issuer. _(one of `issuing`, `active`, `frozen`, `suspended`, `closed`, `expired`)_ |\n| `cards[].currency` **required** | `string | null` | — |\n| `cards[].last4` **required** | `string | null` | — |\n| `cards[].name_on_card` **required** | `string | null` | The cardholder name you gave at issue — our record, whether or not the BIN prints it on the plastic. |\n| `cards[].issued_at` **required** | `string<date-time>` | — |\n| `cards[].user_id` | `string` | The cardholder's external_user_id. |\n| `cards[].cardholder_name` | `string | null` | — |\n| `cards[].program` | `string | null` | — |\n| `cards[].balance` | `string | null` | Live available balance (≤60 s cache); null when unreadable. |\n| `cards[].pending` | `string | null` | Holds in flight; null when the rail does not expose them or the read failed. |\n\n```json\n{\n  \"user_id\": \"string\",\n  \"cards\": [\n    {\n      \"id\": 1,\n      \"rail\": \"rail_a\",\n      \"core_id\": \"string\",\n      \"bin\": \"string\",\n      \"product_id\": \"string\",\n      \"product_code\": \"string\",\n      \"card_nature\": \"VIRTUAL\",\n      \"status\": 1,\n      \"status_normalized\": \"issuing\",\n      \"currency\": \"string\",\n      \"last4\": \"string\",\n      \"name_on_card\": \"string\",\n      \"issued_at\": \"2026-08-31T09:15:00.000Z\",\n      \"user_id\": \"string\",\n      \"cardholder_name\": \"string\",\n      \"program\": \"string\",\n      \"balance\": \"string\",\n      \"pending\": \"string\"\n    }\n  ]\n}\n```\n\n#### Example request\n\n```bash\ncurl -X GET 'https://api.cryptoinfra.build/v1/cards' \\\n  -H 'Authorization: Bearer ck_live_your_key'\n```\n\n## POST /v1/cards\n\n**Issue a card**  ·  _Auth: bearer API key_  ·  `operationId: issueCard`\n\n**A card is issued FUNDED.** `initial_amount` is required and must be at\nleast the product's `min_opening_balance` from GET /v1/cards/products\n(read that value, do not hard-code a minimum — it tracks platform policy\nand the issuer's own floor, whichever is higher). The cardholder is\ndebited `total` from GET /v1/cards/issue-quote — our issue fee, the\nopening balance priced as a load (our load fee + gas), and your own fees\nif set — at request time, and refunded together if the application\ndefinitively fails. Read `issue_total` on the shelf or `total` on the\nquote; never add the parts yourself.\n\nPass one of `product_id` / `product_code`, taken from\nGET /v1/cards/products rather than hard-coded. `provider` is almost\nnever needed: the product code names its own rail.\n`delivery_address_id` (from POST /v1/cards/delivery/address) is required\nfor PHYSICAL products. Requires an `approved` holder. `idempotency_key`\nis required and replay-safe. The holder pays: our issue fee, the opening\nbalance priced as a load, gas, and your own fees if you set any — see\n`issue_total` on GET /v1/cards/products.\n\n**201** = the card exists (`card` populated). **202** = the application\nis still resolving (`card` is null; a `card.status` webhook or the\nreconciler links it later). Treat 202 as PENDING.\n\n**The normal answer is 202.** The opening balance is sent on-chain to\nthe rail first, so the response is `202` with `order.status: funding`\n(then `requested`/`sent`); the card is applied for once the rail shows\nthe credit and arrives on the `card.status` webhook, usually within\nminutes.\n\n**A 201 card is not spendable yet.** It is returned with\n`status_normalized: issuing`, and turns `active` only once the opening\nbalance has landed at the issuer — usually minutes. An integrator that\npolls for `active` immediately after the 201 will see `issuing` first;\nthat is the normal path, not an error. Watch `GET /v1/cards/{id}` or\nthe `card.status` webhook for the flip.\n\n#### Request body\n\n`application/json` · required\n\n| Field | Type | Description |\n| --- | --- | --- |\n| `user_id` **required** | `string` |  _(minLength `1`; maxLength `255`)_ |\n| `core_id` | `string` | Which card programme to issue from — the `core_id` from the product in GET /v1/cards/products. Send it. It names both the balance that pays and the BIN that prices, so it is the only thing that stays correct once you hold a second programme. With exactly one programme open it may be omitted; with two, an omitted `core_id` is refused with `card_core_required` (the acceptable values ride along in the error) rather than charged to a balance you did not mean. _(minLength `1`; maxLength `64`)_ |\n| `provider` | `string` | Deprecated in favour of `core_id`. Which card rail to issue on — the `rail` value from the product in GET /v1/cards/products. Ignored when `core_id` is sent. _(minLength `1`; maxLength `64`)_ |\n| `product_id` | `integer` | Numeric product id, kept for back-compat. One of product_id / product_code is required. _(min `1`)_ |\n| `product_code` | `string` | Provider-scoped product code (preferred; wins when both are sent). _(minLength `1`; maxLength `64`)_ |\n| `delivery_address_id` | `integer` | Required for PHYSICAL products (from POST /v1/cards/delivery/address). _(min `1`)_ |\n| `idempotency_key` **required** | `string` | Required; replay-safe. _(minLength `1`; maxLength `255`)_ |\n| `name_on_card` | `string` | Embossed/printed cardholder name. Uppercased on the way to the issuer. **Not every card is PRINTED with one.** BIN 493 has no cardholder name on the plastic — its issuer has no such field. We keep it regardless. The name you send is stored against the card and returned by every card read, because a list of cards has to be readable and `last4` does not tell one cardholder from another. It reaches the issuer only on a rail that can emboss it. _(minLength `2`; maxLength `26`; pattern `^[A-Za-z][A-Za-z .'-]*$`)_ |\n| `initial_amount` **required** | `string` | **Required.** Opening balance in USD (whole cents), on the card the moment it opens. Cards are issued FUNDED: an empty card is not a usable card, and on some rails it does not even activate until a first load lands. The minimum is `min_opening_balance` from `GET /v1/cards/products` — read it, do not hard-code it; it moves with platform policy and with the issuer's own floor. The cardholder is debited `issue_total`: our issue fee, the opening balance priced as a load (our load fee + gas), and your own fees if you set any. Read `issue_total` from GET /v1/cards/products or `total` from GET /v1/cards/issue-quote rather than adding `issue_fee` and `initial_amount`. (There is no netting: an issuer's own mandatory opening credit is NOT discounted out of the fee.) Below `min_opening_balance` the issue is refused with `400 invalid_request`; the message names the required amount. `min_opening_balance` is computed per product and already includes the product's own activation minimum (`active_min_limit`), so opening at it is enough for the card to come alive. _(pattern `^\\d+(\\.\\d+)?$`)_ |\n\n#### Responses\n\n| Status | Body | Meaning |\n| --- | --- | --- |\n| `201` | `CardIssueResult` | Card issued. `card.status_normalized` is `issuing` — it becomes `active` once the opening balance lands (see the description). |\n| `202` | `CardIssueResult` | Application accepted, still resolving (`card` is null). The usual answer: `order.status` is `funding` while the opening balance travels on-chain to the rail. |\n| `400` | `Error` | Request validation failed or a domain rule was violated. `error` is one of: validation_error, invalid_request, invalid_amount, card_core_required, card_provider_required, unknown_provider, not_supported_by_provider. `card_core_required` carries `details.cores` — the card programmes this call will accept as `core_id`. It appears when you hold more than one programme that could serve the request. We refuse rather than pick, because picking would spend a balance that did not pay for the card and nothing in the response would say so. Send `core_id`. `card_provider_required` carries `details.rails` — the rail ids this call will accept as `provider`. It only ever appears when two enabled rails could both serve the request; naming a `core_id`, or having cards on just one rail, resolves it without your having to choose. |\n| `401` | `Error` | Missing, malformed or unknown API key. |\n| `404` | `Error` | No matching card holder (`holder_not_found`), or an unknown user/payer (`user_not_found`), for this tenant. |\n| `409` | `Error` | A conflicting card state. `error` is one of: kyc_not_approved (details.kyc_status), card_state, card_issuing (the card is still being issued — under two minutes; `details.card_id`; poll GET /v1/cards/{id} until status_normalized is active, or wait for the card.status webhook, then retry), provider_rejected (the rail refused — DEFINITIVE, so retrying the identical call cannot help; `details.order_id` on a money call, where any charge is already reversed, and `details.provider_code` otherwise), email_taken (details.email — the rail already holds an account on that address; send a different one), idempotency_conflict, insufficient_balance, no_own_account (you have set a card fee of your own but your account has no balance of its own to credit it to — ask us to open one). |\n| `503` | `Error` | Temporarily unable to serve this card call — your request was valid. Retry; nothing has been charged. `error` is one of: `provider_unavailable` — the rail did not answer, or answered unreadably. Transient. `card_rail_unavailable` — this card's own rail is not reachable (it may have been closed to new business). Other rails are unaffected, and the card itself is fine. `cards_disabled` — no card rail is enabled on this deployment at all. `rail_unfunded` — the platform cannot fund the on-chain send behind this load right now. Nothing was charged; retry later. `409 card_coins_not_on_chain` — the holder's coins are not at their own address yet (a close refund or a deposit still on its way): a load pays its own way on-chain from that address and waits for them. `details` carry `needed`. Nothing was charged; try again once they have arrived. |\n\n**`201` response body** — `CardIssueResult`\n\n| Field | Type | Description |\n| --- | --- | --- |\n| `order` **required** | `object` | A money order against a card (top-up, card→wallet withdraw, or issue fee). See CardOrderStatus for the lifecycle; a `card.topup` / `card.withdraw` / `card.status` webhook (or the reconciler) settles it. |\n| `order.id` **required** | `integer` | — |\n| `order.kind` **required** | `string` | `close_fee` is the close fee charged to the cardholder on DELETE /v1/cards/{id}: `amount` is 0, `fee` is the charge. _(one of `topup`, `withdraw`, `issue_fee`, `close_fee`)_ |\n| `order.status` **required** | `string` | `funding` = the cardholder has been charged and the money is on its way to the card rail on-chain. `requested` = confirmed on-chain, waiting for the rail to show the credit. `sent` = the rail has been asked, outcome still pending. `confirmed` = settled. `failed` = definitively failed; the WHOLE charge — `amount`, `fee`, `gas` and `merchant_fee`, i.e. `total` — is refunded to the balance it was taken from, the cardholder's, in the same moment (top-up / issue only — a withdraw holds nothing). Nothing goes anywhere else: not to the card, not to your treasury. When the on-chain send behind a load reverted (`provider_status: send_failed`), no coins left the cardholder's address either, so on-chain and ledger agree. `reversed` is reserved for manual operator reversals of a confirmed order. _(one of `funding`, `requested`, `sent`, `confirmed`, `failed`, `reversed`)_ |\n| `order.txid` | `string | null` | The hash of the on-chain send behind a load or an issue's opening balance — link your receipt to it the way you do for a deposit or a payout. Null until the send is broadcast, and for an order the rail served from its float. A `failed` order keeps the hash of the send that reverted, the proof of what happened; after a re-send it is the current send's hash and the reverted one is named in `error`. Always null on a withdraw order. |\n| `order.asset_id` **required** | `string` | — |\n| `order.amount` **required** | `string` | A monetary value as a decimal string. Always paired with a `*_base` field that carries the exact integer base-unit value; compute with the `_base` string. |\n| `order.amount_base` **required** | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |\n| `order.fee` **required** | `string` | A monetary value as a decimal string. Always paired with a `*_base` field that carries the exact integer base-unit value; compute with the `_base` string. |\n| `order.fee_base` **required** | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |\n| `order.gas` | `string` | The network fee actually CHARGED for the on-chain send behind a load or issue — the figure the cardholder was debited, fixed when the order was created. Not the quote's figure: reconcile a quote against this. |\n| `order.merchant_fee` | `string` | Your own fee on this order, credited to your balance. |\n| `order.total` | `string` | Everything the cardholder was debited for this order. |\n| `order.sent_on_chain` | `string` | What the on-chain send behind this order carried (\"0\" when the rail already held it). It is the attempt, not the outcome: on a `failed` order whose send reverted, this still reads the amount that was sent for and nothing moved. |\n| `order.amount_credited` **required** | `string | null` | WITHDRAW only: the NET actually credited after the provider's unload tariff, which differs per rail. `null` until a withdraw settles `confirmed`, and always `null` for top-ups / issue fees. Credit your user from THIS (or `amount_credited_base`), never from `amount` — the requested gross — or you over-credit by the tariff on every unload. |\n| `order.amount_credited_base` **required** | `string | null` | Integer-string base units of `amount_credited` (`null` until a withdraw confirms). |\n| `order.card_id` **required** | `integer | null` | — |\n| `order.request_order_id` **required** | `string | null` | Server-minted correlation handle (`ct-`/`cw-`/`ci-` prefix). |\n| `order.provider_status` **required** | `string | null` | Where a `failed` order failed: `send_failed` = the on-chain send to the rail reverted (our side; the charge was refunded, nothing left the address); anything else is the rail's own word for its refusal. Informational — act on `status`. |\n| `order.error` **required** | `string | null` | — |\n| `order.idempotency_key` **required** | `string | null` | — |\n| `order.created_at` **required** | `string<date-time>` | — |\n| `card` **required** | `object | null` | The issued card, or null while the application is still resolving (202). |\n| `card.id` **required** | `integer` | — |\n| `card.rail` **required** | `string` | Opaque, stable id of the card rail a product, holder or card sits on — and the value to pass as `provider` when a call needs disambiguating. It tells you WHICH rail, never who runs it: two cards on different rails are different arrangements of ours, and that is all you need to route a call correctly. Read it from the resource and echo it back; never hard-code one, and never parse it — the string is an identifier, not a description. |\n| `card.core_id` **required** | `string | null` | The programme this card was issued from — the pair (`core_id`, `bin`) is what prices its loads and close. A programme holds no money; the cardholder pays. null on cards issued before programmes existed. |\n| `card.bin` **required** | `string | null` | The card's BIN. Read it WITH `core_id`, never alone: a BIN number is unique only inside its issuer, so the same number under a different programme is a different card with different fees, limits and validity. |\n| `card.product_id` **required** | `string | null` | Provider-scoped product CODE (kept as an alias of product_code); null when the card was listed untagged. |\n| `card.product_code` **required** | `string | null` | Provider-scoped product code; null when the card was listed untagged. |\n| `card.card_nature` **required** | `string` |  _(one of `VIRTUAL`, `PHYSICAL`)_ |\n| `card.status` **required** | `integer` | The provider's RAW status code, verbatim — interpret via status_normalized. |\n| `card.status_normalized` **required** | `string` | Provider-neutral status. A just-issued card is `issuing`; it becomes `active` once the opening balance has landed at the issuer. _(one of `issuing`, `active`, `frozen`, `suspended`, `closed`, `expired`)_ |\n| `card.currency` **required** | `string | null` | — |\n| `card.last4` **required** | `string | null` | — |\n| `card.name_on_card` **required** | `string | null` | The cardholder name you gave at issue — our record, whether or not the BIN prints it on the plastic. |\n| `card.issued_at` **required** | `string<date-time>` | — |\n\n```json\n{\n  \"order\": {\n    \"id\": 1,\n    \"kind\": \"topup\",\n    \"status\": \"funding\",\n    \"txid\": \"string\",\n    \"asset_id\": \"usdt_trc20\",\n    \"amount\": \"1.5\",\n    \"amount_base\": \"1500000000\",\n    \"fee\": \"1.5\",\n    \"fee_base\": \"1500000000\",\n    \"gas\": \"1.5\",\n    \"merchant_fee\": \"1.5\",\n    \"total\": \"1.5\",\n    \"sent_on_chain\": \"1.5\",\n    \"amount_credited\": \"1.5\",\n    \"amount_credited_base\": \"1500000000\",\n    \"card_id\": 1,\n    \"request_order_id\": \"string\",\n    \"provider_status\": \"string\",\n    \"error\": \"string\",\n    \"idempotency_key\": \"string\",\n    \"created_at\": \"2026-08-31T09:15:00.000Z\"\n  },\n  \"card\": {\n    \"id\": 1,\n    \"rail\": \"rail_a\",\n    \"core_id\": \"string\",\n    \"bin\": \"string\",\n    \"product_id\": \"string\",\n    \"product_code\": \"string\",\n    \"card_nature\": \"VIRTUAL\",\n    \"status\": 1,\n    \"status_normalized\": \"issuing\",\n    \"currency\": \"string\",\n    \"last4\": \"string\",\n    \"name_on_card\": \"string\",\n    \"issued_at\": \"2026-08-31T09:15:00.000Z\"\n  }\n}\n```\n\n#### Example request\n\n```bash\ncurl -X POST 'https://api.cryptoinfra.build/v1/cards' \\\n  -H 'Authorization: Bearer ck_live_your_key' \\\n  -H 'Content-Type: application/json' \\\n  -d '{\n  \"user_id\": \"string\",\n  \"idempotency_key\": \"string\",\n  \"initial_amount\": \"string\"\n}'\n```\n\n## GET /v1/cards/topup-quote\n\n**Preview a top-up's fee and total**  ·  _Auth: bearer API key_  ·  `operationId: cardTopupQuote`\n\nRead-only, holds nothing. The fees are charged ON TOP of `amount`: the card receives exactly `amount` (so `credited` equals it), and `total` is what the cardholder's balance is debited — `amount` plus `fee`, `gas` and `merchant_fee`. Show `total` wherever you tell someone what a load will cost, and check it against the balance before you send. Pass `card_id` and `user_id` together to price a specific card (its rail, BIN and product); otherwise the rail's default row prices it.\n\n#### Parameters\n\n| Name | In | Type | Required | Description |\n| --- | --- | --- | --- | --- |\n| `amount` | query | `string` | yes | Decimal amount to top up (≤6 decimal places). |\n| `provider` | query | `string` | no | Rail id (a product's `rail`); the quote then uses that rail's fee row — the same one the charge uses. |\n| `card_id` | query | `integer` | no | Price for THIS card — its rail, BIN and product. Send with `user_id`. _(min `1`)_ |\n| `user_id` | query | `string` | no | The card's holder (your external user id). Send with `card_id`. |\n\n#### Responses\n\n| Status | Body | Meaning |\n| --- | --- | --- |\n| `200` | `object` | The fee preview — every component the cardholder is debited. |\n| `400` | `Error` | Request validation failed or a domain rule was violated. `error` is one of: validation_error, invalid_request, invalid_amount, card_core_required, card_provider_required, unknown_provider, not_supported_by_provider. `card_core_required` carries `details.cores` — the card programmes this call will accept as `core_id`. It appears when you hold more than one programme that could serve the request. We refuse rather than pick, because picking would spend a balance that did not pay for the card and nothing in the response would say so. Send `core_id`. `card_provider_required` carries `details.rails` — the rail ids this call will accept as `provider`. It only ever appears when two enabled rails could both serve the request; naming a `core_id`, or having cards on just one rail, resolves it without your having to choose. |\n| `401` | `Error` | Missing, malformed or unknown API key. |\n| `503` | `Error` | Temporarily unable to serve this card call — your request was valid. Retry; nothing has been charged. `error` is one of: `provider_unavailable` — the rail did not answer, or answered unreadably. Transient. `card_rail_unavailable` — this card's own rail is not reachable (it may have been closed to new business). Other rails are unaffected, and the card itself is fine. `cards_disabled` — no card rail is enabled on this deployment at all. `rail_unfunded` — the platform cannot fund the on-chain send behind this load right now. Nothing was charged; retry later. `409 card_coins_not_on_chain` — the holder's coins are not at their own address yet (a close refund or a deposit still on its way): a load pays its own way on-chain from that address and waits for them. `details` carry `needed`. Nothing was charged; try again once they have arrived. |\n\n**`200` response body**\n\n| Field | Type | Description |\n| --- | --- | --- |\n| `asset_id` **required** | `string` | — |\n| `amount` **required** | `string` | A monetary value as a decimal string. Always paired with a `*_base` field that carries the exact integer base-unit value; compute with the `_base` string. |\n| `amount_base` **required** | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |\n| `fee` **required** | `string` | Our fee. |\n| `fee_base` **required** | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |\n| `gas` **required** | `string` | The network fee for the on-chain send behind this load. |\n| `gas_base` **required** | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |\n| `merchant_fee` **required** | `string` | Your own fee (PUT /v1/merchant/card-fees/load). \"0\" until you set one. |\n| `merchant_fee_base` **required** | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |\n| `credited` **required** | `string` | What reaches the card — `amount` itself, since the fees are charged on top. The figure to show next to \"on your card\". |\n| `credited_base` **required** | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |\n| `total` **required** | `string` | What the cardholder's balance is debited — amount + fee + gas + merchant_fee. |\n| `total_base` **required** | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |\n| `charged_to` **required** | `string` |  _(one of `cardholder`)_ |\n| `dynamic` | `boolean` | true when `gas` was priced NOW from the live energy rate and TRX price (the same bill as a payout's `network_fee`); false when the live price could not be read and `gas` is the static fallback. Label the figure \"live\" or \"approx\" from this. |\n\n```json\n{\n  \"asset_id\": \"usdt_trc20\",\n  \"amount\": \"1.5\",\n  \"amount_base\": \"1500000000\",\n  \"fee\": \"1.5\",\n  \"fee_base\": \"1500000000\",\n  \"gas\": \"1.5\",\n  \"gas_base\": \"1500000000\",\n  \"merchant_fee\": \"1.5\",\n  \"merchant_fee_base\": \"1500000000\",\n  \"credited\": \"1.5\",\n  \"credited_base\": \"1500000000\",\n  \"total\": \"1.5\",\n  \"total_base\": \"1500000000\",\n  \"charged_to\": \"cardholder\",\n  \"dynamic\": true\n}\n```\n\n#### Example request\n\n```bash\ncurl -X GET 'https://api.cryptoinfra.build/v1/cards/topup-quote?amount=string' \\\n  -H 'Authorization: Bearer ck_live_your_key'\n```\n\n## GET /v1/cards/issue-quote\n\n**Preview what issuing a card costs the cardholder**  ·  _Auth: bearer API key_  ·  `operationId: cardIssueQuote`\n\nRead-only, holds nothing. `total` is what the cardholder's balance is debited by POST /v1/cards — our issue fee, yours, `initial_amount`, and the load fee and gas on that opening balance, charged on top of it. The card opens with `opening_credited`, which is `initial_amount` itself. Priced by the same code that charges, so the preview and the charge cannot differ. A card is issued FOR one of your users and only from what that user holds: when their balance is short, POST /v1/cards is refused with 409 insufficient_balance before anything is charged or applied for. Read `total`; adding the parts yourself is how a preview ends up smaller than the charge.\n\n#### Parameters\n\n| Name | In | Type | Required | Description |\n| --- | --- | --- | --- | --- |\n| `product_code` | query | `string` | yes | A `code` from GET /v1/cards/products. |\n| `core_id` | query | `string` | no | The programme to issue from. Optional with one programme; send it when you hold several. |\n| `initial_amount` | query | `string` | no | Opening balance (≤6 decimal places). Omitted = the product's `min_opening_balance`; below it is a 400 invalid_amount. |\n\n#### Responses\n\n| Status | Body | Meaning |\n| --- | --- | --- |\n| `200` | `object` | The preview — every component the cardholder is debited. |\n| `400` | `Error` | Request validation failed or a domain rule was violated. `error` is one of: validation_error, invalid_request, invalid_amount, card_core_required, card_provider_required, unknown_provider, not_supported_by_provider. `card_core_required` carries `details.cores` — the card programmes this call will accept as `core_id`. It appears when you hold more than one programme that could serve the request. We refuse rather than pick, because picking would spend a balance that did not pay for the card and nothing in the response would say so. Send `core_id`. `card_provider_required` carries `details.rails` — the rail ids this call will accept as `provider`. It only ever appears when two enabled rails could both serve the request; naming a `core_id`, or having cards on just one rail, resolves it without your having to choose. |\n| `401` | `Error` | Missing, malformed or unknown API key. |\n| `404` | `Error` | Record not found (or belongs to another tenant). |\n| `503` | `Error` | Temporarily unable to serve this card call — your request was valid. Retry; nothing has been charged. `error` is one of: `provider_unavailable` — the rail did not answer, or answered unreadably. Transient. `card_rail_unavailable` — this card's own rail is not reachable (it may have been closed to new business). Other rails are unaffected, and the card itself is fine. `cards_disabled` — no card rail is enabled on this deployment at all. `rail_unfunded` — the platform cannot fund the on-chain send behind this load right now. Nothing was charged; retry later. `409 card_coins_not_on_chain` — the holder's coins are not at their own address yet (a close refund or a deposit still on its way): a load pays its own way on-chain from that address and waits for them. `details` carry `needed`. Nothing was charged; try again once they have arrived. |\n\n**`200` response body**\n\n| Field | Type | Description |\n| --- | --- | --- |\n| `asset_id` **required** | `string` | — |\n| `core_id` **required** | `string` | — |\n| `product_code` **required** | `string` | — |\n| `initial_amount` **required** | `string` | A monetary value as a decimal string. Always paired with a `*_base` field that carries the exact integer base-unit value; compute with the `_base` string. |\n| `initial_amount_base` **required** | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |\n| `min_opening_balance` **required** | `string` | A monetary value as a decimal string. Always paired with a `*_base` field that carries the exact integer base-unit value; compute with the `_base` string. |\n| `fee` **required** | `string` | Our fee — the issue fee plus our load fee on the opening balance. |\n| `fee_base` **required** | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |\n| `gas` **required** | `string` | The network fee for the on-chain send behind the opening balance. |\n| `gas_base` **required** | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |\n| `merchant_fee` **required** | `string` | Your own fees on this issue (PUT /v1/merchant/card-fees/issue and /load). \"0\" until you set one. |\n| `merchant_fee_base` **required** | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |\n| `opening_credited` **required** | `string` | What the opening balance puts on the card — initial_amount itself; its load fee and gas are charged on top. |\n| `opening_credited_base` **required** | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |\n| `total` **required** | `string` | initial_amount + fee + gas + merchant_fee — what the cardholder's balance is debited. |\n| `total_base` **required** | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |\n| `charged_to` **required** | `string` |  _(one of `cardholder`)_ |\n| `dynamic` | `boolean` | true when `gas` was priced NOW from the live energy rate and TRX price; false when it is the static fallback. Always true when there is no opening balance (no send, no gas). |\n\n```json\n{\n  \"asset_id\": \"usdt_trc20\",\n  \"core_id\": \"core-1\",\n  \"product_code\": \"string\",\n  \"initial_amount\": \"1.5\",\n  \"initial_amount_base\": \"1500000000\",\n  \"min_opening_balance\": \"1.5\",\n  \"fee\": \"1.5\",\n  \"fee_base\": \"1500000000\",\n  \"gas\": \"1.5\",\n  \"gas_base\": \"1500000000\",\n  \"merchant_fee\": \"1.5\",\n  \"merchant_fee_base\": \"1500000000\",\n  \"opening_credited\": \"1.5\",\n  \"opening_credited_base\": \"1500000000\",\n  \"total\": \"1.5\",\n  \"total_base\": \"1500000000\",\n  \"charged_to\": \"cardholder\",\n  \"dynamic\": true\n}\n```\n\n#### Example request\n\n```bash\ncurl -X GET 'https://api.cryptoinfra.build/v1/cards/issue-quote?product_code=string' \\\n  -H 'Authorization: Bearer ck_live_your_key'\n```\n\n## GET /v1/cards/orders/{order_id}\n\n**Read a card order's status (the authoritative poll)**  ·  _Auth: bearer API key_  ·  `operationId: getCardOrder`\n\nReturn a card order by its `id`. This is the **authoritative outcome\nread** behind a \"webhooks are hints, the poll is truth\" reconcile model:\na top-up / withdrawal / issue-fee order is created `202 processing`\n(`status: funding`, `requested` or `sent`) and reaches `confirmed` or `failed` as the\nplatform's reconciler and provider webhooks settle it. Poll this when a\n`card.topup` / `card.withdraw` webhook was missed (delivery exhausted, or\nyour endpoint was down) so a debited operation can always be resolved\nwithout a manual investigation. Tenant-scoped: another tenant's order\nreturns `404` and is never disclosed.\n\nRefund discipline (unchanged): refund the customer only on\n`status: failed` (definitive — the money did not move); never on\n`requested`/`sent` (outcome still unknown).\n\n#### Parameters\n\n| Name | In | Type | Required | Description |\n| --- | --- | --- | --- | --- |\n| `order_id` | path | `integer` | yes | The order `id` returned by issue / top-up / withdraw. _(min `1`)_ |\n\n#### Responses\n\n| Status | Body | Meaning |\n| --- | --- | --- |\n| `200` | `CardOrder` | The card order in its current (possibly terminal) state. |\n| `401` | `Error` | Missing, malformed or unknown API key. |\n| `404` | `Error` | Unknown card (`card_not_found`), or an unknown payer user (`user_not_found`), for this tenant. |\n| `503` | `Error` | Temporarily unable to serve this card call — your request was valid. Retry; nothing has been charged. `error` is one of: `provider_unavailable` — the rail did not answer, or answered unreadably. Transient. `card_rail_unavailable` — this card's own rail is not reachable (it may have been closed to new business). Other rails are unaffected, and the card itself is fine. `cards_disabled` — no card rail is enabled on this deployment at all. `rail_unfunded` — the platform cannot fund the on-chain send behind this load right now. Nothing was charged; retry later. `409 card_coins_not_on_chain` — the holder's coins are not at their own address yet (a close refund or a deposit still on its way): a load pays its own way on-chain from that address and waits for them. `details` carry `needed`. Nothing was charged; try again once they have arrived. |\n\n**`200` response body** — `CardOrder`\n\n| Field | Type | Description |\n| --- | --- | --- |\n| `id` **required** | `integer` | — |\n| `kind` **required** | `string` | `close_fee` is the close fee charged to the cardholder on DELETE /v1/cards/{id}: `amount` is 0, `fee` is the charge. _(one of `topup`, `withdraw`, `issue_fee`, `close_fee`)_ |\n| `status` **required** | `string` | `funding` = the cardholder has been charged and the money is on its way to the card rail on-chain. `requested` = confirmed on-chain, waiting for the rail to show the credit. `sent` = the rail has been asked, outcome still pending. `confirmed` = settled. `failed` = definitively failed; the WHOLE charge — `amount`, `fee`, `gas` and `merchant_fee`, i.e. `total` — is refunded to the balance it was taken from, the cardholder's, in the same moment (top-up / issue only — a withdraw holds nothing). Nothing goes anywhere else: not to the card, not to your treasury. When the on-chain send behind a load reverted (`provider_status: send_failed`), no coins left the cardholder's address either, so on-chain and ledger agree. `reversed` is reserved for manual operator reversals of a confirmed order. _(one of `funding`, `requested`, `sent`, `confirmed`, `failed`, `reversed`)_ |\n| `txid` | `string | null` | The hash of the on-chain send behind a load or an issue's opening balance — link your receipt to it the way you do for a deposit or a payout. Null until the send is broadcast, and for an order the rail served from its float. A `failed` order keeps the hash of the send that reverted, the proof of what happened; after a re-send it is the current send's hash and the reverted one is named in `error`. Always null on a withdraw order. |\n| `asset_id` **required** | `string` | — |\n| `amount` **required** | `string` | A monetary value as a decimal string. Always paired with a `*_base` field that carries the exact integer base-unit value; compute with the `_base` string. |\n| `amount_base` **required** | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |\n| `fee` **required** | `string` | A monetary value as a decimal string. Always paired with a `*_base` field that carries the exact integer base-unit value; compute with the `_base` string. |\n| `fee_base` **required** | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |\n| `gas` | `string` | The network fee actually CHARGED for the on-chain send behind a load or issue — the figure the cardholder was debited, fixed when the order was created. Not the quote's figure: reconcile a quote against this. |\n| `merchant_fee` | `string` | Your own fee on this order, credited to your balance. |\n| `total` | `string` | Everything the cardholder was debited for this order. |\n| `sent_on_chain` | `string` | What the on-chain send behind this order carried (\"0\" when the rail already held it). It is the attempt, not the outcome: on a `failed` order whose send reverted, this still reads the amount that was sent for and nothing moved. |\n| `amount_credited` **required** | `string | null` | WITHDRAW only: the NET actually credited after the provider's unload tariff, which differs per rail. `null` until a withdraw settles `confirmed`, and always `null` for top-ups / issue fees. Credit your user from THIS (or `amount_credited_base`), never from `amount` — the requested gross — or you over-credit by the tariff on every unload. |\n| `amount_credited_base` **required** | `string | null` | Integer-string base units of `amount_credited` (`null` until a withdraw confirms). |\n| `card_id` **required** | `integer | null` | — |\n| `request_order_id` **required** | `string | null` | Server-minted correlation handle (`ct-`/`cw-`/`ci-` prefix). |\n| `provider_status` **required** | `string | null` | Where a `failed` order failed: `send_failed` = the on-chain send to the rail reverted (our side; the charge was refunded, nothing left the address); anything else is the rail's own word for its refusal. Informational — act on `status`. |\n| `error` **required** | `string | null` | — |\n| `idempotency_key` **required** | `string | null` | — |\n| `created_at` **required** | `string<date-time>` | — |\n\n```json\n{\n  \"id\": 1,\n  \"kind\": \"topup\",\n  \"status\": \"funding\",\n  \"txid\": \"string\",\n  \"asset_id\": \"usdt_trc20\",\n  \"amount\": \"1.5\",\n  \"amount_base\": \"1500000000\",\n  \"fee\": \"1.5\",\n  \"fee_base\": \"1500000000\",\n  \"gas\": \"1.5\",\n  \"merchant_fee\": \"1.5\",\n  \"total\": \"1.5\",\n  \"sent_on_chain\": \"1.5\",\n  \"amount_credited\": \"1.5\",\n  \"amount_credited_base\": \"1500000000\",\n  \"card_id\": 1,\n  \"request_order_id\": \"string\",\n  \"provider_status\": \"string\",\n  \"error\": \"string\",\n  \"idempotency_key\": \"string\",\n  \"created_at\": \"2026-08-31T09:15:00.000Z\"\n}\n```\n\n#### Example request\n\n```bash\ncurl -X GET 'https://api.cryptoinfra.build/v1/cards/orders/1' \\\n  -H 'Authorization: Bearer ck_live_your_key'\n```\n\n## GET /v1/cards/{id}\n\n**Secure card details**  ·  _Auth: bearer API key_  ·  `operationId: getCard`\n\nLocal card + live balance + a link to this card's hosted card-number\npage (`card_pan_url` — drop it into an `<iframe src>`; it works for\nabout ten minutes, so fetch it fresh each time you show the number).\nThis endpoint never returns PAN/CVV — the full number is a separate,\nexplicit call: POST /v1/cards/{id}/reveal. A live provider read failure\nDEGRADES to stored state (`provider_read_failed: true`, null balance —\nnever fake zeros) rather than failing the call. When the read succeeded\nbut there is no card-number page, `reveal_unavailable: true` is set.\n\n#### Parameters\n\n| Name | In | Type | Required | Description |\n| --- | --- | --- | --- | --- |\n| `id` | path | `integer` | yes | The card's numeric id (from GET /v1/cards). _(min `1`)_ |\n| `user_id` | query | `string` | yes | — |\n\n#### Responses\n\n| Status | Body | Meaning |\n| --- | --- | --- |\n| `200` | `CardDetails` | Card details (possibly degraded — see CardDetails). |\n| `401` | `Error` | Missing, malformed or unknown API key. |\n| `404` | `Error` | Unknown card (`card_not_found`), or an unknown payer user (`user_not_found`), for this tenant. |\n| `503` | `Error` | Temporarily unable to serve this card call — your request was valid. Retry; nothing has been charged. `error` is one of: `provider_unavailable` — the rail did not answer, or answered unreadably. Transient. `card_rail_unavailable` — this card's own rail is not reachable (it may have been closed to new business). Other rails are unaffected, and the card itself is fine. `cards_disabled` — no card rail is enabled on this deployment at all. `rail_unfunded` — the platform cannot fund the on-chain send behind this load right now. Nothing was charged; retry later. `409 card_coins_not_on_chain` — the holder's coins are not at their own address yet (a close refund or a deposit still on its way): a load pays its own way on-chain from that address and waits for them. `details` carry `needed`. Nothing was charged; try again once they have arrived. |\n\n**`200` response body** — `CardDetails`\n\n| Field | Type | Description |\n| --- | --- | --- |\n| `card` **required** | `object` | — |\n| `card.id` **required** | `integer` | — |\n| `card.rail` **required** | `string` | Opaque, stable id of the card rail a product, holder or card sits on — and the value to pass as `provider` when a call needs disambiguating. It tells you WHICH rail, never who runs it: two cards on different rails are different arrangements of ours, and that is all you need to route a call correctly. Read it from the resource and echo it back; never hard-code one, and never parse it — the string is an identifier, not a description. |\n| `card.core_id` **required** | `string | null` | The programme this card was issued from — the pair (`core_id`, `bin`) is what prices its loads and close. A programme holds no money; the cardholder pays. null on cards issued before programmes existed. |\n| `card.bin` **required** | `string | null` | The card's BIN. Read it WITH `core_id`, never alone: a BIN number is unique only inside its issuer, so the same number under a different programme is a different card with different fees, limits and validity. |\n| `card.product_id` **required** | `string | null` | Provider-scoped product CODE (kept as an alias of product_code); null when the card was listed untagged. |\n| `card.product_code` **required** | `string | null` | Provider-scoped product code; null when the card was listed untagged. |\n| `card.card_nature` **required** | `string` |  _(one of `VIRTUAL`, `PHYSICAL`)_ |\n| `card.status` **required** | `integer` | The provider's RAW status code, verbatim — interpret via status_normalized. |\n| `card.status_normalized` **required** | `string` | Provider-neutral status. A just-issued card is `issuing`; it becomes `active` once the opening balance has landed at the issuer. _(one of `issuing`, `active`, `frozen`, `suspended`, `closed`, `expired`)_ |\n| `card.currency` **required** | `string | null` | — |\n| `card.last4` **required** | `string | null` | — |\n| `card.name_on_card` **required** | `string | null` | The cardholder name you gave at issue — our record, whether or not the BIN prints it on the plastic. |\n| `card.issued_at` **required** | `string<date-time>` | — |\n| `balance` **required** | `object | null` | Live card balance, or null when the provider read failed (never a fake zero). |\n| `balance.available` **required** | `string` | Spendable balance |\n| `balance.pending` **required** | `string | null` | Held amount; null (NOT 0) when the provider does not expose holds. |\n| `balance.currency` **required** | `string` | — |\n| `currency` **required** | `string | null` | — |\n| `card_pan_url` **required** | `string | null` | Link to this card's hosted card-number page — put it straight in an `<iframe src>` (or open it) to show the number to the cardholder, for clients that would rather never handle a PAN. To render your own card face instead, use POST /v1/cards/{id}/reveal. It is a link on OUR domain that redirects to the page: the URL carries a one-time handle, works for about ten minutes, and is a credential in its own right — never log, cache or persist it, and fetch a fresh one from this endpoint each time you need to show the number. null when this card has no hosted page (see `reveal_unavailable`). This response itself never carries PAN/CVV. |\n| `expire_date` **required** | `string | null` | `YYYY-MM`. |\n| `masked_number` | `string | null` | Masked PAN as the issuer renders it. |\n| `name_on_card` | `string | null` | The name given at issue, as we stored it — not a claim about what is printed on the card. `null` means none was supplied. |\n| `cardholder_name` | `string | null` | — |\n| `bin` | `string | null` | — |\n| `decline_count` | `integer | null` | Declines counted by the issuer; null when it exposes none. |\n| `decline_limit` | `integer | null` | Declines allowed before the issuer acts; null when it exposes none. |\n| `otp_available` | `boolean` | True when a live wallet/3DS code is waiting for this card. A PEEK — reading this does not consume the code. Fetch the code itself from `GET /v1/cards/{id}/otp`, which does consume it. |\n| `otp_waiting` | `object` | Present only while `otp_available` is true: what the waiting code is for. Never the code itself. |\n| `otp_waiting.kind` **required** | `string` |  _(one of `payment_3ds`, `wallet_provisioning`)_ |\n| `otp_waiting.merchant_name` | `string` | — |\n| `otp_waiting.amount` | `string` | — |\n| `otp_waiting.currency` | `string` | — |\n| `otp_waiting.expires_in_seconds` **required** | `integer` |  _(min `0`)_ |\n| `provider_read_failed` | `boolean` | Present and true only when the live provider read failed (response degraded to stored state). |\n| `note` | `string` | Present alongside provider_read_failed. |\n| `reveal_unavailable` | `boolean` | Present and true when the card read succeeded but no card-number link is available (see `reveal_note` for which of the two reasons). |\n| `reveal_note` | `string` | Present alongside reveal_unavailable. |\n\n```json\n{\n  \"card\": {\n    \"id\": 1,\n    \"rail\": \"rail_a\",\n    \"core_id\": \"string\",\n    \"bin\": \"string\",\n    \"product_id\": \"string\",\n    \"product_code\": \"string\",\n    \"card_nature\": \"VIRTUAL\",\n    \"status\": 1,\n    \"status_normalized\": \"issuing\",\n    \"currency\": \"string\",\n    \"last4\": \"string\",\n    \"name_on_card\": \"string\",\n    \"issued_at\": \"2026-08-31T09:15:00.000Z\"\n  },\n  \"balance\": {\n    \"available\": \"string\",\n    \"pending\": \"string\",\n    \"currency\": \"USD\"\n  },\n  \"currency\": \"string\",\n  \"card_pan_url\": \"string\",\n  \"expire_date\": \"string\",\n  \"masked_number\": \"string\",\n  \"name_on_card\": \"string\",\n  \"cardholder_name\": \"string\",\n  \"bin\": \"string\",\n  \"decline_count\": 1,\n  \"decline_limit\": 1,\n  \"otp_available\": true,\n  \"otp_waiting\": {\n    \"kind\": \"payment_3ds\",\n    \"merchant_name\": \"string\",\n    \"amount\": \"string\",\n    \"currency\": \"string\",\n    \"expires_in_seconds\": 0\n  },\n  \"provider_read_failed\": true,\n  \"note\": \"string\",\n  \"reveal_unavailable\": true,\n  \"reveal_note\": \"string\"\n}\n```\n\n#### Example request\n\n```bash\ncurl -X GET 'https://api.cryptoinfra.build/v1/cards/1?user_id=string' \\\n  -H 'Authorization: Bearer ck_live_your_key'\n```\n\n## DELETE /v1/cards/{id}\n\n**Close (terminate) a card**  ·  _Auth: bearer API key_  ·  `operationId: closeCard`\n\nTerminates the card. **One call is enough.**\n\n1. If the card still holds a balance, this call returns the residual to\n   us (a card→wallet withdrawal order) and answers\n   `202 card_close_in_progress` with `details.card_id` and\n   `details.order_id`. The card is not closed yet — **a 202 is not a\n   closed card**. Until it is, `GET /v1/cards/{id}` carries\n   `close_in_progress: true`.\n2. Within about a minute of that order settling we finish the close\n   ourselves: the close fee — ours plus yours, see\n   GET /v1/cards/{id}/close-quote — is debited from the cardholder and\n   the card is terminated. The `card.status` webhook confirms it, and\n   the card reads `status_normalized: closed`. The fee shows up as a\n   `close_fee` order. You may also call DELETE again yourself once the\n   order is confirmed (`card.withdraw` webhook or\n   GET /v1/cards/orders/{order_id}); that finishes it at once, and a\n   DELETE on a card we already closed answers `200` with the card.\n\nThe close is **always accepted**: it never fails because a refund cannot be paid yet.\nRead `close_refund.state` on `GET /v1/cards/{id}` for where the money is\n(`waiting` → `approved` → `sending` → `paid`, or `held` with a reason).\n\nA card holding **less than the issuer's cancellation fee**, or nothing at all,\ncloses like any other (since 20 September 2026): the issuer keeps what is on\nthe card, there is nothing to return, and `409 card_close_underfunded` is no\nlonger raised. `minimum_card_balance` on the close quote is always `0`.\n\nMoney still **pending** on the card blocks the close with\n`409 card_state` (the error names the amount) — wait for it to settle.\n\nThe card's balance goes back to the holder's own address on-chain, from\na wallet cryptoinfra funds by hand, within **7 working days** of the issuer\nreleasing it. The close is never refused while that is arranged\n(`409 card_refund_wallet_empty` is retired): read `close_refund.state` on\n`GET /v1/cards/{id}` — `waiting`, `approved`, `sending`, `paid`, or `held`\nwith a reason.\nClosing an already-closed or expired card also answers `409 card_state`.\nIf the balance cannot be read the close is refused (`400`) — an\nunreadable balance is never treated as zero.\n\n**Residual-on-close rails** refund the residual as part of the close.\nThe settlement that credits that residual back to the user arrives in a\nlater release; until then close for these providers answers\n`400 not_supported_by_provider`.\n\n#### Parameters\n\n| Name | In | Type | Required | Description |\n| --- | --- | --- | --- | --- |\n| `id` | path | `integer` | yes | The card's numeric id (from GET /v1/cards). _(min `1`)_ |\n\n#### Request body\n\n`application/json` · required · schema `CardUserRef`\n\n| Field | Type | Description |\n| --- | --- | --- |\n| `user_id` **required** | `string` |  _(minLength `1`; maxLength `255`)_ |\n\n#### Responses\n\n| Status | Body | Meaning |\n| --- | --- | --- |\n| `200` | `object` | Card closed; the close fee was debited from the cardholder. |\n| `202` | `object` | `card_close_in_progress` — the balance is being returned; `details.order_id` is the withdrawal order to watch. The card is NOT closed yet; we close it within about a minute of that order settling (or call again yourself once it is confirmed). Watch `card.status`, or `GET /v1/cards/{id}` until `close_in_progress` is gone and `status_normalized` is `closed`. |\n| `400` | `Error` | Request validation failed or a domain rule was violated. `error` is one of: validation_error, invalid_request, invalid_amount, card_core_required, card_provider_required, unknown_provider, not_supported_by_provider. `card_core_required` carries `details.cores` — the card programmes this call will accept as `core_id`. It appears when you hold more than one programme that could serve the request. We refuse rather than pick, because picking would spend a balance that did not pay for the card and nothing in the response would say so. Send `core_id`. `card_provider_required` carries `details.rails` — the rail ids this call will accept as `provider`. It only ever appears when two enabled rails could both serve the request; naming a `core_id`, or having cards on just one rail, resolves it without your having to choose. |\n| `401` | `Error` | Missing, malformed or unknown API key. |\n| `404` | `Error` | Unknown card (`card_not_found`), or an unknown payer user (`user_not_found`), for this tenant. |\n| `409` | `Error` | A conflicting card state. `error` is one of: kyc_not_approved (details.kyc_status), card_state, card_issuing (the card is still being issued — under two minutes; `details.card_id`; poll GET /v1/cards/{id} until status_normalized is active, or wait for the card.status webhook, then retry), provider_rejected (the rail refused — DEFINITIVE, so retrying the identical call cannot help; `details.order_id` on a money call, where any charge is already reversed, and `details.provider_code` otherwise), email_taken (details.email — the rail already holds an account on that address; send a different one), idempotency_conflict, insufficient_balance, no_own_account (you have set a card fee of your own but your account has no balance of its own to credit it to — ask us to open one). |\n| `503` | `Error` | Temporarily unable to serve this card call — your request was valid. Retry; nothing has been charged. `error` is one of: `provider_unavailable` — the rail did not answer, or answered unreadably. Transient. `card_rail_unavailable` — this card's own rail is not reachable (it may have been closed to new business). Other rails are unaffected, and the card itself is fine. `cards_disabled` — no card rail is enabled on this deployment at all. `rail_unfunded` — the platform cannot fund the on-chain send behind this load right now. Nothing was charged; retry later. `409 card_coins_not_on_chain` — the holder's coins are not at their own address yet (a close refund or a deposit still on its way): a load pays its own way on-chain from that address and waits for them. `details` carry `needed`. Nothing was charged; try again once they have arrived. |\n\n**`200` response body**\n\n| Field | Type | Description |\n| --- | --- | --- |\n| `card` **required** | `object` | — |\n| `card.id` **required** | `integer` | — |\n| `card.rail` **required** | `string` | Opaque, stable id of the card rail a product, holder or card sits on — and the value to pass as `provider` when a call needs disambiguating. It tells you WHICH rail, never who runs it: two cards on different rails are different arrangements of ours, and that is all you need to route a call correctly. Read it from the resource and echo it back; never hard-code one, and never parse it — the string is an identifier, not a description. |\n| `card.core_id` **required** | `string | null` | The programme this card was issued from — the pair (`core_id`, `bin`) is what prices its loads and close. A programme holds no money; the cardholder pays. null on cards issued before programmes existed. |\n| `card.bin` **required** | `string | null` | The card's BIN. Read it WITH `core_id`, never alone: a BIN number is unique only inside its issuer, so the same number under a different programme is a different card with different fees, limits and validity. |\n| `card.product_id` **required** | `string | null` | Provider-scoped product CODE (kept as an alias of product_code); null when the card was listed untagged. |\n| `card.product_code` **required** | `string | null` | Provider-scoped product code; null when the card was listed untagged. |\n| `card.card_nature` **required** | `string` |  _(one of `VIRTUAL`, `PHYSICAL`)_ |\n| `card.status` **required** | `integer` | The provider's RAW status code, verbatim — interpret via status_normalized. |\n| `card.status_normalized` **required** | `string` | Provider-neutral status. A just-issued card is `issuing`; it becomes `active` once the opening balance has landed at the issuer. _(one of `issuing`, `active`, `frozen`, `suspended`, `closed`, `expired`)_ |\n| `card.currency` **required** | `string | null` | — |\n| `card.last4` **required** | `string | null` | — |\n| `card.name_on_card` **required** | `string | null` | The cardholder name you gave at issue — our record, whether or not the BIN prints it on the plastic. |\n| `card.issued_at` **required** | `string<date-time>` | — |\n\n```json\n{\n  \"card\": {\n    \"id\": 1,\n    \"rail\": \"rail_a\",\n    \"core_id\": \"string\",\n    \"bin\": \"string\",\n    \"product_id\": \"string\",\n    \"product_code\": \"string\",\n    \"card_nature\": \"VIRTUAL\",\n    \"status\": 1,\n    \"status_normalized\": \"issuing\",\n    \"currency\": \"string\",\n    \"last4\": \"string\",\n    \"name_on_card\": \"string\",\n    \"issued_at\": \"2026-08-31T09:15:00.000Z\"\n  }\n}\n```\n\n#### Example request\n\n```bash\ncurl -X DELETE 'https://api.cryptoinfra.build/v1/cards/1' \\\n  -H 'Authorization: Bearer ck_live_your_key' \\\n  -H 'Content-Type: application/json' \\\n  -d '{\n  \"user_id\": \"string\"\n}'\n```\n\n## GET /v1/cards/{id}/balance\n\n**Live card balance**  ·  _Auth: bearer API key_  ·  `operationId: getCardBalance`\n\n`pending` is null (not 0) when the provider does not expose holds.\n\nWhile the card is still being issued (`status_normalized: issuing`, the\nminute or two after POST /v1/cards) this answers `409 card_issuing` —\npoll GET /v1/cards/{id} or wait for the `card.status` webhook, then retry.\n\n#### Parameters\n\n| Name | In | Type | Required | Description |\n| --- | --- | --- | --- | --- |\n| `id` | path | `integer` | yes | The card's numeric id (from GET /v1/cards). _(min `1`)_ |\n| `user_id` | query | `string` | yes | — |\n\n#### Responses\n\n| Status | Body | Meaning |\n| --- | --- | --- |\n| `200` | `CardBalance` | The card's balance. |\n| `401` | `Error` | Missing, malformed or unknown API key. |\n| `404` | `Error` | Unknown card (`card_not_found`), or an unknown payer user (`user_not_found`), for this tenant. |\n| `409` | `Error` | A conflicting card state. `error` is one of: kyc_not_approved (details.kyc_status), card_state, card_issuing (the card is still being issued — under two minutes; `details.card_id`; poll GET /v1/cards/{id} until status_normalized is active, or wait for the card.status webhook, then retry), provider_rejected (the rail refused — DEFINITIVE, so retrying the identical call cannot help; `details.order_id` on a money call, where any charge is already reversed, and `details.provider_code` otherwise), email_taken (details.email — the rail already holds an account on that address; send a different one), idempotency_conflict, insufficient_balance, no_own_account (you have set a card fee of your own but your account has no balance of its own to credit it to — ask us to open one). |\n| `503` | `Error` | Temporarily unable to serve this card call — your request was valid. Retry; nothing has been charged. `error` is one of: `provider_unavailable` — the rail did not answer, or answered unreadably. Transient. `card_rail_unavailable` — this card's own rail is not reachable (it may have been closed to new business). Other rails are unaffected, and the card itself is fine. `cards_disabled` — no card rail is enabled on this deployment at all. `rail_unfunded` — the platform cannot fund the on-chain send behind this load right now. Nothing was charged; retry later. `409 card_coins_not_on_chain` — the holder's coins are not at their own address yet (a close refund or a deposit still on its way): a load pays its own way on-chain from that address and waits for them. `details` carry `needed`. Nothing was charged; try again once they have arrived. |\n\n**`200` response body** — `CardBalance`\n\n| Field | Type | Description |\n| --- | --- | --- |\n| `available` **required** | `string` | A monetary value as a decimal string. Always paired with a `*_base` field that carries the exact integer base-unit value; compute with the `_base` string. |\n| `available_base` **required** | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |\n| `pending` **required** | `string | null` | Held amount (decimal string); null (NOT 0) when the provider does not expose holds. |\n| `pending_base` **required** | `string | null` | — |\n| `currency` **required** | `string` | — |\n\n```json\n{\n  \"available\": \"1.5\",\n  \"available_base\": \"1500000000\",\n  \"pending\": \"string\",\n  \"pending_base\": \"string\",\n  \"currency\": \"string\"\n}\n```\n\n#### Example request\n\n```bash\ncurl -X GET 'https://api.cryptoinfra.build/v1/cards/1/balance?user_id=string' \\\n  -H 'Authorization: Bearer ck_live_your_key'\n```\n\n## POST /v1/cards/{id}/reveal\n\n**Reveal the full card number**  ·  _Auth: bearer API key_  ·  `operationId: revealCard`\n\nFull card details for the merchant that OWNS the card, on explicit\nrequest. POST (never GET) so the card id never lands in an access-log\nquery string, and the body is the only place the secrets ever appear.\n\n**The issuer decides the shape of the answer, and exactly ONE of the two\nforms is populated — always branch on which:**\n\n- **Raw secrets** — `pan`, `cvv` and `expiry` (`MM/YY`) are filled and\n  `reveal_url` is `null`.\n- **Hosted page** — the issuer reveals only through its own page: `pan`\n  and `cvv` come back `null` and `reveal_url` carries a URL to open.\n  The link is SHORT-LIVED (about a minute) and single-purpose: hand it\n  straight to the cardholder's browser. Do not cache it, store it, put\n  it in an email, or re-serve it later — a stale link is simply dead,\n  and mint a fresh one by calling this endpoint again.\n\nA client that reads only `pan` will therefore show a blank card number\nfor every card on a hosted-reveal product. Handle both.\n\nWhile the card is still being issued (`status_normalized: issuing`) this\nanswers `409 card_issuing` instead of a body with neither form — poll\nGET /v1/cards/{id} or wait for the `card.status` webhook, then retry.\n\n**Handling rules (both forms).** The response is sent with\n`Cache-Control: no-store`. Never log, persist, or forward these values —\nnot to your database, not to analytics, not into a support ticket.\nRender them to the cardholder and drop them.\n\n**This is the way to show a card number.** Both enabled rails answer it\nwith raw secrets, so you can render your own card face — you do not need\nto embed anything. `card_pan_url` on `GET /v1/cards/{id}` remains for\nclients that would rather not handle a PAN at all, and `reveal_url` here\ncovers the rare product whose issuer insists on showing the number\nitself. A rail with no reveal at all answers\n`400 not_supported_by_provider`.\n\nScoped exactly like every other per-card call: the card must belong to\nthis tenant AND to the `user_id` in the body, or the call 404s.\n\n#### Parameters\n\n| Name | In | Type | Required | Description |\n| --- | --- | --- | --- | --- |\n| `id` | path | `integer` | yes | The card's numeric id (from GET /v1/cards). _(min `1`)_ |\n\n#### Request body\n\n`application/json` · required · schema `CardUserRef`\n\n| Field | Type | Description |\n| --- | --- | --- |\n| `user_id` **required** | `string` |  _(minLength `1`; maxLength `255`)_ |\n\n#### Responses\n\n| Status | Body | Meaning |\n| --- | --- | --- |\n| `200` | `CardSecrets` | The card details, returned once. Sent with `Cache-Control: no-store` — do not cache, log, or persist any field. |\n| `400` | `Error` | Request validation failed or a domain rule was violated. `error` is one of: validation_error, invalid_request, invalid_amount, card_core_required, card_provider_required, unknown_provider, not_supported_by_provider. `card_core_required` carries `details.cores` — the card programmes this call will accept as `core_id`. It appears when you hold more than one programme that could serve the request. We refuse rather than pick, because picking would spend a balance that did not pay for the card and nothing in the response would say so. Send `core_id`. `card_provider_required` carries `details.rails` — the rail ids this call will accept as `provider`. It only ever appears when two enabled rails could both serve the request; naming a `core_id`, or having cards on just one rail, resolves it without your having to choose. |\n| `401` | `Error` | Missing, malformed or unknown API key. |\n| `404` | `Error` | Unknown card (`card_not_found`), or an unknown payer user (`user_not_found`), for this tenant. |\n| `409` | `Error` | A conflicting card state. `error` is one of: kyc_not_approved (details.kyc_status), card_state, card_issuing (the card is still being issued — under two minutes; `details.card_id`; poll GET /v1/cards/{id} until status_normalized is active, or wait for the card.status webhook, then retry), provider_rejected (the rail refused — DEFINITIVE, so retrying the identical call cannot help; `details.order_id` on a money call, where any charge is already reversed, and `details.provider_code` otherwise), email_taken (details.email — the rail already holds an account on that address; send a different one), idempotency_conflict, insufficient_balance, no_own_account (you have set a card fee of your own but your account has no balance of its own to credit it to — ask us to open one). |\n| `503` | `Error` | Temporarily unable to serve this card call — your request was valid. Retry; nothing has been charged. `error` is one of: `provider_unavailable` — the rail did not answer, or answered unreadably. Transient. `card_rail_unavailable` — this card's own rail is not reachable (it may have been closed to new business). Other rails are unaffected, and the card itself is fine. `cards_disabled` — no card rail is enabled on this deployment at all. `rail_unfunded` — the platform cannot fund the on-chain send behind this load right now. Nothing was charged; retry later. `409 card_coins_not_on_chain` — the holder's coins are not at their own address yet (a close refund or a deposit still on its way): a load pays its own way on-chain from that address and waits for them. `details` carry `needed`. Nothing was charged; try again once they have arrived. |\n\n**`200` response body** — `CardSecrets`\n\n| Field | Type | Description |\n| --- | --- | --- |\n| `pan` **required** | `string | null` | Full card number; null when the issuer reveals via `reveal_url` instead. |\n| `cvv` **required** | `string | null` | Security code; null when the issuer reveals via `reveal_url` instead. |\n| `expiry` **required** | `string | null` | `MM/YY`; null when the issuer does not disclose it here. |\n| `reveal_url` **required** | `string | null` | Short-lived link to a hosted page showing the number, populated INSTEAD of pan/cvv. A link on OUR domain that redirects; open it immediately, do not cache or store it, and call this endpoint again for a fresh one. |\n\n```json\n{\n  \"pan\": \"string\",\n  \"cvv\": \"string\",\n  \"expiry\": \"string\",\n  \"reveal_url\": \"string\"\n}\n```\n\n#### Example request\n\n```bash\ncurl -X POST 'https://api.cryptoinfra.build/v1/cards/1/reveal' \\\n  -H 'Authorization: Bearer ck_live_your_key' \\\n  -H 'Content-Type: application/json' \\\n  -d '{\n  \"user_id\": \"string\"\n}'\n```\n\n## GET /v1/cards/{id}/otp\n\n**Fetch the card's live one-time code**  ·  _Auth: bearer API key_  ·  `operationId: getCardOtp`\n\nReturns the verification code the ISSUER relayed for this card — either a\n3DS code that approves a payment, or a wallet code that adds the card to\nApple/Google Pay.\n\n**The code never reaches the cardholder's own phone or email.** For an\nissued-on-your-program card the issuer sends it to us, so whichever\nchannel the wallet or checkout offers your customer, the code arrives\nhere. This endpoint is how you hand it to them.\n\nThree properties to build against:\n\n- **Single read.** Fetching CONSUMES the code — a second call returns 404.\n  Never poll this endpoint; call it when your user is ready to type the\n  code. To know a code is waiting, read `otp_available` on\n  `GET /v1/cards/{id}` (a peek that does not consume) or listen for the\n  `card.wallet_otp` webhook. Your API key and your dashboard each have\n  their own read: fetching a code from your server does not take it away\n  from someone looking at the card in the dashboard, and the other way\n  round.\n- **Short-lived.** Codes expire in about ten minutes; `expires_in_seconds`\n  is the remaining life at the moment of the read.\n- **Never stored.** The code is held in memory only — not in the database,\n  not in a webhook payload, not in a log. If it is lost, the customer must\n  trigger a new one.\n\nA 404 is deliberately uniform: it does not distinguish \"no code arrived\"\nfrom \"already read\", \"expired\", or \"not your card\".\n\n#### Parameters\n\n| Name | In | Type | Required | Description |\n| --- | --- | --- | --- | --- |\n| `id` | path | `integer` | yes | The card's numeric id (from GET /v1/cards). _(min `1`)_ |\n| `user_id` | query | `string` | no | Optional second proof of ownership. When supplied it must match the end-user the code was bound to at receipt, or the read returns 404. |\n\n#### Responses\n\n| Status | Body | Meaning |\n| --- | --- | --- |\n| `200` | `object` | The code, returned once. Sent with `Cache-Control: no-store` — do not cache, log, or persist it. |\n| `401` | `Error` | Missing, malformed or unknown API key. |\n| `404` | `object` | No code is available. Uniform by design — no code arrived, it was already read, it expired, or the card is not yours. |\n| `503` | `Error` | Temporarily unable to serve this card call — your request was valid. Retry; nothing has been charged. `error` is one of: `provider_unavailable` — the rail did not answer, or answered unreadably. Transient. `card_rail_unavailable` — this card's own rail is not reachable (it may have been closed to new business). Other rails are unaffected, and the card itself is fine. `cards_disabled` — no card rail is enabled on this deployment at all. `rail_unfunded` — the platform cannot fund the on-chain send behind this load right now. Nothing was charged; retry later. `409 card_coins_not_on_chain` — the holder's coins are not at their own address yet (a close refund or a deposit still on its way): a load pays its own way on-chain from that address and waits for them. `details` carry `needed`. Nothing was charged; try again once they have arrived. |\n\n**`200` response body**\n\n| Field | Type | Description |\n| --- | --- | --- |\n| `card_id` **required** | `integer` | — |\n| `code` **required** | `string` | The one-time code, exactly as the issuer sent it. |\n| `kind` **required** | `string` | `payment_3ds` interrupts a checkout; `wallet_provisioning` gates adding the card to Apple/Google Pay. Surface them differently — they reach the customer at different moments. _(one of `payment_3ds`, `wallet_provisioning`)_ |\n| `merchant_name` | `string` | `payment_3ds` only, when the issuer sent it: the shop the code approves a payment at. Show it so the cardholder can check the code is for their own checkout. |\n| `amount` | `string` | `payment_3ds` only, when sent: the amount of that payment, as a decimal string. |\n| `currency` | `string` | `payment_3ds` only, when sent: the currency of `amount`. |\n| `expires_in_seconds` **required** | `integer` |  _(min `0`)_ |\n\n```json\n{\n  \"card_id\": 1,\n  \"code\": \"441524\",\n  \"kind\": \"payment_3ds\",\n  \"merchant_name\": \"EXAMPLE STORE\",\n  \"amount\": \"12.50\",\n  \"currency\": \"USD\",\n  \"expires_in_seconds\": 477\n}\n```\n\n#### Example request\n\n```bash\ncurl -X GET 'https://api.cryptoinfra.build/v1/cards/1/otp' \\\n  -H 'Authorization: Bearer ck_live_your_key'\n```\n\n## POST /v1/cards/{id}/topup\n\n**Top up a card**  ·  _Auth: bearer API key_  ·  `operationId: cardTopup`\n\nDebits `total` from the cardholder's balance immediately (see\nGET /v1/cards/topup-quote: amount + our fee + gas + your fee), sends what\nthe rail needs on-chain, and funds the card once that send is credited.\nReturns **202** with the order in `funding`; it moves to `requested`\nwhen the send confirms, `sent` when the rail is asked, `confirmed` when\nthe card is funded — a `card.topup` webhook or the reconciler tells you.\nA definitive failure at any stage refunds the charge in full. Treat 202\nas pending; refund your user ONLY on a definitive failure\n(`409 provider_rejected`, or a webhook with order status `failed`), never\non the 202 itself. `idempotency_key` is required.\n\n#### Parameters\n\n| Name | In | Type | Required | Description |\n| --- | --- | --- | --- | --- |\n| `id` | path | `integer` | yes | The card's numeric id (from GET /v1/cards). _(min `1`)_ |\n\n#### Request body\n\n`application/json` · required · schema `CardMoneyRequest`\n\n| Field | Type | Description |\n| --- | --- | --- |\n| `user_id` **required** | `string` |  _(minLength `1`; maxLength `255`)_ |\n| `amount` **required** | `string` | Decimal amount in major units (USD/USDT), at most 6 decimal places. _(pattern `^\\d+(\\.\\d+)?$`)_ |\n| `idempotency_key` **required** | `string` | Required; replay-safe. _(minLength `1`; maxLength `255`)_ |\n\n#### Responses\n\n| Status | Body | Meaning |\n| --- | --- | --- |\n| `202` | `CardOrder` | Top-up accepted; provider outcome pending. |\n| `400` | `Error` | Request validation failed or a domain rule was violated. `error` is one of: validation_error, invalid_request, invalid_amount, card_core_required, card_provider_required, unknown_provider, not_supported_by_provider. `card_core_required` carries `details.cores` — the card programmes this call will accept as `core_id`. It appears when you hold more than one programme that could serve the request. We refuse rather than pick, because picking would spend a balance that did not pay for the card and nothing in the response would say so. Send `core_id`. `card_provider_required` carries `details.rails` — the rail ids this call will accept as `provider`. It only ever appears when two enabled rails could both serve the request; naming a `core_id`, or having cards on just one rail, resolves it without your having to choose. |\n| `401` | `Error` | Missing, malformed or unknown API key. |\n| `404` | `Error` | Unknown card (`card_not_found`), or an unknown payer user (`user_not_found`), for this tenant. |\n| `409` | `Error` | A conflicting card state. `error` is one of: kyc_not_approved (details.kyc_status), card_state, card_issuing (the card is still being issued — under two minutes; `details.card_id`; poll GET /v1/cards/{id} until status_normalized is active, or wait for the card.status webhook, then retry), provider_rejected (the rail refused — DEFINITIVE, so retrying the identical call cannot help; `details.order_id` on a money call, where any charge is already reversed, and `details.provider_code` otherwise), email_taken (details.email — the rail already holds an account on that address; send a different one), idempotency_conflict, insufficient_balance, no_own_account (you have set a card fee of your own but your account has no balance of its own to credit it to — ask us to open one). |\n| `503` | `Error` | Temporarily unable to serve this card call — your request was valid. Retry; nothing has been charged. `error` is one of: `provider_unavailable` — the rail did not answer, or answered unreadably. Transient. `card_rail_unavailable` — this card's own rail is not reachable (it may have been closed to new business). Other rails are unaffected, and the card itself is fine. `cards_disabled` — no card rail is enabled on this deployment at all. `rail_unfunded` — the platform cannot fund the on-chain send behind this load right now. Nothing was charged; retry later. `409 card_coins_not_on_chain` — the holder's coins are not at their own address yet (a close refund or a deposit still on its way): a load pays its own way on-chain from that address and waits for them. `details` carry `needed`. Nothing was charged; try again once they have arrived. |\n\n**`202` response body** — `CardOrder`\n\n| Field | Type | Description |\n| --- | --- | --- |\n| `id` **required** | `integer` | — |\n| `kind` **required** | `string` | `close_fee` is the close fee charged to the cardholder on DELETE /v1/cards/{id}: `amount` is 0, `fee` is the charge. _(one of `topup`, `withdraw`, `issue_fee`, `close_fee`)_ |\n| `status` **required** | `string` | `funding` = the cardholder has been charged and the money is on its way to the card rail on-chain. `requested` = confirmed on-chain, waiting for the rail to show the credit. `sent` = the rail has been asked, outcome still pending. `confirmed` = settled. `failed` = definitively failed; the WHOLE charge — `amount`, `fee`, `gas` and `merchant_fee`, i.e. `total` — is refunded to the balance it was taken from, the cardholder's, in the same moment (top-up / issue only — a withdraw holds nothing). Nothing goes anywhere else: not to the card, not to your treasury. When the on-chain send behind a load reverted (`provider_status: send_failed`), no coins left the cardholder's address either, so on-chain and ledger agree. `reversed` is reserved for manual operator reversals of a confirmed order. _(one of `funding`, `requested`, `sent`, `confirmed`, `failed`, `reversed`)_ |\n| `txid` | `string | null` | The hash of the on-chain send behind a load or an issue's opening balance — link your receipt to it the way you do for a deposit or a payout. Null until the send is broadcast, and for an order the rail served from its float. A `failed` order keeps the hash of the send that reverted, the proof of what happened; after a re-send it is the current send's hash and the reverted one is named in `error`. Always null on a withdraw order. |\n| `asset_id` **required** | `string` | — |\n| `amount` **required** | `string` | A monetary value as a decimal string. Always paired with a `*_base` field that carries the exact integer base-unit value; compute with the `_base` string. |\n| `amount_base` **required** | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |\n| `fee` **required** | `string` | A monetary value as a decimal string. Always paired with a `*_base` field that carries the exact integer base-unit value; compute with the `_base` string. |\n| `fee_base` **required** | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |\n| `gas` | `string` | The network fee actually CHARGED for the on-chain send behind a load or issue — the figure the cardholder was debited, fixed when the order was created. Not the quote's figure: reconcile a quote against this. |\n| `merchant_fee` | `string` | Your own fee on this order, credited to your balance. |\n| `total` | `string` | Everything the cardholder was debited for this order. |\n| `sent_on_chain` | `string` | What the on-chain send behind this order carried (\"0\" when the rail already held it). It is the attempt, not the outcome: on a `failed` order whose send reverted, this still reads the amount that was sent for and nothing moved. |\n| `amount_credited` **required** | `string | null` | WITHDRAW only: the NET actually credited after the provider's unload tariff, which differs per rail. `null` until a withdraw settles `confirmed`, and always `null` for top-ups / issue fees. Credit your user from THIS (or `amount_credited_base`), never from `amount` — the requested gross — or you over-credit by the tariff on every unload. |\n| `amount_credited_base` **required** | `string | null` | Integer-string base units of `amount_credited` (`null` until a withdraw confirms). |\n| `card_id` **required** | `integer | null` | — |\n| `request_order_id` **required** | `string | null` | Server-minted correlation handle (`ct-`/`cw-`/`ci-` prefix). |\n| `provider_status` **required** | `string | null` | Where a `failed` order failed: `send_failed` = the on-chain send to the rail reverted (our side; the charge was refunded, nothing left the address); anything else is the rail's own word for its refusal. Informational — act on `status`. |\n| `error` **required** | `string | null` | — |\n| `idempotency_key` **required** | `string | null` | — |\n| `created_at` **required** | `string<date-time>` | — |\n\n```json\n{\n  \"id\": 1,\n  \"kind\": \"topup\",\n  \"status\": \"funding\",\n  \"txid\": \"string\",\n  \"asset_id\": \"usdt_trc20\",\n  \"amount\": \"1.5\",\n  \"amount_base\": \"1500000000\",\n  \"fee\": \"1.5\",\n  \"fee_base\": \"1500000000\",\n  \"gas\": \"1.5\",\n  \"merchant_fee\": \"1.5\",\n  \"total\": \"1.5\",\n  \"sent_on_chain\": \"1.5\",\n  \"amount_credited\": \"1.5\",\n  \"amount_credited_base\": \"1500000000\",\n  \"card_id\": 1,\n  \"request_order_id\": \"string\",\n  \"provider_status\": \"string\",\n  \"error\": \"string\",\n  \"idempotency_key\": \"string\",\n  \"created_at\": \"2026-08-31T09:15:00.000Z\"\n}\n```\n\n#### Example request\n\n```bash\ncurl -X POST 'https://api.cryptoinfra.build/v1/cards/1/topup' \\\n  -H 'Authorization: Bearer ck_live_your_key' \\\n  -H 'Content-Type: application/json' \\\n  -d '{\n  \"user_id\": \"string\",\n  \"amount\": \"string\",\n  \"idempotency_key\": \"string\"\n}'\n```\n\n## GET /v1/cards/{id}/close-quote\n\n**What closing this card would charge the cardholder**  ·  _Auth: bearer API key_  ·  `operationId: cardCloseQuote`\n\nRead-only. Our flat close fee, the live network figure for the on-chain refund, plus your own fee if you set one — `total` is the holder's debit on DELETE /v1/cards/{id}.\n\n#### Parameters\n\n| Name | In | Type | Required | Description |\n| --- | --- | --- | --- | --- |\n| `id` | path | `integer` | yes | The card's numeric id (from GET /v1/cards). _(min `1`)_ |\n| `user_id` | query | `string` | yes | — |\n\n#### Responses\n\n| Status | Body | Meaning |\n| --- | --- | --- |\n| `200` | `object` | The close preview. |\n| `401` | `Error` | Missing, malformed or unknown API key. |\n| `404` | `Error` | Unknown card (`card_not_found`), or an unknown payer user (`user_not_found`), for this tenant. |\n\n**`200` response body**\n\n| Field | Type | Description |\n| --- | --- | --- |\n| `asset_id` **required** | `string` | — |\n| `fee` **required** | `string` | A monetary value as a decimal string. Always paired with a `*_base` field that carries the exact integer base-unit value; compute with the `_base` string. |\n| `fee_base` **required** | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |\n| `gas` | `string` | The live network figure for returning the balance to the holder's address on-chain. Part of `total`. |\n| `gas_base` | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |\n| `dynamic` | `boolean` | true when `gas` was priced live now; false on the fallback. |\n| `merchant_fee` **required** | `string` | A monetary value as a decimal string. Always paired with a `*_base` field that carries the exact integer base-unit value; compute with the `_base` string. |\n| `merchant_fee_base` **required** | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |\n| `total` **required** | `string` | A monetary value as a decimal string. Always paired with a `*_base` field that carries the exact integer base-unit value; compute with the `_base` string. |\n| `total_base` **required** | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |\n| `charged_to` **required** | `string` | Where the close is paid from. Every cost — ours, the network figure, yours and the rail's release charge — comes out of the balance being returned, never out of the holder's wallet balance. They receive `refund_quote.refund`. _(one of `refund`)_ |\n| `minimum_card_balance` **required** | `string` | Always `0` since 20 September 2026: a close is no longer refused because the card holds little or nothing. Kept so existing clients do not break. |\n| `issuer_fee` | `string` | What the issuer keeps on the card as its cancellation charge when the card holds that much. A card holding less simply closes, and the issuer keeps whatever is there. |\n| `issuer_fee_base` | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |\n| `minimum_card_balance_base` **required** | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |\n| `refund_quote` | `object` | What the holder actually receives, on the card's balance right now — the figure to show them before they close. `card_balance` is read live from the rail; when it cannot be read it is `null`, `live` is `false` and the other lines are 0. The issuer's cancellation fee comes off the card, and the rail's charge for releasing it, our close fee, the network cost of the send and your own fee all come off what is returned. Nothing is taken from the holder's wallet balance. |\n| `refund_quote.card_balance` **required** | `string` | A monetary value as a decimal string. Always paired with a `*_base` field that carries the exact integer base-unit value; compute with the `_base` string. |\n| `refund_quote.card_balance_base` **required** | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |\n| `refund_quote.issuer_fee` **required** | `string` | A monetary value as a decimal string. Always paired with a `*_base` field that carries the exact integer base-unit value; compute with the `_base` string. |\n| `refund_quote.issuer_fee_base` **required** | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |\n| `refund_quote.rail_return_fee` **required** | `string` | A monetary value as a decimal string. Always paired with a `*_base` field that carries the exact integer base-unit value; compute with the `_base` string. |\n| `refund_quote.rail_return_fee_base` **required** | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |\n| `refund_quote.close_fee` **required** | `string` | A monetary value as a decimal string. Always paired with a `*_base` field that carries the exact integer base-unit value; compute with the `_base` string. |\n| `refund_quote.close_fee_base` **required** | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |\n| `refund_quote.gas` **required** | `string` | A monetary value as a decimal string. Always paired with a `*_base` field that carries the exact integer base-unit value; compute with the `_base` string. |\n| `refund_quote.gas_base` **required** | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |\n| `refund_quote.merchant_fee` **required** | `string` | A monetary value as a decimal string. Always paired with a `*_base` field that carries the exact integer base-unit value; compute with the `_base` string. |\n| `refund_quote.merchant_fee_base` **required** | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |\n| `refund_quote.refund` **required** | `string` | A monetary value as a decimal string. Always paired with a `*_base` field that carries the exact integer base-unit value; compute with the `_base` string. |\n| `refund_quote.refund_base` **required** | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |\n| `refund_quote.live` **required** | `boolean` | — |\n| `refund_eta_typical` | `string` | What a refund usually takes once the card is closed (e.g. \"24–48 hours\"). |\n| `refund_eta_max_days` | `integer` | The issuer's outside limit. Cancelling a card opens a settlement period during which the balance is not released, and the issuer states it can run to this many days. It has been far quicker in practice, but this is the figure to quote a customer rather than a promise of your own. |\n| `refund_eta_working_days` **required** | `integer` | The card's balance reaches the holder's own address on-chain within this many working days of the close. |\n\n```json\n{\n  \"asset_id\": \"usdt_trc20\",\n  \"fee\": \"1.5\",\n  \"fee_base\": \"1500000000\",\n  \"gas\": \"1.5\",\n  \"gas_base\": \"1500000000\",\n  \"dynamic\": true,\n  \"merchant_fee\": \"1.5\",\n  \"merchant_fee_base\": \"1500000000\",\n  \"total\": \"1.5\",\n  \"total_base\": \"1500000000\",\n  \"charged_to\": \"refund\",\n  \"minimum_card_balance\": \"1.5\",\n  \"issuer_fee\": \"1.5\",\n  \"issuer_fee_base\": \"1500000000\",\n  \"minimum_card_balance_base\": \"1500000000\",\n  \"refund_quote\": {\n    \"card_balance\": \"1.5\",\n    \"card_balance_base\": \"1500000000\",\n    \"issuer_fee\": \"1.5\",\n    \"issuer_fee_base\": \"1500000000\",\n    \"rail_return_fee\": \"1.5\",\n    \"rail_return_fee_base\": \"1500000000\",\n    \"close_fee\": \"1.5\",\n    \"close_fee_base\": \"1500000000\",\n    \"gas\": \"1.5\",\n    \"gas_base\": \"1500000000\",\n    \"merchant_fee\": \"1.5\",\n    \"merchant_fee_base\": \"1500000000\",\n    \"refund\": \"1.5\",\n    \"refund_base\": \"1500000000\",\n    \"live\": true\n  },\n  \"refund_eta_typical\": \"string\",\n  \"refund_eta_max_days\": 1,\n  \"refund_eta_working_days\": 1\n}\n```\n\n#### Example request\n\n```bash\ncurl -X GET 'https://api.cryptoinfra.build/v1/cards/1/close-quote?user_id=string' \\\n  -H 'Authorization: Bearer ck_live_your_key'\n```\n\n## GET /v1/merchant/card-fees\n\n**Your own card fees, on top of ours**  ·  _Auth: bearer API key_  ·  `operationId: merchantCardFeesGet`\n\nOurs is the floor and is always charged; what you set here is ADDED to it, charged to the cardholder, and credited to your own balance in the same journal as the charge. Nothing to move, nothing to pay out. Works with an API key or a dashboard login.\n\n#### Responses\n\n| Status | Body | Meaning |\n| --- | --- | --- |\n| `200` | `object` | Our prices and yours, per operation. |\n| `401` | `Error` | Missing, malformed or unknown API key. |\n\n**`200` response body**\n\n| Field | Type | Description |\n| --- | --- | --- |\n| `max_percent_bps` | `integer` | — |\n| `charged_to` | `string` |  _(one of `cardholder`)_ |\n| `ours` | `object` | — |\n| `ours.issue` | `object` | — |\n| `ours.issue.flat` | `string` | A monetary value as a decimal string. Always paired with a `*_base` field that carries the exact integer base-unit value; compute with the `_base` string. |\n| `ours.issue.flat_physical` | `string` | A monetary value as a decimal string. Always paired with a `*_base` field that carries the exact integer base-unit value; compute with the `_base` string. |\n| `ours.load` | `object` | — |\n| `ours.load.percent_bps` | `integer` | — |\n| `ours.load.min` | `string` | A monetary value as a decimal string. Always paired with a `*_base` field that carries the exact integer base-unit value; compute with the `_base` string. |\n| `ours.load.gas` | `string` | The network line of every load, priced when you ask — the live cost of the two TRON transfers behind a load, the same way a payout's `network_fee` is priced. Never set by hand, never stored: it moves with energy prices and the TRX price. If you mirror this figure, show `gas_dynamic` beside it and re-read it, do not print it as a fixed charge. |\n| `ours.load.gas_dynamic` | `boolean` | true = priced live now; false = the live price could not be read and a conservative fallback applies. |\n| `ours.load.gas_priced_at` | `string<date-time>` | When this figure was priced — the moment of this response. |\n| `ours.close` | `object` | — |\n| `ours.close.flat` | `string` | A monetary value as a decimal string. Always paired with a `*_base` field that carries the exact integer base-unit value; compute with the `_base` string. |\n| `yours` | `object[]` | — |\n| `yours[].operation` | `string` |  _(one of `issue`, `load`, `close`)_ |\n| `yours[].percent_bps` | `integer` | 100 = 1%. |\n| `yours[].flat` | `string` | A monetary value as a decimal string. Always paired with a `*_base` field that carries the exact integer base-unit value; compute with the `_base` string. |\n| `yours[].updated_at` | `string<date-time> | null` | — |\n\n```json\n{\n  \"max_percent_bps\": 1000,\n  \"charged_to\": \"cardholder\",\n  \"ours\": {\n    \"issue\": {\n      \"flat\": \"1.5\",\n      \"flat_physical\": \"1.5\"\n    },\n    \"load\": {\n      \"percent_bps\": 1,\n      \"min\": \"1.5\",\n      \"gas\": \"1.5\",\n      \"gas_dynamic\": true,\n      \"gas_priced_at\": \"2026-08-31T09:15:00.000Z\"\n    },\n    \"close\": {\n      \"flat\": \"1.5\"\n    }\n  },\n  \"yours\": [\n    {\n      \"operation\": \"issue\",\n      \"percent_bps\": 1,\n      \"flat\": \"1.5\",\n      \"updated_at\": \"2026-08-31T09:15:00.000Z\"\n    }\n  ]\n}\n```\n\n#### Example request\n\n```bash\ncurl -X GET 'https://api.cryptoinfra.build/v1/merchant/card-fees' \\\n  -H 'Authorization: Bearer ck_live_your_key'\n```\n\n## PUT /v1/merchant/card-fees/{operation}\n\n**Set your fee on one card operation**  ·  _Auth: bearer API key_  ·  `operationId: merchantCardFeeSet`\n\nA percentage of the amount (`percent_bps`, 100 = 1%, at most 1000), a flat amount (`flat`, in the card asset), or both. Omitted fields keep their current value. Refused with `409 no_own_account` when your account has no balance of its own for the fee to land in.\n\n#### Parameters\n\n| Name | In | Type | Required | Description |\n| --- | --- | --- | --- | --- |\n| `operation` | path | `string` | yes | — _(one of `issue`, `load`, `close`)_ |\n\n#### Request body\n\n`application/json` · required\n\n| Field | Type | Description |\n| --- | --- | --- |\n| `percent_bps` | `integer` |  _(min `0`; max `1000`)_ |\n| `flat` | `string` |  _(pattern `^\\d+(\\.\\d+)?$`)_ |\n\n#### Responses\n\n| Status | Body | Meaning |\n| --- | --- | --- |\n| `200` | `object` | The fee as saved. |\n| `400` | `Error` | Request validation failed or a domain rule was violated. `error` is one of: validation_error, invalid_request, invalid_amount, card_core_required, card_provider_required, unknown_provider, not_supported_by_provider. `card_core_required` carries `details.cores` — the card programmes this call will accept as `core_id`. It appears when you hold more than one programme that could serve the request. We refuse rather than pick, because picking would spend a balance that did not pay for the card and nothing in the response would say so. Send `core_id`. `card_provider_required` carries `details.rails` — the rail ids this call will accept as `provider`. It only ever appears when two enabled rails could both serve the request; naming a `core_id`, or having cards on just one rail, resolves it without your having to choose. |\n| `401` | `Error` | Missing, malformed or unknown API key. |\n| `409` | `Error` | `no_own_account` — open an own-balance account first. |\n\n**`200` response body**\n\n| Field | Type | Description |\n| --- | --- | --- |\n| `operation` | `string` |  _(one of `issue`, `load`, `close`)_ |\n| `percent_bps` | `integer` | — |\n| `flat` | `string` | A monetary value as a decimal string. Always paired with a `*_base` field that carries the exact integer base-unit value; compute with the `_base` string. |\n\n```json\n{\n  \"operation\": \"issue\",\n  \"percent_bps\": 1,\n  \"flat\": \"1.5\"\n}\n```\n\n#### Example request\n\n```bash\ncurl -X PUT 'https://api.cryptoinfra.build/v1/merchant/card-fees/issue' \\\n  -H 'Authorization: Bearer ck_live_your_key' \\\n  -H 'Content-Type: application/json' \\\n  -d '{}'\n```\n\n## DELETE /v1/merchant/card-fees/{operation}\n\n**Remove your fee on one card operation**  ·  _Auth: bearer API key_  ·  `operationId: merchantCardFeeClear`\n\n#### Parameters\n\n| Name | In | Type | Required | Description |\n| --- | --- | --- | --- | --- |\n| `operation` | path | `string` | yes | — _(one of `issue`, `load`, `close`)_ |\n\n#### Responses\n\n| Status | Body | Meaning |\n| --- | --- | --- |\n| `204` | — | Removed; the cardholder pays exactly our rate again. |\n| `401` | `Error` | Missing, malformed or unknown API key. |\n\n#### Example request\n\n```bash\ncurl -X DELETE 'https://api.cryptoinfra.build/v1/merchant/card-fees/issue' \\\n  -H 'Authorization: Bearer ck_live_your_key'\n```\n\n## POST /v1/cards/{id}/withdraw\n\n**Retired — moving part of a card balance back to the wallet**  ·  **Deprecated.**  ·  _Auth: bearer API key_  ·  `operationId: cardWithdraw`\n\n**Retired on 2026-09-14.** Always answers `410 card_unload_retired`. The\nonly way value leaves a card is `DELETE /v1/cards/{id}`, which returns\nthe whole balance to the holder's own address on-chain. An unload would\ncredit the holder's balance with coins that never reach their address.\n\n#### Parameters\n\n| Name | In | Type | Required | Description |\n| --- | --- | --- | --- | --- |\n| `id` | path | `integer` | yes | The card's numeric id (from GET /v1/cards). _(min `1`)_ |\n\n#### Request body\n\n`application/json` · required · schema `CardMoneyRequest`\n\n| Field | Type | Description |\n| --- | --- | --- |\n| `user_id` **required** | `string` |  _(minLength `1`; maxLength `255`)_ |\n| `amount` **required** | `string` | Decimal amount in major units (USD/USDT), at most 6 decimal places. _(pattern `^\\d+(\\.\\d+)?$`)_ |\n| `idempotency_key` **required** | `string` | Required; replay-safe. _(minLength `1`; maxLength `255`)_ |\n\n#### Responses\n\n| Status | Body | Meaning |\n| --- | --- | --- |\n| `401` | `Error` | Missing, malformed or unknown API key. |\n| `409` | `Error` | A conflicting card state. `error` is one of: kyc_not_approved (details.kyc_status), card_state, card_issuing (the card is still being issued — under two minutes; `details.card_id`; poll GET /v1/cards/{id} until status_normalized is active, or wait for the card.status webhook, then retry), provider_rejected (the rail refused — DEFINITIVE, so retrying the identical call cannot help; `details.order_id` on a money call, where any charge is already reversed, and `details.provider_code` otherwise), email_taken (details.email — the rail already holds an account on that address; send a different one), idempotency_conflict, insufficient_balance, no_own_account (you have set a card fee of your own but your account has no balance of its own to credit it to — ask us to open one). |\n| `410` | `Error` | Retired — `card_unload_retired`. Close the card instead. |\n| `503` | `Error` | Temporarily unable to serve this card call — your request was valid. Retry; nothing has been charged. `error` is one of: `provider_unavailable` — the rail did not answer, or answered unreadably. Transient. `card_rail_unavailable` — this card's own rail is not reachable (it may have been closed to new business). Other rails are unaffected, and the card itself is fine. `cards_disabled` — no card rail is enabled on this deployment at all. `rail_unfunded` — the platform cannot fund the on-chain send behind this load right now. Nothing was charged; retry later. `409 card_coins_not_on_chain` — the holder's coins are not at their own address yet (a close refund or a deposit still on its way): a load pays its own way on-chain from that address and waits for them. `details` carry `needed`. Nothing was charged; try again once they have arrived. |\n\n#### Example request\n\n```bash\ncurl -X POST 'https://api.cryptoinfra.build/v1/cards/1/withdraw' \\\n  -H 'Authorization: Bearer ck_live_your_key' \\\n  -H 'Content-Type: application/json' \\\n  -d '{\n  \"user_id\": \"string\",\n  \"amount\": \"string\",\n  \"idempotency_key\": \"string\"\n}'\n```\n\n## POST /v1/cards/{id}/freeze\n\n**Freeze a card**  ·  _Auth: bearer API key_  ·  `operationId: freezeCard`\n\nFreezes an `active` card (`409 card_state` otherwise). The authoritative status is confirmed by a `card.status` webhook.\n\n#### Parameters\n\n| Name | In | Type | Required | Description |\n| --- | --- | --- | --- | --- |\n| `id` | path | `integer` | yes | The card's numeric id (from GET /v1/cards). _(min `1`)_ |\n\n#### Request body\n\n`application/json` · required · schema `CardUserRef`\n\n| Field | Type | Description |\n| --- | --- | --- |\n| `user_id` **required** | `string` |  _(minLength `1`; maxLength `255`)_ |\n\n#### Responses\n\n| Status | Body | Meaning |\n| --- | --- | --- |\n| `200` | `object` | Card frozen. |\n| `400` | `Error` | Request validation failed or a domain rule was violated. `error` is one of: validation_error, invalid_request, invalid_amount, card_core_required, card_provider_required, unknown_provider, not_supported_by_provider. `card_core_required` carries `details.cores` — the card programmes this call will accept as `core_id`. It appears when you hold more than one programme that could serve the request. We refuse rather than pick, because picking would spend a balance that did not pay for the card and nothing in the response would say so. Send `core_id`. `card_provider_required` carries `details.rails` — the rail ids this call will accept as `provider`. It only ever appears when two enabled rails could both serve the request; naming a `core_id`, or having cards on just one rail, resolves it without your having to choose. |\n| `401` | `Error` | Missing, malformed or unknown API key. |\n| `404` | `Error` | Unknown card (`card_not_found`), or an unknown payer user (`user_not_found`), for this tenant. |\n| `409` | `Error` | A conflicting card state. `error` is one of: kyc_not_approved (details.kyc_status), card_state, card_issuing (the card is still being issued — under two minutes; `details.card_id`; poll GET /v1/cards/{id} until status_normalized is active, or wait for the card.status webhook, then retry), provider_rejected (the rail refused — DEFINITIVE, so retrying the identical call cannot help; `details.order_id` on a money call, where any charge is already reversed, and `details.provider_code` otherwise), email_taken (details.email — the rail already holds an account on that address; send a different one), idempotency_conflict, insufficient_balance, no_own_account (you have set a card fee of your own but your account has no balance of its own to credit it to — ask us to open one). |\n| `503` | `Error` | Temporarily unable to serve this card call — your request was valid. Retry; nothing has been charged. `error` is one of: `provider_unavailable` — the rail did not answer, or answered unreadably. Transient. `card_rail_unavailable` — this card's own rail is not reachable (it may have been closed to new business). Other rails are unaffected, and the card itself is fine. `cards_disabled` — no card rail is enabled on this deployment at all. `rail_unfunded` — the platform cannot fund the on-chain send behind this load right now. Nothing was charged; retry later. `409 card_coins_not_on_chain` — the holder's coins are not at their own address yet (a close refund or a deposit still on its way): a load pays its own way on-chain from that address and waits for them. `details` carry `needed`. Nothing was charged; try again once they have arrived. |\n\n**`200` response body**\n\n| Field | Type | Description |\n| --- | --- | --- |\n| `card` **required** | `object` | — |\n| `card.id` **required** | `integer` | — |\n| `card.rail` **required** | `string` | Opaque, stable id of the card rail a product, holder or card sits on — and the value to pass as `provider` when a call needs disambiguating. It tells you WHICH rail, never who runs it: two cards on different rails are different arrangements of ours, and that is all you need to route a call correctly. Read it from the resource and echo it back; never hard-code one, and never parse it — the string is an identifier, not a description. |\n| `card.core_id` **required** | `string | null` | The programme this card was issued from — the pair (`core_id`, `bin`) is what prices its loads and close. A programme holds no money; the cardholder pays. null on cards issued before programmes existed. |\n| `card.bin` **required** | `string | null` | The card's BIN. Read it WITH `core_id`, never alone: a BIN number is unique only inside its issuer, so the same number under a different programme is a different card with different fees, limits and validity. |\n| `card.product_id` **required** | `string | null` | Provider-scoped product CODE (kept as an alias of product_code); null when the card was listed untagged. |\n| `card.product_code` **required** | `string | null` | Provider-scoped product code; null when the card was listed untagged. |\n| `card.card_nature` **required** | `string` |  _(one of `VIRTUAL`, `PHYSICAL`)_ |\n| `card.status` **required** | `integer` | The provider's RAW status code, verbatim — interpret via status_normalized. |\n| `card.status_normalized` **required** | `string` | Provider-neutral status. A just-issued card is `issuing`; it becomes `active` once the opening balance has landed at the issuer. _(one of `issuing`, `active`, `frozen`, `suspended`, `closed`, `expired`)_ |\n| `card.currency` **required** | `string | null` | — |\n| `card.last4` **required** | `string | null` | — |\n| `card.name_on_card` **required** | `string | null` | The cardholder name you gave at issue — our record, whether or not the BIN prints it on the plastic. |\n| `card.issued_at` **required** | `string<date-time>` | — |\n\n```json\n{\n  \"card\": {\n    \"id\": 1,\n    \"rail\": \"rail_a\",\n    \"core_id\": \"string\",\n    \"bin\": \"string\",\n    \"product_id\": \"string\",\n    \"product_code\": \"string\",\n    \"card_nature\": \"VIRTUAL\",\n    \"status\": 1,\n    \"status_normalized\": \"issuing\",\n    \"currency\": \"string\",\n    \"last4\": \"string\",\n    \"name_on_card\": \"string\",\n    \"issued_at\": \"2026-08-31T09:15:00.000Z\"\n  }\n}\n```\n\n#### Example request\n\n```bash\ncurl -X POST 'https://api.cryptoinfra.build/v1/cards/1/freeze' \\\n  -H 'Authorization: Bearer ck_live_your_key' \\\n  -H 'Content-Type: application/json' \\\n  -d '{\n  \"user_id\": \"string\"\n}'\n```\n\n## POST /v1/cards/{id}/unfreeze\n\n**Unfreeze a card**  ·  _Auth: bearer API key_  ·  `operationId: unfreezeCard`\n\nUnfreezes a `frozen` card (`409 card_state` otherwise).\n\n#### Parameters\n\n| Name | In | Type | Required | Description |\n| --- | --- | --- | --- | --- |\n| `id` | path | `integer` | yes | The card's numeric id (from GET /v1/cards). _(min `1`)_ |\n\n#### Request body\n\n`application/json` · required · schema `CardUserRef`\n\n| Field | Type | Description |\n| --- | --- | --- |\n| `user_id` **required** | `string` |  _(minLength `1`; maxLength `255`)_ |\n\n#### Responses\n\n| Status | Body | Meaning |\n| --- | --- | --- |\n| `200` | `object` | Card unfrozen. |\n| `400` | `Error` | Request validation failed or a domain rule was violated. `error` is one of: validation_error, invalid_request, invalid_amount, card_core_required, card_provider_required, unknown_provider, not_supported_by_provider. `card_core_required` carries `details.cores` — the card programmes this call will accept as `core_id`. It appears when you hold more than one programme that could serve the request. We refuse rather than pick, because picking would spend a balance that did not pay for the card and nothing in the response would say so. Send `core_id`. `card_provider_required` carries `details.rails` — the rail ids this call will accept as `provider`. It only ever appears when two enabled rails could both serve the request; naming a `core_id`, or having cards on just one rail, resolves it without your having to choose. |\n| `401` | `Error` | Missing, malformed or unknown API key. |\n| `404` | `Error` | Unknown card (`card_not_found`), or an unknown payer user (`user_not_found`), for this tenant. |\n| `409` | `Error` | A conflicting card state. `error` is one of: kyc_not_approved (details.kyc_status), card_state, card_issuing (the card is still being issued — under two minutes; `details.card_id`; poll GET /v1/cards/{id} until status_normalized is active, or wait for the card.status webhook, then retry), provider_rejected (the rail refused — DEFINITIVE, so retrying the identical call cannot help; `details.order_id` on a money call, where any charge is already reversed, and `details.provider_code` otherwise), email_taken (details.email — the rail already holds an account on that address; send a different one), idempotency_conflict, insufficient_balance, no_own_account (you have set a card fee of your own but your account has no balance of its own to credit it to — ask us to open one). |\n| `503` | `Error` | Temporarily unable to serve this card call — your request was valid. Retry; nothing has been charged. `error` is one of: `provider_unavailable` — the rail did not answer, or answered unreadably. Transient. `card_rail_unavailable` — this card's own rail is not reachable (it may have been closed to new business). Other rails are unaffected, and the card itself is fine. `cards_disabled` — no card rail is enabled on this deployment at all. `rail_unfunded` — the platform cannot fund the on-chain send behind this load right now. Nothing was charged; retry later. `409 card_coins_not_on_chain` — the holder's coins are not at their own address yet (a close refund or a deposit still on its way): a load pays its own way on-chain from that address and waits for them. `details` carry `needed`. Nothing was charged; try again once they have arrived. |\n\n**`200` response body**\n\n| Field | Type | Description |\n| --- | --- | --- |\n| `card` **required** | `object` | — |\n| `card.id` **required** | `integer` | — |\n| `card.rail` **required** | `string` | Opaque, stable id of the card rail a product, holder or card sits on — and the value to pass as `provider` when a call needs disambiguating. It tells you WHICH rail, never who runs it: two cards on different rails are different arrangements of ours, and that is all you need to route a call correctly. Read it from the resource and echo it back; never hard-code one, and never parse it — the string is an identifier, not a description. |\n| `card.core_id` **required** | `string | null` | The programme this card was issued from — the pair (`core_id`, `bin`) is what prices its loads and close. A programme holds no money; the cardholder pays. null on cards issued before programmes existed. |\n| `card.bin` **required** | `string | null` | The card's BIN. Read it WITH `core_id`, never alone: a BIN number is unique only inside its issuer, so the same number under a different programme is a different card with different fees, limits and validity. |\n| `card.product_id` **required** | `string | null` | Provider-scoped product CODE (kept as an alias of product_code); null when the card was listed untagged. |\n| `card.product_code` **required** | `string | null` | Provider-scoped product code; null when the card was listed untagged. |\n| `card.card_nature` **required** | `string` |  _(one of `VIRTUAL`, `PHYSICAL`)_ |\n| `card.status` **required** | `integer` | The provider's RAW status code, verbatim — interpret via status_normalized. |\n| `card.status_normalized` **required** | `string` | Provider-neutral status. A just-issued card is `issuing`; it becomes `active` once the opening balance has landed at the issuer. _(one of `issuing`, `active`, `frozen`, `suspended`, `closed`, `expired`)_ |\n| `card.currency` **required** | `string | null` | — |\n| `card.last4` **required** | `string | null` | — |\n| `card.name_on_card` **required** | `string | null` | The cardholder name you gave at issue — our record, whether or not the BIN prints it on the plastic. |\n| `card.issued_at` **required** | `string<date-time>` | — |\n\n```json\n{\n  \"card\": {\n    \"id\": 1,\n    \"rail\": \"rail_a\",\n    \"core_id\": \"string\",\n    \"bin\": \"string\",\n    \"product_id\": \"string\",\n    \"product_code\": \"string\",\n    \"card_nature\": \"VIRTUAL\",\n    \"status\": 1,\n    \"status_normalized\": \"issuing\",\n    \"currency\": \"string\",\n    \"last4\": \"string\",\n    \"name_on_card\": \"string\",\n    \"issued_at\": \"2026-08-31T09:15:00.000Z\"\n  }\n}\n```\n\n#### Example request\n\n```bash\ncurl -X POST 'https://api.cryptoinfra.build/v1/cards/1/unfreeze' \\\n  -H 'Authorization: Bearer ck_live_your_key' \\\n  -H 'Content-Type: application/json' \\\n  -d '{\n  \"user_id\": \"string\"\n}'\n```\n\n## GET /v1/cards/{id}/transactions\n\n**Card transaction history**  ·  _Auth: bearer API key_  ·  `operationId: listCardTransactions`\n\nNormalized live passthrough from the provider (nothing is stored in v1).\n\n#### Parameters\n\n| Name | In | Type | Required | Description |\n| --- | --- | --- | --- | --- |\n| `id` | path | `integer` | yes | The card's numeric id (from GET /v1/cards). _(min `1`)_ |\n| `user_id` | query | `string` | yes | — |\n| `limit` | query | `integer` | no | Rows per page. _(min `1`; max `100`)_ |\n| `page` | query | `integer` | no | 1-based page number. _(min `1`; max `100000`)_ |\n| `page_size` | query | `integer` | no | Deprecated alias of `limit`; `limit` wins when both are sent. _(min `1`; max `100`)_ |\n| `page_num` | query | `integer` | no | Deprecated alias of `page`; `page` wins when both are sent. _(min `1`; max `100000`)_ |\n\n#### Responses\n\n| Status | Body | Meaning |\n| --- | --- | --- |\n| `200` | `object` | Transactions. |\n| `401` | `Error` | Missing, malformed or unknown API key. |\n| `404` | `Error` | Unknown card (`card_not_found`), or an unknown payer user (`user_not_found`), for this tenant. |\n| `503` | `Error` | Temporarily unable to serve this card call — your request was valid. Retry; nothing has been charged. `error` is one of: `provider_unavailable` — the rail did not answer, or answered unreadably. Transient. `card_rail_unavailable` — this card's own rail is not reachable (it may have been closed to new business). Other rails are unaffected, and the card itself is fine. `cards_disabled` — no card rail is enabled on this deployment at all. `rail_unfunded` — the platform cannot fund the on-chain send behind this load right now. Nothing was charged; retry later. `409 card_coins_not_on_chain` — the holder's coins are not at their own address yet (a close refund or a deposit still on its way): a load pays its own way on-chain from that address and waits for them. `details` carry `needed`. Nothing was charged; try again once they have arrived. |\n\n**`200` response body**\n\n| Field | Type | Description |\n| --- | --- | --- |\n| `transactions` **required** | `object[]` | — |\n| `transactions[].id` **required** | `string` | — |\n| `transactions[].occurred_at` | `string | null` | — |\n| `transactions[].amount` | `string | null` | Decimal string; null when the provider omits it. |\n| `transactions[].amount_base` | `string | null` | — |\n| `transactions[].currency` | `string | null` | — |\n| `transactions[].fee` | `string | null` | — |\n| `transactions[].fee_base` | `string | null` | — |\n| `transactions[].merchant_name` | `string | null` | — |\n| `transactions[].mcc` | `string | null` | — |\n| `transactions[].type` | `string | null` | Transaction code: 170 payment, 171 refund, 172 card fee, 173 ATM withdrawal, 174 pre-authorization, 150 top-up, 140 card issue. |\n| `transactions[].status` | `string | null` | 1 processing, 2 success, 3 failed. |\n| `transactions[].authorization_code` | `string | null` | — |\n| `transactions[].related_authorization_code` | `string | null` | — |\n\n```json\n{\n  \"transactions\": [\n    {\n      \"id\": \"string\",\n      \"occurred_at\": \"string\",\n      \"amount\": \"string\",\n      \"amount_base\": \"string\",\n      \"currency\": \"string\",\n      \"fee\": \"string\",\n      \"fee_base\": \"string\",\n      \"merchant_name\": \"string\",\n      \"mcc\": \"string\",\n      \"type\": \"string\",\n      \"status\": \"string\",\n      \"authorization_code\": \"string\",\n      \"related_authorization_code\": \"string\"\n    }\n  ]\n}\n```\n\n#### Example request\n\n```bash\ncurl -X GET 'https://api.cryptoinfra.build/v1/cards/1/transactions?user_id=string' \\\n  -H 'Authorization: Bearer ck_live_your_key'\n```\n\n## GET /v1/cards/delivery/regions\n\n**Physical-card delivery regions**  ·  _Auth: bearer API key_  ·  `operationId: listCardDeliveryRegions`\n\nShipping regions for physical cards. Supported on one rail in v1 — returns\n`400 not_supported_by_provider` on the others.\n\n#### Parameters\n\n| Name | In | Type | Required | Description |\n| --- | --- | --- | --- | --- |\n| `local` | query | `string` | no | Display locale (default en_US). _(one of `zh_CN`, `zh_HK`, `en_US`, `ko_KR`)_ |\n\n#### Responses\n\n| Status | Body | Meaning |\n| --- | --- | --- |\n| `200` | `object` | Regions. |\n| `400` | `Error` | Request validation failed or a domain rule was violated. `error` is one of: validation_error, invalid_request, invalid_amount, card_core_required, card_provider_required, unknown_provider, not_supported_by_provider. `card_core_required` carries `details.cores` — the card programmes this call will accept as `core_id`. It appears when you hold more than one programme that could serve the request. We refuse rather than pick, because picking would spend a balance that did not pay for the card and nothing in the response would say so. Send `core_id`. `card_provider_required` carries `details.rails` — the rail ids this call will accept as `provider`. It only ever appears when two enabled rails could both serve the request; naming a `core_id`, or having cards on just one rail, resolves it without your having to choose. |\n| `401` | `Error` | Missing, malformed or unknown API key. |\n| `503` | `Error` | Temporarily unable to serve this card call — your request was valid. Retry; nothing has been charged. `error` is one of: `provider_unavailable` — the rail did not answer, or answered unreadably. Transient. `card_rail_unavailable` — this card's own rail is not reachable (it may have been closed to new business). Other rails are unaffected, and the card itself is fine. `cards_disabled` — no card rail is enabled on this deployment at all. `rail_unfunded` — the platform cannot fund the on-chain send behind this load right now. Nothing was charged; retry later. `409 card_coins_not_on_chain` — the holder's coins are not at their own address yet (a close refund or a deposit still on its way): a load pays its own way on-chain from that address and waits for them. `details` carry `needed`. Nothing was charged; try again once they have arrived. |\n\n**`200` response body**\n\n| Field | Type | Description |\n| --- | --- | --- |\n| `regions` **required** | `object[]` | — |\n| `regions[].id` | `string` | Region id; pass its numeric value as country_region_id to POST /v1/cards/delivery/address. |\n| `regions[].country_region` | `string` | — |\n| `regions[].country_region_zh` | `string` | — |\n| `regions[].national_code` | `string` | Dialing code. |\n| `regions[].price` | `string | null` | Postage as a decimal string; currency unspecified. null = unknown (NOT free). |\n\n```json\n{\n  \"regions\": [\n    {\n      \"id\": \"string\",\n      \"country_region\": \"string\",\n      \"country_region_zh\": \"string\",\n      \"national_code\": \"string\",\n      \"price\": \"string\"\n    }\n  ]\n}\n```\n\n#### Example request\n\n```bash\ncurl -X GET 'https://api.cryptoinfra.build/v1/cards/delivery/regions' \\\n  -H 'Authorization: Bearer ck_live_your_key'\n```\n\n## POST /v1/cards/delivery/address\n\n**Add a physical-card delivery address**  ·  _Auth: bearer API key_  ·  `operationId: addCardDeliveryAddress`\n\nCreates a mailing address at the provider AND records its ownership\nlocally. The returned `delivery_address_id` is passed as\n`delivery_address_id` when issuing a PHYSICAL card (issuance only accepts\nan id recorded for the same holder).\n\n#### Request body\n\n`application/json` · required\n\n| Field | Type | Description |\n| --- | --- | --- |\n| `user_id` **required** | `string` |  _(minLength `1`; maxLength `255`)_ |\n| `country_region_id` **required** | `integer` | Region id from GET /v1/cards/delivery/regions. _(min `1`)_ |\n| `country` **required** | `string` |  _(minLength `1`; maxLength `128`)_ |\n| `city` **required** | `string` |  _(minLength `1`; maxLength `128`)_ |\n| `receiver_name` **required** | `string` |  _(minLength `1`; maxLength `128`)_ |\n| `receiver_mobile` **required** | `string` |  _(minLength `1`; maxLength `32`)_ |\n| `receiver_address` **required** | `string` |  _(minLength `1`; maxLength `512`)_ |\n| `post_code` **required** | `string` |  _(minLength `1`; maxLength `32`)_ |\n\n#### Responses\n\n| Status | Body | Meaning |\n| --- | --- | --- |\n| `201` | `object` | Address created. |\n| `400` | `Error` | Request validation failed or a domain rule was violated. `error` is one of: validation_error, invalid_request, invalid_amount, card_core_required, card_provider_required, unknown_provider, not_supported_by_provider. `card_core_required` carries `details.cores` — the card programmes this call will accept as `core_id`. It appears when you hold more than one programme that could serve the request. We refuse rather than pick, because picking would spend a balance that did not pay for the card and nothing in the response would say so. Send `core_id`. `card_provider_required` carries `details.rails` — the rail ids this call will accept as `provider`. It only ever appears when two enabled rails could both serve the request; naming a `core_id`, or having cards on just one rail, resolves it without your having to choose. |\n| `401` | `Error` | Missing, malformed or unknown API key. |\n| `404` | `Error` | No matching card holder (`holder_not_found`), or an unknown user/payer (`user_not_found`), for this tenant. |\n| `503` | `Error` | Temporarily unable to serve this card call — your request was valid. Retry; nothing has been charged. `error` is one of: `provider_unavailable` — the rail did not answer, or answered unreadably. Transient. `card_rail_unavailable` — this card's own rail is not reachable (it may have been closed to new business). Other rails are unaffected, and the card itself is fine. `cards_disabled` — no card rail is enabled on this deployment at all. `rail_unfunded` — the platform cannot fund the on-chain send behind this load right now. Nothing was charged; retry later. `409 card_coins_not_on_chain` — the holder's coins are not at their own address yet (a close refund or a deposit still on its way): a load pays its own way on-chain from that address and waits for them. `details` carry `needed`. Nothing was charged; try again once they have arrived. |\n\n**`201` response body**\n\n| Field | Type | Description |\n| --- | --- | --- |\n| `delivery_address_id` **required** | `integer` | — |\n\n```json\n{\n  \"delivery_address_id\": 1\n}\n```\n\n#### Example request\n\n```bash\ncurl -X POST 'https://api.cryptoinfra.build/v1/cards/delivery/address' \\\n  -H 'Authorization: Bearer ck_live_your_key' \\\n  -H 'Content-Type: application/json' \\\n  -d '{\n  \"user_id\": \"string\",\n  \"country_region_id\": 1,\n  \"country\": \"string\",\n  \"city\": \"string\",\n  \"receiver_name\": \"string\",\n  \"receiver_mobile\": \"string\",\n  \"receiver_address\": \"string\",\n  \"post_code\": \"string\"\n}'\n```\n",
      "sections": [
        {
          "anchor": "get-v1-cards-holders",
          "heading": "GET /v1/cards/holders",
          "text": "List your card holders  ·  Auth: bearer API key  ·  operationId: listCardHolders\n\nOne row per provider identity, newest first, with a card count and KYC state.\n\nResponses\n\nStatus · Body · Meaning\n200 · object · Your card holders.\n401 · Error · Missing, malformed or unknown API key.\n503 · Error · Temporarily unable to serve this card call — your request was valid. Retry; nothing has been charged. error is one of: provider_unavailable — the rail did not answer, or answered unreadably. Transient. card_rail_unavailable — this card's own rail is not reachable (it may have been closed to new business). Other rails are unaffected, and the card itself is fine. cards_disabled — no card rail is enabled on this deployment at all. rail_unfunded — the platform cannot fund the on-chain send behind this load right now. Nothing was charged; retry later. 409 card_coins_not_on_chain — the holder's coins are not at their own address yet (a close refund or a deposit still on its way): a load pays its own way on-chain from that address and waits for them. details carry needed. Nothing was charged; try again once they have arrived.\n\n200 response body\n\nField · Type · Description\nholders required · object[] · —\nholders[].id required · integer · —\nholders[].user_id required · string · Your external_user_id.\nholders[].rail required · string · Opaque, stable id of the card rail a product, holder or card sits on — and the value to pass as provider when a call needs disambiguating. It tells you WHICH rail, never who runs it: two cards on different rails are different arrangements of ours, and that is all you need to route a call correctly. Read it from the resource and echo it back; never hard-code one, and never parse it — the string is an identifier, not a description.\nholders[].name required · string · The holder's display name as registered.\nholders[].program required · string · —\nholders[].email required · string · The email the holder was registered with.\nholders[].kyc_status required · string · (one of none, pending, approved, rejected)\nholders[].card_count required · integer · —\nholders[].created_at required · string<date-time> · —\n\n{\n\"holders\": [\n{\n\"id\": 1,\n\"user_id\": \"string\",\n\"rail\": \"rail_a\",\n\"name\": \"string\",\n\"program\": \"string\",\n\"email\": \"string\",\n\"kyc_status\": \"none\",\n\"card_count\": 1,\n\"created_at\": \"2026-08-31T09:15:00.000Z\"\n}\n]\n}\n\nExample request\n\ncurl -X GET 'https://api.cryptoinfra.build/v1/cards/holders' \\\n-H 'Authorization: Bearer ck_live_your_key'"
        },
        {
          "anchor": "post-v1-cards-holders",
          "heading": "POST /v1/cards/holders",
          "text": "Register a card holder  ·  Auth: bearer API key  ·  operationId: createCardHolder\n\nThe first step of the card flow: registers your user at the card provider\nand persists the holder. Idempotent per (user, rail) — replaying\nreturns the existing holder untouched (still 201). Pass provider\nonly when two enabled rails could both take the holder.\n\nThe same customer may hold a card at any number of merchants with the\nsame email address: the rail's account is opened under a tagged copy of\nit, so one merchant's holder never blocks another's. 409 email_taken\n(with details.email) is answered only when no tag is free for that\naddress — send a different one; it is not a retryable failure.\n\nRequest body\n\napplication/json · required\n\nField · Type · Description\nuser_id required · string · Your external_user_id. (minLength 1; maxLength 255)\nemail required · string<email> · (maxLength 255)\nprovider · string · Rail id (a product's rail), for the identity to register. Omit it unless two enabled rails could both take this holder. (minLength 1; maxLength 64)\nfirst_name · string · (maxLength 64)\nlast_name · string · (maxLength 64)\nphone · string · E.164\ndate_of_birth · string<date> · —\ncountry · string · (minLength 2; maxLength 2)\nzip_code · string · (maxLength 16)\naddress · string · (maxLength 128)\ncity · string · (maxLength 64)\nstate · string · (maxLength 64)\n\nResponses\n\nStatus · Body · Meaning\n201 · CardHolder · Holder registered (or already existed).\n400 · Error · Request validation failed or a domain rule was violated. error is one of: validation_error, invalid_request, invalid_amount, card_core_required, card_provider_required, unknown_provider, not_supported_by_provider. card_core_required carries details.cores — the card programmes this call will accept as core_id. It appears when you hold more than one programme that could serve the request. We refuse rather than pick, because picking would spend a balance that did not pay for the card and nothing in the response would say so. Send core_id. card_provider_required carries details.rails — the rail ids this call will accept as provider. It only ever appears when two enabled rails could both serve the request; naming a core_id, or having cards on just one rail, resolves it without your having to choose.\n401 · Error · Missing, malformed or unknown API key.\n404 · Error · Unknown external_user_id for this tenant.\n409 · Error · A conflicting card state. error is one of: kyc_not_approved (details.kyc_status), card_state, card_issuing (the card is still being issued — under two minutes; details.card_id; poll GET /v1/cards/{id} until status_normalized is active, or wait for the card.status webhook, then retry), provider_rejected (the rail refused — DEFINITIVE, so retrying the identical call cannot help; details.order_id on a money call, where any charge is already reversed, and details.provider_code otherwise), email_taken (details.email — the rail already holds an account on that address; send a different one), idempotency_conflict, insufficient_balance, no_own_account (you have set a card fee of your own but your account has no balance of its own to credit it to — ask us to open one).\n503 · Error · Temporarily unable to serve this card call — your request was valid. Retry; nothing has been charged. error is one of: provider_unavailable — the rail did not answer, or answered unreadably. Transient. card_rail_unavailable — this card's own rail is not reachable (it may have been closed to new business). Other rails are unaffected, and the card itself is fine. cards_disabled — no card rail is enabled on this deployment at all. rail_unfunded — the platform cannot fund the on-chain send behind this load right now. Nothing was charged; retry later. 409 card_coins_not_on_chain — the holder's coins are not at their own address yet (a close refund or a deposit still on its way): a load pays its own way on-chain from that address and waits for them. details carry needed. Nothing was charged; try again once they have arrived.\n\n201 response body — CardHolder\n\nField · Type · Description\nuser_id required · string · Your external_user_id.\nrail required · string · Opaque, stable id of the card rail a product, holder or card sits on — and the value to pass as provider when a call needs disambiguating. It tells you WHICH rail, never who runs it: two cards on different rails are different arrangements of ours, and that is all you need to route a call correctly. Read it from the resource and echo it back; never hard-code one, and never parse it — the string is an identifier, not a description.\nemail required · string · —\nkyc_status required · string · Legacy issuer-KYC lifecycle on a card holder. Distinct from KycStatus, the cryptoinfra-owned verdict at /v1/kyc/*. (one of none, pending, approved, rejected)\nkyc_remark required · string · null · Provider remark (e.g. a rejection reason); null when none.\n\n{\n\"user_id\": \"string\",\n\"rail\": \"rail_a\",\n\"email\": \"string\",\n\"kyc_status\": \"none\",\n\"kyc_remark\": \"string\"\n}\n\nExample request\n\ncurl -X POST 'https://api.cryptoinfra.build/v1/cards/holders' \\\n-H 'Authorization: Bearer ck_live_your_key' \\\n-H 'Content-Type: application/json' \\\n-d '{\n\"user_id\": \"string\",\n\"email\": \"string\",\n\"phone\": \"+6591234567\"\n}'"
        },
        {
          "anchor": "get-v1-cards-summary",
          "heading": "GET /v1/cards/summary",
          "text": "Card counts at a glance  ·  Auth: bearer API key  ·  operationId: cardsSummary\n\nTotals for a dashboard header — cards by normalized status, plus how many holders exist.\n\nParameters\n\nName · In · Type · Required · Description\ncore_id · query · string · no · Count one programme's cards only.\n\nResponses\n\nStatus · Body · Meaning\n200 · object · The counts.\n401 · Error · Missing, malformed or unknown API key.\n503 · Error · Temporarily unable to serve this card call — your request was valid. Retry; nothing has been charged. error is one of: provider_unavailable — the rail did not answer, or answered unreadably. Transient. card_rail_unavailable — this card's own rail is not reachable (it may have been closed to new business). Other rails are unaffected, and the card itself is fine. cards_disabled — no card rail is enabled on this deployment at all. rail_unfunded — the platform cannot fund the on-chain send behind this load right now. Nothing was charged; retry later. 409 card_coins_not_on_chain — the holder's coins are not at their own address yet (a close refund or a deposit still on its way): a load pays its own way on-chain from that address and waits for them. details carry needed. Nothing was charged; try again once they have arrived.\n\n200 response body\n\nField · Type · Description\ncards required · integer · —\nby_status required · object · Keys are issuing/active/frozen/suspended/closed/expired.\nholders required · integer · —\non_cards · string · Sum of live card balances (≤60s cache); null when none were readable.\nprocessing · string · Sum of pending holds.\nbalances_read · integer · —\n\n{\n\"cards\": 1,\n\"by_status\": {},\n\"holders\": 1,\n\"on_cards\": \"string\",\n\"processing\": \"string\",\n\"balances_read\": 1\n}\n\nExample request\n\ncurl -X GET 'https://api.cryptoinfra.build/v1/cards/summary' \\\n-H 'Authorization: Bearer ck_live_your_key'"
        },
        {
          "anchor": "get-v1-cards-orders",
          "heading": "GET /v1/cards/orders",
          "text": "Card-order ledger  ·  Auth: bearer API key  ·  operationId: listCardOrders\n\nEvery issue, top-up, card withdrawal and close fee on your account, newest first. card_id filters to one card, core_id to one programme.\n\nParameters\n\nName · In · Type · Required · Description\ncard_id · query · integer · no · — (min 1)\ncore_id · query · string · no · One programme's orders.\nlimit · query · integer · no · — (min 1; max 500)\n\nResponses\n\nStatus · Body · Meaning\n200 · object · The orders.\n401 · Error · Missing, malformed or unknown API key.\n503 · Error · Temporarily unable to serve this card call — your request was valid. Retry; nothing has been charged. error is one of: provider_unavailable — the rail did not answer, or answered unreadably. Transient. card_rail_unavailable — this card's own rail is not reachable (it may have been closed to new business). Other rails are unaffected, and the card itself is fine. cards_disabled — no card rail is enabled on this deployment at all. rail_unfunded — the platform cannot fund the on-chain send behind this load right now. Nothing was charged; retry later. 409 card_coins_not_on_chain — the holder's coins are not at their own address yet (a close refund or a deposit still on its way): a load pays its own way on-chain from that address and waits for them. details carry needed. Nothing was charged; try again once they have arrived.\n\n200 response body\n\nField · Type · Description\norders required · object[] · —\norders[].id · integer · —\norders[].kind · string · close_fee is the close fee charged to the cardholder on DELETE /v1/cards/{id}: amount is 0, fee is the charge. _(one of topup, withdraw, issue_fee, close_fee)_\norders[].status · string · —\norders[].amount · string · —\norders[].fee · string · —\norders[].amount_credited · string · —\norders[].card_id · integer · —\norders[].card_last4 · string · —\norders[].rail · string · Opaque, stable id of the card rail a product, holder or card sits on — and the value to pass as provider when a call needs disambiguating. It tells you WHICH rail, never who runs it: two cards on different rails are different arrangements of ours, and that is all you need to route a call correctly. Read it from the resource and echo it back; never hard-code one, and never parse it — the string is an identifier, not a description.\norders[].user_id · string · —\norders[].error · string · —\norders[].txid · string · The on-chain send's hash; null until broadcast or when the rail already held the money.\norders[].created_at · string<date-time> · —\n\n{\n\"orders\": [\n{\n\"id\": 1,\n\"kind\": \"topup\",\n\"status\": \"string\",\n\"amount\": \"string\",\n\"fee\": \"string\",\n\"amount_credited\": \"string\",\n\"card_id\": 1,\n\"card_last4\": \"string\",\n\"rail\": \"rail_a\",\n\"user_id\": \"string\",\n\"error\": \"string\",\n\"txid\": \"string\",\n\"created_at\": \"2026-08-31T09:15:00.000Z\"\n}\n]\n}\n\nExample request\n\ncurl -X GET 'https://api.cryptoinfra.build/v1/cards/orders' \\\n-H 'Authorization: Bearer ck_live_your_key'"
        },
        {
          "anchor": "get-v1-cards-kyc-countries",
          "heading": "GET /v1/cards/kyc/countries",
          "text": "KYC country catalogue  ·  Auth: bearer API key  ·  operationId: getCardKycCountries\n\nThe provider's supported KYC countries. value is the ISO alpha-3 code\nto pass as nation_code / country_code in POST /v1/cards/kyc.\n\nParameters\n\nName · In · Type · Required · Description\nname · query · string · no · Optional local case-insensitive filter on name/en_name.\nprovider · query · string · no · Rail id (a product's rail); omit unless two enabled rails could both answer.\n\nResponses\n\nStatus · Body · Meaning\n200 · object · Supported countries.\n400 · Error · Request validation failed or a domain rule was violated. error is one of: validation_error, invalid_request, invalid_amount, card_core_required, card_provider_required, unknown_provider, not_supported_by_provider. card_core_required carries details.cores — the card programmes this call will accept as core_id. It appears when you hold more than one programme that could serve the request. We refuse rather than pick, because picking would spend a balance that did not pay for the card and nothing in the response would say so. Send core_id. card_provider_required carries details.rails — the rail ids this call will accept as provider. It only ever appears when two enabled rails could both serve the request; naming a core_id, or having cards on just one rail, resolves it without your having to choose.\n401 · Error · Missing, malformed or unknown API key.\n503 · Error · Temporarily unable to serve this card call — your request was valid. Retry; nothing has been charged. error is one of: provider_unavailable — the rail did not answer, or answered unreadably. Transient. card_rail_unavailable — this card's own rail is not reachable (it may have been closed to new business). Other rails are unaffected, and the card itself is fine. cards_disabled — no card rail is enabled on this deployment at all. rail_unfunded — the platform cannot fund the on-chain send behind this load right now. Nothing was charged; retry later. 409 card_coins_not_on_chain — the holder's coins are not at their own address yet (a close refund or a deposit still on its way): a load pays its own way on-chain from that address and waits for them. details carry needed. Nothing was charged; try again once they have arrived.\n\n200 response body\n\nField · Type · Description\ncountries required · object[] · —\ncountries[].name · string · —\ncountries[].en_name · string · —\ncountries[].value · string · ISO alpha-3 code.\ncountries[].prof_value · string · —\ncountries[].code · string · —\ncountries[].telephone_code · string · —\ncountries[].need_certificate · boolean · true → this country additionally needs file_type/file_url/face_url in POST /v1/cards/kyc.\n\n{\n\"countries\": [\n{\n\"name\": \"string\",\n\"en_name\": \"string\",\n\"value\": \"string\",\n\"prof_value\": \"string\",\n\"code\": \"string\",\n\"telephone_code\": \"string\",\n\"need_certificate\": true\n}\n]\n}\n\nExample request\n\ncurl -X GET 'https://api.cryptoinfra.build/v1/cards/kyc/countries' \\\n-H 'Authorization: Bearer ck_live_your_key'"
        },
        {
          "anchor": "post-v1-cards-kyc-upload",
          "heading": "POST /v1/cards/kyc/upload",
          "text": "Upload a KYC document  ·  Auth: bearer API key  ·  operationId: uploadCardKycDocument\n\nPasses a KYC document through to the provider and returns its hosted URL\n(feed that URL into POST /v1/cards/kyc). The bytes live only in this\nrequest — never stored on disk or in the DB. Max 5 MB decoded; the\nextension must be .png/.jpg/.jpeg/.pdf.\n\nRequest body\n\napplication/json · required\n\nField · Type · Description\nuser_id required · string · (minLength 1; maxLength 255)\nprovider · string · Opaque, stable id of the card rail a product, holder or card sits on — and the value to pass as provider when a call needs disambiguating. It tells you WHICH rail, never who runs it: two cards on different rails are different arrangements of ours, and that is all you need to route a call correctly. Read it from the resource and echo it back; never hard-code one, and never parse it — the string is an identifier, not a description.\nfilename required · string · (minLength 1; maxLength 255)\ncontent_base64 required · string · Base64 of the document (≤5 MB decoded).\n\nResponses\n\nStatus · Body · Meaning\n201 · object · Uploaded.\n400 · Error · Request validation failed or a domain rule was violated. error is one of: validation_error, invalid_request, invalid_amount, card_core_required, card_provider_required, unknown_provider, not_supported_by_provider. card_core_required carries details.cores — the card programmes this call will accept as core_id. It appears when you hold more than one programme that could serve the request. We refuse rather than pick, because picking would spend a balance that did not pay for the card and nothing in the response would say so. Send core_id. card_provider_required carries details.rails — the rail ids this call will accept as provider. It only ever appears when two enabled rails could both serve the request; naming a core_id, or having cards on just one rail, resolves it without your having to choose.\n401 · Error · Missing, malformed or unknown API key.\n404 · Error · No matching card holder (holder_not_found), or an unknown user/payer (user_not_found), for this tenant.\n503 · Error · Temporarily unable to serve this card call — your request was valid. Retry; nothing has been charged. error is one of: provider_unavailable — the rail did not answer, or answered unreadably. Transient. card_rail_unavailable — this card's own rail is not reachable (it may have been closed to new business). Other rails are unaffected, and the card itself is fine. cards_disabled — no card rail is enabled on this deployment at all. rail_unfunded — the platform cannot fund the on-chain send behind this load right now. Nothing was charged; retry later. 409 card_coins_not_on_chain — the holder's coins are not at their own address yet (a close refund or a deposit still on its way): a load pays its own way on-chain from that address and waits for them. details carry needed. Nothing was charged; try again once they have arrived.\n\n201 response body\n\nField · Type · Description\nfile_url required · string · An opaque handle for the uploaded document, valid for 24 hours and only on your account. Pass it as id_url / id_back_url / file_url / face_url / selfie_url in POST /v1/cards/kyc. It is not a link to open. _(pattern ^kycfile_[A-Za-z0-9_-]{16,64}$)_\n\n{\n\"file_url\": \"string\"\n}\n\nExample request\n\ncurl -X POST 'https://api.cryptoinfra.build/v1/cards/kyc/upload' \\\n-H 'Authorization: Bearer ck_live_your_key' \\\n-H 'Content-Type: application/json' \\\n-d '{\n\"user_id\": \"string\",\n\"provider\": \"rail_a\",\n\"filename\": \"string\",\n\"content_base64\": \"string\"\n}'"
        },
        {
          "anchor": "post-v1-cards-kyc",
          "heading": "POST /v1/cards/kyc",
          "text": "Submit the KYC dossier  ·  Auth: bearer API key  ·  operationId: submitCardKyc\n\nSubmits KYC to the provider and flips the holder to pending. We keep no\nPII — the fields go straight to the provider. Resubmission after a\nrejection is allowed; an already-approved holder is rejected\n(400 invalid_request). id_back_url is required for cert_type 1 (ID\ncard) and 3 (driver's licence).\n\nRequest body\n\napplication/json · required · schema CardKycApply\n\nField · Type · Description\nuser_id required · string · (minLength 1; maxLength 255)\nprovider · string · Opaque, stable id of the card rail a product, holder or card sits on — and the value to pass as provider when a call needs disambiguating. It tells you WHICH rail, never who runs it: two cards on different rails are different arrangements of ours, and that is all you need to route a call correctly. Read it from the resource and echo it back; never hard-code one, and never parse it — the string is an identifier, not a description.\nfirst_name required · string · (minLength 1; maxLength 128)\nlast_name required · string · (minLength 1; maxLength 128)\nid_no required · string · (minLength 1; maxLength 64)\nemail required · string<email> · (maxLength 255)\nnation_code required · string · Nationality, ISO alpha-3 (the value from GET /v1/cards/kyc/countries). (minLength 3; maxLength 3)\ncert_type required · integer · 1 = ID card, 2 = passport, 3 = driver's licence. (one of 1, 2, 3)\nid_url required · string · FRONT document photo — the kycfile_… handle from POST /v1/cards/kyc/upload, or a URL you host. (maxLength 1024)\nbirthday required · string · YYYY-MM-DD. (pattern ^\\d{4}-\\d{2}-\\d{2}$)\ncountry_code required · string · Country of residence, ISO alpha-3. (minLength 3; maxLength 3)\narea_code required · string · Dialing code without '+' (e.g. '86'). (minLength 1; maxLength 8)\nphone required · string · (minLength 3; maxLength 32)\nid_back_url · string · BACK document photo (handle or URL, as id_url); REQUIRED for cert_type 1 (ID card) and 3 (driver's licence). (maxLength 1024)\nfile_type · integer · (min 1; max 100)\nfile_url · string · Handle from the upload, or a URL you host. (maxLength 1024)\nface_url · string · Handle from the upload, or a URL you host. (maxLength 1024)\nselfie_url · string · Handle from the upload, or a URL you host. (maxLength 1024)\nreference_id · string · (maxLength 128)\nreference_type · string · (maxLength 64)\n\nResponses\n\nStatus · Body · Meaning\n202 · CardHolder · KYC accepted; the holder's status is now pending.\n400 · Error · Request validation failed or a domain rule was violated. error is one of: validation_error, invalid_request, invalid_amount, card_core_required, card_provider_required, unknown_provider, not_supported_by_provider. card_core_required carries details.cores — the card programmes this call will accept as core_id. It appears when you hold more than one programme that could serve the request. We refuse rather than pick, because picking would spend a balance that did not pay for the card and nothing in the response would say so. Send core_id. card_provider_required carries details.rails — the rail ids this call will accept as provider. It only ever appears when two enabled rails could both serve the request; naming a core_id, or having cards on just one rail, resolves it without your having to choose.\n401 · Error · Missing, malformed or unknown API key.\n404 · Error · No matching card holder (holder_not_found), or an unknown user/payer (user_not_found), for this tenant.\n503 · Error · Temporarily unable to serve this card call — your request was valid. Retry; nothing has been charged. error is one of: provider_unavailable — the rail did not answer, or answered unreadably. Transient. card_rail_unavailable — this card's own rail is not reachable (it may have been closed to new business). Other rails are unaffected, and the card itself is fine. cards_disabled — no card rail is enabled on this deployment at all. rail_unfunded — the platform cannot fund the on-chain send behind this load right now. Nothing was charged; retry later. 409 card_coins_not_on_chain — the holder's coins are not at their own address yet (a close refund or a deposit still on its way): a load pays its own way on-chain from that address and waits for them. details carry needed. Nothing was charged; try again once they have arrived.\n\n202 response body — CardHolder\n\nField · Type · Description\nuser_id required · string · Your external_user_id.\nrail required · string · Opaque, stable id of the card rail a product, holder or card sits on — and the value to pass as provider when a call needs disambiguating. It tells you WHICH rail, never who runs it: two cards on different rails are different arrangements of ours, and that is all you need to route a call correctly. Read it from the resource and echo it back; never hard-code one, and never parse it — the string is an identifier, not a description.\nemail required · string · —\nkyc_status required · string · Legacy issuer-KYC lifecycle on a card holder. Distinct from KycStatus, the cryptoinfra-owned verdict at /v1/kyc/*. (one of none, pending, approved, rejected)\nkyc_remark required · string · null · Provider remark (e.g. a rejection reason); null when none.\n\n{\n\"user_id\": \"string\",\n\"rail\": \"rail_a\",\n\"email\": \"string\",\n\"kyc_status\": \"none\",\n\"kyc_remark\": \"string\"\n}\n\nExample request\n\ncurl -X POST 'https://api.cryptoinfra.build/v1/cards/kyc' \\\n-H 'Authorization: Bearer ck_live_your_key' \\\n-H 'Content-Type: application/json' \\\n-d '{\n\"user_id\": \"string\",\n\"provider\": \"rail_a\",\n\"first_name\": \"string\",\n\"last_name\": \"string\",\n\"id_no\": \"string\",\n\"email\": \"string\",\n\"nation_code\": \"string\",\n\"cert_type\": 1,\n\"id_url\": \"string\",\n\"birthday\": \"string\",\n\"country_code\": \"string\",\n\"area_code\": \"string\",\n\"phone\": \"string\"\n}'"
        },
        {
          "anchor": "get-v1-cards-kyc-status",
          "heading": "GET /v1/cards/kyc/status",
          "text": "Authoritative KYC status  ·  Auth: bearer API key  ·  operationId: getCardKycStatus\n\nPolls the rail and syncs the holder row before answering. Pass provider only when the user holds LIVE identities on two rails at once.\n\nParameters\n\nName · In · Type · Required · Description\nuser_id · query · string · yes · —\nprovider · query · string · no · Rail id (a holder's or product's rail). Omit it: we follow the rail this user's own identity is on, and only ask when the user has live identities on two rails at once.\n\nResponses\n\nStatus · Body · Meaning\n200 · CardHolder · The holder with its current KYC status.\n400 · Error · Request validation failed or a domain rule was violated. error is one of: validation_error, invalid_request, invalid_amount, card_core_required, card_provider_required, unknown_provider, not_supported_by_provider. card_core_required carries details.cores — the card programmes this call will accept as core_id. It appears when you hold more than one programme that could serve the request. We refuse rather than pick, because picking would spend a balance that did not pay for the card and nothing in the response would say so. Send core_id. card_provider_required carries details.rails — the rail ids this call will accept as provider. It only ever appears when two enabled rails could both serve the request; naming a core_id, or having cards on just one rail, resolves it without your having to choose.\n401 · Error · Missing, malformed or unknown API key.\n404 · Error · No matching card holder (holder_not_found), or an unknown user/payer (user_not_found), for this tenant.\n503 · Error · Temporarily unable to serve this card call — your request was valid. Retry; nothing has been charged. error is one of: provider_unavailable — the rail did not answer, or answered unreadably. Transient. card_rail_unavailable — this card's own rail is not reachable (it may have been closed to new business). Other rails are unaffected, and the card itself is fine. cards_disabled — no card rail is enabled on this deployment at all. rail_unfunded — the platform cannot fund the on-chain send behind this load right now. Nothing was charged; retry later. 409 card_coins_not_on_chain — the holder's coins are not at their own address yet (a close refund or a deposit still on its way): a load pays its own way on-chain from that address and waits for them. details carry needed. Nothing was charged; try again once they have arrived.\n\n200 response body — CardHolder\n\nField · Type · Description\nuser_id required · string · Your external_user_id.\nrail required · string · Opaque, stable id of the card rail a product, holder or card sits on — and the value to pass as provider when a call needs disambiguating. It tells you WHICH rail, never who runs it: two cards on different rails are different arrangements of ours, and that is all you need to route a call correctly. Read it from the resource and echo it back; never hard-code one, and never parse it — the string is an identifier, not a description.\nemail required · string · —\nkyc_status required · string · Legacy issuer-KYC lifecycle on a card holder. Distinct from KycStatus, the cryptoinfra-owned verdict at /v1/kyc/*. (one of none, pending, approved, rejected)\nkyc_remark required · string · null · Provider remark (e.g. a rejection reason); null when none.\n\n{\n\"user_id\": \"string\",\n\"rail\": \"rail_a\",\n\"email\": \"string\",\n\"kyc_status\": \"none\",\n\"kyc_remark\": \"string\"\n}\n\nExample request\n\ncurl -X GET 'https://api.cryptoinfra.build/v1/cards/kyc/status?user_id=string' \\\n-H 'Authorization: Bearer ck_live_your_key'"
        },
        {
          "anchor": "post-v1-kyc-start",
          "heading": "POST /v1/kyc/start",
          "text": "Start (or resume) identity verification  ·  Auth: bearer API key  ·  operationId: startKyc\n\ncryptoinfra-owned KYC, independent of the card rail. Creates\n(or RESUMES) a verification session for user_id and returns the hosted\nURL to send the user to. approved is terminal — a verified user is\nnever sent around again (no url, status: approved). Call this when a\nproduct's requires_kyc is true before issuing it.\n\nServer-to-server only: authenticate with the tenant key and proxy from\nyour backend — never expose this to a browser (the tenant key operates\nevery card on the tenant). The verdict itself is always re-read from the\nprovider's decision endpoint; poll GET /v1/kyc/status to observe it.\n\nRequest body\n\napplication/json · required\n\nField · Type · Description\nuser_id required · string · The tenant's user ref (same one that scopes card reads). (minLength 1; maxLength 255)\nlanguage · string · Optional ISO language hint for the hosted flow. (minLength 2; maxLength 8)\n\nResponses\n\nStatus · Body · Meaning\n200 · object · Session created or resumed (or already approved).\n400 · Error · Request validation failed or a domain rule was violated. error is one of: validation_error, invalid_request, invalid_amount, card_core_required, card_provider_required, unknown_provider, not_supported_by_provider. card_core_required carries details.cores — the card programmes this call will accept as core_id. It appears when you hold more than one programme that could serve the request. We refuse rather than pick, because picking would spend a balance that did not pay for the card and nothing in the response would say so. Send core_id. card_provider_required carries details.rails — the rail ids this call will accept as provider. It only ever appears when two enabled rails could both serve the request; naming a core_id, or having cards on just one rail, resolves it without your having to choose.\n401 · Error · Missing, malformed or unknown API key.\n404 · Error · Unknown external_user_id for this tenant.\n503 · Error · KYC is disabled or not configured.\n\n200 response body\n\nField · Type · Description\nstatus required · string · cryptoinfra-owned identity verdict for /v1/kyc/*. Issuance of a requires_kyc product needs approved. none = never started. (one of none, pending, approved, declined, review, expired)\nurl · string · Hosted verification URL; absent when already approved.\n\n{\n\"status\": \"none\",\n\"url\": \"string\"\n}\n\nExample request\n\ncurl -X POST 'https://api.cryptoinfra.build/v1/kyc/start' \\\n-H 'Authorization: Bearer ck_live_your_key' \\\n-H 'Content-Type: application/json' \\\n-d '{\n\"user_id\": \"string\"\n}'"
        },
        {
          "anchor": "get-v1-kyc-status",
          "heading": "GET /v1/kyc/status",
          "text": "Current KYC verdict (reconciled at read time)  ·  Auth: bearer API key  ·  operationId: getKycStatus\n\nThe user's verdict, re-read from the provider while it can still change\n(pending/review); terminal states are not re-fetched. none when the\nuser has never started verification.\n\nParameters\n\nName · In · Type · Required · Description\nuser_id · query · string · yes · —\n\nResponses\n\nStatus · Body · Meaning\n200 · object · The current verdict.\n400 · Error · Request validation failed or a domain rule was violated. error is one of: validation_error, invalid_request, invalid_amount, card_core_required, card_provider_required, unknown_provider, not_supported_by_provider. card_core_required carries details.cores — the card programmes this call will accept as core_id. It appears when you hold more than one programme that could serve the request. We refuse rather than pick, because picking would spend a balance that did not pay for the card and nothing in the response would say so. Send core_id. card_provider_required carries details.rails — the rail ids this call will accept as provider. It only ever appears when two enabled rails could both serve the request; naming a core_id, or having cards on just one rail, resolves it without your having to choose.\n401 · Error · Missing, malformed or unknown API key.\n404 · Error · Unknown external_user_id for this tenant.\n\n200 response body\n\nField · Type · Description\nstatus required · string · cryptoinfra-owned identity verdict for /v1/kyc/*. Issuance of a requires_kyc product needs approved. none = never started. (one of none, pending, approved, declined, review, expired)\n\n{\n\"status\": \"none\"\n}\n\nExample request\n\ncurl -X GET 'https://api.cryptoinfra.build/v1/kyc/status?user_id=string' \\\n-H 'Authorization: Bearer ck_live_your_key'"
        },
        {
          "anchor": "get-v1-cards-products",
          "heading": "GET /v1/cards/products",
          "text": "Card product catalogue  ·  Auth: bearer API key  ·  operationId: listCardProducts\n\nEvery issuable product, across every card programme you can issue from —\nthe source of truth for the product_code and core_id you pass to\nPOST /v1/cards. Read both from here; never hard-code either. A programme\nthat is closed to new cards simply does not appear, so an empty list\nmeans nothing is currently on sale rather than that the feature is\nbroken.\n\nEach row carries the programme it belongs to (core_id, core_name)\nand is PRICED FOR THAT PROGRAMME. The same BIN under two programmes can\ncost different amounts, so compare prices by (core_id, card_bin) —\nnever by BIN alone.\n\nEach row also carries what the user pays: issue_fee (our one-time\nissuance fee for THAT product, on THAT programme), min_opening_balance\n— the smallest initial_amount the issue will accept — and\nissue_total, everything the cardholder is debited to open the card at\nthat minimum: our issue fee, the opening balance priced as a load (our\nload fee + gas), and your own fees if set. Budget from issue_total\n(or total on GET /v1/cards/issue-quote for another opening balance);\nnever add issue_fee and initial_amount yourself.\n\nThe envelope's top-level issue_fee is deprecated (virtual-card fee off\nthe default row only) — use products[].issue_fee.\n\nParameters\n\nName · In · Type · Required · Description\ncore_id · query · string · no · Show one programme's shelf, priced for it. Omit to see every programme on offer.\n\nResponses\n\nStatus · Body · Meaning\n200 · object · Products.\n401 · Error · Missing, malformed or unknown API key.\n503 · Error · Temporarily unable to serve this card call — your request was valid. Retry; nothing has been charged. error is one of: provider_unavailable — the rail did not answer, or answered unreadably. Transient. card_rail_unavailable — this card's own rail is not reachable (it may have been closed to new business). Other rails are unaffected, and the card itself is fine. cards_disabled — no card rail is enabled on this deployment at all. rail_unfunded — the platform cannot fund the on-chain send behind this load right now. Nothing was charged; retry later. 409 card_coins_not_on_chain — the holder's coins are not at their own address yet (a close refund or a deposit still on its way): a load pays its own way on-chain from that address and waits for them. details carry needed. Nothing was charged; try again once they have arrived.\n\n200 response body\n\nField · Type · Description\nasset_id required · string · —\ngas · string · The network line inside every issue_total below, priced for this response.\ngas_dynamic · boolean · true = priced live now; false = the static fallback is inside the totals.\ngas_priced_at · string<date-time> · —\nissue_fee · string · Deprecated — virtual-card fee. Use products[].issue_fee.\nissue_fee_base · string · Exact integer amount in the asset's smallest unit. (pattern ^[0-9]+$)\nproducts required · object[] · —\nproducts[].id required · integer · null · Numeric id, kept for back-compat; null when the product code is not numeric.\nproducts[].rail required · string · Opaque, stable id of the card rail a product, holder or card sits on — and the value to pass as provider when a call needs disambiguating. It tells you WHICH rail, never who runs it: two cards on different rails are different arrangements of ours, and that is all you need to route a call correctly. Read it from the resource and echo it back; never hard-code one, and never parse it — the string is an identifier, not a description.\nproducts[].core_id · string · The card programme this row belongs to — pass it as core_id to POST /v1/cards. Every price on this row is resolved FOR this programme, so compare prices by (core_id, card_bin).\nproducts[].core_name · string · What this programme is called, for display. e.g. Core-1.\nproducts[].code required · string · Provider-scoped product code — pass as product_code to POST /v1/cards.\nproducts[].card_nature required · string · (one of VIRTUAL, PHYSICAL)\nproducts[].ccy required · string · Card currency (only USD is supported for money operations).\nproducts[].region · string · null · —\nproducts[].card_bin · string · null · —\nproducts[].card_brand · string · null · —\nproducts[].card_mode · string · null · —\nproducts[].title · string · null · —\nproducts[].requires_kyc · boolean · Whether getting this card requires an ID check (cryptoinfra-owned KYC via /v1/kyc/*). Per-BIN; false while KYC is disabled. Show it on the shelf (\"ID check required\") so a customer is not refused at issuance — a card whose requires_kyc is true rejects POST /v1/cards with 409 kyc_not_approved until the user's status is approved.\nproducts[].active_min_limit · string · null · Minimum FIRST top-up that activates a virtual card (decimal string); null when none.\nproducts[].recharge_min_limit · string · null · Minimum single top-up (decimal string); null when the provider imposes none.\nproducts[].withdraw_min_limit · string · null · Minimum single withdraw/unload the issuer will process (decimal string); null when the provider does not declare one. A residual balance below this cannot be withdrawn — surface it so a user can be warned before trying to take the last few dollars off a card.\nproducts[].issue_fee required · string · OUR issue fee for THIS product.\nproducts[].issue_fee_base required · string · Exact integer amount in the asset's smallest unit. (pattern ^[0-9]+$)\nproducts[].merchant_issue_fee · string · Your own issue fee (PUT /v1/merchant/card-fees/issue). \"0\" until you set one.\nproducts[].issue_total · string · Everything the cardholder is debited to issue this card at min_opening_balance: our issue fee, the opening balance priced as a load (our load fee + gas), and your own fees. A card opened with more pays the load parts on more.\nproducts[].issue_total_base · string · Exact integer amount in the asset's smallest unit. (pattern ^[0-9]+$)\nproducts[].charged_to · string · (one of cardholder)\nproducts[].load_fee_bps required · integer · Our load fee for cards of this product, basis points of the amount loaded.\nproducts[].load_fee_min required · string · The floor on that load fee, in USD.\nproducts[].load_fee_flat required · string · A flat platform fee on every load, in USD, on top of the percentage (or its floor).\nproducts[].close_fee · string · The flat part of OUR fee to close a card of this product — our own fee plus the issuer's cancellation fee billed to us — debited from the cardholder's balance when the card terminates. The live network figure for returning the balance on-chain is added on top; GET /v1/cards/{id}/close-quote prices a specific card, gas and all. The issuer's own cancellation charge is separate and stays on the card — see cardholder_fees.cancellation.\nproducts[].close_fee_base · string · Exact integer amount in the asset's smallest unit. (pattern ^[0-9]+$)\nproducts[].cardholder_fees required · object · null · What the CARDHOLDER pays when the card is used, per BIN — separate from issue_fee / load_fee_bps, which are charged when you issue and load. Surface them, or a $3.86 purchase that took $4.11 becomes a support ticket. null for a BIN whose terms we have not transcribed.\nproducts[].cardholder_fees.per_transaction · string · USD\nproducts[].cardholder_fees.three_d_secure · string · USD\nproducts[].cardholder_fees.foreign_currency_pct · string · Percent\nproducts[].cardholder_fees.atm_flat · string · USD\nproducts[].cardholder_fees.atm_pct · string · Percent\nproducts[].cardholder_fees.cancellation · string · USD\nproducts[].cardholder_fees.dispute · string · USD\nproducts[].cardholder_fees.monthly_service · string · null · Recurring holding fee; null when there is none.\nproducts[].limits required · object · null · Ceilings the cardholder hits, per BIN. null with cardholder_fees.\nproducts[].limits.per_transaction · string · —\nproducts[].limits.daily · string · —\nproducts[].limits.monthly · string · —\nproducts[].limits.atm_single · string · —\nproducts[].limits.atm_per_day · integer · —\nproducts[].limits.atm_monthly · string · —\nproducts[].limits.cards_per_user · integer · How many of this card one rail account may hold. Not a ceiling on your customer — when it is reached\nproducts[].limits.validity_years · integer · Years from issue.\nproducts[].min_opening_balance · string · Smallest initial_amount this product's issue accepts, in USD. Read it rather than hard-coding a minimum: it tracks platform policy, the issuer's floor and the product's own activation minimum, whichever is highest.\nproducts[].min_opening_balance_base · string · Exact integer amount in the asset's smallest unit. (pattern ^[0-9]+$)\n\n{\n\"asset_id\": \"usdt_trc20\",\n\"gas\": \"1.5\",\n\"gas_dynamic\": true,\n\"gas_priced_at\": \"2026-08-31T09:15:00.000Z\",\n\"issue_fee\": \"1.5\",\n\"issue_fee_base\": \"1500000000\",\n\"products\": [\n{\n\"id\": 1,\n\"rail\": \"rail_a\",\n\"core_id\": \"string\",\n\"core_name\": \"string\",\n\"code\": \"string\",\n\"card_nature\": \"VIRTUAL\",\n\"ccy\": \"string\",\n\"region\": \"string\",\n\"card_bin\": \"string\",\n\"card_brand\": \"string\",\n\"card_mode\": \"string\",\n\"title\": \"string\",\n\"requires_kyc\": true,\n\"active_min_limit\": \"string\",\n\"recharge_min_limit\": \"string\",\n\"withdraw_min_limit\": \"string\",\n\"issue_fee\": \"1.5\",\n\"issue_fee_base\": \"1500000000\",\n\"merchant_issue_fee\": \"1.5\",\n\"issue_total\": \"1.5\",\n\"issue_total_base\": \"1500000000\",\n\"charged_to\": \"cardholder\",\n\"load_fee_bps\": 1,\n\"load_fee_min\": \"1.5\",\n\"load_fee_flat\": \"1.5\",\n\"close_fee\": \"1.5\",\n\"close_fee_base\": \"1500000000\",\n\"cardholder_fees\": {\n\"per_transaction\": \"string\",\n\"three_d_secure\": \"string\",\n\"foreign_currency_pct\": \"string\",\n\"atm_flat\": \"string\",\n\"atm_pct\": \"string\",\n\"cancellation\": \"string\",\n\"dispute\": \"string\",\n\"monthly_service\": \"string\"\n},\n\"limits\": {\n\"per_transaction\": \"string\",\n\"daily\": \"string\",\n\"monthly\": \"string\",\n\"atm_single\": \"string\",\n\"atm_per_day\": 1,\n\"atm_monthly\": \"string\",\n\"cards_per_user\": 1,\n\"validity_years\": 1\n},\n\"min_opening_balance\": \"5.000000\",\n\"min_opening_balance_base\": \"1500000000\"\n}\n]\n}\n\nExample request\n\ncurl -X GET 'https://api.cryptoinfra.build/v1/cards/products' \\\n-H 'Authorization: Bearer ck_live_your_key'"
        },
        {
          "anchor": "get-v1-cards",
          "heading": "GET /v1/cards",
          "text": "List cards  ·  Auth: bearer API key  ·  operationId: listCards\n\nWith user_id: that user's cards across all their provider identities\n(404 when the user has no card holder). Without it: every card on your\naccount, newest first — each row additionally carries user_id,\ncardholder_name, the holder's program, and a cached (≤60 s) live\nbalance / pending (null when unreadable, never a fake zero), so any\nper-card endpoint can be called from a row. core_id narrows either\nform to one programme.\n\nParameters\n\nName · In · Type · Required · Description\nuser_id · query · string · no · —\ncore_id · query · string · no · One programme's cards only.\n\nResponses\n\nStatus · Body · Meaning\n200 · object · The cards.\n401 · Error · Missing, malformed or unknown API key.\n404 · Error · No matching card holder (holder_not_found), or an unknown user/payer (user_not_found), for this tenant.\n503 · Error · Temporarily unable to serve this card call — your request was valid. Retry; nothing has been charged. error is one of: provider_unavailable — the rail did not answer, or answered unreadably. Transient. card_rail_unavailable — this card's own rail is not reachable (it may have been closed to new business). Other rails are unaffected, and the card itself is fine. cards_disabled — no card rail is enabled on this deployment at all. rail_unfunded — the platform cannot fund the on-chain send behind this load right now. Nothing was charged; retry later. 409 card_coins_not_on_chain — the holder's coins are not at their own address yet (a close refund or a deposit still on its way): a load pays its own way on-chain from that address and waits for them. details carry needed. Nothing was charged; try again once they have arrived.\n\n200 response body\n\nField · Type · Description\nuser_id · string · Echoed only on the one-user form.\ncards required · object[] · —\ncards[].id required · integer · —\ncards[].rail required · string · Opaque, stable id of the card rail a product, holder or card sits on — and the value to pass as provider when a call needs disambiguating. It tells you WHICH rail, never who runs it: two cards on different rails are different arrangements of ours, and that is all you need to route a call correctly. Read it from the resource and echo it back; never hard-code one, and never parse it — the string is an identifier, not a description.\ncards[].core_id required · string · null · The programme this card was issued from — the pair (core_id, bin) is what prices its loads and close. A programme holds no money; the cardholder pays. null on cards issued before programmes existed.\ncards[].bin required · string · null · The card's BIN. Read it WITH core_id, never alone: a BIN number is unique only inside its issuer, so the same number under a different programme is a different card with different fees, limits and validity.\ncards[].product_id required · string · null · Provider-scoped product CODE (kept as an alias of product_code); null when the card was listed untagged.\ncards[].product_code required · string · null · Provider-scoped product code; null when the card was listed untagged.\ncards[].card_nature required · string · (one of VIRTUAL, PHYSICAL)\ncards[].status required · integer · The provider's RAW status code, verbatim — interpret via status_normalized.\ncards[].status_normalized required · string · Provider-neutral status. A just-issued card is issuing; it becomes active once the opening balance has landed at the issuer. (one of issuing, active, frozen, suspended, closed, expired)\ncards[].currency required · string · null · —\ncards[].last4 required · string · null · —\ncards[].name_on_card required · string · null · The cardholder name you gave at issue — our record, whether or not the BIN prints it on the plastic.\ncards[].issued_at required · string<date-time> · —\ncards[].user_id · string · The cardholder's external_user_id.\ncards[].cardholder_name · string · null · —\ncards[].program · string · null · —\ncards[].balance · string · null · Live available balance (≤60 s cache); null when unreadable.\ncards[].pending · string · null · Holds in flight; null when the rail does not expose them or the read failed.\n\n{\n\"user_id\": \"string\",\n\"cards\": [\n{\n\"id\": 1,\n\"rail\": \"rail_a\",\n\"core_id\": \"string\",\n\"bin\": \"string\",\n\"product_id\": \"string\",\n\"product_code\": \"string\",\n\"card_nature\": \"VIRTUAL\",\n\"status\": 1,\n\"status_normalized\": \"issuing\",\n\"currency\": \"string\",\n\"last4\": \"string\",\n\"name_on_card\": \"string\",\n\"issued_at\": \"2026-08-31T09:15:00.000Z\",\n\"user_id\": \"string\",\n\"cardholder_name\": \"string\",\n\"program\": \"string\",\n\"balance\": \"string\",\n\"pending\": \"string\"\n}\n]\n}\n\nExample request\n\ncurl -X GET 'https://api.cryptoinfra.build/v1/cards' \\\n-H 'Authorization: Bearer ck_live_your_key'"
        },
        {
          "anchor": "post-v1-cards",
          "heading": "POST /v1/cards",
          "text": "Issue a card  ·  Auth: bearer API key  ·  operationId: issueCard\n\nA card is issued FUNDED. initial_amount is required and must be at\nleast the product's min_opening_balance from GET /v1/cards/products\n(read that value, do not hard-code a minimum — it tracks platform policy\nand the issuer's own floor, whichever is higher). The cardholder is\ndebited total from GET /v1/cards/issue-quote — our issue fee, the\nopening balance priced as a load (our load fee + gas), and your own fees\nif set — at request time, and refunded together if the application\ndefinitively fails. Read issue_total on the shelf or total on the\nquote; never add the parts yourself.\n\nPass one of product_id / product_code, taken from\nGET /v1/cards/products rather than hard-coded. provider is almost\nnever needed: the product code names its own rail.\ndelivery_address_id (from POST /v1/cards/delivery/address) is required\nfor PHYSICAL products. Requires an approved holder. idempotency_key\nis required and replay-safe. The holder pays: our issue fee, the opening\nbalance priced as a load, gas, and your own fees if you set any — see\nissue_total on GET /v1/cards/products.\n\n201 = the card exists (card populated). 202 = the application\nis still resolving (card is null; a card.status webhook or the\nreconciler links it later). Treat 202 as PENDING.\n\nThe normal answer is 202. The opening balance is sent on-chain to\nthe rail first, so the response is 202 with order.status: funding\n(then requested/sent); the card is applied for once the rail shows\nthe credit and arrives on the card.status webhook, usually within\nminutes.\n\nA 201 card is not spendable yet. It is returned with\nstatus_normalized: issuing, and turns active only once the opening\nbalance has landed at the issuer — usually minutes. An integrator that\npolls for active immediately after the 201 will see issuing first;\nthat is the normal path, not an error. Watch GET /v1/cards/{id} or\nthe card.status webhook for the flip.\n\nRequest body\n\napplication/json · required\n\nField · Type · Description\nuser_id required · string · (minLength 1; maxLength 255)\ncore_id · string · Which card programme to issue from — the core_id from the product in GET /v1/cards/products. Send it. It names both the balance that pays and the BIN that prices, so it is the only thing that stays correct once you hold a second programme. With exactly one programme open it may be omitted; with two, an omitted core_id is refused with card_core_required (the acceptable values ride along in the error) rather than charged to a balance you did not mean. (minLength 1; maxLength 64)\nprovider · string · Deprecated in favour of core_id. Which card rail to issue on — the rail value from the product in GET /v1/cards/products. Ignored when core_id is sent. (minLength 1; maxLength 64)\nproduct_id · integer · Numeric product id, kept for back-compat. One of product_id / product_code is required. (min 1)\nproduct_code · string · Provider-scoped product code (preferred; wins when both are sent). (minLength 1; maxLength 64)\ndelivery_address_id · integer · Required for PHYSICAL products (from POST /v1/cards/delivery/address). (min 1)\nidempotency_key required · string · Required; replay-safe. (minLength 1; maxLength 255)\nname_on_card · string · Embossed/printed cardholder name. Uppercased on the way to the issuer. Not every card is PRINTED with one. BIN 493 has no cardholder name on the plastic — its issuer has no such field. We keep it regardless. The name you send is stored against the card and returned by every card read, because a list of cards has to be readable and last4 does not tell one cardholder from another. It reaches the issuer only on a rail that can emboss it. (minLength 2; maxLength 26; pattern ^[A-Za-z][A-Za-z .'-]*$)\ninitial_amount required · string · Required. Opening balance in USD (whole cents), on the card the moment it opens. Cards are issued FUNDED: an empty card is not a usable card, and on some rails it does not even activate until a first load lands. The minimum is min_opening_balance from GET /v1/cards/products — read it, do not hard-code it; it moves with platform policy and with the issuer's own floor. The cardholder is debited issue_total: our issue fee, the opening balance priced as a load (our load fee + gas), and your own fees if you set any. Read issue_total from GET /v1/cards/products or total from GET /v1/cards/issue-quote rather than adding issue_fee and initial_amount. (There is no netting: an issuer's own mandatory opening credit is NOT discounted out of the fee.) Below min_opening_balance the issue is refused with 400 invalid_request; the message names the required amount. min_opening_balance is computed per product and already includes the product's own activation minimum (active_min_limit), so opening at it is enough for the card to come alive. (pattern ^\\d+(\\.\\d+)?$)\n\nResponses\n\nStatus · Body · Meaning\n201 · CardIssueResult · Card issued. card.status_normalized is issuing — it becomes active once the opening balance lands (see the description).\n202 · CardIssueResult · Application accepted, still resolving (card is null). The usual answer: order.status is funding while the opening balance travels on-chain to the rail.\n400 · Error · Request validation failed or a domain rule was violated. error is one of: validation_error, invalid_request, invalid_amount, card_core_required, card_provider_required, unknown_provider, not_supported_by_provider. card_core_required carries details.cores — the card programmes this call will accept as core_id. It appears when you hold more than one programme that could serve the request. We refuse rather than pick, because picking would spend a balance that did not pay for the card and nothing in the response would say so. Send core_id. card_provider_required carries details.rails — the rail ids this call will accept as provider. It only ever appears when two enabled rails could both serve the request; naming a core_id, or having cards on just one rail, resolves it without your having to choose.\n401 · Error · Missing, malformed or unknown API key.\n404 · Error · No matching card holder (holder_not_found), or an unknown user/payer (user_not_found), for this tenant.\n409 · Error · A conflicting card state. error is one of: kyc_not_approved (details.kyc_status), card_state, card_issuing (the card is still being issued — under two minutes; details.card_id; poll GET /v1/cards/{id} until status_normalized is active, or wait for the card.status webhook, then retry), provider_rejected (the rail refused — DEFINITIVE, so retrying the identical call cannot help; details.order_id on a money call, where any charge is already reversed, and details.provider_code otherwise), email_taken (details.email — the rail already holds an account on that address; send a different one), idempotency_conflict, insufficient_balance, no_own_account (you have set a card fee of your own but your account has no balance of its own to credit it to — ask us to open one).\n503 · Error · Temporarily unable to serve this card call — your request was valid. Retry; nothing has been charged. error is one of: provider_unavailable — the rail did not answer, or answered unreadably. Transient. card_rail_unavailable — this card's own rail is not reachable (it may have been closed to new business). Other rails are unaffected, and the card itself is fine. cards_disabled — no card rail is enabled on this deployment at all. rail_unfunded — the platform cannot fund the on-chain send behind this load right now. Nothing was charged; retry later. 409 card_coins_not_on_chain — the holder's coins are not at their own address yet (a close refund or a deposit still on its way): a load pays its own way on-chain from that address and waits for them. details carry needed. Nothing was charged; try again once they have arrived.\n\n201 response body — CardIssueResult\n\nField · Type · Description\norder required · object · A money order against a card (top-up, card→wallet withdraw, or issue fee). See CardOrderStatus for the lifecycle; a card.topup / card.withdraw / card.status webhook (or the reconciler) settles it.\norder.id required · integer · —\norder.kind required · string · close_fee is the close fee charged to the cardholder on DELETE /v1/cards/{id}: amount is 0, fee is the charge. _(one of topup, withdraw, issue_fee, close_fee)_\norder.status required · string · funding = the cardholder has been charged and the money is on its way to the card rail on-chain. requested = confirmed on-chain, waiting for the rail to show the credit. sent = the rail has been asked, outcome still pending. confirmed = settled. failed = definitively failed; the WHOLE charge — amount, fee, gas and merchant_fee, i.e. total — is refunded to the balance it was taken from, the cardholder's, in the same moment (top-up / issue only — a withdraw holds nothing). Nothing goes anywhere else: not to the card, not to your treasury. When the on-chain send behind a load reverted (provider_status: send_failed), no coins left the cardholder's address either, so on-chain and ledger agree. reversed is reserved for manual operator reversals of a confirmed order. (one of funding, requested, sent, confirmed, failed, reversed)\norder.txid · string · null · The hash of the on-chain send behind a load or an issue's opening balance — link your receipt to it the way you do for a deposit or a payout. Null until the send is broadcast, and for an order the rail served from its float. A failed order keeps the hash of the send that reverted, the proof of what happened; after a re-send it is the current send's hash and the reverted one is named in error. Always null on a withdraw order.\norder.asset_id required · string · —\norder.amount required · string · A monetary value as a decimal string. Always paired with a *_base field that carries the exact integer base-unit value; compute with the _base string.\norder.amount_base required · string · Exact integer amount in the asset's smallest unit. (pattern ^[0-9]+$)\norder.fee required · string · A monetary value as a decimal string. Always paired with a *_base field that carries the exact integer base-unit value; compute with the _base string.\norder.fee_base required · string · Exact integer amount in the asset's smallest unit. (pattern ^[0-9]+$)\norder.gas · string · The network fee actually CHARGED for the on-chain send behind a load or issue — the figure the cardholder was debited, fixed when the order was created. Not the quote's figure: reconcile a quote against this.\norder.merchant_fee · string · Your own fee on this order, credited to your balance.\norder.total · string · Everything the cardholder was debited for this order.\norder.sent_on_chain · string · What the on-chain send behind this order carried (\"0\" when the rail already held it). It is the attempt, not the outcome: on a failed order whose send reverted, this still reads the amount that was sent for and nothing moved.\norder.amount_credited required · string · null · WITHDRAW only: the NET actually credited after the provider's unload tariff, which differs per rail. null until a withdraw settles confirmed, and always null for top-ups / issue fees. Credit your user from THIS (or amount_credited_base), never from amount — the requested gross — or you over-credit by the tariff on every unload.\norder.amount_credited_base required · string · null · Integer-string base units of amount_credited (null until a withdraw confirms).\norder.card_id required · integer · null · —\norder.request_order_id required · string · null · Server-minted correlation handle (ct-/cw-/ci- prefix).\norder.provider_status required · string · null · Where a failed order failed: send_failed = the on-chain send to the rail reverted (our side; the charge was refunded, nothing left the address); anything else is the rail's own word for its refusal. Informational — act on status.\norder.error required · string · null · —\norder.idempotency_key required · string · null · —\norder.created_at required · string<date-time> · —\ncard required · object · null · The issued card, or null while the application is still resolving (202).\ncard.id required · integer · —\ncard.rail required · string · Opaque, stable id of the card rail a product, holder or card sits on — and the value to pass as provider when a call needs disambiguating. It tells you WHICH rail, never who runs it: two cards on different rails are different arrangements of ours, and that is all you need to route a call correctly. Read it from the resource and echo it back; never hard-code one, and never parse it — the string is an identifier, not a description.\ncard.core_id required · string · null · The programme this card was issued from — the pair (core_id, bin) is what prices its loads and close. A programme holds no money; the cardholder pays. null on cards issued before programmes existed.\ncard.bin required · string · null · The card's BIN. Read it WITH core_id, never alone: a BIN number is unique only inside its issuer, so the same number under a different programme is a different card with different fees, limits and validity.\ncard.product_id required · string · null · Provider-scoped product CODE (kept as an alias of product_code); null when the card was listed untagged.\ncard.product_code required · string · null · Provider-scoped product code; null when the card was listed untagged.\ncard.card_nature required · string · (one of VIRTUAL, PHYSICAL)\ncard.status required · integer · The provider's RAW status code, verbatim — interpret via status_normalized.\ncard.status_normalized required · string · Provider-neutral status. A just-issued card is issuing; it becomes active once the opening balance has landed at the issuer. (one of issuing, active, frozen, suspended, closed, expired)\ncard.currency required · string · null · —\ncard.last4 required · string · null · —\ncard.name_on_card required · string · null · The cardholder name you gave at issue — our record, whether or not the BIN prints it on the plastic.\ncard.issued_at required · string<date-time> · —\n\n{\n\"order\": {\n\"id\": 1,\n\"kind\": \"topup\",\n\"status\": \"funding\",\n\"txid\": \"string\",\n\"asset_id\": \"usdt_trc20\",\n\"amount\": \"1.5\",\n\"amount_base\": \"1500000000\",\n\"fee\": \"1.5\",\n\"fee_base\": \"1500000000\",\n\"gas\": \"1.5\",\n\"merchant_fee\": \"1.5\",\n\"total\": \"1.5\",\n\"sent_on_chain\": \"1.5\",\n\"amount_credited\": \"1.5\",\n\"amount_credited_base\": \"1500000000\",\n\"card_id\": 1,\n\"request_order_id\": \"string\",\n\"provider_status\": \"string\",\n\"error\": \"string\",\n\"idempotency_key\": \"string\",\n\"created_at\": \"2026-08-31T09:15:00.000Z\"\n},\n\"card\": {\n\"id\": 1,\n\"rail\": \"rail_a\",\n\"core_id\": \"string\",\n\"bin\": \"string\",\n\"product_id\": \"string\",\n\"product_code\": \"string\",\n\"card_nature\": \"VIRTUAL\",\n\"status\": 1,\n\"status_normalized\": \"issuing\",\n\"currency\": \"string\",\n\"last4\": \"string\",\n\"name_on_card\": \"string\",\n\"issued_at\": \"2026-08-31T09:15:00.000Z\"\n}\n}\n\nExample request\n\ncurl -X POST 'https://api.cryptoinfra.build/v1/cards' \\\n-H 'Authorization: Bearer ck_live_your_key' \\\n-H 'Content-Type: application/json' \\\n-d '{\n\"user_id\": \"string\",\n\"idempotency_key\": \"string\",\n\"initial_amount\": \"string\"\n}'"
        },
        {
          "anchor": "get-v1-cards-topup-quote",
          "heading": "GET /v1/cards/topup-quote",
          "text": "Preview a top-up's fee and total  ·  Auth: bearer API key  ·  operationId: cardTopupQuote\n\nRead-only, holds nothing. The fees are charged ON TOP of amount: the card receives exactly amount (so credited equals it), and total is what the cardholder's balance is debited — amount plus fee, gas and merchant_fee. Show total wherever you tell someone what a load will cost, and check it against the balance before you send. Pass card_id and user_id together to price a specific card (its rail, BIN and product); otherwise the rail's default row prices it.\n\nParameters\n\nName · In · Type · Required · Description\namount · query · string · yes · Decimal amount to top up (≤6 decimal places).\nprovider · query · string · no · Rail id (a product's rail); the quote then uses that rail's fee row — the same one the charge uses.\ncard_id · query · integer · no · Price for THIS card — its rail, BIN and product. Send with user_id. (min 1)\nuser_id · query · string · no · The card's holder (your external user id). Send with card_id.\n\nResponses\n\nStatus · Body · Meaning\n200 · object · The fee preview — every component the cardholder is debited.\n400 · Error · Request validation failed or a domain rule was violated. error is one of: validation_error, invalid_request, invalid_amount, card_core_required, card_provider_required, unknown_provider, not_supported_by_provider. card_core_required carries details.cores — the card programmes this call will accept as core_id. It appears when you hold more than one programme that could serve the request. We refuse rather than pick, because picking would spend a balance that did not pay for the card and nothing in the response would say so. Send core_id. card_provider_required carries details.rails — the rail ids this call will accept as provider. It only ever appears when two enabled rails could both serve the request; naming a core_id, or having cards on just one rail, resolves it without your having to choose.\n401 · Error · Missing, malformed or unknown API key.\n503 · Error · Temporarily unable to serve this card call — your request was valid. Retry; nothing has been charged. error is one of: provider_unavailable — the rail did not answer, or answered unreadably. Transient. card_rail_unavailable — this card's own rail is not reachable (it may have been closed to new business). Other rails are unaffected, and the card itself is fine. cards_disabled — no card rail is enabled on this deployment at all. rail_unfunded — the platform cannot fund the on-chain send behind this load right now. Nothing was charged; retry later. 409 card_coins_not_on_chain — the holder's coins are not at their own address yet (a close refund or a deposit still on its way): a load pays its own way on-chain from that address and waits for them. details carry needed. Nothing was charged; try again once they have arrived.\n\n200 response body\n\nField · Type · Description\nasset_id required · string · —\namount required · string · A monetary value as a decimal string. Always paired with a *_base field that carries the exact integer base-unit value; compute with the _base string.\namount_base required · string · Exact integer amount in the asset's smallest unit. (pattern ^[0-9]+$)\nfee required · string · Our fee.\nfee_base required · string · Exact integer amount in the asset's smallest unit. (pattern ^[0-9]+$)\ngas required · string · The network fee for the on-chain send behind this load.\ngas_base required · string · Exact integer amount in the asset's smallest unit. (pattern ^[0-9]+$)\nmerchant_fee required · string · Your own fee (PUT /v1/merchant/card-fees/load). \"0\" until you set one.\nmerchant_fee_base required · string · Exact integer amount in the asset's smallest unit. (pattern ^[0-9]+$)\ncredited required · string · What reaches the card — amount itself, since the fees are charged on top. The figure to show next to \"on your card\".\ncredited_base required · string · Exact integer amount in the asset's smallest unit. (pattern ^[0-9]+$)\ntotal required · string · What the cardholder's balance is debited — amount + fee + gas + merchant_fee.\ntotal_base required · string · Exact integer amount in the asset's smallest unit. (pattern ^[0-9]+$)\ncharged_to required · string · (one of cardholder)\ndynamic · boolean · true when gas was priced NOW from the live energy rate and TRX price (the same bill as a payout's network_fee); false when the live price could not be read and gas is the static fallback. Label the figure \"live\" or \"approx\" from this.\n\n{\n\"asset_id\": \"usdt_trc20\",\n\"amount\": \"1.5\",\n\"amount_base\": \"1500000000\",\n\"fee\": \"1.5\",\n\"fee_base\": \"1500000000\",\n\"gas\": \"1.5\",\n\"gas_base\": \"1500000000\",\n\"merchant_fee\": \"1.5\",\n\"merchant_fee_base\": \"1500000000\",\n\"credited\": \"1.5\",\n\"credited_base\": \"1500000000\",\n\"total\": \"1.5\",\n\"total_base\": \"1500000000\",\n\"charged_to\": \"cardholder\",\n\"dynamic\": true\n}\n\nExample request\n\ncurl -X GET 'https://api.cryptoinfra.build/v1/cards/topup-quote?amount=string' \\\n-H 'Authorization: Bearer ck_live_your_key'"
        },
        {
          "anchor": "get-v1-cards-issue-quote",
          "heading": "GET /v1/cards/issue-quote",
          "text": "Preview what issuing a card costs the cardholder  ·  Auth: bearer API key  ·  operationId: cardIssueQuote\n\nRead-only, holds nothing. total is what the cardholder's balance is debited by POST /v1/cards — our issue fee, yours, initial_amount, and the load fee and gas on that opening balance, charged on top of it. The card opens with opening_credited, which is initial_amount itself. Priced by the same code that charges, so the preview and the charge cannot differ. A card is issued FOR one of your users and only from what that user holds: when their balance is short, POST /v1/cards is refused with 409 insufficient_balance before anything is charged or applied for. Read total; adding the parts yourself is how a preview ends up smaller than the charge.\n\nParameters\n\nName · In · Type · Required · Description\nproduct_code · query · string · yes · A code from GET /v1/cards/products.\ncore_id · query · string · no · The programme to issue from. Optional with one programme; send it when you hold several.\ninitial_amount · query · string · no · Opening balance (≤6 decimal places). Omitted = the product's min_opening_balance; below it is a 400 invalid_amount.\n\nResponses\n\nStatus · Body · Meaning\n200 · object · The preview — every component the cardholder is debited.\n400 · Error · Request validation failed or a domain rule was violated. error is one of: validation_error, invalid_request, invalid_amount, card_core_required, card_provider_required, unknown_provider, not_supported_by_provider. card_core_required carries details.cores — the card programmes this call will accept as core_id. It appears when you hold more than one programme that could serve the request. We refuse rather than pick, because picking would spend a balance that did not pay for the card and nothing in the response would say so. Send core_id. card_provider_required carries details.rails — the rail ids this call will accept as provider. It only ever appears when two enabled rails could both serve the request; naming a core_id, or having cards on just one rail, resolves it without your having to choose.\n401 · Error · Missing, malformed or unknown API key.\n404 · Error · Record not found (or belongs to another tenant).\n503 · Error · Temporarily unable to serve this card call — your request was valid. Retry; nothing has been charged. error is one of: provider_unavailable — the rail did not answer, or answered unreadably. Transient. card_rail_unavailable — this card's own rail is not reachable (it may have been closed to new business). Other rails are unaffected, and the card itself is fine. cards_disabled — no card rail is enabled on this deployment at all. rail_unfunded — the platform cannot fund the on-chain send behind this load right now. Nothing was charged; retry later. 409 card_coins_not_on_chain — the holder's coins are not at their own address yet (a close refund or a deposit still on its way): a load pays its own way on-chain from that address and waits for them. details carry needed. Nothing was charged; try again once they have arrived.\n\n200 response body\n\nField · Type · Description\nasset_id required · string · —\ncore_id required · string · —\nproduct_code required · string · —\ninitial_amount required · string · A monetary value as a decimal string. Always paired with a *_base field that carries the exact integer base-unit value; compute with the _base string.\ninitial_amount_base required · string · Exact integer amount in the asset's smallest unit. (pattern ^[0-9]+$)\nmin_opening_balance required · string · A monetary value as a decimal string. Always paired with a *_base field that carries the exact integer base-unit value; compute with the _base string.\nfee required · string · Our fee — the issue fee plus our load fee on the opening balance.\nfee_base required · string · Exact integer amount in the asset's smallest unit. (pattern ^[0-9]+$)\ngas required · string · The network fee for the on-chain send behind the opening balance.\ngas_base required · string · Exact integer amount in the asset's smallest unit. (pattern ^[0-9]+$)\nmerchant_fee required · string · Your own fees on this issue (PUT /v1/merchant/card-fees/issue and /load). \"0\" until you set one.\nmerchant_fee_base required · string · Exact integer amount in the asset's smallest unit. (pattern ^[0-9]+$)\nopening_credited required · string · What the opening balance puts on the card — initial_amount itself; its load fee and gas are charged on top.\nopening_credited_base required · string · Exact integer amount in the asset's smallest unit. (pattern ^[0-9]+$)\ntotal required · string · initial_amount + fee + gas + merchant_fee — what the cardholder's balance is debited.\ntotal_base required · string · Exact integer amount in the asset's smallest unit. (pattern ^[0-9]+$)\ncharged_to required · string · (one of cardholder)\ndynamic · boolean · true when gas was priced NOW from the live energy rate and TRX price; false when it is the static fallback. Always true when there is no opening balance (no send, no gas).\n\n{\n\"asset_id\": \"usdt_trc20\",\n\"core_id\": \"core-1\",\n\"product_code\": \"string\",\n\"initial_amount\": \"1.5\",\n\"initial_amount_base\": \"1500000000\",\n\"min_opening_balance\": \"1.5\",\n\"fee\": \"1.5\",\n\"fee_base\": \"1500000000\",\n\"gas\": \"1.5\",\n\"gas_base\": \"1500000000\",\n\"merchant_fee\": \"1.5\",\n\"merchant_fee_base\": \"1500000000\",\n\"opening_credited\": \"1.5\",\n\"opening_credited_base\": \"1500000000\",\n\"total\": \"1.5\",\n\"total_base\": \"1500000000\",\n\"charged_to\": \"cardholder\",\n\"dynamic\": true\n}\n\nExample request\n\ncurl -X GET 'https://api.cryptoinfra.build/v1/cards/issue-quote?product_code=string' \\\n-H 'Authorization: Bearer ck_live_your_key'"
        },
        {
          "anchor": "get-v1-cards-orders-order-id",
          "heading": "GET /v1/cards/orders/{order_id}",
          "text": "Read a card order's status (the authoritative poll)  ·  Auth: bearer API key  ·  operationId: getCardOrder\n\nReturn a card order by its id. This is the authoritative outcome\nread behind a \"webhooks are hints, the poll is truth\" reconcile model:\na top-up / withdrawal / issue-fee order is created 202 processing\n(status: funding, requested or sent) and reaches confirmed or failed as the\nplatform's reconciler and provider webhooks settle it. Poll this when a\ncard.topup / card.withdraw webhook was missed (delivery exhausted, or\nyour endpoint was down) so a debited operation can always be resolved\nwithout a manual investigation. Tenant-scoped: another tenant's order\nreturns 404 and is never disclosed.\n\nRefund discipline (unchanged): refund the customer only on\nstatus: failed (definitive — the money did not move); never on\nrequested/sent (outcome still unknown).\n\nParameters\n\nName · In · Type · Required · Description\norder_id · path · integer · yes · The order id returned by issue / top-up / withdraw. (min 1)\n\nResponses\n\nStatus · Body · Meaning\n200 · CardOrder · The card order in its current (possibly terminal) state.\n401 · Error · Missing, malformed or unknown API key.\n404 · Error · Unknown card (card_not_found), or an unknown payer user (user_not_found), for this tenant.\n503 · Error · Temporarily unable to serve this card call — your request was valid. Retry; nothing has been charged. error is one of: provider_unavailable — the rail did not answer, or answered unreadably. Transient. card_rail_unavailable — this card's own rail is not reachable (it may have been closed to new business). Other rails are unaffected, and the card itself is fine. cards_disabled — no card rail is enabled on this deployment at all. rail_unfunded — the platform cannot fund the on-chain send behind this load right now. Nothing was charged; retry later. 409 card_coins_not_on_chain — the holder's coins are not at their own address yet (a close refund or a deposit still on its way): a load pays its own way on-chain from that address and waits for them. details carry needed. Nothing was charged; try again once they have arrived.\n\n200 response body — CardOrder\n\nField · Type · Description\nid required · integer · —\nkind required · string · close_fee is the close fee charged to the cardholder on DELETE /v1/cards/{id}: amount is 0, fee is the charge. _(one of topup, withdraw, issue_fee, close_fee)_\nstatus required · string · funding = the cardholder has been charged and the money is on its way to the card rail on-chain. requested = confirmed on-chain, waiting for the rail to show the credit. sent = the rail has been asked, outcome still pending. confirmed = settled. failed = definitively failed; the WHOLE charge — amount, fee, gas and merchant_fee, i.e. total — is refunded to the balance it was taken from, the cardholder's, in the same moment (top-up / issue only — a withdraw holds nothing). Nothing goes anywhere else: not to the card, not to your treasury. When the on-chain send behind a load reverted (provider_status: send_failed), no coins left the cardholder's address either, so on-chain and ledger agree. reversed is reserved for manual operator reversals of a confirmed order. (one of funding, requested, sent, confirmed, failed, reversed)\ntxid · string · null · The hash of the on-chain send behind a load or an issue's opening balance — link your receipt to it the way you do for a deposit or a payout. Null until the send is broadcast, and for an order the rail served from its float. A failed order keeps the hash of the send that reverted, the proof of what happened; after a re-send it is the current send's hash and the reverted one is named in error. Always null on a withdraw order.\nasset_id required · string · —\namount required · string · A monetary value as a decimal string. Always paired with a *_base field that carries the exact integer base-unit value; compute with the _base string.\namount_base required · string · Exact integer amount in the asset's smallest unit. (pattern ^[0-9]+$)\nfee required · string · A monetary value as a decimal string. Always paired with a *_base field that carries the exact integer base-unit value; compute with the _base string.\nfee_base required · string · Exact integer amount in the asset's smallest unit. (pattern ^[0-9]+$)\ngas · string · The network fee actually CHARGED for the on-chain send behind a load or issue — the figure the cardholder was debited, fixed when the order was created. Not the quote's figure: reconcile a quote against this.\nmerchant_fee · string · Your own fee on this order, credited to your balance.\ntotal · string · Everything the cardholder was debited for this order.\nsent_on_chain · string · What the on-chain send behind this order carried (\"0\" when the rail already held it). It is the attempt, not the outcome: on a failed order whose send reverted, this still reads the amount that was sent for and nothing moved.\namount_credited required · string · null · WITHDRAW only: the NET actually credited after the provider's unload tariff, which differs per rail. null until a withdraw settles confirmed, and always null for top-ups / issue fees. Credit your user from THIS (or amount_credited_base), never from amount — the requested gross — or you over-credit by the tariff on every unload.\namount_credited_base required · string · null · Integer-string base units of amount_credited (null until a withdraw confirms).\ncard_id required · integer · null · —\nrequest_order_id required · string · null · Server-minted correlation handle (ct-/cw-/ci- prefix).\nprovider_status required · string · null · Where a failed order failed: send_failed = the on-chain send to the rail reverted (our side; the charge was refunded, nothing left the address); anything else is the rail's own word for its refusal. Informational — act on status.\nerror required · string · null · —\nidempotency_key required · string · null · —\ncreated_at required · string<date-time> · —\n\n{\n\"id\": 1,\n\"kind\": \"topup\",\n\"status\": \"funding\",\n\"txid\": \"string\",\n\"asset_id\": \"usdt_trc20\",\n\"amount\": \"1.5\",\n\"amount_base\": \"1500000000\",\n\"fee\": \"1.5\",\n\"fee_base\": \"1500000000\",\n\"gas\": \"1.5\",\n\"merchant_fee\": \"1.5\",\n\"total\": \"1.5\",\n\"sent_on_chain\": \"1.5\",\n\"amount_credited\": \"1.5\",\n\"amount_credited_base\": \"1500000000\",\n\"card_id\": 1,\n\"request_order_id\": \"string\",\n\"provider_status\": \"string\",\n\"error\": \"string\",\n\"idempotency_key\": \"string\",\n\"created_at\": \"2026-08-31T09:15:00.000Z\"\n}\n\nExample request\n\ncurl -X GET 'https://api.cryptoinfra.build/v1/cards/orders/1' \\\n-H 'Authorization: Bearer ck_live_your_key'"
        },
        {
          "anchor": "get-v1-cards-id",
          "heading": "GET /v1/cards/{id}",
          "text": "Secure card details  ·  Auth: bearer API key  ·  operationId: getCard\n\nLocal card + live balance + a link to this card's hosted card-number\npage (card_pan_url — drop it into an <iframe src>; it works for\nabout ten minutes, so fetch it fresh each time you show the number).\nThis endpoint never returns PAN/CVV — the full number is a separate,\nexplicit call: POST /v1/cards/{id}/reveal. A live provider read failure\nDEGRADES to stored state (provider_read_failed: true, null balance —\nnever fake zeros) rather than failing the call. When the read succeeded\nbut there is no card-number page, reveal_unavailable: true is set.\n\nParameters\n\nName · In · Type · Required · Description\nid · path · integer · yes · The card's numeric id (from GET /v1/cards). (min 1)\nuser_id · query · string · yes · —\n\nResponses\n\nStatus · Body · Meaning\n200 · CardDetails · Card details (possibly degraded — see CardDetails).\n401 · Error · Missing, malformed or unknown API key.\n404 · Error · Unknown card (card_not_found), or an unknown payer user (user_not_found), for this tenant.\n503 · Error · Temporarily unable to serve this card call — your request was valid. Retry; nothing has been charged. error is one of: provider_unavailable — the rail did not answer, or answered unreadably. Transient. card_rail_unavailable — this card's own rail is not reachable (it may have been closed to new business). Other rails are unaffected, and the card itself is fine. cards_disabled — no card rail is enabled on this deployment at all. rail_unfunded — the platform cannot fund the on-chain send behind this load right now. Nothing was charged; retry later. 409 card_coins_not_on_chain — the holder's coins are not at their own address yet (a close refund or a deposit still on its way): a load pays its own way on-chain from that address and waits for them. details carry needed. Nothing was charged; try again once they have arrived.\n\n200 response body — CardDetails\n\nField · Type · Description\ncard required · object · —\ncard.id required · integer · —\ncard.rail required · string · Opaque, stable id of the card rail a product, holder or card sits on — and the value to pass as provider when a call needs disambiguating. It tells you WHICH rail, never who runs it: two cards on different rails are different arrangements of ours, and that is all you need to route a call correctly. Read it from the resource and echo it back; never hard-code one, and never parse it — the string is an identifier, not a description.\ncard.core_id required · string · null · The programme this card was issued from — the pair (core_id, bin) is what prices its loads and close. A programme holds no money; the cardholder pays. null on cards issued before programmes existed.\ncard.bin required · string · null · The card's BIN. Read it WITH core_id, never alone: a BIN number is unique only inside its issuer, so the same number under a different programme is a different card with different fees, limits and validity.\ncard.product_id required · string · null · Provider-scoped product CODE (kept as an alias of product_code); null when the card was listed untagged.\ncard.product_code required · string · null · Provider-scoped product code; null when the card was listed untagged.\ncard.card_nature required · string · (one of VIRTUAL, PHYSICAL)\ncard.status required · integer · The provider's RAW status code, verbatim — interpret via status_normalized.\ncard.status_normalized required · string · Provider-neutral status. A just-issued card is issuing; it becomes active once the opening balance has landed at the issuer. (one of issuing, active, frozen, suspended, closed, expired)\ncard.currency required · string · null · —\ncard.last4 required · string · null · —\ncard.name_on_card required · string · null · The cardholder name you gave at issue — our record, whether or not the BIN prints it on the plastic.\ncard.issued_at required · string<date-time> · —\nbalance required · object · null · Live card balance, or null when the provider read failed (never a fake zero).\nbalance.available required · string · Spendable balance\nbalance.pending required · string · null · Held amount; null (NOT 0) when the provider does not expose holds.\nbalance.currency required · string · —\ncurrency required · string · null · —\ncard_pan_url required · string · null · Link to this card's hosted card-number page — put it straight in an <iframe src> (or open it) to show the number to the cardholder, for clients that would rather never handle a PAN. To render your own card face instead, use POST /v1/cards/{id}/reveal. It is a link on OUR domain that redirects to the page: the URL carries a one-time handle, works for about ten minutes, and is a credential in its own right — never log, cache or persist it, and fetch a fresh one from this endpoint each time you need to show the number. null when this card has no hosted page (see reveal_unavailable). This response itself never carries PAN/CVV.\nexpire_date required · string · null · YYYY-MM.\nmasked_number · string · null · Masked PAN as the issuer renders it.\nname_on_card · string · null · The name given at issue, as we stored it — not a claim about what is printed on the card. null means none was supplied.\ncardholder_name · string · null · —\nbin · string · null · —\ndecline_count · integer · null · Declines counted by the issuer; null when it exposes none.\ndecline_limit · integer · null · Declines allowed before the issuer acts; null when it exposes none.\notp_available · boolean · True when a live wallet/3DS code is waiting for this card. A PEEK — reading this does not consume the code. Fetch the code itself from GET /v1/cards/{id}/otp, which does consume it.\notp_waiting · object · Present only while otp_available is true: what the waiting code is for. Never the code itself.\notp_waiting.kind required · string · _(one of payment_3ds, wallet_provisioning)_\notp_waiting.merchant_name · string · —\notp_waiting.amount · string · —\notp_waiting.currency · string · —\notp_waiting.expires_in_seconds required · integer · (min 0)\nprovider_read_failed · boolean · Present and true only when the live provider read failed (response degraded to stored state).\nnote · string · Present alongside provider_read_failed.\nreveal_unavailable · boolean · Present and true when the card read succeeded but no card-number link is available (see reveal_note for which of the two reasons).\nreveal_note · string · Present alongside reveal_unavailable.\n\n{\n\"card\": {\n\"id\": 1,\n\"rail\": \"rail_a\",\n\"core_id\": \"string\",\n\"bin\": \"string\",\n\"product_id\": \"string\",\n\"product_code\": \"string\",\n\"card_nature\": \"VIRTUAL\",\n\"status\": 1,\n\"status_normalized\": \"issuing\",\n\"currency\": \"string\",\n\"last4\": \"string\",\n\"name_on_card\": \"string\",\n\"issued_at\": \"2026-08-31T09:15:00.000Z\"\n},\n\"balance\": {\n\"available\": \"string\",\n\"pending\": \"string\",\n\"currency\": \"USD\"\n},\n\"currency\": \"string\",\n\"card_pan_url\": \"string\",\n\"expire_date\": \"string\",\n\"masked_number\": \"string\",\n\"name_on_card\": \"string\",\n\"cardholder_name\": \"string\",\n\"bin\": \"string\",\n\"decline_count\": 1,\n\"decline_limit\": 1,\n\"otp_available\": true,\n\"otp_waiting\": {\n\"kind\": \"payment_3ds\",\n\"merchant_name\": \"string\",\n\"amount\": \"string\",\n\"currency\": \"string\",\n\"expires_in_seconds\": 0\n},\n\"provider_read_failed\": true,\n\"note\": \"string\",\n\"reveal_unavailable\": true,\n\"reveal_note\": \"string\"\n}\n\nExample request\n\ncurl -X GET 'https://api.cryptoinfra.build/v1/cards/1?user_id=string' \\\n-H 'Authorization: Bearer ck_live_your_key'"
        },
        {
          "anchor": "delete-v1-cards-id",
          "heading": "DELETE /v1/cards/{id}",
          "text": "Close (terminate) a card  ·  Auth: bearer API key  ·  operationId: closeCard\n\nTerminates the card. One call is enough.\n\nIf the card still holds a balance, this call returns the residual to\nus (a card→wallet withdrawal order) and answers\n202 card_close_in_progress with details.card_id and\ndetails.order_id. The card is not closed yet — a 202 is not a\nclosed card. Until it is, GET /v1/cards/{id} carries\nclose_in_progress: true.\nWithin about a minute of that order settling we finish the close\nourselves: the close fee — ours plus yours, see\nGET /v1/cards/{id}/close-quote — is debited from the cardholder and\nthe card is terminated. The card.status webhook confirms it, and\nthe card reads status_normalized: closed. The fee shows up as a\nclose_fee order. You may also call DELETE again yourself once the\norder is confirmed (card.withdraw webhook or\nGET /v1/cards/orders/{order_id}); that finishes it at once, and a\nDELETE on a card we already closed answers 200 with the card.\n\nThe close is always accepted: it never fails because a refund cannot be paid yet.\nRead close_refund.state on GET /v1/cards/{id} for where the money is\n(waiting → approved → sending → paid, or held with a reason).\n\nA card holding less than the issuer's cancellation fee, or nothing at all,\ncloses like any other (since 20 September 2026): the issuer keeps what is on\nthe card, there is nothing to return, and 409 card_close_underfunded is no\nlonger raised. minimum_card_balance on the close quote is always 0.\n\nMoney still pending on the card blocks the close with\n409 card_state (the error names the amount) — wait for it to settle.\n\nThe card's balance goes back to the holder's own address on-chain, from\na wallet cryptoinfra funds by hand, within 7 working days of the issuer\nreleasing it. The close is never refused while that is arranged\n(409 card_refund_wallet_empty is retired): read close_refund.state on\nGET /v1/cards/{id} — waiting, approved, sending, paid, or held\nwith a reason.\nClosing an already-closed or expired card also answers 409 card_state.\nIf the balance cannot be read the close is refused (400) — an\nunreadable balance is never treated as zero.\n\nResidual-on-close rails refund the residual as part of the close.\nThe settlement that credits that residual back to the user arrives in a\nlater release; until then close for these providers answers\n400 not_supported_by_provider.\n\nParameters\n\nName · In · Type · Required · Description\nid · path · integer · yes · The card's numeric id (from GET /v1/cards). (min 1)\n\nRequest body\n\napplication/json · required · schema CardUserRef\n\nField · Type · Description\nuser_id required · string · (minLength 1; maxLength 255)\n\nResponses\n\nStatus · Body · Meaning\n200 · object · Card closed; the close fee was debited from the cardholder.\n202 · object · card_close_in_progress — the balance is being returned; details.order_id is the withdrawal order to watch. The card is NOT closed yet; we close it within about a minute of that order settling (or call again yourself once it is confirmed). Watch card.status, or GET /v1/cards/{id} until close_in_progress is gone and status_normalized is closed.\n400 · Error · Request validation failed or a domain rule was violated. error is one of: validation_error, invalid_request, invalid_amount, card_core_required, card_provider_required, unknown_provider, not_supported_by_provider. card_core_required carries details.cores — the card programmes this call will accept as core_id. It appears when you hold more than one programme that could serve the request. We refuse rather than pick, because picking would spend a balance that did not pay for the card and nothing in the response would say so. Send core_id. card_provider_required carries details.rails — the rail ids this call will accept as provider. It only ever appears when two enabled rails could both serve the request; naming a core_id, or having cards on just one rail, resolves it without your having to choose.\n401 · Error · Missing, malformed or unknown API key.\n404 · Error · Unknown card (card_not_found), or an unknown payer user (user_not_found), for this tenant.\n409 · Error · A conflicting card state. error is one of: kyc_not_approved (details.kyc_status), card_state, card_issuing (the card is still being issued — under two minutes; details.card_id; poll GET /v1/cards/{id} until status_normalized is active, or wait for the card.status webhook, then retry), provider_rejected (the rail refused — DEFINITIVE, so retrying the identical call cannot help; details.order_id on a money call, where any charge is already reversed, and details.provider_code otherwise), email_taken (details.email — the rail already holds an account on that address; send a different one), idempotency_conflict, insufficient_balance, no_own_account (you have set a card fee of your own but your account has no balance of its own to credit it to — ask us to open one).\n503 · Error · Temporarily unable to serve this card call — your request was valid. Retry; nothing has been charged. error is one of: provider_unavailable — the rail did not answer, or answered unreadably. Transient. card_rail_unavailable — this card's own rail is not reachable (it may have been closed to new business). Other rails are unaffected, and the card itself is fine. cards_disabled — no card rail is enabled on this deployment at all. rail_unfunded — the platform cannot fund the on-chain send behind this load right now. Nothing was charged; retry later. 409 card_coins_not_on_chain — the holder's coins are not at their own address yet (a close refund or a deposit still on its way): a load pays its own way on-chain from that address and waits for them. details carry needed. Nothing was charged; try again once they have arrived.\n\n200 response body\n\nField · Type · Description\ncard required · object · —\ncard.id required · integer · —\ncard.rail required · string · Opaque, stable id of the card rail a product, holder or card sits on — and the value to pass as provider when a call needs disambiguating. It tells you WHICH rail, never who runs it: two cards on different rails are different arrangements of ours, and that is all you need to route a call correctly. Read it from the resource and echo it back; never hard-code one, and never parse it — the string is an identifier, not a description.\ncard.core_id required · string · null · The programme this card was issued from — the pair (core_id, bin) is what prices its loads and close. A programme holds no money; the cardholder pays. null on cards issued before programmes existed.\ncard.bin required · string · null · The card's BIN. Read it WITH core_id, never alone: a BIN number is unique only inside its issuer, so the same number under a different programme is a different card with different fees, limits and validity.\ncard.product_id required · string · null · Provider-scoped product CODE (kept as an alias of product_code); null when the card was listed untagged.\ncard.product_code required · string · null · Provider-scoped product code; null when the card was listed untagged.\ncard.card_nature required · string · (one of VIRTUAL, PHYSICAL)\ncard.status required · integer · The provider's RAW status code, verbatim — interpret via status_normalized.\ncard.status_normalized required · string · Provider-neutral status. A just-issued card is issuing; it becomes active once the opening balance has landed at the issuer. (one of issuing, active, frozen, suspended, closed, expired)\ncard.currency required · string · null · —\ncard.last4 required · string · null · —\ncard.name_on_card required · string · null · The cardholder name you gave at issue — our record, whether or not the BIN prints it on the plastic.\ncard.issued_at required · string<date-time> · —\n\n{\n\"card\": {\n\"id\": 1,\n\"rail\": \"rail_a\",\n\"core_id\": \"string\",\n\"bin\": \"string\",\n\"product_id\": \"string\",\n\"product_code\": \"string\",\n\"card_nature\": \"VIRTUAL\",\n\"status\": 1,\n\"status_normalized\": \"issuing\",\n\"currency\": \"string\",\n\"last4\": \"string\",\n\"name_on_card\": \"string\",\n\"issued_at\": \"2026-08-31T09:15:00.000Z\"\n}\n}\n\nExample request\n\ncurl -X DELETE 'https://api.cryptoinfra.build/v1/cards/1' \\\n-H 'Authorization: Bearer ck_live_your_key' \\\n-H 'Content-Type: application/json' \\\n-d '{\n\"user_id\": \"string\"\n}'"
        },
        {
          "anchor": "get-v1-cards-id-balance",
          "heading": "GET /v1/cards/{id}/balance",
          "text": "Live card balance  ·  Auth: bearer API key  ·  operationId: getCardBalance\n\npending is null (not 0) when the provider does not expose holds.\n\nWhile the card is still being issued (status_normalized: issuing, the\nminute or two after POST /v1/cards) this answers 409 card_issuing —\npoll GET /v1/cards/{id} or wait for the card.status webhook, then retry.\n\nParameters\n\nName · In · Type · Required · Description\nid · path · integer · yes · The card's numeric id (from GET /v1/cards). (min 1)\nuser_id · query · string · yes · —\n\nResponses\n\nStatus · Body · Meaning\n200 · CardBalance · The card's balance.\n401 · Error · Missing, malformed or unknown API key.\n404 · Error · Unknown card (card_not_found), or an unknown payer user (user_not_found), for this tenant.\n409 · Error · A conflicting card state. error is one of: kyc_not_approved (details.kyc_status), card_state, card_issuing (the card is still being issued — under two minutes; details.card_id; poll GET /v1/cards/{id} until status_normalized is active, or wait for the card.status webhook, then retry), provider_rejected (the rail refused — DEFINITIVE, so retrying the identical call cannot help; details.order_id on a money call, where any charge is already reversed, and details.provider_code otherwise), email_taken (details.email — the rail already holds an account on that address; send a different one), idempotency_conflict, insufficient_balance, no_own_account (you have set a card fee of your own but your account has no balance of its own to credit it to — ask us to open one).\n503 · Error · Temporarily unable to serve this card call — your request was valid. Retry; nothing has been charged. error is one of: provider_unavailable — the rail did not answer, or answered unreadably. Transient. card_rail_unavailable — this card's own rail is not reachable (it may have been closed to new business). Other rails are unaffected, and the card itself is fine. cards_disabled — no card rail is enabled on this deployment at all. rail_unfunded — the platform cannot fund the on-chain send behind this load right now. Nothing was charged; retry later. 409 card_coins_not_on_chain — the holder's coins are not at their own address yet (a close refund or a deposit still on its way): a load pays its own way on-chain from that address and waits for them. details carry needed. Nothing was charged; try again once they have arrived.\n\n200 response body — CardBalance\n\nField · Type · Description\navailable required · string · A monetary value as a decimal string. Always paired with a *_base field that carries the exact integer base-unit value; compute with the _base string.\navailable_base required · string · Exact integer amount in the asset's smallest unit. (pattern ^[0-9]+$)\npending required · string · null · Held amount (decimal string); null (NOT 0) when the provider does not expose holds.\npending_base required · string · null · —\ncurrency required · string · —\n\n{\n\"available\": \"1.5\",\n\"available_base\": \"1500000000\",\n\"pending\": \"string\",\n\"pending_base\": \"string\",\n\"currency\": \"string\"\n}\n\nExample request\n\ncurl -X GET 'https://api.cryptoinfra.build/v1/cards/1/balance?user_id=string' \\\n-H 'Authorization: Bearer ck_live_your_key'"
        },
        {
          "anchor": "post-v1-cards-id-reveal",
          "heading": "POST /v1/cards/{id}/reveal",
          "text": "Reveal the full card number  ·  Auth: bearer API key  ·  operationId: revealCard\n\nFull card details for the merchant that OWNS the card, on explicit\nrequest. POST (never GET) so the card id never lands in an access-log\nquery string, and the body is the only place the secrets ever appear.\n\nThe issuer decides the shape of the answer, and exactly ONE of the two\nforms is populated — always branch on which:\n\nRaw secrets — pan, cvv and expiry (MM/YY) are filled and\nreveal_url is null.\nHosted page — the issuer reveals only through its own page: pan\nand cvv come back null and reveal_url carries a URL to open.\nThe link is SHORT-LIVED (about a minute) and single-purpose: hand it\nstraight to the cardholder's browser. Do not cache it, store it, put\nit in an email, or re-serve it later — a stale link is simply dead,\nand mint a fresh one by calling this endpoint again.\n\nA client that reads only pan will therefore show a blank card number\nfor every card on a hosted-reveal product. Handle both.\n\nWhile the card is still being issued (status_normalized: issuing) this\nanswers 409 card_issuing instead of a body with neither form — poll\nGET /v1/cards/{id} or wait for the card.status webhook, then retry.\n\nHandling rules (both forms). The response is sent with\nCache-Control: no-store. Never log, persist, or forward these values —\nnot to your database, not to analytics, not into a support ticket.\nRender them to the cardholder and drop them.\n\nThis is the way to show a card number. Both enabled rails answer it\nwith raw secrets, so you can render your own card face — you do not need\nto embed anything. card_pan_url on GET /v1/cards/{id} remains for\nclients that would rather not handle a PAN at all, and reveal_url here\ncovers the rare product whose issuer insists on showing the number\nitself. A rail with no reveal at all answers\n400 not_supported_by_provider.\n\nScoped exactly like every other per-card call: the card must belong to\nthis tenant AND to the user_id in the body, or the call 404s.\n\nParameters\n\nName · In · Type · Required · Description\nid · path · integer · yes · The card's numeric id (from GET /v1/cards). (min 1)\n\nRequest body\n\napplication/json · required · schema CardUserRef\n\nField · Type · Description\nuser_id required · string · (minLength 1; maxLength 255)\n\nResponses\n\nStatus · Body · Meaning\n200 · CardSecrets · The card details, returned once. Sent with Cache-Control: no-store — do not cache, log, or persist any field.\n400 · Error · Request validation failed or a domain rule was violated. error is one of: validation_error, invalid_request, invalid_amount, card_core_required, card_provider_required, unknown_provider, not_supported_by_provider. card_core_required carries details.cores — the card programmes this call will accept as core_id. It appears when you hold more than one programme that could serve the request. We refuse rather than pick, because picking would spend a balance that did not pay for the card and nothing in the response would say so. Send core_id. card_provider_required carries details.rails — the rail ids this call will accept as provider. It only ever appears when two enabled rails could both serve the request; naming a core_id, or having cards on just one rail, resolves it without your having to choose.\n401 · Error · Missing, malformed or unknown API key.\n404 · Error · Unknown card (card_not_found), or an unknown payer user (user_not_found), for this tenant.\n409 · Error · A conflicting card state. error is one of: kyc_not_approved (details.kyc_status), card_state, card_issuing (the card is still being issued — under two minutes; details.card_id; poll GET /v1/cards/{id} until status_normalized is active, or wait for the card.status webhook, then retry), provider_rejected (the rail refused — DEFINITIVE, so retrying the identical call cannot help; details.order_id on a money call, where any charge is already reversed, and details.provider_code otherwise), email_taken (details.email — the rail already holds an account on that address; send a different one), idempotency_conflict, insufficient_balance, no_own_account (you have set a card fee of your own but your account has no balance of its own to credit it to — ask us to open one).\n503 · Error · Temporarily unable to serve this card call — your request was valid. Retry; nothing has been charged. error is one of: provider_unavailable — the rail did not answer, or answered unreadably. Transient. card_rail_unavailable — this card's own rail is not reachable (it may have been closed to new business). Other rails are unaffected, and the card itself is fine. cards_disabled — no card rail is enabled on this deployment at all. rail_unfunded — the platform cannot fund the on-chain send behind this load right now. Nothing was charged; retry later. 409 card_coins_not_on_chain — the holder's coins are not at their own address yet (a close refund or a deposit still on its way): a load pays its own way on-chain from that address and waits for them. details carry needed. Nothing was charged; try again once they have arrived.\n\n200 response body — CardSecrets\n\nField · Type · Description\npan required · string · null · Full card number; null when the issuer reveals via reveal_url instead.\ncvv required · string · null · Security code; null when the issuer reveals via reveal_url instead.\nexpiry required · string · null · MM/YY; null when the issuer does not disclose it here.\nreveal_url required · string · null · Short-lived link to a hosted page showing the number, populated INSTEAD of pan/cvv. A link on OUR domain that redirects; open it immediately, do not cache or store it, and call this endpoint again for a fresh one.\n\n{\n\"pan\": \"string\",\n\"cvv\": \"string\",\n\"expiry\": \"string\",\n\"reveal_url\": \"string\"\n}\n\nExample request\n\ncurl -X POST 'https://api.cryptoinfra.build/v1/cards/1/reveal' \\\n-H 'Authorization: Bearer ck_live_your_key' \\\n-H 'Content-Type: application/json' \\\n-d '{\n\"user_id\": \"string\"\n}'"
        },
        {
          "anchor": "get-v1-cards-id-otp",
          "heading": "GET /v1/cards/{id}/otp",
          "text": "Fetch the card's live one-time code  ·  Auth: bearer API key  ·  operationId: getCardOtp\n\nReturns the verification code the ISSUER relayed for this card — either a\n3DS code that approves a payment, or a wallet code that adds the card to\nApple/Google Pay.\n\nThe code never reaches the cardholder's own phone or email. For an\nissued-on-your-program card the issuer sends it to us, so whichever\nchannel the wallet or checkout offers your customer, the code arrives\nhere. This endpoint is how you hand it to them.\n\nThree properties to build against:\n\nSingle read. Fetching CONSUMES the code — a second call returns 404.\nNever poll this endpoint; call it when your user is ready to type the\ncode. To know a code is waiting, read otp_available on\nGET /v1/cards/{id} (a peek that does not consume) or listen for the\ncard.wallet_otp webhook. Your API key and your dashboard each have\ntheir own read: fetching a code from your server does not take it away\nfrom someone looking at the card in the dashboard, and the other way\nround.\nShort-lived. Codes expire in about ten minutes; expires_in_seconds\nis the remaining life at the moment of the read.\nNever stored. The code is held in memory only — not in the database,\nnot in a webhook payload, not in a log. If it is lost, the customer must\ntrigger a new one.\n\nA 404 is deliberately uniform: it does not distinguish \"no code arrived\"\nfrom \"already read\", \"expired\", or \"not your card\".\n\nParameters\n\nName · In · Type · Required · Description\nid · path · integer · yes · The card's numeric id (from GET /v1/cards). (min 1)\nuser_id · query · string · no · Optional second proof of ownership. When supplied it must match the end-user the code was bound to at receipt, or the read returns 404.\n\nResponses\n\nStatus · Body · Meaning\n200 · object · The code, returned once. Sent with Cache-Control: no-store — do not cache, log, or persist it.\n401 · Error · Missing, malformed or unknown API key.\n404 · object · No code is available. Uniform by design — no code arrived, it was already read, it expired, or the card is not yours.\n503 · Error · Temporarily unable to serve this card call — your request was valid. Retry; nothing has been charged. error is one of: provider_unavailable — the rail did not answer, or answered unreadably. Transient. card_rail_unavailable — this card's own rail is not reachable (it may have been closed to new business). Other rails are unaffected, and the card itself is fine. cards_disabled — no card rail is enabled on this deployment at all. rail_unfunded — the platform cannot fund the on-chain send behind this load right now. Nothing was charged; retry later. 409 card_coins_not_on_chain — the holder's coins are not at their own address yet (a close refund or a deposit still on its way): a load pays its own way on-chain from that address and waits for them. details carry needed. Nothing was charged; try again once they have arrived.\n\n200 response body\n\nField · Type · Description\ncard_id required · integer · —\ncode required · string · The one-time code, exactly as the issuer sent it.\nkind required · string · payment_3ds interrupts a checkout; wallet_provisioning gates adding the card to Apple/Google Pay. Surface them differently — they reach the customer at different moments. _(one of payment_3ds, wallet_provisioning)_\nmerchant_name · string · payment_3ds only, when the issuer sent it: the shop the code approves a payment at. Show it so the cardholder can check the code is for their own checkout.\namount · string · payment_3ds only, when sent: the amount of that payment, as a decimal string.\ncurrency · string · payment_3ds only, when sent: the currency of amount.\nexpires_in_seconds required · integer · (min 0)\n\n{\n\"card_id\": 1,\n\"code\": \"441524\",\n\"kind\": \"payment_3ds\",\n\"merchant_name\": \"EXAMPLE STORE\",\n\"amount\": \"12.50\",\n\"currency\": \"USD\",\n\"expires_in_seconds\": 477\n}\n\nExample request\n\ncurl -X GET 'https://api.cryptoinfra.build/v1/cards/1/otp' \\\n-H 'Authorization: Bearer ck_live_your_key'"
        },
        {
          "anchor": "post-v1-cards-id-topup",
          "heading": "POST /v1/cards/{id}/topup",
          "text": "Top up a card  ·  Auth: bearer API key  ·  operationId: cardTopup\n\nDebits total from the cardholder's balance immediately (see\nGET /v1/cards/topup-quote: amount + our fee + gas + your fee), sends what\nthe rail needs on-chain, and funds the card once that send is credited.\nReturns 202 with the order in funding; it moves to requested\nwhen the send confirms, sent when the rail is asked, confirmed when\nthe card is funded — a card.topup webhook or the reconciler tells you.\nA definitive failure at any stage refunds the charge in full. Treat 202\nas pending; refund your user ONLY on a definitive failure\n(409 provider_rejected, or a webhook with order status failed), never\non the 202 itself. idempotency_key is required.\n\nParameters\n\nName · In · Type · Required · Description\nid · path · integer · yes · The card's numeric id (from GET /v1/cards). (min 1)\n\nRequest body\n\napplication/json · required · schema CardMoneyRequest\n\nField · Type · Description\nuser_id required · string · (minLength 1; maxLength 255)\namount required · string · Decimal amount in major units (USD/USDT), at most 6 decimal places. (pattern ^\\d+(\\.\\d+)?$)\nidempotency_key required · string · Required; replay-safe. (minLength 1; maxLength 255)\n\nResponses\n\nStatus · Body · Meaning\n202 · CardOrder · Top-up accepted; provider outcome pending.\n400 · Error · Request validation failed or a domain rule was violated. error is one of: validation_error, invalid_request, invalid_amount, card_core_required, card_provider_required, unknown_provider, not_supported_by_provider. card_core_required carries details.cores — the card programmes this call will accept as core_id. It appears when you hold more than one programme that could serve the request. We refuse rather than pick, because picking would spend a balance that did not pay for the card and nothing in the response would say so. Send core_id. card_provider_required carries details.rails — the rail ids this call will accept as provider. It only ever appears when two enabled rails could both serve the request; naming a core_id, or having cards on just one rail, resolves it without your having to choose.\n401 · Error · Missing, malformed or unknown API key.\n404 · Error · Unknown card (card_not_found), or an unknown payer user (user_not_found), for this tenant.\n409 · Error · A conflicting card state. error is one of: kyc_not_approved (details.kyc_status), card_state, card_issuing (the card is still being issued — under two minutes; details.card_id; poll GET /v1/cards/{id} until status_normalized is active, or wait for the card.status webhook, then retry), provider_rejected (the rail refused — DEFINITIVE, so retrying the identical call cannot help; details.order_id on a money call, where any charge is already reversed, and details.provider_code otherwise), email_taken (details.email — the rail already holds an account on that address; send a different one), idempotency_conflict, insufficient_balance, no_own_account (you have set a card fee of your own but your account has no balance of its own to credit it to — ask us to open one).\n503 · Error · Temporarily unable to serve this card call — your request was valid. Retry; nothing has been charged. error is one of: provider_unavailable — the rail did not answer, or answered unreadably. Transient. card_rail_unavailable — this card's own rail is not reachable (it may have been closed to new business). Other rails are unaffected, and the card itself is fine. cards_disabled — no card rail is enabled on this deployment at all. rail_unfunded — the platform cannot fund the on-chain send behind this load right now. Nothing was charged; retry later. 409 card_coins_not_on_chain — the holder's coins are not at their own address yet (a close refund or a deposit still on its way): a load pays its own way on-chain from that address and waits for them. details carry needed. Nothing was charged; try again once they have arrived.\n\n202 response body — CardOrder\n\nField · Type · Description\nid required · integer · —\nkind required · string · close_fee is the close fee charged to the cardholder on DELETE /v1/cards/{id}: amount is 0, fee is the charge. _(one of topup, withdraw, issue_fee, close_fee)_\nstatus required · string · funding = the cardholder has been charged and the money is on its way to the card rail on-chain. requested = confirmed on-chain, waiting for the rail to show the credit. sent = the rail has been asked, outcome still pending. confirmed = settled. failed = definitively failed; the WHOLE charge — amount, fee, gas and merchant_fee, i.e. total — is refunded to the balance it was taken from, the cardholder's, in the same moment (top-up / issue only — a withdraw holds nothing). Nothing goes anywhere else: not to the card, not to your treasury. When the on-chain send behind a load reverted (provider_status: send_failed), no coins left the cardholder's address either, so on-chain and ledger agree. reversed is reserved for manual operator reversals of a confirmed order. (one of funding, requested, sent, confirmed, failed, reversed)\ntxid · string · null · The hash of the on-chain send behind a load or an issue's opening balance — link your receipt to it the way you do for a deposit or a payout. Null until the send is broadcast, and for an order the rail served from its float. A failed order keeps the hash of the send that reverted, the proof of what happened; after a re-send it is the current send's hash and the reverted one is named in error. Always null on a withdraw order.\nasset_id required · string · —\namount required · string · A monetary value as a decimal string. Always paired with a *_base field that carries the exact integer base-unit value; compute with the _base string.\namount_base required · string · Exact integer amount in the asset's smallest unit. (pattern ^[0-9]+$)\nfee required · string · A monetary value as a decimal string. Always paired with a *_base field that carries the exact integer base-unit value; compute with the _base string.\nfee_base required · string · Exact integer amount in the asset's smallest unit. (pattern ^[0-9]+$)\ngas · string · The network fee actually CHARGED for the on-chain send behind a load or issue — the figure the cardholder was debited, fixed when the order was created. Not the quote's figure: reconcile a quote against this.\nmerchant_fee · string · Your own fee on this order, credited to your balance.\ntotal · string · Everything the cardholder was debited for this order.\nsent_on_chain · string · What the on-chain send behind this order carried (\"0\" when the rail already held it). It is the attempt, not the outcome: on a failed order whose send reverted, this still reads the amount that was sent for and nothing moved.\namount_credited required · string · null · WITHDRAW only: the NET actually credited after the provider's unload tariff, which differs per rail. null until a withdraw settles confirmed, and always null for top-ups / issue fees. Credit your user from THIS (or amount_credited_base), never from amount — the requested gross — or you over-credit by the tariff on every unload.\namount_credited_base required · string · null · Integer-string base units of amount_credited (null until a withdraw confirms).\ncard_id required · integer · null · —\nrequest_order_id required · string · null · Server-minted correlation handle (ct-/cw-/ci- prefix).\nprovider_status required · string · null · Where a failed order failed: send_failed = the on-chain send to the rail reverted (our side; the charge was refunded, nothing left the address); anything else is the rail's own word for its refusal. Informational — act on status.\nerror required · string · null · —\nidempotency_key required · string · null · —\ncreated_at required · string<date-time> · —\n\n{\n\"id\": 1,\n\"kind\": \"topup\",\n\"status\": \"funding\",\n\"txid\": \"string\",\n\"asset_id\": \"usdt_trc20\",\n\"amount\": \"1.5\",\n\"amount_base\": \"1500000000\",\n\"fee\": \"1.5\",\n\"fee_base\": \"1500000000\",\n\"gas\": \"1.5\",\n\"merchant_fee\": \"1.5\",\n\"total\": \"1.5\",\n\"sent_on_chain\": \"1.5\",\n\"amount_credited\": \"1.5\",\n\"amount_credited_base\": \"1500000000\",\n\"card_id\": 1,\n\"request_order_id\": \"string\",\n\"provider_status\": \"string\",\n\"error\": \"string\",\n\"idempotency_key\": \"string\",\n\"created_at\": \"2026-08-31T09:15:00.000Z\"\n}\n\nExample request\n\ncurl -X POST 'https://api.cryptoinfra.build/v1/cards/1/topup' \\\n-H 'Authorization: Bearer ck_live_your_key' \\\n-H 'Content-Type: application/json' \\\n-d '{\n\"user_id\": \"string\",\n\"amount\": \"string\",\n\"idempotency_key\": \"string\"\n}'"
        },
        {
          "anchor": "get-v1-cards-id-close-quote",
          "heading": "GET /v1/cards/{id}/close-quote",
          "text": "What closing this card would charge the cardholder  ·  Auth: bearer API key  ·  operationId: cardCloseQuote\n\nRead-only. Our flat close fee, the live network figure for the on-chain refund, plus your own fee if you set one — total is the holder's debit on DELETE /v1/cards/{id}.\n\nParameters\n\nName · In · Type · Required · Description\nid · path · integer · yes · The card's numeric id (from GET /v1/cards). (min 1)\nuser_id · query · string · yes · —\n\nResponses\n\nStatus · Body · Meaning\n200 · object · The close preview.\n401 · Error · Missing, malformed or unknown API key.\n404 · Error · Unknown card (card_not_found), or an unknown payer user (user_not_found), for this tenant.\n\n200 response body\n\nField · Type · Description\nasset_id required · string · —\nfee required · string · A monetary value as a decimal string. Always paired with a *_base field that carries the exact integer base-unit value; compute with the _base string.\nfee_base required · string · Exact integer amount in the asset's smallest unit. (pattern ^[0-9]+$)\ngas · string · The live network figure for returning the balance to the holder's address on-chain. Part of total.\ngas_base · string · Exact integer amount in the asset's smallest unit. (pattern ^[0-9]+$)\ndynamic · boolean · true when gas was priced live now; false on the fallback.\nmerchant_fee required · string · A monetary value as a decimal string. Always paired with a *_base field that carries the exact integer base-unit value; compute with the _base string.\nmerchant_fee_base required · string · Exact integer amount in the asset's smallest unit. (pattern ^[0-9]+$)\ntotal required · string · A monetary value as a decimal string. Always paired with a *_base field that carries the exact integer base-unit value; compute with the _base string.\ntotal_base required · string · Exact integer amount in the asset's smallest unit. (pattern ^[0-9]+$)\ncharged_to required · string · Where the close is paid from. Every cost — ours, the network figure, yours and the rail's release charge — comes out of the balance being returned, never out of the holder's wallet balance. They receive refund_quote.refund. (one of refund)\nminimum_card_balance required · string · Always 0 since 20 September 2026: a close is no longer refused because the card holds little or nothing. Kept so existing clients do not break.\nissuer_fee · string · What the issuer keeps on the card as its cancellation charge when the card holds that much. A card holding less simply closes, and the issuer keeps whatever is there.\nissuer_fee_base · string · Exact integer amount in the asset's smallest unit. (pattern ^[0-9]+$)\nminimum_card_balance_base required · string · Exact integer amount in the asset's smallest unit. (pattern ^[0-9]+$)\nrefund_quote · object · What the holder actually receives, on the card's balance right now — the figure to show them before they close. card_balance is read live from the rail; when it cannot be read it is null, live is false and the other lines are 0. The issuer's cancellation fee comes off the card, and the rail's charge for releasing it, our close fee, the network cost of the send and your own fee all come off what is returned. Nothing is taken from the holder's wallet balance.\nrefund_quote.card_balance required · string · A monetary value as a decimal string. Always paired with a *_base field that carries the exact integer base-unit value; compute with the _base string.\nrefund_quote.card_balance_base required · string · Exact integer amount in the asset's smallest unit. (pattern ^[0-9]+$)\nrefund_quote.issuer_fee required · string · A monetary value as a decimal string. Always paired with a *_base field that carries the exact integer base-unit value; compute with the _base string.\nrefund_quote.issuer_fee_base required · string · Exact integer amount in the asset's smallest unit. (pattern ^[0-9]+$)\nrefund_quote.rail_return_fee required · string · A monetary value as a decimal string. Always paired with a *_base field that carries the exact integer base-unit value; compute with the _base string.\nrefund_quote.rail_return_fee_base required · string · Exact integer amount in the asset's smallest unit. (pattern ^[0-9]+$)\nrefund_quote.close_fee required · string · A monetary value as a decimal string. Always paired with a *_base field that carries the exact integer base-unit value; compute with the _base string.\nrefund_quote.close_fee_base required · string · Exact integer amount in the asset's smallest unit. (pattern ^[0-9]+$)\nrefund_quote.gas required · string · A monetary value as a decimal string. Always paired with a *_base field that carries the exact integer base-unit value; compute with the _base string.\nrefund_quote.gas_base required · string · Exact integer amount in the asset's smallest unit. (pattern ^[0-9]+$)\nrefund_quote.merchant_fee required · string · A monetary value as a decimal string. Always paired with a *_base field that carries the exact integer base-unit value; compute with the _base string.\nrefund_quote.merchant_fee_base required · string · Exact integer amount in the asset's smallest unit. (pattern ^[0-9]+$)\nrefund_quote.refund required · string · A monetary value as a decimal string. Always paired with a *_base field that carries the exact integer base-unit value; compute with the _base string.\nrefund_quote.refund_base required · string · Exact integer amount in the asset's smallest unit. (pattern ^[0-9]+$)\nrefund_quote.live required · boolean · —\nrefund_eta_typical · string · What a refund usually takes once the card is closed (e.g. \"24–48 hours\").\nrefund_eta_max_days · integer · The issuer's outside limit. Cancelling a card opens a settlement period during which the balance is not released, and the issuer states it can run to this many days. It has been far quicker in practice, but this is the figure to quote a customer rather than a promise of your own.\nrefund_eta_working_days required · integer · The card's balance reaches the holder's own address on-chain within this many working days of the close.\n\n{\n\"asset_id\": \"usdt_trc20\",\n\"fee\": \"1.5\",\n\"fee_base\": \"1500000000\",\n\"gas\": \"1.5\",\n\"gas_base\": \"1500000000\",\n\"dynamic\": true,\n\"merchant_fee\": \"1.5\",\n\"merchant_fee_base\": \"1500000000\",\n\"total\": \"1.5\",\n\"total_base\": \"1500000000\",\n\"charged_to\": \"refund\",\n\"minimum_card_balance\": \"1.5\",\n\"issuer_fee\": \"1.5\",\n\"issuer_fee_base\": \"1500000000\",\n\"minimum_card_balance_base\": \"1500000000\",\n\"refund_quote\": {\n\"card_balance\": \"1.5\",\n\"card_balance_base\": \"1500000000\",\n\"issuer_fee\": \"1.5\",\n\"issuer_fee_base\": \"1500000000\",\n\"rail_return_fee\": \"1.5\",\n\"rail_return_fee_base\": \"1500000000\",\n\"close_fee\": \"1.5\",\n\"close_fee_base\": \"1500000000\",\n\"gas\": \"1.5\",\n\"gas_base\": \"1500000000\",\n\"merchant_fee\": \"1.5\",\n\"merchant_fee_base\": \"1500000000\",\n\"refund\": \"1.5\",\n\"refund_base\": \"1500000000\",\n\"live\": true\n},\n\"refund_eta_typical\": \"string\",\n\"refund_eta_max_days\": 1,\n\"refund_eta_working_days\": 1\n}\n\nExample request\n\ncurl -X GET 'https://api.cryptoinfra.build/v1/cards/1/close-quote?user_id=string' \\\n-H 'Authorization: Bearer ck_live_your_key'"
        },
        {
          "anchor": "get-v1-merchant-card-fees",
          "heading": "GET /v1/merchant/card-fees",
          "text": "Your own card fees, on top of ours  ·  Auth: bearer API key  ·  operationId: merchantCardFeesGet\n\nOurs is the floor and is always charged; what you set here is ADDED to it, charged to the cardholder, and credited to your own balance in the same journal as the charge. Nothing to move, nothing to pay out. Works with an API key or a dashboard login.\n\nResponses\n\nStatus · Body · Meaning\n200 · object · Our prices and yours, per operation.\n401 · Error · Missing, malformed or unknown API key.\n\n200 response body\n\nField · Type · Description\nmax_percent_bps · integer · —\ncharged_to · string · (one of cardholder)\nours · object · —\nours.issue · object · —\nours.issue.flat · string · A monetary value as a decimal string. Always paired with a *_base field that carries the exact integer base-unit value; compute with the _base string.\nours.issue.flat_physical · string · A monetary value as a decimal string. Always paired with a *_base field that carries the exact integer base-unit value; compute with the _base string.\nours.load · object · —\nours.load.percent_bps · integer · —\nours.load.min · string · A monetary value as a decimal string. Always paired with a *_base field that carries the exact integer base-unit value; compute with the _base string.\nours.load.gas · string · The network line of every load, priced when you ask — the live cost of the two TRON transfers behind a load, the same way a payout's network_fee is priced. Never set by hand, never stored: it moves with energy prices and the TRX price. If you mirror this figure, show gas_dynamic beside it and re-read it, do not print it as a fixed charge.\nours.load.gas_dynamic · boolean · true = priced live now; false = the live price could not be read and a conservative fallback applies.\nours.load.gas_priced_at · string<date-time> · When this figure was priced — the moment of this response.\nours.close · object · —\nours.close.flat · string · A monetary value as a decimal string. Always paired with a *_base field that carries the exact integer base-unit value; compute with the _base string.\nyours · object[] · —\nyours[].operation · string · (one of issue, load, close)\nyours[].percent_bps · integer · 100 = 1%.\nyours[].flat · string · A monetary value as a decimal string. Always paired with a *_base field that carries the exact integer base-unit value; compute with the _base string.\nyours[].updated_at · string<date-time> · null · —\n\n{\n\"max_percent_bps\": 1000,\n\"charged_to\": \"cardholder\",\n\"ours\": {\n\"issue\": {\n\"flat\": \"1.5\",\n\"flat_physical\": \"1.5\"\n},\n\"load\": {\n\"percent_bps\": 1,\n\"min\": \"1.5\",\n\"gas\": \"1.5\",\n\"gas_dynamic\": true,\n\"gas_priced_at\": \"2026-08-31T09:15:00.000Z\"\n},\n\"close\": {\n\"flat\": \"1.5\"\n}\n},\n\"yours\": [\n{\n\"operation\": \"issue\",\n\"percent_bps\": 1,\n\"flat\": \"1.5\",\n\"updated_at\": \"2026-08-31T09:15:00.000Z\"\n}\n]\n}\n\nExample request\n\ncurl -X GET 'https://api.cryptoinfra.build/v1/merchant/card-fees' \\\n-H 'Authorization: Bearer ck_live_your_key'"
        },
        {
          "anchor": "put-v1-merchant-card-fees-operation",
          "heading": "PUT /v1/merchant/card-fees/{operation}",
          "text": "Set your fee on one card operation  ·  Auth: bearer API key  ·  operationId: merchantCardFeeSet\n\nA percentage of the amount (percent_bps, 100 = 1%, at most 1000), a flat amount (flat, in the card asset), or both. Omitted fields keep their current value. Refused with 409 no_own_account when your account has no balance of its own for the fee to land in.\n\nParameters\n\nName · In · Type · Required · Description\noperation · path · string · yes · — (one of issue, load, close)\n\nRequest body\n\napplication/json · required\n\nField · Type · Description\npercent_bps · integer · (min 0; max 1000)\nflat · string · (pattern ^\\d+(\\.\\d+)?$)\n\nResponses\n\nStatus · Body · Meaning\n200 · object · The fee as saved.\n400 · Error · Request validation failed or a domain rule was violated. error is one of: validation_error, invalid_request, invalid_amount, card_core_required, card_provider_required, unknown_provider, not_supported_by_provider. card_core_required carries details.cores — the card programmes this call will accept as core_id. It appears when you hold more than one programme that could serve the request. We refuse rather than pick, because picking would spend a balance that did not pay for the card and nothing in the response would say so. Send core_id. card_provider_required carries details.rails — the rail ids this call will accept as provider. It only ever appears when two enabled rails could both serve the request; naming a core_id, or having cards on just one rail, resolves it without your having to choose.\n401 · Error · Missing, malformed or unknown API key.\n409 · Error · no_own_account — open an own-balance account first.\n\n200 response body\n\nField · Type · Description\noperation · string · (one of issue, load, close)\npercent_bps · integer · —\nflat · string · A monetary value as a decimal string. Always paired with a *_base field that carries the exact integer base-unit value; compute with the _base string.\n\n{\n\"operation\": \"issue\",\n\"percent_bps\": 1,\n\"flat\": \"1.5\"\n}\n\nExample request\n\ncurl -X PUT 'https://api.cryptoinfra.build/v1/merchant/card-fees/issue' \\\n-H 'Authorization: Bearer ck_live_your_key' \\\n-H 'Content-Type: application/json' \\\n-d '{}'"
        },
        {
          "anchor": "delete-v1-merchant-card-fees-operation",
          "heading": "DELETE /v1/merchant/card-fees/{operation}",
          "text": "Remove your fee on one card operation  ·  Auth: bearer API key  ·  operationId: merchantCardFeeClear\n\nParameters\n\nName · In · Type · Required · Description\noperation · path · string · yes · — (one of issue, load, close)\n\nResponses\n\nStatus · Body · Meaning\n204 · — · Removed; the cardholder pays exactly our rate again.\n401 · Error · Missing, malformed or unknown API key.\n\nExample request\n\ncurl -X DELETE 'https://api.cryptoinfra.build/v1/merchant/card-fees/issue' \\\n-H 'Authorization: Bearer ck_live_your_key'"
        },
        {
          "anchor": "post-v1-cards-id-withdraw",
          "heading": "POST /v1/cards/{id}/withdraw",
          "text": "Retired — moving part of a card balance back to the wallet  ·  Deprecated.  ·  Auth: bearer API key  ·  operationId: cardWithdraw\n\nRetired on 2026-09-14. Always answers 410 card_unload_retired. The\nonly way value leaves a card is DELETE /v1/cards/{id}, which returns\nthe whole balance to the holder's own address on-chain. An unload would\ncredit the holder's balance with coins that never reach their address.\n\nParameters\n\nName · In · Type · Required · Description\nid · path · integer · yes · The card's numeric id (from GET /v1/cards). (min 1)\n\nRequest body\n\napplication/json · required · schema CardMoneyRequest\n\nField · Type · Description\nuser_id required · string · (minLength 1; maxLength 255)\namount required · string · Decimal amount in major units (USD/USDT), at most 6 decimal places. (pattern ^\\d+(\\.\\d+)?$)\nidempotency_key required · string · Required; replay-safe. (minLength 1; maxLength 255)\n\nResponses\n\nStatus · Body · Meaning\n401 · Error · Missing, malformed or unknown API key.\n409 · Error · A conflicting card state. error is one of: kyc_not_approved (details.kyc_status), card_state, card_issuing (the card is still being issued — under two minutes; details.card_id; poll GET /v1/cards/{id} until status_normalized is active, or wait for the card.status webhook, then retry), provider_rejected (the rail refused — DEFINITIVE, so retrying the identical call cannot help; details.order_id on a money call, where any charge is already reversed, and details.provider_code otherwise), email_taken (details.email — the rail already holds an account on that address; send a different one), idempotency_conflict, insufficient_balance, no_own_account (you have set a card fee of your own but your account has no balance of its own to credit it to — ask us to open one).\n410 · Error · Retired — card_unload_retired. Close the card instead.\n503 · Error · Temporarily unable to serve this card call — your request was valid. Retry; nothing has been charged. error is one of: provider_unavailable — the rail did not answer, or answered unreadably. Transient. card_rail_unavailable — this card's own rail is not reachable (it may have been closed to new business). Other rails are unaffected, and the card itself is fine. cards_disabled — no card rail is enabled on this deployment at all. rail_unfunded — the platform cannot fund the on-chain send behind this load right now. Nothing was charged; retry later. 409 card_coins_not_on_chain — the holder's coins are not at their own address yet (a close refund or a deposit still on its way): a load pays its own way on-chain from that address and waits for them. details carry needed. Nothing was charged; try again once they have arrived.\n\nExample request\n\ncurl -X POST 'https://api.cryptoinfra.build/v1/cards/1/withdraw' \\\n-H 'Authorization: Bearer ck_live_your_key' \\\n-H 'Content-Type: application/json' \\\n-d '{\n\"user_id\": \"string\",\n\"amount\": \"string\",\n\"idempotency_key\": \"string\"\n}'"
        },
        {
          "anchor": "post-v1-cards-id-freeze",
          "heading": "POST /v1/cards/{id}/freeze",
          "text": "Freeze a card  ·  Auth: bearer API key  ·  operationId: freezeCard\n\nFreezes an active card (409 card_state otherwise). The authoritative status is confirmed by a card.status webhook.\n\nParameters\n\nName · In · Type · Required · Description\nid · path · integer · yes · The card's numeric id (from GET /v1/cards). (min 1)\n\nRequest body\n\napplication/json · required · schema CardUserRef\n\nField · Type · Description\nuser_id required · string · (minLength 1; maxLength 255)\n\nResponses\n\nStatus · Body · Meaning\n200 · object · Card frozen.\n400 · Error · Request validation failed or a domain rule was violated. error is one of: validation_error, invalid_request, invalid_amount, card_core_required, card_provider_required, unknown_provider, not_supported_by_provider. card_core_required carries details.cores — the card programmes this call will accept as core_id. It appears when you hold more than one programme that could serve the request. We refuse rather than pick, because picking would spend a balance that did not pay for the card and nothing in the response would say so. Send core_id. card_provider_required carries details.rails — the rail ids this call will accept as provider. It only ever appears when two enabled rails could both serve the request; naming a core_id, or having cards on just one rail, resolves it without your having to choose.\n401 · Error · Missing, malformed or unknown API key.\n404 · Error · Unknown card (card_not_found), or an unknown payer user (user_not_found), for this tenant.\n409 · Error · A conflicting card state. error is one of: kyc_not_approved (details.kyc_status), card_state, card_issuing (the card is still being issued — under two minutes; details.card_id; poll GET /v1/cards/{id} until status_normalized is active, or wait for the card.status webhook, then retry), provider_rejected (the rail refused — DEFINITIVE, so retrying the identical call cannot help; details.order_id on a money call, where any charge is already reversed, and details.provider_code otherwise), email_taken (details.email — the rail already holds an account on that address; send a different one), idempotency_conflict, insufficient_balance, no_own_account (you have set a card fee of your own but your account has no balance of its own to credit it to — ask us to open one).\n503 · Error · Temporarily unable to serve this card call — your request was valid. Retry; nothing has been charged. error is one of: provider_unavailable — the rail did not answer, or answered unreadably. Transient. card_rail_unavailable — this card's own rail is not reachable (it may have been closed to new business). Other rails are unaffected, and the card itself is fine. cards_disabled — no card rail is enabled on this deployment at all. rail_unfunded — the platform cannot fund the on-chain send behind this load right now. Nothing was charged; retry later. 409 card_coins_not_on_chain — the holder's coins are not at their own address yet (a close refund or a deposit still on its way): a load pays its own way on-chain from that address and waits for them. details carry needed. Nothing was charged; try again once they have arrived.\n\n200 response body\n\nField · Type · Description\ncard required · object · —\ncard.id required · integer · —\ncard.rail required · string · Opaque, stable id of the card rail a product, holder or card sits on — and the value to pass as provider when a call needs disambiguating. It tells you WHICH rail, never who runs it: two cards on different rails are different arrangements of ours, and that is all you need to route a call correctly. Read it from the resource and echo it back; never hard-code one, and never parse it — the string is an identifier, not a description.\ncard.core_id required · string · null · The programme this card was issued from — the pair (core_id, bin) is what prices its loads and close. A programme holds no money; the cardholder pays. null on cards issued before programmes existed.\ncard.bin required · string · null · The card's BIN. Read it WITH core_id, never alone: a BIN number is unique only inside its issuer, so the same number under a different programme is a different card with different fees, limits and validity.\ncard.product_id required · string · null · Provider-scoped product CODE (kept as an alias of product_code); null when the card was listed untagged.\ncard.product_code required · string · null · Provider-scoped product code; null when the card was listed untagged.\ncard.card_nature required · string · (one of VIRTUAL, PHYSICAL)\ncard.status required · integer · The provider's RAW status code, verbatim — interpret via status_normalized.\ncard.status_normalized required · string · Provider-neutral status. A just-issued card is issuing; it becomes active once the opening balance has landed at the issuer. (one of issuing, active, frozen, suspended, closed, expired)\ncard.currency required · string · null · —\ncard.last4 required · string · null · —\ncard.name_on_card required · string · null · The cardholder name you gave at issue — our record, whether or not the BIN prints it on the plastic.\ncard.issued_at required · string<date-time> · —\n\n{\n\"card\": {\n\"id\": 1,\n\"rail\": \"rail_a\",\n\"core_id\": \"string\",\n\"bin\": \"string\",\n\"product_id\": \"string\",\n\"product_code\": \"string\",\n\"card_nature\": \"VIRTUAL\",\n\"status\": 1,\n\"status_normalized\": \"issuing\",\n\"currency\": \"string\",\n\"last4\": \"string\",\n\"name_on_card\": \"string\",\n\"issued_at\": \"2026-08-31T09:15:00.000Z\"\n}\n}\n\nExample request\n\ncurl -X POST 'https://api.cryptoinfra.build/v1/cards/1/freeze' \\\n-H 'Authorization: Bearer ck_live_your_key' \\\n-H 'Content-Type: application/json' \\\n-d '{\n\"user_id\": \"string\"\n}'"
        },
        {
          "anchor": "post-v1-cards-id-unfreeze",
          "heading": "POST /v1/cards/{id}/unfreeze",
          "text": "Unfreeze a card  ·  Auth: bearer API key  ·  operationId: unfreezeCard\n\nUnfreezes a frozen card (409 card_state otherwise).\n\nParameters\n\nName · In · Type · Required · Description\nid · path · integer · yes · The card's numeric id (from GET /v1/cards). (min 1)\n\nRequest body\n\napplication/json · required · schema CardUserRef\n\nField · Type · Description\nuser_id required · string · (minLength 1; maxLength 255)\n\nResponses\n\nStatus · Body · Meaning\n200 · object · Card unfrozen.\n400 · Error · Request validation failed or a domain rule was violated. error is one of: validation_error, invalid_request, invalid_amount, card_core_required, card_provider_required, unknown_provider, not_supported_by_provider. card_core_required carries details.cores — the card programmes this call will accept as core_id. It appears when you hold more than one programme that could serve the request. We refuse rather than pick, because picking would spend a balance that did not pay for the card and nothing in the response would say so. Send core_id. card_provider_required carries details.rails — the rail ids this call will accept as provider. It only ever appears when two enabled rails could both serve the request; naming a core_id, or having cards on just one rail, resolves it without your having to choose.\n401 · Error · Missing, malformed or unknown API key.\n404 · Error · Unknown card (card_not_found), or an unknown payer user (user_not_found), for this tenant.\n409 · Error · A conflicting card state. error is one of: kyc_not_approved (details.kyc_status), card_state, card_issuing (the card is still being issued — under two minutes; details.card_id; poll GET /v1/cards/{id} until status_normalized is active, or wait for the card.status webhook, then retry), provider_rejected (the rail refused — DEFINITIVE, so retrying the identical call cannot help; details.order_id on a money call, where any charge is already reversed, and details.provider_code otherwise), email_taken (details.email — the rail already holds an account on that address; send a different one), idempotency_conflict, insufficient_balance, no_own_account (you have set a card fee of your own but your account has no balance of its own to credit it to — ask us to open one).\n503 · Error · Temporarily unable to serve this card call — your request was valid. Retry; nothing has been charged. error is one of: provider_unavailable — the rail did not answer, or answered unreadably. Transient. card_rail_unavailable — this card's own rail is not reachable (it may have been closed to new business). Other rails are unaffected, and the card itself is fine. cards_disabled — no card rail is enabled on this deployment at all. rail_unfunded — the platform cannot fund the on-chain send behind this load right now. Nothing was charged; retry later. 409 card_coins_not_on_chain — the holder's coins are not at their own address yet (a close refund or a deposit still on its way): a load pays its own way on-chain from that address and waits for them. details carry needed. Nothing was charged; try again once they have arrived.\n\n200 response body\n\nField · Type · Description\ncard required · object · —\ncard.id required · integer · —\ncard.rail required · string · Opaque, stable id of the card rail a product, holder or card sits on — and the value to pass as provider when a call needs disambiguating. It tells you WHICH rail, never who runs it: two cards on different rails are different arrangements of ours, and that is all you need to route a call correctly. Read it from the resource and echo it back; never hard-code one, and never parse it — the string is an identifier, not a description.\ncard.core_id required · string · null · The programme this card was issued from — the pair (core_id, bin) is what prices its loads and close. A programme holds no money; the cardholder pays. null on cards issued before programmes existed.\ncard.bin required · string · null · The card's BIN. Read it WITH core_id, never alone: a BIN number is unique only inside its issuer, so the same number under a different programme is a different card with different fees, limits and validity.\ncard.product_id required · string · null · Provider-scoped product CODE (kept as an alias of product_code); null when the card was listed untagged.\ncard.product_code required · string · null · Provider-scoped product code; null when the card was listed untagged.\ncard.card_nature required · string · (one of VIRTUAL, PHYSICAL)\ncard.status required · integer · The provider's RAW status code, verbatim — interpret via status_normalized.\ncard.status_normalized required · string · Provider-neutral status. A just-issued card is issuing; it becomes active once the opening balance has landed at the issuer. (one of issuing, active, frozen, suspended, closed, expired)\ncard.currency required · string · null · —\ncard.last4 required · string · null · —\ncard.name_on_card required · string · null · The cardholder name you gave at issue — our record, whether or not the BIN prints it on the plastic.\ncard.issued_at required · string<date-time> · —\n\n{\n\"card\": {\n\"id\": 1,\n\"rail\": \"rail_a\",\n\"core_id\": \"string\",\n\"bin\": \"string\",\n\"product_id\": \"string\",\n\"product_code\": \"string\",\n\"card_nature\": \"VIRTUAL\",\n\"status\": 1,\n\"status_normalized\": \"issuing\",\n\"currency\": \"string\",\n\"last4\": \"string\",\n\"name_on_card\": \"string\",\n\"issued_at\": \"2026-08-31T09:15:00.000Z\"\n}\n}\n\nExample request\n\ncurl -X POST 'https://api.cryptoinfra.build/v1/cards/1/unfreeze' \\\n-H 'Authorization: Bearer ck_live_your_key' \\\n-H 'Content-Type: application/json' \\\n-d '{\n\"user_id\": \"string\"\n}'"
        },
        {
          "anchor": "get-v1-cards-id-transactions",
          "heading": "GET /v1/cards/{id}/transactions",
          "text": "Card transaction history  ·  Auth: bearer API key  ·  operationId: listCardTransactions\n\nNormalized live passthrough from the provider (nothing is stored in v1).\n\nParameters\n\nName · In · Type · Required · Description\nid · path · integer · yes · The card's numeric id (from GET /v1/cards). (min 1)\nuser_id · query · string · yes · —\nlimit · query · integer · no · Rows per page. (min 1; max 100)\npage · query · integer · no · 1-based page number. (min 1; max 100000)\npage_size · query · integer · no · Deprecated alias of limit; limit wins when both are sent. (min 1; max 100)\npage_num · query · integer · no · Deprecated alias of page; page wins when both are sent. (min 1; max 100000)\n\nResponses\n\nStatus · Body · Meaning\n200 · object · Transactions.\n401 · Error · Missing, malformed or unknown API key.\n404 · Error · Unknown card (card_not_found), or an unknown payer user (user_not_found), for this tenant.\n503 · Error · Temporarily unable to serve this card call — your request was valid. Retry; nothing has been charged. error is one of: provider_unavailable — the rail did not answer, or answered unreadably. Transient. card_rail_unavailable — this card's own rail is not reachable (it may have been closed to new business). Other rails are unaffected, and the card itself is fine. cards_disabled — no card rail is enabled on this deployment at all. rail_unfunded — the platform cannot fund the on-chain send behind this load right now. Nothing was charged; retry later. 409 card_coins_not_on_chain — the holder's coins are not at their own address yet (a close refund or a deposit still on its way): a load pays its own way on-chain from that address and waits for them. details carry needed. Nothing was charged; try again once they have arrived.\n\n200 response body\n\nField · Type · Description\ntransactions required · object[] · —\ntransactions[].id required · string · —\ntransactions[].occurred_at · string · null · —\ntransactions[].amount · string · null · Decimal string; null when the provider omits it.\ntransactions[].amount_base · string · null · —\ntransactions[].currency · string · null · —\ntransactions[].fee · string · null · —\ntransactions[].fee_base · string · null · —\ntransactions[].merchant_name · string · null · —\ntransactions[].mcc · string · null · —\ntransactions[].type · string · null · Transaction code: 170 payment, 171 refund, 172 card fee, 173 ATM withdrawal, 174 pre-authorization, 150 top-up, 140 card issue.\ntransactions[].status · string · null · 1 processing, 2 success, 3 failed.\ntransactions[].authorization_code · string · null · —\ntransactions[].related_authorization_code · string · null · —\n\n{\n\"transactions\": [\n{\n\"id\": \"string\",\n\"occurred_at\": \"string\",\n\"amount\": \"string\",\n\"amount_base\": \"string\",\n\"currency\": \"string\",\n\"fee\": \"string\",\n\"fee_base\": \"string\",\n\"merchant_name\": \"string\",\n\"mcc\": \"string\",\n\"type\": \"string\",\n\"status\": \"string\",\n\"authorization_code\": \"string\",\n\"related_authorization_code\": \"string\"\n}\n]\n}\n\nExample request\n\ncurl -X GET 'https://api.cryptoinfra.build/v1/cards/1/transactions?user_id=string' \\\n-H 'Authorization: Bearer ck_live_your_key'"
        },
        {
          "anchor": "get-v1-cards-delivery-regions",
          "heading": "GET /v1/cards/delivery/regions",
          "text": "Physical-card delivery regions  ·  Auth: bearer API key  ·  operationId: listCardDeliveryRegions\n\nShipping regions for physical cards. Supported on one rail in v1 — returns\n400 not_supported_by_provider on the others.\n\nParameters\n\nName · In · Type · Required · Description\nlocal · query · string · no · Display locale (default en_US). _(one of zh_CN, zh_HK, en_US, ko_KR)_\n\nResponses\n\nStatus · Body · Meaning\n200 · object · Regions.\n400 · Error · Request validation failed or a domain rule was violated. error is one of: validation_error, invalid_request, invalid_amount, card_core_required, card_provider_required, unknown_provider, not_supported_by_provider. card_core_required carries details.cores — the card programmes this call will accept as core_id. It appears when you hold more than one programme that could serve the request. We refuse rather than pick, because picking would spend a balance that did not pay for the card and nothing in the response would say so. Send core_id. card_provider_required carries details.rails — the rail ids this call will accept as provider. It only ever appears when two enabled rails could both serve the request; naming a core_id, or having cards on just one rail, resolves it without your having to choose.\n401 · Error · Missing, malformed or unknown API key.\n503 · Error · Temporarily unable to serve this card call — your request was valid. Retry; nothing has been charged. error is one of: provider_unavailable — the rail did not answer, or answered unreadably. Transient. card_rail_unavailable — this card's own rail is not reachable (it may have been closed to new business). Other rails are unaffected, and the card itself is fine. cards_disabled — no card rail is enabled on this deployment at all. rail_unfunded — the platform cannot fund the on-chain send behind this load right now. Nothing was charged; retry later. 409 card_coins_not_on_chain — the holder's coins are not at their own address yet (a close refund or a deposit still on its way): a load pays its own way on-chain from that address and waits for them. details carry needed. Nothing was charged; try again once they have arrived.\n\n200 response body\n\nField · Type · Description\nregions required · object[] · —\nregions[].id · string · Region id; pass its numeric value as country_region_id to POST /v1/cards/delivery/address.\nregions[].country_region · string · —\nregions[].country_region_zh · string · —\nregions[].national_code · string · Dialing code.\nregions[].price · string · null · Postage as a decimal string; currency unspecified. null = unknown (NOT free).\n\n{\n\"regions\": [\n{\n\"id\": \"string\",\n\"country_region\": \"string\",\n\"country_region_zh\": \"string\",\n\"national_code\": \"string\",\n\"price\": \"string\"\n}\n]\n}\n\nExample request\n\ncurl -X GET 'https://api.cryptoinfra.build/v1/cards/delivery/regions' \\\n-H 'Authorization: Bearer ck_live_your_key'"
        },
        {
          "anchor": "post-v1-cards-delivery-address",
          "heading": "POST /v1/cards/delivery/address",
          "text": "Add a physical-card delivery address  ·  Auth: bearer API key  ·  operationId: addCardDeliveryAddress\n\nCreates a mailing address at the provider AND records its ownership\nlocally. The returned delivery_address_id is passed as\ndelivery_address_id when issuing a PHYSICAL card (issuance only accepts\nan id recorded for the same holder).\n\nRequest body\n\napplication/json · required\n\nField · Type · Description\nuser_id required · string · (minLength 1; maxLength 255)\ncountry_region_id required · integer · Region id from GET /v1/cards/delivery/regions. (min 1)\ncountry required · string · (minLength 1; maxLength 128)\ncity required · string · (minLength 1; maxLength 128)\nreceiver_name required · string · (minLength 1; maxLength 128)\nreceiver_mobile required · string · (minLength 1; maxLength 32)\nreceiver_address required · string · (minLength 1; maxLength 512)\npost_code required · string · (minLength 1; maxLength 32)\n\nResponses\n\nStatus · Body · Meaning\n201 · object · Address created.\n400 · Error · Request validation failed or a domain rule was violated. error is one of: validation_error, invalid_request, invalid_amount, card_core_required, card_provider_required, unknown_provider, not_supported_by_provider. card_core_required carries details.cores — the card programmes this call will accept as core_id. It appears when you hold more than one programme that could serve the request. We refuse rather than pick, because picking would spend a balance that did not pay for the card and nothing in the response would say so. Send core_id. card_provider_required carries details.rails — the rail ids this call will accept as provider. It only ever appears when two enabled rails could both serve the request; naming a core_id, or having cards on just one rail, resolves it without your having to choose.\n401 · Error · Missing, malformed or unknown API key.\n404 · Error · No matching card holder (holder_not_found), or an unknown user/payer (user_not_found), for this tenant.\n503 · Error · Temporarily unable to serve this card call — your request was valid. Retry; nothing has been charged. error is one of: provider_unavailable — the rail did not answer, or answered unreadably. Transient. card_rail_unavailable — this card's own rail is not reachable (it may have been closed to new business). Other rails are unaffected, and the card itself is fine. cards_disabled — no card rail is enabled on this deployment at all. rail_unfunded — the platform cannot fund the on-chain send behind this load right now. Nothing was charged; retry later. 409 card_coins_not_on_chain — the holder's coins are not at their own address yet (a close refund or a deposit still on its way): a load pays its own way on-chain from that address and waits for them. details carry needed. Nothing was charged; try again once they have arrived.\n\n201 response body\n\nField · Type · Description\ndelivery_address_id required · integer · —\n\n{\n\"delivery_address_id\": 1\n}\n\nExample request\n\ncurl -X POST 'https://api.cryptoinfra.build/v1/cards/delivery/address' \\\n-H 'Authorization: Bearer ck_live_your_key' \\\n-H 'Content-Type: application/json' \\\n-d '{\n\"user_id\": \"string\",\n\"country_region_id\": 1,\n\"country\": \"string\",\n\"city\": \"string\",\n\"receiver_name\": \"string\",\n\"receiver_mobile\": \"string\",\n\"receiver_address\": \"string\",\n\"post_code\": \"string\"\n}'"
        }
      ]
    },
    {
      "slug": "developer",
      "title": "Developer",
      "summary": "Self-service integration plumbing — your webhook endpoints and their secrets, a test delivery, a log of recent API requests, and a live event stream. The API key itself is managed from the dashboard (Settings → API keys), not through the AP",
      "kind": "reference",
      "markdown": "# Developer\n\nSelf-service integration plumbing — your webhook endpoints and their secrets, a test delivery, a log of recent API requests, and a live event stream. The API key itself is managed from the dashboard (Settings → API keys), not through the API.\n\n8 endpoints. Generated from `docs/openapi.yaml` v1.0.0.\n\n| Endpoint | Summary |\n| --- | --- |\n| [`GET /v1/developer/webhooks`](#get-v1-developer-webhooks) | List your webhook endpoints |\n| [`POST /v1/developer/webhooks`](#post-v1-developer-webhooks) | Add a webhook endpoint |\n| [`POST /v1/developer/webhooks/{id}`](#post-v1-developer-webhooks-id) | Enable or disable an endpoint |\n| [`DELETE /v1/developer/webhooks/{id}`](#delete-v1-developer-webhooks-id) | Delete an endpoint |\n| [`POST /v1/developer/webhooks/{id}/regenerate`](#post-v1-developer-webhooks-id-regenerate) | Regenerate an endpoint's signing secret |\n| [`POST /v1/developer/webhooks/{id}/test`](#post-v1-developer-webhooks-id-test) | Send a signed test event |\n| [`GET /v1/developer/logs`](#get-v1-developer-logs) | Your recent API requests |\n| [`GET /v1/events`](#get-v1-events) | Live event stream (Server-Sent Events) |\n\n## GET /v1/developer/webhooks\n\n**List your webhook endpoints**  ·  _Auth: bearer API key_  ·  `operationId: listWebhookEndpoints`\n\n#### Responses\n\n| Status | Body | Meaning |\n| --- | --- | --- |\n| `200` | `object` | Endpoints (secret shown as an 8-char hint only). |\n| `401` | `Error` | Missing, malformed or unknown API key. |\n\n**`200` response body**\n\n| Field | Type | Description |\n| --- | --- | --- |\n| `endpoints` | `object[]` | — |\n| `endpoints[].id` | `integer` | — |\n| `endpoints[].url` | `string` | — |\n| `endpoints[].enabled` | `boolean` | — |\n| `endpoints[].created_at` | `string<date-time>` | — |\n| `endpoints[].secret_hint` | `string` | First 8 characters of the signing secret. |\n| `endpoints[].secret` | `string` | Full signing secret — present ONLY on create/regenerate. |\n\n```json\n{\n  \"endpoints\": [\n    {\n      \"id\": 1,\n      \"url\": \"string\",\n      \"enabled\": true,\n      \"created_at\": \"2026-08-31T09:15:00.000Z\",\n      \"secret_hint\": \"string\",\n      \"secret\": \"string\"\n    }\n  ]\n}\n```\n\n#### Example request\n\n```bash\ncurl -X GET 'https://api.cryptoinfra.build/v1/developer/webhooks' \\\n  -H 'Authorization: Bearer ck_live_your_key'\n```\n\n## POST /v1/developer/webhooks\n\n**Add a webhook endpoint**  ·  _Auth: bearer API key_  ·  `operationId: createWebhookEndpoint`\n\nEvery event (deposits, withdrawals, card activity, payments) is delivered to each enabled\nendpoint, signed HMAC-SHA256 with the endpoint's secret — **returned once in this response**.\nhttps URLs with public hostnames only; at most 5 endpoints.\n\n#### Request body\n\n`application/json` · required\n\n| Field | Type | Description |\n| --- | --- | --- |\n| `url` **required** | `string<uri>` |  _(maxLength `512`)_ |\n\n#### Responses\n\n| Status | Body | Meaning |\n| --- | --- | --- |\n| `201` | `object` | Created; `endpoint.secret` is present ONLY here. |\n| `400` | — | Invalid or unsafe URL. |\n| `401` | `Error` | Missing, malformed or unknown API key. |\n| `409` | — | Endpoint limit reached. |\n\n**`201` response body**\n\n| Field | Type | Description |\n| --- | --- | --- |\n| `endpoint` | `object` | — |\n| `endpoint.id` | `integer` | — |\n| `endpoint.url` | `string` | — |\n| `endpoint.enabled` | `boolean` | — |\n| `endpoint.created_at` | `string<date-time>` | — |\n| `endpoint.secret_hint` | `string` | First 8 characters of the signing secret. |\n| `endpoint.secret` | `string` | Full signing secret — present ONLY on create/regenerate. |\n\n```json\n{\n  \"endpoint\": {\n    \"id\": 1,\n    \"url\": \"string\",\n    \"enabled\": true,\n    \"created_at\": \"2026-08-31T09:15:00.000Z\",\n    \"secret_hint\": \"string\",\n    \"secret\": \"string\"\n  }\n}\n```\n\n#### Example request\n\n```bash\ncurl -X POST 'https://api.cryptoinfra.build/v1/developer/webhooks' \\\n  -H 'Authorization: Bearer ck_live_your_key' \\\n  -H 'Content-Type: application/json' \\\n  -d '{\n  \"url\": \"https://example.com\"\n}'\n```\n\n## POST /v1/developer/webhooks/{id}\n\n**Enable or disable an endpoint**  ·  _Auth: bearer API key_  ·  `operationId: toggleWebhookEndpoint`\n\n#### Parameters\n\n| Name | In | Type | Required | Description |\n| --- | --- | --- | --- | --- |\n| `id` | path | `integer` | yes | — |\n\n#### Request body\n\n`application/json` · required\n\n| Field | Type | Description |\n| --- | --- | --- |\n| `enabled` **required** | `boolean` | — |\n\n#### Responses\n\n| Status | Body | Meaning |\n| --- | --- | --- |\n| `200` | — | Updated. |\n| `404` | — | No such endpoint. |\n\n#### Example request\n\n```bash\ncurl -X POST 'https://api.cryptoinfra.build/v1/developer/webhooks/1' \\\n  -H 'Authorization: Bearer ck_live_your_key' \\\n  -H 'Content-Type: application/json' \\\n  -d '{\n  \"enabled\": true\n}'\n```\n\n## DELETE /v1/developer/webhooks/{id}\n\n**Delete an endpoint**  ·  _Auth: bearer API key_  ·  `operationId: deleteWebhookEndpoint`\n\n#### Parameters\n\n| Name | In | Type | Required | Description |\n| --- | --- | --- | --- | --- |\n| `id` | path | `integer` | yes | — |\n\n#### Responses\n\n| Status | Body | Meaning |\n| --- | --- | --- |\n| `200` | — | Deleted (delivery history is kept |\n| `404` | — | No such endpoint. |\n\n#### Example request\n\n```bash\ncurl -X DELETE 'https://api.cryptoinfra.build/v1/developer/webhooks/1' \\\n  -H 'Authorization: Bearer ck_live_your_key'\n```\n\n## POST /v1/developer/webhooks/{id}/regenerate\n\n**Regenerate an endpoint's signing secret**  ·  _Auth: bearer API key_  ·  `operationId: regenerateWebhookSecret`\n\nThe new secret is returned once; the old one stops verifying immediately.\n\n#### Parameters\n\n| Name | In | Type | Required | Description |\n| --- | --- | --- | --- | --- |\n| `id` | path | `integer` | yes | — |\n\n#### Responses\n\n| Status | Body | Meaning |\n| --- | --- | --- |\n| `200` | — | New secret in `endpoint.secret` (only here). |\n| `404` | — | No such endpoint. |\n\n#### Example request\n\n```bash\ncurl -X POST 'https://api.cryptoinfra.build/v1/developer/webhooks/1/regenerate' \\\n  -H 'Authorization: Bearer ck_live_your_key'\n```\n\n## POST /v1/developer/webhooks/{id}/test\n\n**Send a signed test event**  ·  _Auth: bearer API key_  ·  `operationId: testWebhookEndpoint`\n\n#### Parameters\n\n| Name | In | Type | Required | Description |\n| --- | --- | --- | --- | --- |\n| `id` | path | `integer` | yes | — |\n\n#### Request body\n\n`application/json` · optional\n\n| Field | Type | Description |\n| --- | --- | --- |\n| `event_type` | `string` | `payment.received` is deprecated — payment links are retired and the event is no longer emitted; it is still accepted here so an older client's test-fire does not fail. _(one of `webhook.test`, `deposit.credited`, `withdrawal.confirmed`, `withdrawal.failed`, `card.kyc`, `card.status`, `card.topup`, `card.withdraw`, `card.transaction`, `card.wallet_otp`, `payment.received`)_ |\n\n#### Responses\n\n| Status | Body | Meaning |\n| --- | --- | --- |\n| `202` | — | Queued through the real dispatcher. |\n| `404` | — | No such enabled endpoint. |\n\n#### Example request\n\n```bash\ncurl -X POST 'https://api.cryptoinfra.build/v1/developer/webhooks/1/test' \\\n  -H 'Authorization: Bearer ck_live_your_key' \\\n  -H 'Content-Type: application/json' \\\n  -d '{}'\n```\n\n## GET /v1/developer/logs\n\n**Your recent API requests**  ·  _Auth: bearer API key_  ·  `operationId: listApiLogs`\n\nMethod, route, status and duration of your last calls (kept 14 days; no bodies, no query strings).\n\n#### Parameters\n\n| Name | In | Type | Required | Description |\n| --- | --- | --- | --- | --- |\n| `limit` | query | `integer` | no | — _(min `1`; max `200`)_ |\n\n#### Responses\n\n| Status | Body | Meaning |\n| --- | --- | --- |\n| `200` | `object` | The request log. |\n| `401` | `Error` | Missing, malformed or unknown API key. |\n\n**`200` response body**\n\n| Field | Type | Description |\n| --- | --- | --- |\n| `requests` | `object[]` | — |\n| `requests[].id` | `integer` | — |\n| `requests[].method` | `string` | — |\n| `requests[].path` | `string` | — |\n| `requests[].status` | `integer` | — |\n| `requests[].duration_ms` | `integer` | — |\n| `requests[].created_at` | `string<date-time>` | — |\n\n```json\n{\n  \"requests\": [\n    {\n      \"id\": 1,\n      \"method\": \"string\",\n      \"path\": \"string\",\n      \"status\": 1,\n      \"duration_ms\": 1,\n      \"created_at\": \"2026-08-31T09:15:00.000Z\"\n    }\n  ]\n}\n```\n\n#### Example request\n\n```bash\ncurl -X GET 'https://api.cryptoinfra.build/v1/developer/logs' \\\n  -H 'Authorization: Bearer ck_live_your_key'\n```\n\n## GET /v1/events\n\n**Live event stream (Server-Sent Events)**  ·  _Auth: bearer API key_  ·  `operationId: eventStream`\n\nA `text/event-stream` of your account's activity, for a dashboard that\nupdates without refreshing. Same key as every other route. Each message\nis one JSON object `{ type, ts, data }`; `type` is one of\n`deposit.credited` (data: depositId, userExternalId, chain, assetId,\nsymbol, amount, txid), `withdrawal.updated` (withdrawalId, status, chain,\nassetId, symbol, amount, txid) or `card.order.updated` (orderId, kind,\nstatus, providerStatus, userExternalId). A comment line is sent every\n25 seconds as a heartbeat.\n\nBest-effort and not a webhook: a dropped connection loses what happened\nwhile it was down, and nothing is replayed. It is a hint to go and read\nthe record; the poll endpoints stay the truth.\n\n#### Responses\n\n| Status | Body | Meaning |\n| --- | --- | --- |\n| `200` | — | The stream. Stays open for the life of the connection. |\n| `401` | `Error` | Missing, malformed or unknown API key. |\n| `503` | `Error` | `live_events_unavailable` — no event hub on this deployment. |\n\n#### Example request\n\n```bash\ncurl -X GET 'https://api.cryptoinfra.build/v1/events' \\\n  -H 'Authorization: Bearer ck_live_your_key'\n```\n",
      "sections": [
        {
          "anchor": "get-v1-developer-webhooks",
          "heading": "GET /v1/developer/webhooks",
          "text": "List your webhook endpoints  ·  Auth: bearer API key  ·  operationId: listWebhookEndpoints\n\nResponses\n\nStatus · Body · Meaning\n200 · object · Endpoints (secret shown as an 8-char hint only).\n401 · Error · Missing, malformed or unknown API key.\n\n200 response body\n\nField · Type · Description\nendpoints · object[] · —\nendpoints[].id · integer · —\nendpoints[].url · string · —\nendpoints[].enabled · boolean · —\nendpoints[].created_at · string<date-time> · —\nendpoints[].secret_hint · string · First 8 characters of the signing secret.\nendpoints[].secret · string · Full signing secret — present ONLY on create/regenerate.\n\n{\n\"endpoints\": [\n{\n\"id\": 1,\n\"url\": \"string\",\n\"enabled\": true,\n\"created_at\": \"2026-08-31T09:15:00.000Z\",\n\"secret_hint\": \"string\",\n\"secret\": \"string\"\n}\n]\n}\n\nExample request\n\ncurl -X GET 'https://api.cryptoinfra.build/v1/developer/webhooks' \\\n-H 'Authorization: Bearer ck_live_your_key'"
        },
        {
          "anchor": "post-v1-developer-webhooks",
          "heading": "POST /v1/developer/webhooks",
          "text": "Add a webhook endpoint  ·  Auth: bearer API key  ·  operationId: createWebhookEndpoint\n\nEvery event (deposits, withdrawals, card activity, payments) is delivered to each enabled\nendpoint, signed HMAC-SHA256 with the endpoint's secret — returned once in this response.\nhttps URLs with public hostnames only; at most 5 endpoints.\n\nRequest body\n\napplication/json · required\n\nField · Type · Description\nurl required · string<uri> · (maxLength 512)\n\nResponses\n\nStatus · Body · Meaning\n201 · object · Created; endpoint.secret is present ONLY here.\n400 · — · Invalid or unsafe URL.\n401 · Error · Missing, malformed or unknown API key.\n409 · — · Endpoint limit reached.\n\n201 response body\n\nField · Type · Description\nendpoint · object · —\nendpoint.id · integer · —\nendpoint.url · string · —\nendpoint.enabled · boolean · —\nendpoint.created_at · string<date-time> · —\nendpoint.secret_hint · string · First 8 characters of the signing secret.\nendpoint.secret · string · Full signing secret — present ONLY on create/regenerate.\n\n{\n\"endpoint\": {\n\"id\": 1,\n\"url\": \"string\",\n\"enabled\": true,\n\"created_at\": \"2026-08-31T09:15:00.000Z\",\n\"secret_hint\": \"string\",\n\"secret\": \"string\"\n}\n}\n\nExample request\n\ncurl -X POST 'https://api.cryptoinfra.build/v1/developer/webhooks' \\\n-H 'Authorization: Bearer ck_live_your_key' \\\n-H 'Content-Type: application/json' \\\n-d '{\n\"url\": \"https://example.com\"\n}'"
        },
        {
          "anchor": "post-v1-developer-webhooks-id",
          "heading": "POST /v1/developer/webhooks/{id}",
          "text": "Enable or disable an endpoint  ·  Auth: bearer API key  ·  operationId: toggleWebhookEndpoint\n\nParameters\n\nName · In · Type · Required · Description\nid · path · integer · yes · —\n\nRequest body\n\napplication/json · required\n\nField · Type · Description\nenabled required · boolean · —\n\nResponses\n\nStatus · Body · Meaning\n200 · — · Updated.\n404 · — · No such endpoint.\n\nExample request\n\ncurl -X POST 'https://api.cryptoinfra.build/v1/developer/webhooks/1' \\\n-H 'Authorization: Bearer ck_live_your_key' \\\n-H 'Content-Type: application/json' \\\n-d '{\n\"enabled\": true\n}'"
        },
        {
          "anchor": "delete-v1-developer-webhooks-id",
          "heading": "DELETE /v1/developer/webhooks/{id}",
          "text": "Delete an endpoint  ·  Auth: bearer API key  ·  operationId: deleteWebhookEndpoint\n\nParameters\n\nName · In · Type · Required · Description\nid · path · integer · yes · —\n\nResponses\n\nStatus · Body · Meaning\n200 · — · Deleted (delivery history is kept\n404 · — · No such endpoint.\n\nExample request\n\ncurl -X DELETE 'https://api.cryptoinfra.build/v1/developer/webhooks/1' \\\n-H 'Authorization: Bearer ck_live_your_key'"
        },
        {
          "anchor": "post-v1-developer-webhooks-id-regenerate",
          "heading": "POST /v1/developer/webhooks/{id}/regenerate",
          "text": "Regenerate an endpoint's signing secret  ·  Auth: bearer API key  ·  operationId: regenerateWebhookSecret\n\nThe new secret is returned once; the old one stops verifying immediately.\n\nParameters\n\nName · In · Type · Required · Description\nid · path · integer · yes · —\n\nResponses\n\nStatus · Body · Meaning\n200 · — · New secret in endpoint.secret (only here).\n404 · — · No such endpoint.\n\nExample request\n\ncurl -X POST 'https://api.cryptoinfra.build/v1/developer/webhooks/1/regenerate' \\\n-H 'Authorization: Bearer ck_live_your_key'"
        },
        {
          "anchor": "post-v1-developer-webhooks-id-test",
          "heading": "POST /v1/developer/webhooks/{id}/test",
          "text": "Send a signed test event  ·  Auth: bearer API key  ·  operationId: testWebhookEndpoint\n\nParameters\n\nName · In · Type · Required · Description\nid · path · integer · yes · —\n\nRequest body\n\napplication/json · optional\n\nField · Type · Description\nevent_type · string · payment.received is deprecated — payment links are retired and the event is no longer emitted; it is still accepted here so an older client's test-fire does not fail. _(one of webhook.test, deposit.credited, withdrawal.confirmed, withdrawal.failed, card.kyc, card.status, card.topup, card.withdraw, card.transaction, card.wallet_otp, payment.received)_\n\nResponses\n\nStatus · Body · Meaning\n202 · — · Queued through the real dispatcher.\n404 · — · No such enabled endpoint.\n\nExample request\n\ncurl -X POST 'https://api.cryptoinfra.build/v1/developer/webhooks/1/test' \\\n-H 'Authorization: Bearer ck_live_your_key' \\\n-H 'Content-Type: application/json' \\\n-d '{}'"
        },
        {
          "anchor": "get-v1-developer-logs",
          "heading": "GET /v1/developer/logs",
          "text": "Your recent API requests  ·  Auth: bearer API key  ·  operationId: listApiLogs\n\nMethod, route, status and duration of your last calls (kept 14 days; no bodies, no query strings).\n\nParameters\n\nName · In · Type · Required · Description\nlimit · query · integer · no · — (min 1; max 200)\n\nResponses\n\nStatus · Body · Meaning\n200 · object · The request log.\n401 · Error · Missing, malformed or unknown API key.\n\n200 response body\n\nField · Type · Description\nrequests · object[] · —\nrequests[].id · integer · —\nrequests[].method · string · —\nrequests[].path · string · —\nrequests[].status · integer · —\nrequests[].duration_ms · integer · —\nrequests[].created_at · string<date-time> · —\n\n{\n\"requests\": [\n{\n\"id\": 1,\n\"method\": \"string\",\n\"path\": \"string\",\n\"status\": 1,\n\"duration_ms\": 1,\n\"created_at\": \"2026-08-31T09:15:00.000Z\"\n}\n]\n}\n\nExample request\n\ncurl -X GET 'https://api.cryptoinfra.build/v1/developer/logs' \\\n-H 'Authorization: Bearer ck_live_your_key'"
        },
        {
          "anchor": "get-v1-events",
          "heading": "GET /v1/events",
          "text": "Live event stream (Server-Sent Events)  ·  Auth: bearer API key  ·  operationId: eventStream\n\nA text/event-stream of your account's activity, for a dashboard that\nupdates without refreshing. Same key as every other route. Each message\nis one JSON object { type, ts, data }; type is one of\ndeposit.credited (data: depositId, userExternalId, chain, assetId,\nsymbol, amount, txid), withdrawal.updated (withdrawalId, status, chain,\nassetId, symbol, amount, txid) or card.order.updated (orderId, kind,\nstatus, providerStatus, userExternalId). A comment line is sent every\n25 seconds as a heartbeat.\n\nBest-effort and not a webhook: a dropped connection loses what happened\nwhile it was down, and nothing is replayed. It is a hint to go and read\nthe record; the poll endpoints stay the truth.\n\nResponses\n\nStatus · Body · Meaning\n200 · — · The stream. Stays open for the life of the connection.\n401 · Error · Missing, malformed or unknown API key.\n503 · Error · live_events_unavailable — no event hub on this deployment.\n\nExample request\n\ncurl -X GET 'https://api.cryptoinfra.build/v1/events' \\\n-H 'Authorization: Bearer ck_live_your_key'"
        }
      ]
    },
    {
      "slug": "reference",
      "title": "Reference",
      "summary": "Asset catalogue, fee schedule, live prices",
      "kind": "reference",
      "markdown": "# Reference\n\nAsset catalogue, fee schedule, live prices\n\n7 endpoints. Generated from `docs/openapi.yaml` v1.0.0.\n\n| Endpoint | Summary |\n| --- | --- |\n| [`GET /v1/assets`](#get-v1-assets) | List enabled assets |\n| [`GET /v1/a/{token}`](#get-v1-a-token) | PUBLIC — a merchant's affiliate ledger |\n| [`GET /v1/merchant/affiliate-ledger`](#get-v1-merchant-affiliate-ledger) | The link to your public affiliate ledger |\n| [`POST /v1/merchant/affiliate-ledger/rotate`](#post-v1-merchant-affiliate-ledger-rotate) | Replace the link (dashboard owner, both codes) |\n| [`GET /v1/pricing`](#get-v1-pricing) | PUBLIC — the live price sheet |\n| [`GET /v1/fees`](#get-v1-fees) | Your effective fee schedule |\n| [`GET /v1/prices`](#get-v1-prices) | Live market prices |\n\n## GET /v1/assets\n\n**List enabled assets**  ·  _Auth: bearer API key_  ·  `operationId: listAssets`\n\nThe asset catalogue with decimals, confirmation requirements, the flat withdrawal fee, and `swappable` — false when no exchange can route the asset (leave it out of a swap picker; deposits and withdrawals are unaffected).\n\n#### Responses\n\n| Status | Body | Meaning |\n| --- | --- | --- |\n| `200` | `object[]` | Enabled assets. |\n| `401` | `Error` | Missing, malformed or unknown API key. |\n\n**`200` response body**\n\n| Field | Type | Description |\n| --- | --- | --- |\n| `[].id` **required** | `string` | — |\n| `[].chain` **required** | `string` | The chain an address or asset is on. TRON is the only network the platform serves: `btc`, `eth`, `bsc` and `sol` were retired — historical addresses on them are never handed out again and nothing scans them. Read the value as an open string rather than a closed list, so a chain added later does not break your client. _(one of `tron`)_ |\n| `[].symbol` **required** | `string` | — |\n| `[].contract_address` **required** | `string | null` | null for native coins; the token contract/mint otherwise. |\n| `[].decimals` **required** | `integer` | — |\n| `[].confirmations_required` **required** | `integer` | — |\n| `[].min_deposit` **required** | `string` | A monetary value as a decimal string. Always paired with a `*_base` field that carries the exact integer base-unit value; compute with the `_base` string. |\n| `[].withdraw_fee` **required** | `string` | The STATIC fallback fee for a withdrawal of this asset — what is charged as `fee` only when no live network price can be read (`dynamic: false` on the quote). The live figure, network gas included, is on GET /v1/withdrawal-fee-quote and GET /v1/pricing. The energy a transfer consumes is rented by us, so you never hold TRX and never top up a gas balance. It does not vary with the recipient. A TRC20 transfer to an address that has never held the token costs roughly twice the energy of one to an existing holder, and that difference is absorbed here rather than passed on — you cannot see which case a customer's wallet is, so you cannot price it. Read it at runtime; it is the number that will be charged. |\n| `[].min_withdrawal` **required** | `string` | A monetary value as a decimal string. Always paired with a `*_base` field that carries the exact integer base-unit value; compute with the `_base` string. |\n| `[].swappable` **required** | `boolean` | False when no exchange can route this asset. Leave it out of a swap picker — POST /v1/swap/quote and POST /v1/exchange/quote refuse it with 400 invalid_pair. Deposits and withdrawals are unaffected. |\n\n```json\n[\n  {\n    \"id\": \"usdt_trc20\",\n    \"chain\": \"tron\",\n    \"symbol\": \"string\",\n    \"contract_address\": \"string\",\n    \"decimals\": 1,\n    \"confirmations_required\": 1,\n    \"min_deposit\": \"1.5\",\n    \"withdraw_fee\": \"1.5\",\n    \"min_withdrawal\": \"1.5\",\n    \"swappable\": true\n  }\n]\n```\n\n#### Example request\n\n```bash\ncurl -X GET 'https://api.cryptoinfra.build/v1/assets' \\\n  -H 'Authorization: Bearer ck_live_your_key'\n```\n\n## GET /v1/a/{token}\n\n**PUBLIC — a merchant's affiliate ledger**  ·  _Auth: none — public_  ·  `operationId: getAffiliateLedger`\n\nNo authentication: the token (256 bits, minted for the merchant on `GET /v1/merchant/affiliate-ledger`) is the whole authorization, and it reads that ONE merchant's affiliate commission — their share of our fee, total per asset and one line per operation that earned some — and nothing else: no customer ids, no addresses. This is what the hosted page `/a/{token}` on the merchant origin renders. A rotated token answers `404` from that moment; responses are `no-store`.\n\n#### Parameters\n\n| Name | In | Type | Required | Description |\n| --- | --- | --- | --- | --- |\n| `token` | path | `string` | yes | — |\n| `limit` | query | `integer` | no | — _(default `50`; min `1`; max `200`)_ |\n| `offset` | query | `integer` | no | — _(default `0`; min `0`)_ |\n\n#### Responses\n\n| Status | Body | Meaning |\n| --- | --- | --- |\n| `200` | `object` | The ledger. |\n| `404` | — | No merchant holds this token (or it was rotated). |\n\n**`200` response body**\n\n| Field | Type | Description |\n| --- | --- | --- |\n| `merchant` | `object` | — |\n| `merchant.name` | `string` | — |\n| `merchant.merchant_id` | `string | null` | — |\n| `share_bps` | `integer` | — |\n| `share_percent` | `number` | — |\n| `totals` | `object[]` | — |\n| `totals[].asset_id` | `string` | — |\n| `totals[].symbol` | `string` | — |\n| `totals[].amount` | `string` | A monetary value as a decimal string. Always paired with a `*_base` field that carries the exact integer base-unit value; compute with the `_base` string. |\n| `totals[].amount_base` | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |\n| `totals[].count` | `integer` | — |\n| `lines` | `object[]` | — |\n| `lines[].id` | `integer` | — |\n| `lines[].at` | `string<date-time>` | — |\n| `lines[].operation` | `string` |  _(one of `deposit`, `withdrawal`, `swap`, `card_issue`, `card_load`)_ |\n| `lines[].ref` | `object` | What earned it |\n| `lines[].ref.kind` | `string` | — |\n| `lines[].ref.id` | `string` | — |\n| `lines[].our_fee` | `object` | Our fee the share was taken of. |\n| `lines[].share_bps` | `integer` | — |\n| `lines[].commission` | `object` | What the merchant received. |\n| `total_lines` | `integer` | — |\n| `limit` | `integer` | — |\n| `offset` | `integer` | — |\n| `generated_at` | `string<date-time>` | — |\n\n```json\n{\n  \"merchant\": {\n    \"name\": \"string\",\n    \"merchant_id\": \"string\"\n  },\n  \"share_bps\": 1,\n  \"share_percent\": 0,\n  \"totals\": [\n    {\n      \"asset_id\": \"string\",\n      \"symbol\": \"string\",\n      \"amount\": \"1.5\",\n      \"amount_base\": \"1500000000\",\n      \"count\": 1\n    }\n  ],\n  \"lines\": [\n    {\n      \"id\": 1,\n      \"at\": \"2026-08-31T09:15:00.000Z\",\n      \"operation\": \"deposit\",\n      \"ref\": {\n        \"kind\": \"string\",\n        \"id\": \"string\"\n      },\n      \"our_fee\": {},\n      \"share_bps\": 1,\n      \"commission\": {}\n    }\n  ],\n  \"total_lines\": 1,\n  \"limit\": 1,\n  \"offset\": 1,\n  \"generated_at\": \"2026-08-31T09:15:00.000Z\"\n}\n```\n\n#### Example request\n\n```bash\ncurl -X GET 'https://api.cryptoinfra.build/v1/a/string'\n```\n\n## GET /v1/merchant/affiliate-ledger\n\n**The link to your public affiliate ledger**  ·  _Auth: bearer API key_  ·  `operationId: merchantAffiliateLedgerGet`\n\nYour shareable page — your commission, total and line by line, readable without a login by anyone holding the link. Minted on first read. Works with an API key or a dashboard login.\n\n#### Responses\n\n| Status | Body | Meaning |\n| --- | --- | --- |\n| `200` | `object` | The link. |\n| `401` | `Error` | Missing, malformed or unknown API key. |\n\n**`200` response body**\n\n| Field | Type | Description |\n| --- | --- | --- |\n| `url` **required** | `string` | — |\n\n```json\n{\n  \"url\": \"https://merchant.cryptoinfra.build/a/…\"\n}\n```\n\n#### Example request\n\n```bash\ncurl -X GET 'https://api.cryptoinfra.build/v1/merchant/affiliate-ledger' \\\n  -H 'Authorization: Bearer ck_live_your_key'\n```\n\n## POST /v1/merchant/affiliate-ledger/rotate\n\n**Replace the link (dashboard owner, both codes)**  ·  _Auth: bearer API key_  ·  `operationId: merchantAffiliateLedgerRotate`\n\nMints a fresh token; the old link answers `404` from that moment. Dashboard only — an owner login with the authenticator and the emailed code — and `403 session_required` to an API key.\n\n#### Responses\n\n| Status | Body | Meaning |\n| --- | --- | --- |\n| `200` | `object` | The new link. |\n| `403` | — | Not a dashboard owner |\n\n**`200` response body**\n\n| Field | Type | Description |\n| --- | --- | --- |\n| `url` **required** | `string` | — |\n\n```json\n{\n  \"url\": \"string\"\n}\n```\n\n#### Example request\n\n```bash\ncurl -X POST 'https://api.cryptoinfra.build/v1/merchant/affiliate-ledger/rotate' \\\n  -H 'Authorization: Bearer ck_live_your_key'\n```\n\n## GET /v1/pricing\n\n**PUBLIC — the live price sheet**  ·  _Auth: none — public_  ·  `operationId: getPricing`\n\nNo authentication. Every price the platform charges, read live: the flat platform fee and the network figures per asset for deposits (free), payouts and swaps, plus every card product's issue, load and close terms. Built once a minute and shared, so it is cheap to poll. It is what the documentation renders, so the docs can never show a number this endpoint does not. A merchant's own fees are not here — they are on `GET /v1/fees` with that merchant's key. The exact charge for one send is `GET /v1/withdrawal-fee-quote`. `as_of` is when the sheet was priced; every network figure carries `dynamic` (live now, or the `static_fallback`) so nothing here is mirrored as a fixed charge.\n\n#### Responses\n\n| Status | Body | Meaning |\n| --- | --- | --- |\n| `200` | `object` | The price sheet. |\n| `503` | — | The sheet cannot be built right now. |\n\n**`200` response body**\n\n| Field | Type | Description |\n| --- | --- | --- |\n| `as_of` | `string<date-time>` | — |\n| `cached_for_seconds` | `integer` | — |\n| `rule` | `string` | — |\n| `assets` | `object[]` | — |\n| `assets[].asset_id` | `string` | — |\n| `assets[].symbol` | `string` | — |\n| `assets[].chain` | `string` | — |\n| `assets[].min_deposit` | `string` | — |\n| `assets[].min_withdrawal` | `string` | — |\n| `assets[].deposit` | `object` | platform_flat |\n| `assets[].withdrawal` | `object` | platform_flat, network_fee, network_fee_fresh_recipient, first_send_activation_fee, total, total_fresh_recipient, dynamic, static_fallback, priced_from, and network_fee_burning_trx / network_fee_burning_trx_fresh_recipient — what the same transfer burns in a wallet without energy, at the chain's own rates, for a \"you save\" comparison |\n| `assets[].swap` | `object` | platform_flat |\n| `cards` | `object` | gas (per load, priced live), gas_dynamic, gas_burning_trx (what that send burns in a wallet without energy), and products[] with issue_fee, min_opening_balance, load_fee_bps, load_fee_min, load_fee_flat, close_fee, issue_total_at_minimum, cardholder_fees, and limits (per_transaction, daily, monthly, atm_single, atm_per_day, atm_monthly in the card's currency; validity_years) — null on both for a BIN whose terms are not published yet. |\n\n```json\n{\n  \"as_of\": \"2026-08-31T09:15:00.000Z\",\n  \"cached_for_seconds\": 1,\n  \"rule\": \"string\",\n  \"assets\": [\n    {\n      \"asset_id\": \"string\",\n      \"symbol\": \"string\",\n      \"chain\": \"string\",\n      \"min_deposit\": \"string\",\n      \"min_withdrawal\": \"string\",\n      \"deposit\": {},\n      \"withdrawal\": {},\n      \"swap\": {}\n    }\n  ],\n  \"cards\": {}\n}\n```\n\n#### Example request\n\n```bash\ncurl -X GET 'https://api.cryptoinfra.build/v1/pricing'\n```\n\n## GET /v1/fees\n\n**Your effective fee schedule**  ·  _Auth: bearer API key_  ·  `operationId: getFees`\n\nThe exact fees that will be charged, per asset, for deposits and payouts. The swap/exchange fee is not here — it is visible only on POST /v1/swap/quote and POST /v1/exchange/quote (`fee`, `fee_base`).\n\n#### Responses\n\n| Status | Body | Meaning |\n| --- | --- | --- |\n| `200` | `FeesResponse` | Effective fee schedule. |\n| `401` | `Error` | Missing, malformed or unknown API key. |\n\n**`200` response body** — `FeesResponse`\n\n| Field | Type | Description |\n| --- | --- | --- |\n| `priced_at` | `string<date-time>` | When every network figure in this response was priced — computed for this response, never stored. |\n| `note` **required** | `string` | — |\n| `assets` **required** | `object[]` | — |\n| `assets[].asset_id` **required** | `string` | — |\n| `assets[].symbol` **required** | `string` | — |\n| `assets[].deposit` **required** | `object` | Deposits to the addresses we issue are free: `flat`, `network_fee` and `total` read 0, and the whole amount is credited (less only your own merchant_fee, if you set one). `dynamic` says whether a non-zero network part was priced live. |\n| `assets[].deposit.percent_bps` **required** | `integer` | Basis points (100 = 1%). |\n| `assets[].deposit.percent` **required** | `number` | Same as percent_bps / 100. |\n| `assets[].deposit.flat` **required** | `string` | A monetary value as a decimal string. Always paired with a `*_base` field that carries the exact integer base-unit value; compute with the `_base` string. |\n| `assets[].deposit.flat_base` **required** | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |\n| `assets[].deposit.min` **required** | `string` | A monetary value as a decimal string. Always paired with a `*_base` field that carries the exact integer base-unit value; compute with the `_base` string. |\n| `assets[].deposit.min_base` **required** | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |\n| `assets[].deposit.max` **required** | `string | null` | null when uncapped. |\n| `assets[].deposit.max_base` **required** | `string | null` | — |\n| `assets[].withdrawal` **required** | `object` | Our live network cost plus a flat platform fee. Your flat platform part (`flat`) plus today's live network figures: `network_fee` for a recipient that already holds the token, `network_fee_fresh_recipient` for one that does not, `first_send_activation_fee` added once on a user's first send, and `total` = flat + network_fee. `dynamic: false` means the static fallback is showing. GET /v1/withdrawal-fee-quote is the charge for a specific send. |\n| `assets[].withdrawal.percent_bps` **required** | `integer` | Basis points (100 = 1%). |\n| `assets[].withdrawal.percent` **required** | `number` | Same as percent_bps / 100. |\n| `assets[].withdrawal.flat` **required** | `string` | A monetary value as a decimal string. Always paired with a `*_base` field that carries the exact integer base-unit value; compute with the `_base` string. |\n| `assets[].withdrawal.flat_base` **required** | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |\n| `assets[].withdrawal.min` **required** | `string` | A monetary value as a decimal string. Always paired with a `*_base` field that carries the exact integer base-unit value; compute with the `_base` string. |\n| `assets[].withdrawal.min_base` **required** | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |\n| `assets[].withdrawal.max` **required** | `string | null` | null when uncapped. |\n| `assets[].withdrawal.max_base` **required** | `string | null` | — |\n\n```json\n{\n  \"priced_at\": \"2026-08-31T09:15:00.000Z\",\n  \"note\": \"string\",\n  \"assets\": [\n    {\n      \"asset_id\": \"string\",\n      \"symbol\": \"string\",\n      \"deposit\": {\n        \"percent_bps\": 1,\n        \"percent\": 0,\n        \"flat\": \"1.5\",\n        \"flat_base\": \"1500000000\",\n        \"min\": \"1.5\",\n        \"min_base\": \"1500000000\",\n        \"max\": \"string\",\n        \"max_base\": \"string\"\n      },\n      \"withdrawal\": {\n        \"percent_bps\": 1,\n        \"percent\": 0,\n        \"flat\": \"1.5\",\n        \"flat_base\": \"1500000000\",\n        \"min\": \"1.5\",\n        \"min_base\": \"1500000000\",\n        \"max\": \"string\",\n        \"max_base\": \"string\"\n      }\n    }\n  ]\n}\n```\n\n#### Example request\n\n```bash\ncurl -X GET 'https://api.cryptoinfra.build/v1/fees' \\\n  -H 'Authorization: Bearer ck_live_your_key'\n```\n\n## GET /v1/prices\n\n**Live market prices**  ·  _Auth: bearer API key_  ·  `operationId: getPrices`\n\nUSD spot prices for the symbols in the catalogue (`GET /v1/assets`): TRX, USDT, USDC. Display/estimation only — never for settlement.\n\n#### Responses\n\n| Status | Body | Meaning |\n| --- | --- | --- |\n| `200` | `PricesResponse` | Prices (best-effort; may be empty on a feed outage). |\n| `401` | `Error` | Missing, malformed or unknown API key. |\n\n**`200` response body** — `PricesResponse`\n\n| Field | Type | Description |\n| --- | --- | --- |\n| `currency` **required** | `string` |  _(one of `usd`)_ |\n| `prices` **required** | `object[]` | — |\n| `prices[].symbol` **required** | `string` | — |\n| `prices[].price_usd` **required** | `number` | — |\n| `prices[].change_24h_pct` **required** | `number` | — |\n| `prices[].sparkline` **required** | `number[]` | 7-day downsampled price points for a sparkline; empty when unavailable. |\n\n```json\n{\n  \"currency\": \"usd\",\n  \"prices\": [\n    {\n      \"symbol\": \"USDT\",\n      \"price_usd\": 0,\n      \"change_24h_pct\": 0,\n      \"sparkline\": [\n        0\n      ]\n    }\n  ]\n}\n```\n\n#### Example request\n\n```bash\ncurl -X GET 'https://api.cryptoinfra.build/v1/prices' \\\n  -H 'Authorization: Bearer ck_live_your_key'\n```\n",
      "sections": [
        {
          "anchor": "get-v1-assets",
          "heading": "GET /v1/assets",
          "text": "List enabled assets  ·  Auth: bearer API key  ·  operationId: listAssets\n\nThe asset catalogue with decimals, confirmation requirements, the flat withdrawal fee, and swappable — false when no exchange can route the asset (leave it out of a swap picker; deposits and withdrawals are unaffected).\n\nResponses\n\nStatus · Body · Meaning\n200 · object[] · Enabled assets.\n401 · Error · Missing, malformed or unknown API key.\n\n200 response body\n\nField · Type · Description\n[].id required · string · —\n[].chain required · string · The chain an address or asset is on. TRON is the only network the platform serves: btc, eth, bsc and sol were retired — historical addresses on them are never handed out again and nothing scans them. Read the value as an open string rather than a closed list, so a chain added later does not break your client. (one of tron)\n[].symbol required · string · —\n[].contract_address required · string · null · null for native coins; the token contract/mint otherwise.\n[].decimals required · integer · —\n[].confirmations_required required · integer · —\n[].min_deposit required · string · A monetary value as a decimal string. Always paired with a *_base field that carries the exact integer base-unit value; compute with the _base string.\n[].withdraw_fee required · string · The STATIC fallback fee for a withdrawal of this asset — what is charged as fee only when no live network price can be read (dynamic: false on the quote). The live figure, network gas included, is on GET /v1/withdrawal-fee-quote and GET /v1/pricing. The energy a transfer consumes is rented by us, so you never hold TRX and never top up a gas balance. It does not vary with the recipient. A TRC20 transfer to an address that has never held the token costs roughly twice the energy of one to an existing holder, and that difference is absorbed here rather than passed on — you cannot see which case a customer's wallet is, so you cannot price it. Read it at runtime; it is the number that will be charged.\n[].min_withdrawal required · string · A monetary value as a decimal string. Always paired with a *_base field that carries the exact integer base-unit value; compute with the _base string.\n[].swappable required · boolean · False when no exchange can route this asset. Leave it out of a swap picker — POST /v1/swap/quote and POST /v1/exchange/quote refuse it with 400 invalid_pair. Deposits and withdrawals are unaffected.\n\n[\n{\n\"id\": \"usdt_trc20\",\n\"chain\": \"tron\",\n\"symbol\": \"string\",\n\"contract_address\": \"string\",\n\"decimals\": 1,\n\"confirmations_required\": 1,\n\"min_deposit\": \"1.5\",\n\"withdraw_fee\": \"1.5\",\n\"min_withdrawal\": \"1.5\",\n\"swappable\": true\n}\n]\n\nExample request\n\ncurl -X GET 'https://api.cryptoinfra.build/v1/assets' \\\n-H 'Authorization: Bearer ck_live_your_key'"
        },
        {
          "anchor": "get-v1-a-token",
          "heading": "GET /v1/a/{token}",
          "text": "PUBLIC — a merchant's affiliate ledger  ·  Auth: none — public  ·  operationId: getAffiliateLedger\n\nNo authentication: the token (256 bits, minted for the merchant on GET /v1/merchant/affiliate-ledger) is the whole authorization, and it reads that ONE merchant's affiliate commission — their share of our fee, total per asset and one line per operation that earned some — and nothing else: no customer ids, no addresses. This is what the hosted page /a/{token} on the merchant origin renders. A rotated token answers 404 from that moment; responses are no-store.\n\nParameters\n\nName · In · Type · Required · Description\ntoken · path · string · yes · —\nlimit · query · integer · no · — (default 50; min 1; max 200)\noffset · query · integer · no · — (default 0; min 0)\n\nResponses\n\nStatus · Body · Meaning\n200 · object · The ledger.\n404 · — · No merchant holds this token (or it was rotated).\n\n200 response body\n\nField · Type · Description\nmerchant · object · —\nmerchant.name · string · —\nmerchant.merchant_id · string · null · —\nshare_bps · integer · —\nshare_percent · number · —\ntotals · object[] · —\ntotals[].asset_id · string · —\ntotals[].symbol · string · —\ntotals[].amount · string · A monetary value as a decimal string. Always paired with a *_base field that carries the exact integer base-unit value; compute with the _base string.\ntotals[].amount_base · string · Exact integer amount in the asset's smallest unit. (pattern ^[0-9]+$)\ntotals[].count · integer · —\nlines · object[] · —\nlines[].id · integer · —\nlines[].at · string<date-time> · —\nlines[].operation · string · _(one of deposit, withdrawal, swap, card_issue, card_load)_\nlines[].ref · object · What earned it\nlines[].ref.kind · string · —\nlines[].ref.id · string · —\nlines[].our_fee · object · Our fee the share was taken of.\nlines[].share_bps · integer · —\nlines[].commission · object · What the merchant received.\ntotal_lines · integer · —\nlimit · integer · —\noffset · integer · —\ngenerated_at · string<date-time> · —\n\n{\n\"merchant\": {\n\"name\": \"string\",\n\"merchant_id\": \"string\"\n},\n\"share_bps\": 1,\n\"share_percent\": 0,\n\"totals\": [\n{\n\"asset_id\": \"string\",\n\"symbol\": \"string\",\n\"amount\": \"1.5\",\n\"amount_base\": \"1500000000\",\n\"count\": 1\n}\n],\n\"lines\": [\n{\n\"id\": 1,\n\"at\": \"2026-08-31T09:15:00.000Z\",\n\"operation\": \"deposit\",\n\"ref\": {\n\"kind\": \"string\",\n\"id\": \"string\"\n},\n\"our_fee\": {},\n\"share_bps\": 1,\n\"commission\": {}\n}\n],\n\"total_lines\": 1,\n\"limit\": 1,\n\"offset\": 1,\n\"generated_at\": \"2026-08-31T09:15:00.000Z\"\n}\n\nExample request\n\ncurl -X GET 'https://api.cryptoinfra.build/v1/a/string'"
        },
        {
          "anchor": "get-v1-merchant-affiliate-ledger",
          "heading": "GET /v1/merchant/affiliate-ledger",
          "text": "The link to your public affiliate ledger  ·  Auth: bearer API key  ·  operationId: merchantAffiliateLedgerGet\n\nYour shareable page — your commission, total and line by line, readable without a login by anyone holding the link. Minted on first read. Works with an API key or a dashboard login.\n\nResponses\n\nStatus · Body · Meaning\n200 · object · The link.\n401 · Error · Missing, malformed or unknown API key.\n\n200 response body\n\nField · Type · Description\nurl required · string · —\n\n{\n\"url\": \"https://merchant.cryptoinfra.build/a/…\"\n}\n\nExample request\n\ncurl -X GET 'https://api.cryptoinfra.build/v1/merchant/affiliate-ledger' \\\n-H 'Authorization: Bearer ck_live_your_key'"
        },
        {
          "anchor": "post-v1-merchant-affiliate-ledger-rotate",
          "heading": "POST /v1/merchant/affiliate-ledger/rotate",
          "text": "Replace the link (dashboard owner, both codes)  ·  Auth: bearer API key  ·  operationId: merchantAffiliateLedgerRotate\n\nMints a fresh token; the old link answers 404 from that moment. Dashboard only — an owner login with the authenticator and the emailed code — and 403 session_required to an API key.\n\nResponses\n\nStatus · Body · Meaning\n200 · object · The new link.\n403 · — · Not a dashboard owner\n\n200 response body\n\nField · Type · Description\nurl required · string · —\n\n{\n\"url\": \"string\"\n}\n\nExample request\n\ncurl -X POST 'https://api.cryptoinfra.build/v1/merchant/affiliate-ledger/rotate' \\\n-H 'Authorization: Bearer ck_live_your_key'"
        },
        {
          "anchor": "get-v1-pricing",
          "heading": "GET /v1/pricing",
          "text": "PUBLIC — the live price sheet  ·  Auth: none — public  ·  operationId: getPricing\n\nNo authentication. Every price the platform charges, read live: the flat platform fee and the network figures per asset for deposits (free), payouts and swaps, plus every card product's issue, load and close terms. Built once a minute and shared, so it is cheap to poll. It is what the documentation renders, so the docs can never show a number this endpoint does not. A merchant's own fees are not here — they are on GET /v1/fees with that merchant's key. The exact charge for one send is GET /v1/withdrawal-fee-quote. as_of is when the sheet was priced; every network figure carries dynamic (live now, or the static_fallback) so nothing here is mirrored as a fixed charge.\n\nResponses\n\nStatus · Body · Meaning\n200 · object · The price sheet.\n503 · — · The sheet cannot be built right now.\n\n200 response body\n\nField · Type · Description\nas_of · string<date-time> · —\ncached_for_seconds · integer · —\nrule · string · —\nassets · object[] · —\nassets[].asset_id · string · —\nassets[].symbol · string · —\nassets[].chain · string · —\nassets[].min_deposit · string · —\nassets[].min_withdrawal · string · —\nassets[].deposit · object · platform_flat\nassets[].withdrawal · object · platform_flat, network_fee, network_fee_fresh_recipient, first_send_activation_fee, total, total_fresh_recipient, dynamic, static_fallback, priced_from, and network_fee_burning_trx / network_fee_burning_trx_fresh_recipient — what the same transfer burns in a wallet without energy, at the chain's own rates, for a \"you save\" comparison\nassets[].swap · object · platform_flat\ncards · object · gas (per load, priced live), gas_dynamic, gas_burning_trx (what that send burns in a wallet without energy), and products[] with issue_fee, min_opening_balance, load_fee_bps, load_fee_min, load_fee_flat, close_fee, issue_total_at_minimum, cardholder_fees, and limits (per_transaction, daily, monthly, atm_single, atm_per_day, atm_monthly in the card's currency; validity_years) — null on both for a BIN whose terms are not published yet.\n\n{\n\"as_of\": \"2026-08-31T09:15:00.000Z\",\n\"cached_for_seconds\": 1,\n\"rule\": \"string\",\n\"assets\": [\n{\n\"asset_id\": \"string\",\n\"symbol\": \"string\",\n\"chain\": \"string\",\n\"min_deposit\": \"string\",\n\"min_withdrawal\": \"string\",\n\"deposit\": {},\n\"withdrawal\": {},\n\"swap\": {}\n}\n],\n\"cards\": {}\n}\n\nExample request\n\ncurl -X GET 'https://api.cryptoinfra.build/v1/pricing'"
        },
        {
          "anchor": "get-v1-fees",
          "heading": "GET /v1/fees",
          "text": "Your effective fee schedule  ·  Auth: bearer API key  ·  operationId: getFees\n\nThe exact fees that will be charged, per asset, for deposits and payouts. The swap/exchange fee is not here — it is visible only on POST /v1/swap/quote and POST /v1/exchange/quote (fee, fee_base).\n\nResponses\n\nStatus · Body · Meaning\n200 · FeesResponse · Effective fee schedule.\n401 · Error · Missing, malformed or unknown API key.\n\n200 response body — FeesResponse\n\nField · Type · Description\npriced_at · string<date-time> · When every network figure in this response was priced — computed for this response, never stored.\nnote required · string · —\nassets required · object[] · —\nassets[].asset_id required · string · —\nassets[].symbol required · string · —\nassets[].deposit required · object · Deposits to the addresses we issue are free: flat, network_fee and total read 0, and the whole amount is credited (less only your own merchant_fee, if you set one). dynamic says whether a non-zero network part was priced live.\nassets[].deposit.percent_bps required · integer · Basis points (100 = 1%).\nassets[].deposit.percent required · number · Same as percent_bps / 100.\nassets[].deposit.flat required · string · A monetary value as a decimal string. Always paired with a *_base field that carries the exact integer base-unit value; compute with the _base string.\nassets[].deposit.flat_base required · string · Exact integer amount in the asset's smallest unit. (pattern ^[0-9]+$)\nassets[].deposit.min required · string · A monetary value as a decimal string. Always paired with a *_base field that carries the exact integer base-unit value; compute with the _base string.\nassets[].deposit.min_base required · string · Exact integer amount in the asset's smallest unit. (pattern ^[0-9]+$)\nassets[].deposit.max required · string · null · null when uncapped.\nassets[].deposit.max_base required · string · null · —\nassets[].withdrawal required · object · Our live network cost plus a flat platform fee. Your flat platform part (flat) plus today's live network figures: network_fee for a recipient that already holds the token, network_fee_fresh_recipient for one that does not, first_send_activation_fee added once on a user's first send, and total = flat + network_fee. dynamic: false means the static fallback is showing. GET /v1/withdrawal-fee-quote is the charge for a specific send.\nassets[].withdrawal.percent_bps required · integer · Basis points (100 = 1%).\nassets[].withdrawal.percent required · number · Same as percent_bps / 100.\nassets[].withdrawal.flat required · string · A monetary value as a decimal string. Always paired with a *_base field that carries the exact integer base-unit value; compute with the _base string.\nassets[].withdrawal.flat_base required · string · Exact integer amount in the asset's smallest unit. (pattern ^[0-9]+$)\nassets[].withdrawal.min required · string · A monetary value as a decimal string. Always paired with a *_base field that carries the exact integer base-unit value; compute with the _base string.\nassets[].withdrawal.min_base required · string · Exact integer amount in the asset's smallest unit. (pattern ^[0-9]+$)\nassets[].withdrawal.max required · string · null · null when uncapped.\nassets[].withdrawal.max_base required · string · null · —\n\n{\n\"priced_at\": \"2026-08-31T09:15:00.000Z\",\n\"note\": \"string\",\n\"assets\": [\n{\n\"asset_id\": \"string\",\n\"symbol\": \"string\",\n\"deposit\": {\n\"percent_bps\": 1,\n\"percent\": 0,\n\"flat\": \"1.5\",\n\"flat_base\": \"1500000000\",\n\"min\": \"1.5\",\n\"min_base\": \"1500000000\",\n\"max\": \"string\",\n\"max_base\": \"string\"\n},\n\"withdrawal\": {\n\"percent_bps\": 1,\n\"percent\": 0,\n\"flat\": \"1.5\",\n\"flat_base\": \"1500000000\",\n\"min\": \"1.5\",\n\"min_base\": \"1500000000\",\n\"max\": \"string\",\n\"max_base\": \"string\"\n}\n}\n]\n}\n\nExample request\n\ncurl -X GET 'https://api.cryptoinfra.build/v1/fees' \\\n-H 'Authorization: Bearer ck_live_your_key'"
        },
        {
          "anchor": "get-v1-prices",
          "heading": "GET /v1/prices",
          "text": "Live market prices  ·  Auth: bearer API key  ·  operationId: getPrices\n\nUSD spot prices for the symbols in the catalogue (GET /v1/assets): TRX, USDT, USDC. Display/estimation only — never for settlement.\n\nResponses\n\nStatus · Body · Meaning\n200 · PricesResponse · Prices (best-effort; may be empty on a feed outage).\n401 · Error · Missing, malformed or unknown API key.\n\n200 response body — PricesResponse\n\nField · Type · Description\ncurrency required · string · (one of usd)\nprices required · object[] · —\nprices[].symbol required · string · —\nprices[].price_usd required · number · —\nprices[].change_24h_pct required · number · —\nprices[].sparkline required · number[] · 7-day downsampled price points for a sparkline; empty when unavailable.\n\n{\n\"currency\": \"usd\",\n\"prices\": [\n{\n\"symbol\": \"USDT\",\n\"price_usd\": 0,\n\"change_24h_pct\": 0,\n\"sparkline\": [\n0\n]\n}\n]\n}\n\nExample request\n\ncurl -X GET 'https://api.cryptoinfra.build/v1/prices' \\\n-H 'Authorization: Bearer ck_live_your_key'"
        }
      ]
    },
    {
      "slug": "system",
      "title": "System",
      "summary": "Health and metrics (no auth)",
      "kind": "reference",
      "markdown": "# System\n\nHealth and metrics (no auth)\n\n2 endpoints. Generated from `docs/openapi.yaml` v1.0.0.\n\n| Endpoint | Summary |\n| --- | --- |\n| [`GET /healthz`](#get-healthz) | Liveness |\n| [`GET /readyz`](#get-readyz) | Readiness |\n\n## GET /healthz\n\n**Liveness**  ·  _Auth: none — public_  ·  `operationId: healthz`\n\n#### Responses\n\n| Status | Body | Meaning |\n| --- | --- | --- |\n| `200` | `object` | Alive. |\n\n**`200` response body**\n\n| Field | Type | Description |\n| --- | --- | --- |\n| `ok` | `boolean` | — |\n\n```json\n{\n  \"ok\": true\n}\n```\n\n#### Example request\n\n```bash\ncurl -X GET 'https://api.cryptoinfra.build/healthz'\n```\n\n## GET /readyz\n\n**Readiness**  ·  _Auth: none — public_  ·  `operationId: readyz`\n\n#### Responses\n\n| Status | Body | Meaning |\n| --- | --- | --- |\n| `200` | `object` | Ready. |\n| `503` | `object` | Not ready. |\n\n**`200` response body**\n\n| Field | Type | Description |\n| --- | --- | --- |\n| `ready` | `boolean` | — |\n\n```json\n{\n  \"ready\": true\n}\n```\n\n#### Example request\n\n```bash\ncurl -X GET 'https://api.cryptoinfra.build/readyz'\n```\n",
      "sections": [
        {
          "anchor": "get-healthz",
          "heading": "GET /healthz",
          "text": "Liveness  ·  Auth: none — public  ·  operationId: healthz\n\nResponses\n\nStatus · Body · Meaning\n200 · object · Alive.\n\n200 response body\n\nField · Type · Description\nok · boolean · —\n\n{\n\"ok\": true\n}\n\nExample request\n\ncurl -X GET 'https://api.cryptoinfra.build/healthz'"
        },
        {
          "anchor": "get-readyz",
          "heading": "GET /readyz",
          "text": "Readiness  ·  Auth: none — public  ·  operationId: readyz\n\nResponses\n\nStatus · Body · Meaning\n200 · object · Ready.\n503 · object · Not ready.\n\n200 response body\n\nField · Type · Description\nready · boolean · —\n\n{\n\"ready\": true\n}\n\nExample request\n\ncurl -X GET 'https://api.cryptoinfra.build/readyz'"
        }
      ]
    },
    {
      "slug": "webhook-events",
      "title": "Webhook events",
      "summary": "Payload reference for all 9 events cryptoinfra emits, generated from the OpenAPI contract.",
      "kind": "reference",
      "markdown": "# Webhook events\n\nThe complete set of events cryptoinfra sends — 9 of them, generated from `docs/openapi.yaml` v1.0.0. Nothing outside this list is ever emitted. See [Webhooks](webhooks) for signature verification, retries and the polling rules.\n\n| Event | Fires when |\n| --- | --- |\n| [`deposit.credited`](#deposit-credited) | A deposit cleared confirmations and was credited |\n| [`withdrawal.confirmed`](#withdrawal-confirmed) | A payout confirmed on-chain |\n| [`withdrawal.failed`](#withdrawal-failed) | A payout failed and the hold was refunded |\n| [`card.kyc`](#card-kyc) | A card holder's KYC status changed |\n| [`card.status`](#card-status) | A card's status changed (also emitted when an issue order settles) |\n| [`card.topup`](#card-topup) | A card top-up order reached a terminal state (confirmed or failed) |\n| [`card.withdraw`](#card-withdraw) | A card→wallet withdrawal order reached a terminal state (confirmed or failed) |\n| [`card.transaction`](#card-transaction) | A card transaction was created |\n| [`card.wallet_otp`](#card-wallet-otp) | A live 3DS wallet OTP is available (notification only — the code is NOT in the payload) |\n\n## deposit.credited\n\n**A deposit cleared confirmations and was credited**\n\nDelivered as `POST` to each of your enabled endpoints with `x-cryptoinfra-event: deposit.credited` and `x-cryptoinfra-signature: <hex hmac-sha256 of the raw body>`.\n\nSent to your registered endpoint. Headers: `x-cryptoinfra-event: deposit.credited`\nand `x-cryptoinfra-signature: <hex hmac-sha256 of the raw body>`. Verify the HMAC\nover the raw bytes before parsing.\n\n#### Payload\n\nSchema `WebhookDepositCredited`.\n\n| Field | Type | Description |\n| --- | --- | --- |\n| `depositId` | `integer` | — |\n| `userExternalId` | `string` | — |\n| `assetId` | `string` | — |\n| `chain` | `string` | The chain an address or asset is on. TRON is the only network the platform serves: `btc`, `eth`, `bsc` and `sol` were retired — historical addresses on them are never handed out again and nothing scans them. Read the value as an open string rather than a closed list, so a chain added later does not break your client. _(one of `tron`)_ |\n| `amount` | `string` | A monetary value as a decimal string. Always paired with a `*_base` field that carries the exact integer base-unit value; compute with the `_base` string. |\n| `fee` | `string` | A monetary value as a decimal string. Always paired with a `*_base` field that carries the exact integer base-unit value; compute with the `_base` string. |\n| `amountCredited` | `string` | A monetary value as a decimal string. Always paired with a `*_base` field that carries the exact integer base-unit value; compute with the `_base` string. |\n| `txid` | `string` | — |\n| `address` | `string` | — |\n| `fromAddress` | `string` | Who paid it. For a token (USDT, USDC), the `from` of the Transfer event that credited the user's address — not the transaction's signer, which for an exchange's batch or proxy payout is an operator address. For TRX, the transaction's sender. `null` only for a deposit recorded before 19 September 2026 whose sender could not be read. |\n\n```json\n{\n  \"depositId\": 1,\n  \"userExternalId\": \"string\",\n  \"assetId\": \"string\",\n  \"chain\": \"tron\",\n  \"amount\": \"1.5\",\n  \"fee\": \"1.5\",\n  \"amountCredited\": \"1.5\",\n  \"txid\": \"string\",\n  \"address\": \"string\",\n  \"fromAddress\": \"string\"\n}\n```\n\n## withdrawal.confirmed\n\n**A payout confirmed on-chain**\n\nDelivered as `POST` to each of your enabled endpoints with `x-cryptoinfra-event: withdrawal.confirmed` and `x-cryptoinfra-signature: <hex hmac-sha256 of the raw body>`.\n\n#### Payload\n\nSchema `WebhookWithdrawalConfirmed`.\n\n| Field | Type | Description |\n| --- | --- | --- |\n| `withdrawalId` | `integer` | — |\n| `userExternalId` | `string` | — |\n| `assetId` | `string` | — |\n| `chain` | `string` | The chain an address or asset is on. TRON is the only network the platform serves: `btc`, `eth`, `bsc` and `sol` were retired — historical addresses on them are never handed out again and nothing scans them. Read the value as an open string rather than a closed list, so a chain added later does not break your client. _(one of `tron`)_ |\n| `amount` | `string` | A monetary value as a decimal string. Always paired with a `*_base` field that carries the exact integer base-unit value; compute with the `_base` string. |\n| `fee` | `string` | A monetary value as a decimal string. Always paired with a `*_base` field that carries the exact integer base-unit value; compute with the `_base` string. |\n| `feeAssetId` | `string` | The asset `fee` is charged in — always equals `assetId` (the option that could make it differ was retired on 2026-09-05; the field stays so an older consumer keeps working). |\n| `platformFee` | `string` | A monetary value as a decimal string. Always paired with a `*_base` field that carries the exact integer base-unit value; compute with the `_base` string. |\n| `toAddress` | `string` | — |\n| `txid` | `string | null` | — |\n\n```json\n{\n  \"withdrawalId\": 1,\n  \"userExternalId\": \"string\",\n  \"assetId\": \"string\",\n  \"chain\": \"tron\",\n  \"amount\": \"1.5\",\n  \"fee\": \"1.5\",\n  \"feeAssetId\": \"string\",\n  \"platformFee\": \"1.5\",\n  \"toAddress\": \"string\",\n  \"txid\": \"string\"\n}\n```\n\n## withdrawal.failed\n\n**A payout failed and the hold was refunded**\n\nDelivered as `POST` to each of your enabled endpoints with `x-cryptoinfra-event: withdrawal.failed` and `x-cryptoinfra-signature: <hex hmac-sha256 of the raw body>`.\n\n#### Payload\n\nSchema `WebhookWithdrawalFailed`.\n\n| Field | Type | Description |\n| --- | --- | --- |\n| `withdrawalId` | `integer` | — |\n| `userExternalId` | `string` | — |\n| `assetId` | `string` | — |\n| `chain` | `string` | The chain an address or asset is on. TRON is the only network the platform serves: `btc`, `eth`, `bsc` and `sol` were retired — historical addresses on them are never handed out again and nothing scans them. Read the value as an open string rather than a closed list, so a chain added later does not break your client. _(one of `tron`)_ |\n| `amount` | `string` | A monetary value as a decimal string. Always paired with a `*_base` field that carries the exact integer base-unit value; compute with the `_base` string. |\n| `fee` | `string` | A monetary value as a decimal string. Always paired with a `*_base` field that carries the exact integer base-unit value; compute with the `_base` string. |\n| `feeAssetId` | `string` | The asset `fee` is charged in — always equals `assetId` (see withdrawal.confirmed). |\n| `platformFee` | `string` | A monetary value as a decimal string. Always paired with a `*_base` field that carries the exact integer base-unit value; compute with the `_base` string. |\n| `toAddress` | `string` | — |\n| `refunded` | `boolean` | Always true — every hold, fee included, was refunded in full. |\n| `error` | `string` | Why. `rejected from the dashboard` when an owner or finance member refused an awaiting_approval send. |\n\n```json\n{\n  \"withdrawalId\": 1,\n  \"userExternalId\": \"string\",\n  \"assetId\": \"string\",\n  \"chain\": \"tron\",\n  \"amount\": \"1.5\",\n  \"fee\": \"1.5\",\n  \"feeAssetId\": \"string\",\n  \"platformFee\": \"1.5\",\n  \"toAddress\": \"string\",\n  \"refunded\": true,\n  \"error\": \"string\"\n}\n```\n\n## card.kyc\n\n**A card holder's KYC status changed**\n\nDelivered as `POST` to each of your enabled endpoints with `x-cryptoinfra-event: card.kyc` and `x-cryptoinfra-signature: <hex hmac-sha256 of the raw body>`.\n\nSigned like every tenant webhook: `x-cryptoinfra-event: card.kyc` and\n`x-cryptoinfra-signature: <hex hmac-sha256 of the raw body>`.\n\n#### Payload\n\nSchema `WebhookCardKyc`.\n\n| Field | Type | Description |\n| --- | --- | --- |\n| `userExternalId` | `string` | — |\n| `provider` | `string` | — |\n| `kycStatus` | `string` | Legacy issuer-KYC lifecycle on a card holder. Distinct from `KycStatus`, the cryptoinfra-owned verdict at /v1/kyc/*. _(one of `none`, `pending`, `approved`, `rejected`)_ |\n| `remark` | `string | null` | — |\n\n```json\n{\n  \"userExternalId\": \"string\",\n  \"provider\": \"string\",\n  \"kycStatus\": \"none\",\n  \"remark\": \"string\"\n}\n```\n\n## card.status\n\n**A card's status changed (also emitted when an issue order settles)**\n\nDelivered as `POST` to each of your enabled endpoints with `x-cryptoinfra-event: card.status` and `x-cryptoinfra-signature: <hex hmac-sha256 of the raw body>`.\n\n#### Payload\n\nSchema `WebhookCardStatus`.\n\n| Field | Type | Description |\n| --- | --- | --- |\n| `cardId` | `integer` | — |\n| `userExternalId` | `string` | — |\n| `provider` | `string` | Present on the generic path only. |\n| `status` | `integer | string` | Provider RAW status code (integer) on a card-lifecycle change; a CardOrderStatus string on the issue-order variant. |\n| `providerStatus` | `string` | — |\n| `reason` | `string` | Optional; present on card status-change events. |\n| `refundAmount` | `string` | Informational remaining-balance figure on card close; no ledger entry is posted for it in v1. |\n| `orderId` | `integer` | Present only on the issue-order settlement variant. |\n| `kind` | `string` | `close_fee` is the close fee charged to the cardholder on DELETE /v1/cards/{id}: `amount` is 0, `fee` is the charge. _(one of `topup`, `withdraw`, `issue_fee`, `close_fee`)_ |\n| `assetId` | `string` | Present on the issue-order settlement variant. |\n| `amount` | `string` | Present on the issue-order settlement variant. |\n| `fee` | `string` | Present on the issue-order settlement variant. |\n| `gas` | `string` | Issue-order variant — gas for the on-chain send. |\n| `merchantFee` | `string` | Issue-order variant — your own fee. |\n| `total` | `string` | Issue-order variant — everything the cardholder was debited; mirror this |\n| `error` | `string` | Present on the issue-order settlement variant when it failed. |\n\n```json\n{\n  \"cardId\": 1,\n  \"userExternalId\": \"string\",\n  \"provider\": \"string\",\n  \"status\": 1,\n  \"providerStatus\": \"string\",\n  \"reason\": \"string\",\n  \"refundAmount\": \"string\",\n  \"orderId\": 1,\n  \"kind\": \"topup\",\n  \"assetId\": \"string\",\n  \"amount\": \"1.5\",\n  \"fee\": \"1.5\",\n  \"gas\": \"1.5\",\n  \"merchantFee\": \"1.5\",\n  \"total\": \"1.5\",\n  \"error\": \"string\"\n}\n```\n\n## card.topup\n\n**A card top-up order reached a terminal state (confirmed or failed)**\n\nDelivered as `POST` to each of your enabled endpoints with `x-cryptoinfra-event: card.topup` and `x-cryptoinfra-signature: <hex hmac-sha256 of the raw body>`.\n\n#### Payload\n\nSchema `WebhookCardOrder`.\n\n| Field | Type | Description |\n| --- | --- | --- |\n| `orderId` | `integer` | — |\n| `kind` | `string` | `close_fee` is the close fee charged to the cardholder on DELETE /v1/cards/{id}: `amount` is 0, `fee` is the charge. _(one of `topup`, `withdraw`, `issue_fee`, `close_fee`)_ |\n| `txid` | `string | null` | The hash of the on-chain send behind the order, as on GET /v1/cards/orders/{id}; null when there was none. |\n| `userExternalId` | `string` | — |\n| `cardId` | `integer | null` | — |\n| `assetId` | `string` | — |\n| `amount` | `string` | A monetary value as a decimal string. Always paired with a `*_base` field that carries the exact integer base-unit value; compute with the `_base` string. |\n| `fee` | `string` | A monetary value as a decimal string. Always paired with a `*_base` field that carries the exact integer base-unit value; compute with the `_base` string. |\n| `gas` | `string` | The network fee actually CHARGED for the on-chain send behind the order — what the cardholder was debited, not the quoted figure. |\n| `merchantFee` | `string` | Your own fee on this order, credited to your balance. |\n| `total` | `string` | Everything the cardholder was debited — amount + fee + gas + merchantFee. |\n| `amountCredited` | `string` | A monetary value as a decimal string. Always paired with a `*_base` field that carries the exact integer base-unit value; compute with the `_base` string. |\n| `amountCreditedBase` | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |\n| `status` | `string` | `funding` = the cardholder has been charged and the money is on its way to the card rail on-chain. `requested` = confirmed on-chain, waiting for the rail to show the credit. `sent` = the rail has been asked, outcome still pending. `confirmed` = settled. `failed` = definitively failed; the WHOLE charge — `amount`, `fee`, `gas` and `merchant_fee`, i.e. `total` — is refunded to the balance it was taken from, the cardholder's, in the same moment (top-up / issue only — a withdraw holds nothing). Nothing goes anywhere else: not to the card, not to your treasury. When the on-chain send behind a load reverted (`provider_status: send_failed`), no coins left the cardholder's address either, so on-chain and ledger agree. `reversed` is reserved for manual operator reversals of a confirmed order. _(one of `funding`, `requested`, `sent`, `confirmed`, `failed`, `reversed`)_ |\n| `error` | `string` | — |\n| `providerFee` | `string` | — |\n\n```json\n{\n  \"orderId\": 1,\n  \"kind\": \"topup\",\n  \"txid\": \"string\",\n  \"userExternalId\": \"string\",\n  \"cardId\": 1,\n  \"assetId\": \"string\",\n  \"amount\": \"1.5\",\n  \"fee\": \"1.5\",\n  \"gas\": \"1.5\",\n  \"merchantFee\": \"1.5\",\n  \"total\": \"1.5\",\n  \"amountCredited\": \"1.5\",\n  \"amountCreditedBase\": \"1500000000\",\n  \"status\": \"funding\",\n  \"error\": \"string\",\n  \"providerFee\": \"string\"\n}\n```\n\n## card.withdraw\n\n**A card→wallet withdrawal order reached a terminal state (confirmed or failed)**\n\nDelivered as `POST` to each of your enabled endpoints with `x-cryptoinfra-event: card.withdraw` and `x-cryptoinfra-signature: <hex hmac-sha256 of the raw body>`.\n\n#### Payload\n\nSchema `WebhookCardOrder`.\n\n| Field | Type | Description |\n| --- | --- | --- |\n| `orderId` | `integer` | — |\n| `kind` | `string` | `close_fee` is the close fee charged to the cardholder on DELETE /v1/cards/{id}: `amount` is 0, `fee` is the charge. _(one of `topup`, `withdraw`, `issue_fee`, `close_fee`)_ |\n| `txid` | `string | null` | The hash of the on-chain send behind the order, as on GET /v1/cards/orders/{id}; null when there was none. |\n| `userExternalId` | `string` | — |\n| `cardId` | `integer | null` | — |\n| `assetId` | `string` | — |\n| `amount` | `string` | A monetary value as a decimal string. Always paired with a `*_base` field that carries the exact integer base-unit value; compute with the `_base` string. |\n| `fee` | `string` | A monetary value as a decimal string. Always paired with a `*_base` field that carries the exact integer base-unit value; compute with the `_base` string. |\n| `gas` | `string` | The network fee actually CHARGED for the on-chain send behind the order — what the cardholder was debited, not the quoted figure. |\n| `merchantFee` | `string` | Your own fee on this order, credited to your balance. |\n| `total` | `string` | Everything the cardholder was debited — amount + fee + gas + merchantFee. |\n| `amountCredited` | `string` | A monetary value as a decimal string. Always paired with a `*_base` field that carries the exact integer base-unit value; compute with the `_base` string. |\n| `amountCreditedBase` | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |\n| `status` | `string` | `funding` = the cardholder has been charged and the money is on its way to the card rail on-chain. `requested` = confirmed on-chain, waiting for the rail to show the credit. `sent` = the rail has been asked, outcome still pending. `confirmed` = settled. `failed` = definitively failed; the WHOLE charge — `amount`, `fee`, `gas` and `merchant_fee`, i.e. `total` — is refunded to the balance it was taken from, the cardholder's, in the same moment (top-up / issue only — a withdraw holds nothing). Nothing goes anywhere else: not to the card, not to your treasury. When the on-chain send behind a load reverted (`provider_status: send_failed`), no coins left the cardholder's address either, so on-chain and ledger agree. `reversed` is reserved for manual operator reversals of a confirmed order. _(one of `funding`, `requested`, `sent`, `confirmed`, `failed`, `reversed`)_ |\n| `error` | `string` | — |\n| `providerFee` | `string` | — |\n\n```json\n{\n  \"orderId\": 1,\n  \"kind\": \"topup\",\n  \"txid\": \"string\",\n  \"userExternalId\": \"string\",\n  \"cardId\": 1,\n  \"assetId\": \"string\",\n  \"amount\": \"1.5\",\n  \"fee\": \"1.5\",\n  \"gas\": \"1.5\",\n  \"merchantFee\": \"1.5\",\n  \"total\": \"1.5\",\n  \"amountCredited\": \"1.5\",\n  \"amountCreditedBase\": \"1500000000\",\n  \"status\": \"funding\",\n  \"error\": \"string\",\n  \"providerFee\": \"string\"\n}\n```\n\n## card.transaction\n\n**A card transaction was created**\n\nDelivered as `POST` to each of your enabled endpoints with `x-cryptoinfra-event: card.transaction` and `x-cryptoinfra-signature: <hex hmac-sha256 of the raw body>`.\n\n#### Payload\n\nSchema `WebhookCardTransaction`.\n\n| Field | Type | Description |\n| --- | --- | --- |\n| `cardId` | `integer` | — |\n| `userExternalId` | `string` | — |\n| `currency` | `string | null` | — |\n| `createAt` | `integer | null` | Provider epoch timestamp. |\n| `transaction` | `object` | — |\n| `transaction.recordNo` | `string | null` | — |\n| `transaction.transactionId` | `string | null` | — |\n| `transaction.originalTransactionId` | `string | null` | On a refund (171) |\n| `transaction.transType` | `integer | null` | 170 payment, 171 refund, 172 card fee, 173 ATM withdrawal, 174 pre-authorization. |\n| `transaction.transStatus` | `integer | null` | 1 processing, 2 success, 3 failed. |\n| `transaction.merchantName` | `string | null` | — |\n| `transaction.merchantCategoryCode` | `string | null` | — |\n| `transaction.localCurrency` | `string | null` | — |\n| `transaction.localCurrencyAmt` | `string | null` | — |\n| `transaction.transCurrency` | `string | null` | — |\n| `transaction.transCurrencyAmt` | `string | null` | — |\n| `transaction.feeAmount` | `string | null` | — |\n| `transaction.feeCurrency` | `string | null` | — |\n| `transaction.balance` | `string | null` | — |\n| `transaction.respCodeDesc` | `string | null` | — |\n\n```json\n{\n  \"cardId\": 1,\n  \"userExternalId\": \"string\",\n  \"currency\": \"string\",\n  \"createAt\": 1,\n  \"transaction\": {\n    \"recordNo\": \"string\",\n    \"transactionId\": \"string\",\n    \"originalTransactionId\": \"string\",\n    \"transType\": 1,\n    \"transStatus\": 1,\n    \"merchantName\": \"string\",\n    \"merchantCategoryCode\": \"string\",\n    \"localCurrency\": \"string\",\n    \"localCurrencyAmt\": \"string\",\n    \"transCurrency\": \"string\",\n    \"transCurrencyAmt\": \"string\",\n    \"feeAmount\": \"string\",\n    \"feeCurrency\": \"string\",\n    \"balance\": \"string\",\n    \"respCodeDesc\": \"string\"\n  }\n}\n```\n\n## card.wallet_otp\n\n**A live 3DS wallet OTP is available (notification only — the code is NOT in the payload)**\n\nDelivered as `POST` to each of your enabled endpoints with `x-cryptoinfra-event: card.wallet_otp` and `x-cryptoinfra-signature: <hex hmac-sha256 of the raw body>`.\n\n#### Payload\n\nSchema `WebhookCardWalletOtp`.\n\n| Field | Type | Description |\n| --- | --- | --- |\n| `cardId` | `integer` | — |\n| `userExternalId` | `string` | — |\n| `provider` | `string` | — |\n| `providerCardId` | `string` | — |\n| `event` | `string` |  _(one of `wallet_otp_available`)_ |\n| `name` | `string` | Provider event name. |\n| `otpKind` | `string` | Which code it is — branch on this, not on `name`. _(one of `payment_3ds`, `wallet_provisioning`)_ |\n| `retrievable` | `boolean` | True while the code can be fetched. |\n| `retrievalPath` | `string` | — |\n| `expiresInSeconds` | `integer` | — |\n\n```json\n{\n  \"cardId\": 1,\n  \"userExternalId\": \"string\",\n  \"provider\": \"string\",\n  \"providerCardId\": \"string\",\n  \"event\": \"wallet_otp_available\",\n  \"name\": \"string\",\n  \"otpKind\": \"payment_3ds\",\n  \"retrievable\": true,\n  \"retrievalPath\": \"/v1/cards/102/otp\",\n  \"expiresInSeconds\": 600\n}\n```\n",
      "sections": [
        {
          "anchor": "deposit-credited",
          "heading": "deposit.credited",
          "text": "A deposit cleared confirmations and was credited\n\nDelivered as POST to each of your enabled endpoints with x-cryptoinfra-event: deposit.credited and x-cryptoinfra-signature: <hex hmac-sha256 of the raw body>.\n\nSent to your registered endpoint. Headers: x-cryptoinfra-event: deposit.credited\nand x-cryptoinfra-signature: <hex hmac-sha256 of the raw body>. Verify the HMAC\nover the raw bytes before parsing.\n\nPayload\n\nSchema WebhookDepositCredited.\n\nField · Type · Description\ndepositId · integer · —\nuserExternalId · string · —\nassetId · string · —\nchain · string · The chain an address or asset is on. TRON is the only network the platform serves: btc, eth, bsc and sol were retired — historical addresses on them are never handed out again and nothing scans them. Read the value as an open string rather than a closed list, so a chain added later does not break your client. (one of tron)\namount · string · A monetary value as a decimal string. Always paired with a *_base field that carries the exact integer base-unit value; compute with the _base string.\nfee · string · A monetary value as a decimal string. Always paired with a *_base field that carries the exact integer base-unit value; compute with the _base string.\namountCredited · string · A monetary value as a decimal string. Always paired with a *_base field that carries the exact integer base-unit value; compute with the _base string.\ntxid · string · —\naddress · string · —\nfromAddress · string · Who paid it. For a token (USDT, USDC), the from of the Transfer event that credited the user's address — not the transaction's signer, which for an exchange's batch or proxy payout is an operator address. For TRX, the transaction's sender. null only for a deposit recorded before 19 September 2026 whose sender could not be read.\n\n{\n\"depositId\": 1,\n\"userExternalId\": \"string\",\n\"assetId\": \"string\",\n\"chain\": \"tron\",\n\"amount\": \"1.5\",\n\"fee\": \"1.5\",\n\"amountCredited\": \"1.5\",\n\"txid\": \"string\",\n\"address\": \"string\",\n\"fromAddress\": \"string\"\n}"
        },
        {
          "anchor": "withdrawal-confirmed",
          "heading": "withdrawal.confirmed",
          "text": "A payout confirmed on-chain\n\nDelivered as POST to each of your enabled endpoints with x-cryptoinfra-event: withdrawal.confirmed and x-cryptoinfra-signature: <hex hmac-sha256 of the raw body>.\n\nPayload\n\nSchema WebhookWithdrawalConfirmed.\n\nField · Type · Description\nwithdrawalId · integer · —\nuserExternalId · string · —\nassetId · string · —\nchain · string · The chain an address or asset is on. TRON is the only network the platform serves: btc, eth, bsc and sol were retired — historical addresses on them are never handed out again and nothing scans them. Read the value as an open string rather than a closed list, so a chain added later does not break your client. (one of tron)\namount · string · A monetary value as a decimal string. Always paired with a *_base field that carries the exact integer base-unit value; compute with the _base string.\nfee · string · A monetary value as a decimal string. Always paired with a *_base field that carries the exact integer base-unit value; compute with the _base string.\nfeeAssetId · string · The asset fee is charged in — always equals assetId (the option that could make it differ was retired on 2026-09-05; the field stays so an older consumer keeps working).\nplatformFee · string · A monetary value as a decimal string. Always paired with a *_base field that carries the exact integer base-unit value; compute with the _base string.\ntoAddress · string · —\ntxid · string · null · —\n\n{\n\"withdrawalId\": 1,\n\"userExternalId\": \"string\",\n\"assetId\": \"string\",\n\"chain\": \"tron\",\n\"amount\": \"1.5\",\n\"fee\": \"1.5\",\n\"feeAssetId\": \"string\",\n\"platformFee\": \"1.5\",\n\"toAddress\": \"string\",\n\"txid\": \"string\"\n}"
        },
        {
          "anchor": "withdrawal-failed",
          "heading": "withdrawal.failed",
          "text": "A payout failed and the hold was refunded\n\nDelivered as POST to each of your enabled endpoints with x-cryptoinfra-event: withdrawal.failed and x-cryptoinfra-signature: <hex hmac-sha256 of the raw body>.\n\nPayload\n\nSchema WebhookWithdrawalFailed.\n\nField · Type · Description\nwithdrawalId · integer · —\nuserExternalId · string · —\nassetId · string · —\nchain · string · The chain an address or asset is on. TRON is the only network the platform serves: btc, eth, bsc and sol were retired — historical addresses on them are never handed out again and nothing scans them. Read the value as an open string rather than a closed list, so a chain added later does not break your client. (one of tron)\namount · string · A monetary value as a decimal string. Always paired with a *_base field that carries the exact integer base-unit value; compute with the _base string.\nfee · string · A monetary value as a decimal string. Always paired with a *_base field that carries the exact integer base-unit value; compute with the _base string.\nfeeAssetId · string · The asset fee is charged in — always equals assetId (see withdrawal.confirmed).\nplatformFee · string · A monetary value as a decimal string. Always paired with a *_base field that carries the exact integer base-unit value; compute with the _base string.\ntoAddress · string · —\nrefunded · boolean · Always true — every hold, fee included, was refunded in full.\nerror · string · Why. rejected from the dashboard when an owner or finance member refused an awaiting_approval send.\n\n{\n\"withdrawalId\": 1,\n\"userExternalId\": \"string\",\n\"assetId\": \"string\",\n\"chain\": \"tron\",\n\"amount\": \"1.5\",\n\"fee\": \"1.5\",\n\"feeAssetId\": \"string\",\n\"platformFee\": \"1.5\",\n\"toAddress\": \"string\",\n\"refunded\": true,\n\"error\": \"string\"\n}"
        },
        {
          "anchor": "card-kyc",
          "heading": "card.kyc",
          "text": "A card holder's KYC status changed\n\nDelivered as POST to each of your enabled endpoints with x-cryptoinfra-event: card.kyc and x-cryptoinfra-signature: <hex hmac-sha256 of the raw body>.\n\nSigned like every tenant webhook: x-cryptoinfra-event: card.kyc and\nx-cryptoinfra-signature: <hex hmac-sha256 of the raw body>.\n\nPayload\n\nSchema WebhookCardKyc.\n\nField · Type · Description\nuserExternalId · string · —\nprovider · string · —\nkycStatus · string · Legacy issuer-KYC lifecycle on a card holder. Distinct from KycStatus, the cryptoinfra-owned verdict at /v1/kyc/*. (one of none, pending, approved, rejected)\nremark · string · null · —\n\n{\n\"userExternalId\": \"string\",\n\"provider\": \"string\",\n\"kycStatus\": \"none\",\n\"remark\": \"string\"\n}"
        },
        {
          "anchor": "card-status",
          "heading": "card.status",
          "text": "A card's status changed (also emitted when an issue order settles)\n\nDelivered as POST to each of your enabled endpoints with x-cryptoinfra-event: card.status and x-cryptoinfra-signature: <hex hmac-sha256 of the raw body>.\n\nPayload\n\nSchema WebhookCardStatus.\n\nField · Type · Description\ncardId · integer · —\nuserExternalId · string · —\nprovider · string · Present on the generic path only.\nstatus · integer · string · Provider RAW status code (integer) on a card-lifecycle change; a CardOrderStatus string on the issue-order variant.\nproviderStatus · string · —\nreason · string · Optional; present on card status-change events.\nrefundAmount · string · Informational remaining-balance figure on card close; no ledger entry is posted for it in v1.\norderId · integer · Present only on the issue-order settlement variant.\nkind · string · close_fee is the close fee charged to the cardholder on DELETE /v1/cards/{id}: amount is 0, fee is the charge. _(one of topup, withdraw, issue_fee, close_fee)_\nassetId · string · Present on the issue-order settlement variant.\namount · string · Present on the issue-order settlement variant.\nfee · string · Present on the issue-order settlement variant.\ngas · string · Issue-order variant — gas for the on-chain send.\nmerchantFee · string · Issue-order variant — your own fee.\ntotal · string · Issue-order variant — everything the cardholder was debited; mirror this\nerror · string · Present on the issue-order settlement variant when it failed.\n\n{\n\"cardId\": 1,\n\"userExternalId\": \"string\",\n\"provider\": \"string\",\n\"status\": 1,\n\"providerStatus\": \"string\",\n\"reason\": \"string\",\n\"refundAmount\": \"string\",\n\"orderId\": 1,\n\"kind\": \"topup\",\n\"assetId\": \"string\",\n\"amount\": \"1.5\",\n\"fee\": \"1.5\",\n\"gas\": \"1.5\",\n\"merchantFee\": \"1.5\",\n\"total\": \"1.5\",\n\"error\": \"string\"\n}"
        },
        {
          "anchor": "card-topup",
          "heading": "card.topup",
          "text": "A card top-up order reached a terminal state (confirmed or failed)\n\nDelivered as POST to each of your enabled endpoints with x-cryptoinfra-event: card.topup and x-cryptoinfra-signature: <hex hmac-sha256 of the raw body>.\n\nPayload\n\nSchema WebhookCardOrder.\n\nField · Type · Description\norderId · integer · —\nkind · string · close_fee is the close fee charged to the cardholder on DELETE /v1/cards/{id}: amount is 0, fee is the charge. _(one of topup, withdraw, issue_fee, close_fee)_\ntxid · string · null · The hash of the on-chain send behind the order, as on GET /v1/cards/orders/{id}; null when there was none.\nuserExternalId · string · —\ncardId · integer · null · —\nassetId · string · —\namount · string · A monetary value as a decimal string. Always paired with a *_base field that carries the exact integer base-unit value; compute with the _base string.\nfee · string · A monetary value as a decimal string. Always paired with a *_base field that carries the exact integer base-unit value; compute with the _base string.\ngas · string · The network fee actually CHARGED for the on-chain send behind the order — what the cardholder was debited, not the quoted figure.\nmerchantFee · string · Your own fee on this order, credited to your balance.\ntotal · string · Everything the cardholder was debited — amount + fee + gas + merchantFee.\namountCredited · string · A monetary value as a decimal string. Always paired with a *_base field that carries the exact integer base-unit value; compute with the _base string.\namountCreditedBase · string · Exact integer amount in the asset's smallest unit. (pattern ^[0-9]+$)\nstatus · string · funding = the cardholder has been charged and the money is on its way to the card rail on-chain. requested = confirmed on-chain, waiting for the rail to show the credit. sent = the rail has been asked, outcome still pending. confirmed = settled. failed = definitively failed; the WHOLE charge — amount, fee, gas and merchant_fee, i.e. total — is refunded to the balance it was taken from, the cardholder's, in the same moment (top-up / issue only — a withdraw holds nothing). Nothing goes anywhere else: not to the card, not to your treasury. When the on-chain send behind a load reverted (provider_status: send_failed), no coins left the cardholder's address either, so on-chain and ledger agree. reversed is reserved for manual operator reversals of a confirmed order. (one of funding, requested, sent, confirmed, failed, reversed)\nerror · string · —\nproviderFee · string · —\n\n{\n\"orderId\": 1,\n\"kind\": \"topup\",\n\"txid\": \"string\",\n\"userExternalId\": \"string\",\n\"cardId\": 1,\n\"assetId\": \"string\",\n\"amount\": \"1.5\",\n\"fee\": \"1.5\",\n\"gas\": \"1.5\",\n\"merchantFee\": \"1.5\",\n\"total\": \"1.5\",\n\"amountCredited\": \"1.5\",\n\"amountCreditedBase\": \"1500000000\",\n\"status\": \"funding\",\n\"error\": \"string\",\n\"providerFee\": \"string\"\n}"
        },
        {
          "anchor": "card-withdraw",
          "heading": "card.withdraw",
          "text": "A card→wallet withdrawal order reached a terminal state (confirmed or failed)\n\nDelivered as POST to each of your enabled endpoints with x-cryptoinfra-event: card.withdraw and x-cryptoinfra-signature: <hex hmac-sha256 of the raw body>.\n\nPayload\n\nSchema WebhookCardOrder.\n\nField · Type · Description\norderId · integer · —\nkind · string · close_fee is the close fee charged to the cardholder on DELETE /v1/cards/{id}: amount is 0, fee is the charge. _(one of topup, withdraw, issue_fee, close_fee)_\ntxid · string · null · The hash of the on-chain send behind the order, as on GET /v1/cards/orders/{id}; null when there was none.\nuserExternalId · string · —\ncardId · integer · null · —\nassetId · string · —\namount · string · A monetary value as a decimal string. Always paired with a *_base field that carries the exact integer base-unit value; compute with the _base string.\nfee · string · A monetary value as a decimal string. Always paired with a *_base field that carries the exact integer base-unit value; compute with the _base string.\ngas · string · The network fee actually CHARGED for the on-chain send behind the order — what the cardholder was debited, not the quoted figure.\nmerchantFee · string · Your own fee on this order, credited to your balance.\ntotal · string · Everything the cardholder was debited — amount + fee + gas + merchantFee.\namountCredited · string · A monetary value as a decimal string. Always paired with a *_base field that carries the exact integer base-unit value; compute with the _base string.\namountCreditedBase · string · Exact integer amount in the asset's smallest unit. (pattern ^[0-9]+$)\nstatus · string · funding = the cardholder has been charged and the money is on its way to the card rail on-chain. requested = confirmed on-chain, waiting for the rail to show the credit. sent = the rail has been asked, outcome still pending. confirmed = settled. failed = definitively failed; the WHOLE charge — amount, fee, gas and merchant_fee, i.e. total — is refunded to the balance it was taken from, the cardholder's, in the same moment (top-up / issue only — a withdraw holds nothing). Nothing goes anywhere else: not to the card, not to your treasury. When the on-chain send behind a load reverted (provider_status: send_failed), no coins left the cardholder's address either, so on-chain and ledger agree. reversed is reserved for manual operator reversals of a confirmed order. (one of funding, requested, sent, confirmed, failed, reversed)\nerror · string · —\nproviderFee · string · —\n\n{\n\"orderId\": 1,\n\"kind\": \"topup\",\n\"txid\": \"string\",\n\"userExternalId\": \"string\",\n\"cardId\": 1,\n\"assetId\": \"string\",\n\"amount\": \"1.5\",\n\"fee\": \"1.5\",\n\"gas\": \"1.5\",\n\"merchantFee\": \"1.5\",\n\"total\": \"1.5\",\n\"amountCredited\": \"1.5\",\n\"amountCreditedBase\": \"1500000000\",\n\"status\": \"funding\",\n\"error\": \"string\",\n\"providerFee\": \"string\"\n}"
        },
        {
          "anchor": "card-transaction",
          "heading": "card.transaction",
          "text": "A card transaction was created\n\nDelivered as POST to each of your enabled endpoints with x-cryptoinfra-event: card.transaction and x-cryptoinfra-signature: <hex hmac-sha256 of the raw body>.\n\nPayload\n\nSchema WebhookCardTransaction.\n\nField · Type · Description\ncardId · integer · —\nuserExternalId · string · —\ncurrency · string · null · —\ncreateAt · integer · null · Provider epoch timestamp.\ntransaction · object · —\ntransaction.recordNo · string · null · —\ntransaction.transactionId · string · null · —\ntransaction.originalTransactionId · string · null · On a refund (171)\ntransaction.transType · integer · null · 170 payment, 171 refund, 172 card fee, 173 ATM withdrawal, 174 pre-authorization.\ntransaction.transStatus · integer · null · 1 processing, 2 success, 3 failed.\ntransaction.merchantName · string · null · —\ntransaction.merchantCategoryCode · string · null · —\ntransaction.localCurrency · string · null · —\ntransaction.localCurrencyAmt · string · null · —\ntransaction.transCurrency · string · null · —\ntransaction.transCurrencyAmt · string · null · —\ntransaction.feeAmount · string · null · —\ntransaction.feeCurrency · string · null · —\ntransaction.balance · string · null · —\ntransaction.respCodeDesc · string · null · —\n\n{\n\"cardId\": 1,\n\"userExternalId\": \"string\",\n\"currency\": \"string\",\n\"createAt\": 1,\n\"transaction\": {\n\"recordNo\": \"string\",\n\"transactionId\": \"string\",\n\"originalTransactionId\": \"string\",\n\"transType\": 1,\n\"transStatus\": 1,\n\"merchantName\": \"string\",\n\"merchantCategoryCode\": \"string\",\n\"localCurrency\": \"string\",\n\"localCurrencyAmt\": \"string\",\n\"transCurrency\": \"string\",\n\"transCurrencyAmt\": \"string\",\n\"feeAmount\": \"string\",\n\"feeCurrency\": \"string\",\n\"balance\": \"string\",\n\"respCodeDesc\": \"string\"\n}\n}"
        },
        {
          "anchor": "card-wallet-otp",
          "heading": "card.wallet_otp",
          "text": "A live 3DS wallet OTP is available (notification only — the code is NOT in the payload)\n\nDelivered as POST to each of your enabled endpoints with x-cryptoinfra-event: card.wallet_otp and x-cryptoinfra-signature: <hex hmac-sha256 of the raw body>.\n\nPayload\n\nSchema WebhookCardWalletOtp.\n\nField · Type · Description\ncardId · integer · —\nuserExternalId · string · —\nprovider · string · —\nproviderCardId · string · —\nevent · string · _(one of wallet_otp_available)_\nname · string · Provider event name.\notpKind · string · Which code it is — branch on this, not on name. _(one of payment_3ds, wallet_provisioning)_\nretrievable · boolean · True while the code can be fetched.\nretrievalPath · string · —\nexpiresInSeconds · integer · —\n\n{\n\"cardId\": 1,\n\"userExternalId\": \"string\",\n\"provider\": \"string\",\n\"providerCardId\": \"string\",\n\"event\": \"wallet_otp_available\",\n\"name\": \"string\",\n\"otpKind\": \"payment_3ds\",\n\"retrievable\": true,\n\"retrievalPath\": \"/v1/cards/102/otp\",\n\"expiresInSeconds\": 600\n}"
        }
      ]
    }
  ]
}