# Cards

Crypto debit cards — holders, KYC, issuance, top-up/withdraw, reveal, and delivery

32 endpoints. Generated from `docs/openapi.yaml` v1.0.0.

| Endpoint | Summary |
| --- | --- |
| [`GET /v1/cards/holders`](#get-v1-cards-holders) | List your card holders |
| [`POST /v1/cards/holders`](#post-v1-cards-holders) | Register a card holder |
| [`GET /v1/cards/summary`](#get-v1-cards-summary) | Card counts at a glance |
| [`GET /v1/cards/orders`](#get-v1-cards-orders) | Card-order ledger |
| [`GET /v1/cards/kyc/countries`](#get-v1-cards-kyc-countries) | KYC country catalogue |
| [`POST /v1/cards/kyc/upload`](#post-v1-cards-kyc-upload) | Upload a KYC document |
| [`POST /v1/cards/kyc`](#post-v1-cards-kyc) | Submit the KYC dossier |
| [`GET /v1/cards/kyc/status`](#get-v1-cards-kyc-status) | Authoritative KYC status |
| [`POST /v1/kyc/start`](#post-v1-kyc-start) | Start (or resume) identity verification |
| [`GET /v1/kyc/status`](#get-v1-kyc-status) | Current KYC verdict (reconciled at read time) |
| [`GET /v1/cards/products`](#get-v1-cards-products) | Card product catalogue |
| [`GET /v1/cards`](#get-v1-cards) | List cards |
| [`POST /v1/cards`](#post-v1-cards) | Issue a card |
| [`GET /v1/cards/topup-quote`](#get-v1-cards-topup-quote) | Preview a top-up's fee and total |
| [`GET /v1/cards/issue-quote`](#get-v1-cards-issue-quote) | Preview what issuing a card costs the cardholder |
| [`GET /v1/cards/orders/{order_id}`](#get-v1-cards-orders-order-id) | Read a card order's status (the authoritative poll) |
| [`GET /v1/cards/{id}`](#get-v1-cards-id) | Secure card details |
| [`DELETE /v1/cards/{id}`](#delete-v1-cards-id) | Close (terminate) a card |
| [`GET /v1/cards/{id}/balance`](#get-v1-cards-id-balance) | Live card balance |
| [`POST /v1/cards/{id}/reveal`](#post-v1-cards-id-reveal) | Reveal the full card number |
| [`GET /v1/cards/{id}/otp`](#get-v1-cards-id-otp) | Fetch the card's live one-time code |
| [`POST /v1/cards/{id}/topup`](#post-v1-cards-id-topup) | Top up a card |
| [`GET /v1/cards/{id}/close-quote`](#get-v1-cards-id-close-quote) | What closing this card would charge the cardholder |
| [`GET /v1/merchant/card-fees`](#get-v1-merchant-card-fees) | Your own card fees, on top of ours |
| [`PUT /v1/merchant/card-fees/{operation}`](#put-v1-merchant-card-fees-operation) | Set your fee on one card operation |
| [`DELETE /v1/merchant/card-fees/{operation}`](#delete-v1-merchant-card-fees-operation) | Remove your fee on one card operation |
| [`POST /v1/cards/{id}/withdraw`](#post-v1-cards-id-withdraw) | Retired — moving part of a card balance back to the wallet |
| [`POST /v1/cards/{id}/freeze`](#post-v1-cards-id-freeze) | Freeze a card |
| [`POST /v1/cards/{id}/unfreeze`](#post-v1-cards-id-unfreeze) | Unfreeze a card |
| [`GET /v1/cards/{id}/transactions`](#get-v1-cards-id-transactions) | Card transaction history |
| [`GET /v1/cards/delivery/regions`](#get-v1-cards-delivery-regions) | Physical-card delivery regions |
| [`POST /v1/cards/delivery/address`](#post-v1-cards-delivery-address) | Add a physical-card delivery address |

## GET /v1/cards/holders

**List your card holders**  ·  _Auth: bearer API key_  ·  `operationId: listCardHolders`

One row per provider identity, newest first, with a card count and KYC state.

#### Responses

| Status | Body | Meaning |
| --- | --- | --- |
| `200` | `object` | Your card holders. |
| `401` | `Error` | Missing, malformed or unknown API key. |
| `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. |

**`200` response body**

| Field | Type | Description |
| --- | --- | --- |
| `holders` **required** | `object[]` | — |
| `holders[].id` **required** | `integer` | — |
| `holders[].user_id` **required** | `string` | Your external_user_id. |
| `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. |
| `holders[].name` **required** | `string` | The holder's display name as registered. |
| `holders[].program` **required** | `string` | — |
| `holders[].email` **required** | `string` | The email the holder was registered with. |
| `holders[].kyc_status` **required** | `string` |  _(one of `none`, `pending`, `approved`, `rejected`)_ |
| `holders[].card_count` **required** | `integer` | — |
| `holders[].created_at` **required** | `string<date-time>` | — |

```json
{
  "holders": [
    {
      "id": 1,
      "user_id": "string",
      "rail": "rail_a",
      "name": "string",
      "program": "string",
      "email": "string",
      "kyc_status": "none",
      "card_count": 1,
      "created_at": "2026-08-31T09:15:00.000Z"
    }
  ]
}
```

#### Example request

```bash
curl -X GET 'https://api.cryptoinfra.build/v1/cards/holders' \
  -H 'Authorization: Bearer ck_live_your_key'
```

## POST /v1/cards/holders

**Register a card holder**  ·  _Auth: bearer API key_  ·  `operationId: createCardHolder`

The first step of the card flow: registers your user at the card provider
and persists the holder. Idempotent per (user, rail) — replaying
returns the existing holder untouched (still `201`). Pass `provider`
only when two enabled rails could both take the holder.

The same customer may hold a card at any number of merchants with the
same email address: the rail's account is opened under a tagged copy of
it, so one merchant's holder never blocks another's. `409 email_taken`
(with `details.email`) is answered only when no tag is free for that
address — send a different one; it is not a retryable failure.

#### Request body

`application/json` · required

| Field | Type | Description |
| --- | --- | --- |
| `user_id` **required** | `string` | Your external_user_id. _(minLength `1`; maxLength `255`)_ |
| `email` **required** | `string<email>` |  _(maxLength `255`)_ |
| `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`)_ |
| `first_name` | `string` |  _(maxLength `64`)_ |
| `last_name` | `string` |  _(maxLength `64`)_ |
| `phone` | `string` | E.164 |
| `date_of_birth` | `string<date>` | — |
| `country` | `string` |  _(minLength `2`; maxLength `2`)_ |
| `zip_code` | `string` |  _(maxLength `16`)_ |
| `address` | `string` |  _(maxLength `128`)_ |
| `city` | `string` |  _(maxLength `64`)_ |
| `state` | `string` |  _(maxLength `64`)_ |

#### Responses

| Status | Body | Meaning |
| --- | --- | --- |
| `201` | `CardHolder` | Holder registered (or already existed). |
| `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. |
| `401` | `Error` | Missing, malformed or unknown API key. |
| `404` | `Error` | Unknown external_user_id for this tenant. |
| `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). |
| `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. |

**`201` response body** — `CardHolder`

| Field | Type | Description |
| --- | --- | --- |
| `user_id` **required** | `string` | Your external_user_id. |
| `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. |
| `email` **required** | `string` | — |
| `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`)_ |
| `kyc_remark` **required** | `string | null` | Provider remark (e.g. a rejection reason); null when none. |

```json
{
  "user_id": "string",
  "rail": "rail_a",
  "email": "string",
  "kyc_status": "none",
  "kyc_remark": "string"
}
```

#### Example request

```bash
curl -X POST 'https://api.cryptoinfra.build/v1/cards/holders' \
  -H 'Authorization: Bearer ck_live_your_key' \
  -H 'Content-Type: application/json' \
  -d '{
  "user_id": "string",
  "email": "string",
  "phone": "+6591234567"
}'
```

## GET /v1/cards/summary

**Card counts at a glance**  ·  _Auth: bearer API key_  ·  `operationId: cardsSummary`

Totals for a dashboard header — cards by normalized status, plus how many holders exist.

#### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `core_id` | query | `string` | no | Count one programme's cards only. |

#### Responses

| Status | Body | Meaning |
| --- | --- | --- |
| `200` | `object` | The counts. |
| `401` | `Error` | Missing, malformed or unknown API key. |
| `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. |

**`200` response body**

| Field | Type | Description |
| --- | --- | --- |
| `cards` **required** | `integer` | — |
| `by_status` **required** | `object` | Keys are issuing/active/frozen/suspended/closed/expired. |
| `holders` **required** | `integer` | — |
| `on_cards` | `string` | Sum of live card balances (≤60s cache); null when none were readable. |
| `processing` | `string` | Sum of pending holds. |
| `balances_read` | `integer` | — |

```json
{
  "cards": 1,
  "by_status": {},
  "holders": 1,
  "on_cards": "string",
  "processing": "string",
  "balances_read": 1
}
```

#### Example request

```bash
curl -X GET 'https://api.cryptoinfra.build/v1/cards/summary' \
  -H 'Authorization: Bearer ck_live_your_key'
```

## GET /v1/cards/orders

**Card-order ledger**  ·  _Auth: bearer API key_  ·  `operationId: listCardOrders`

Every issue, top-up, card withdrawal and close fee on your account, newest first. `card_id` filters to one card, `core_id` to one programme.

#### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `card_id` | query | `integer` | no | — _(min `1`)_ |
| `core_id` | query | `string` | no | One programme's orders. |
| `limit` | query | `integer` | no | — _(min `1`; max `500`)_ |

#### Responses

| Status | Body | Meaning |
| --- | --- | --- |
| `200` | `object` | The orders. |
| `401` | `Error` | Missing, malformed or unknown API key. |
| `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. |

**`200` response body**

| Field | Type | Description |
| --- | --- | --- |
| `orders` **required** | `object[]` | — |
| `orders[].id` | `integer` | — |
| `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`)_ |
| `orders[].status` | `string` | — |
| `orders[].amount` | `string` | — |
| `orders[].fee` | `string` | — |
| `orders[].amount_credited` | `string` | — |
| `orders[].card_id` | `integer` | — |
| `orders[].card_last4` | `string` | — |
| `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. |
| `orders[].user_id` | `string` | — |
| `orders[].error` | `string` | — |
| `orders[].txid` | `string` | The on-chain send's hash; null until broadcast or when the rail already held the money. |
| `orders[].created_at` | `string<date-time>` | — |

```json
{
  "orders": [
    {
      "id": 1,
      "kind": "topup",
      "status": "string",
      "amount": "string",
      "fee": "string",
      "amount_credited": "string",
      "card_id": 1,
      "card_last4": "string",
      "rail": "rail_a",
      "user_id": "string",
      "error": "string",
      "txid": "string",
      "created_at": "2026-08-31T09:15:00.000Z"
    }
  ]
}
```

#### Example request

```bash
curl -X GET 'https://api.cryptoinfra.build/v1/cards/orders' \
  -H 'Authorization: Bearer ck_live_your_key'
```

## GET /v1/cards/kyc/countries

**KYC country catalogue**  ·  _Auth: bearer API key_  ·  `operationId: getCardKycCountries`

The provider's supported KYC countries. `value` is the ISO alpha-3 code
to pass as `nation_code` / `country_code` in POST /v1/cards/kyc.

#### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `name` | query | `string` | no | Optional local case-insensitive filter on name/en_name. |
| `provider` | query | `string` | no | Rail id (a product's `rail`); omit unless two enabled rails could both answer. |

#### Responses

| Status | Body | Meaning |
| --- | --- | --- |
| `200` | `object` | Supported countries. |
| `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. |
| `401` | `Error` | Missing, malformed or unknown API key. |
| `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. |

**`200` response body**

| Field | Type | Description |
| --- | --- | --- |
| `countries` **required** | `object[]` | — |
| `countries[].name` | `string` | — |
| `countries[].en_name` | `string` | — |
| `countries[].value` | `string` | ISO alpha-3 code. |
| `countries[].prof_value` | `string` | — |
| `countries[].code` | `string` | — |
| `countries[].telephone_code` | `string` | — |
| `countries[].need_certificate` | `boolean` | true → this country additionally needs file_type/file_url/face_url in POST /v1/cards/kyc. |

```json
{
  "countries": [
    {
      "name": "string",
      "en_name": "string",
      "value": "string",
      "prof_value": "string",
      "code": "string",
      "telephone_code": "string",
      "need_certificate": true
    }
  ]
}
```

#### Example request

```bash
curl -X GET 'https://api.cryptoinfra.build/v1/cards/kyc/countries' \
  -H 'Authorization: Bearer ck_live_your_key'
```

## POST /v1/cards/kyc/upload

**Upload a KYC document**  ·  _Auth: bearer API key_  ·  `operationId: uploadCardKycDocument`

Passes a KYC document through to the provider and returns its hosted URL
(feed that URL into POST /v1/cards/kyc). The bytes live only in this
request — never stored on disk or in the DB. Max 5 MB decoded; the
extension must be .png/.jpg/.jpeg/.pdf.

#### Request body

`application/json` · required

| Field | Type | Description |
| --- | --- | --- |
| `user_id` **required** | `string` |  _(minLength `1`; maxLength `255`)_ |
| `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. |
| `filename` **required** | `string` |  _(minLength `1`; maxLength `255`)_ |
| `content_base64` **required** | `string` | Base64 of the document (≤5 MB decoded). |

#### Responses

| Status | Body | Meaning |
| --- | --- | --- |
| `201` | `object` | Uploaded. |
| `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. |
| `401` | `Error` | Missing, malformed or unknown API key. |
| `404` | `Error` | No matching card holder (`holder_not_found`), or an unknown user/payer (`user_not_found`), for this tenant. |
| `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. |

**`201` response body**

| Field | Type | Description |
| --- | --- | --- |
| `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}$`)_ |

```json
{
  "file_url": "string"
}
```

#### Example request

```bash
curl -X POST 'https://api.cryptoinfra.build/v1/cards/kyc/upload' \
  -H 'Authorization: Bearer ck_live_your_key' \
  -H 'Content-Type: application/json' \
  -d '{
  "user_id": "string",
  "provider": "rail_a",
  "filename": "string",
  "content_base64": "string"
}'
```

## POST /v1/cards/kyc

**Submit the KYC dossier**  ·  _Auth: bearer API key_  ·  `operationId: submitCardKyc`

Submits KYC to the provider and flips the holder to `pending`. We keep no
PII — the fields go straight to the provider. Resubmission after a
rejection is allowed; an already-approved holder is rejected
(`400 invalid_request`). `id_back_url` is required for cert_type 1 (ID
card) and 3 (driver's licence).

#### Request body

`application/json` · required · schema `CardKycApply`

| Field | Type | Description |
| --- | --- | --- |
| `user_id` **required** | `string` |  _(minLength `1`; maxLength `255`)_ |
| `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. |
| `first_name` **required** | `string` |  _(minLength `1`; maxLength `128`)_ |
| `last_name` **required** | `string` |  _(minLength `1`; maxLength `128`)_ |
| `id_no` **required** | `string` |  _(minLength `1`; maxLength `64`)_ |
| `email` **required** | `string<email>` |  _(maxLength `255`)_ |
| `nation_code` **required** | `string` | Nationality, ISO alpha-3 (the `value` from GET /v1/cards/kyc/countries). _(minLength `3`; maxLength `3`)_ |
| `cert_type` **required** | `integer` | 1 = ID card, 2 = passport, 3 = driver's licence. _(one of `1`, `2`, `3`)_ |
| `id_url` **required** | `string` | FRONT document photo — the `kycfile_…` handle from POST /v1/cards/kyc/upload, or a URL you host. _(maxLength `1024`)_ |
| `birthday` **required** | `string` | YYYY-MM-DD. _(pattern `^\d{4}-\d{2}-\d{2}$`)_ |
| `country_code` **required** | `string` | Country of residence, ISO alpha-3. _(minLength `3`; maxLength `3`)_ |
| `area_code` **required** | `string` | Dialing code without '+' (e.g. '86'). _(minLength `1`; maxLength `8`)_ |
| `phone` **required** | `string` |  _(minLength `3`; maxLength `32`)_ |
| `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`)_ |
| `file_type` | `integer` |  _(min `1`; max `100`)_ |
| `file_url` | `string` | Handle from the upload, or a URL you host. _(maxLength `1024`)_ |
| `face_url` | `string` | Handle from the upload, or a URL you host. _(maxLength `1024`)_ |
| `selfie_url` | `string` | Handle from the upload, or a URL you host. _(maxLength `1024`)_ |
| `reference_id` | `string` |  _(maxLength `128`)_ |
| `reference_type` | `string` |  _(maxLength `64`)_ |

#### Responses

| Status | Body | Meaning |
| --- | --- | --- |
| `202` | `CardHolder` | KYC accepted; the holder's status is now pending. |
| `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. |
| `401` | `Error` | Missing, malformed or unknown API key. |
| `404` | `Error` | No matching card holder (`holder_not_found`), or an unknown user/payer (`user_not_found`), for this tenant. |
| `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. |

**`202` response body** — `CardHolder`

| Field | Type | Description |
| --- | --- | --- |
| `user_id` **required** | `string` | Your external_user_id. |
| `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. |
| `email` **required** | `string` | — |
| `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`)_ |
| `kyc_remark` **required** | `string | null` | Provider remark (e.g. a rejection reason); null when none. |

```json
{
  "user_id": "string",
  "rail": "rail_a",
  "email": "string",
  "kyc_status": "none",
  "kyc_remark": "string"
}
```

#### Example request

```bash
curl -X POST 'https://api.cryptoinfra.build/v1/cards/kyc' \
  -H 'Authorization: Bearer ck_live_your_key' \
  -H 'Content-Type: application/json' \
  -d '{
  "user_id": "string",
  "provider": "rail_a",
  "first_name": "string",
  "last_name": "string",
  "id_no": "string",
  "email": "string",
  "nation_code": "string",
  "cert_type": 1,
  "id_url": "string",
  "birthday": "string",
  "country_code": "string",
  "area_code": "string",
  "phone": "string"
}'
```

## GET /v1/cards/kyc/status

**Authoritative KYC status**  ·  _Auth: bearer API key_  ·  `operationId: getCardKycStatus`

Polls the rail and syncs the holder row before answering. Pass `provider` only when the user holds LIVE identities on two rails at once.

#### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `user_id` | query | `string` | yes | — |
| `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. |

#### Responses

| Status | Body | Meaning |
| --- | --- | --- |
| `200` | `CardHolder` | The holder with its current KYC status. |
| `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. |
| `401` | `Error` | Missing, malformed or unknown API key. |
| `404` | `Error` | No matching card holder (`holder_not_found`), or an unknown user/payer (`user_not_found`), for this tenant. |
| `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. |

**`200` response body** — `CardHolder`

| Field | Type | Description |
| --- | --- | --- |
| `user_id` **required** | `string` | Your external_user_id. |
| `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. |
| `email` **required** | `string` | — |
| `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`)_ |
| `kyc_remark` **required** | `string | null` | Provider remark (e.g. a rejection reason); null when none. |

```json
{
  "user_id": "string",
  "rail": "rail_a",
  "email": "string",
  "kyc_status": "none",
  "kyc_remark": "string"
}
```

#### Example request

```bash
curl -X GET 'https://api.cryptoinfra.build/v1/cards/kyc/status?user_id=string' \
  -H 'Authorization: Bearer ck_live_your_key'
```

## POST /v1/kyc/start

**Start (or resume) identity verification**  ·  _Auth: bearer API key_  ·  `operationId: startKyc`

cryptoinfra-owned KYC, independent of the card rail. Creates
(or RESUMES) a verification session for `user_id` and returns the hosted
URL to send the user to. `approved` is terminal — a verified user is
never sent around again (no `url`, `status: approved`). Call this when a
product's `requires_kyc` is true before issuing it.

Server-to-server only: authenticate with the tenant key and proxy from
your backend — never expose this to a browser (the tenant key operates
every card on the tenant). The verdict itself is always re-read from the
provider's decision endpoint; poll GET /v1/kyc/status to observe it.

#### Request body

`application/json` · required

| Field | Type | Description |
| --- | --- | --- |
| `user_id` **required** | `string` | The tenant's user ref (same one that scopes card reads). _(minLength `1`; maxLength `255`)_ |
| `language` | `string` | Optional ISO language hint for the hosted flow. _(minLength `2`; maxLength `8`)_ |

#### Responses

| Status | Body | Meaning |
| --- | --- | --- |
| `200` | `object` | Session created or resumed (or already approved). |
| `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. |
| `401` | `Error` | Missing, malformed or unknown API key. |
| `404` | `Error` | Unknown external_user_id for this tenant. |
| `503` | `Error` | KYC is disabled or not configured. |

**`200` response body**

| Field | Type | Description |
| --- | --- | --- |
| `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`)_ |
| `url` | `string` | Hosted verification URL; absent when already approved. |

```json
{
  "status": "none",
  "url": "string"
}
```

#### Example request

```bash
curl -X POST 'https://api.cryptoinfra.build/v1/kyc/start' \
  -H 'Authorization: Bearer ck_live_your_key' \
  -H 'Content-Type: application/json' \
  -d '{
  "user_id": "string"
}'
```

## GET /v1/kyc/status

**Current KYC verdict (reconciled at read time)**  ·  _Auth: bearer API key_  ·  `operationId: getKycStatus`

The user's verdict, re-read from the provider while it can still change
(`pending`/`review`); terminal states are not re-fetched. `none` when the
user has never started verification.

#### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `user_id` | query | `string` | yes | — |

#### Responses

| Status | Body | Meaning |
| --- | --- | --- |
| `200` | `object` | The current verdict. |
| `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. |
| `401` | `Error` | Missing, malformed or unknown API key. |
| `404` | `Error` | Unknown external_user_id for this tenant. |

**`200` response body**

| Field | Type | Description |
| --- | --- | --- |
| `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`)_ |

```json
{
  "status": "none"
}
```

#### Example request

```bash
curl -X GET 'https://api.cryptoinfra.build/v1/kyc/status?user_id=string' \
  -H 'Authorization: Bearer ck_live_your_key'
```

## GET /v1/cards/products

**Card product catalogue**  ·  _Auth: bearer API key_  ·  `operationId: listCardProducts`

Every issuable product, across every card programme you can issue from —
the source of truth for the `product_code` and `core_id` you pass to
POST /v1/cards. Read both from here; never hard-code either. A programme
that is closed to new cards simply does not appear, so an empty list
means nothing is currently on sale rather than that the feature is
broken.

Each row carries the programme it belongs to (`core_id`, `core_name`)
and is PRICED FOR THAT PROGRAMME. The same BIN under two programmes can
cost different amounts, so compare prices by (`core_id`, `card_bin`) —
never by BIN alone.

Each row also carries what the user pays: `issue_fee` (our one-time
issuance fee for THAT product, on THAT programme), `min_opening_balance`
— the smallest `initial_amount` the issue will accept — and
`issue_total`, everything the cardholder is debited to open the card at
that minimum: our issue fee, the opening balance priced as a load (our
load fee + gas), and your own fees if set. Budget from `issue_total`
(or `total` on GET /v1/cards/issue-quote for another opening balance);
never add `issue_fee` and `initial_amount` yourself.

The envelope's top-level `issue_fee` is deprecated (virtual-card fee off
the default row only) — use `products[].issue_fee`.

#### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `core_id` | query | `string` | no | Show one programme's shelf, priced for it. Omit to see every programme on offer. |

#### Responses

| Status | Body | Meaning |
| --- | --- | --- |
| `200` | `object` | Products. |
| `401` | `Error` | Missing, malformed or unknown API key. |
| `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. |

**`200` response body**

| Field | Type | Description |
| --- | --- | --- |
| `asset_id` **required** | `string` | — |
| `gas` | `string` | The network line inside every `issue_total` below, priced for this response. |
| `gas_dynamic` | `boolean` | true = priced live now; false = the static fallback is inside the totals. |
| `gas_priced_at` | `string<date-time>` | — |
| `issue_fee` | `string` | Deprecated — virtual-card fee. Use products[].issue_fee. |
| `issue_fee_base` | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |
| `products` **required** | `object[]` | — |
| `products[].id` **required** | `integer | null` | Numeric id, kept for back-compat; null when the product code is not numeric. |
| `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. |
| `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`). |
| `products[].core_name` | `string` | What this programme is called, for display. e.g. `Core-1`. |
| `products[].code` **required** | `string` | Provider-scoped product code — pass as `product_code` to POST /v1/cards. |
| `products[].card_nature` **required** | `string` |  _(one of `VIRTUAL`, `PHYSICAL`)_ |
| `products[].ccy` **required** | `string` | Card currency (only USD is supported for money operations). |
| `products[].region` | `string | null` | — |
| `products[].card_bin` | `string | null` | — |
| `products[].card_brand` | `string | null` | — |
| `products[].card_mode` | `string | null` | — |
| `products[].title` | `string | null` | — |
| `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`. |
| `products[].active_min_limit` | `string | null` | Minimum FIRST top-up that activates a virtual card (decimal string); null when none. |
| `products[].recharge_min_limit` | `string | null` | Minimum single top-up (decimal string); null when the provider imposes none. |
| `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. |
| `products[].issue_fee` **required** | `string` | OUR issue fee for THIS product. |
| `products[].issue_fee_base` **required** | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |
| `products[].merchant_issue_fee` | `string` | Your own issue fee (PUT /v1/merchant/card-fees/issue). "0" until you set one. |
| `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. |
| `products[].issue_total_base` | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |
| `products[].charged_to` | `string` |  _(one of `cardholder`)_ |
| `products[].load_fee_bps` **required** | `integer` | Our load fee for cards of this product, basis points of the amount loaded. |
| `products[].load_fee_min` **required** | `string` | The floor on that load fee, in USD. |
| `products[].load_fee_flat` **required** | `string` | A flat platform fee on every load, in USD, on top of the percentage (or its floor). |
| `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`. |
| `products[].close_fee_base` | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |
| `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. |
| `products[].cardholder_fees.per_transaction` | `string` | USD |
| `products[].cardholder_fees.three_d_secure` | `string` | USD |
| `products[].cardholder_fees.foreign_currency_pct` | `string` | Percent |
| `products[].cardholder_fees.atm_flat` | `string` | USD |
| `products[].cardholder_fees.atm_pct` | `string` | Percent |
| `products[].cardholder_fees.cancellation` | `string` | USD |
| `products[].cardholder_fees.dispute` | `string` | USD |
| `products[].cardholder_fees.monthly_service` | `string | null` | Recurring holding fee; null when there is none. |
| `products[].limits` **required** | `object | null` | Ceilings the cardholder hits, per BIN. null with `cardholder_fees`. |
| `products[].limits.per_transaction` | `string` | — |
| `products[].limits.daily` | `string` | — |
| `products[].limits.monthly` | `string` | — |
| `products[].limits.atm_single` | `string` | — |
| `products[].limits.atm_per_day` | `integer` | — |
| `products[].limits.atm_monthly` | `string` | — |
| `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 |
| `products[].limits.validity_years` | `integer` | Years from issue. |
| `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. |
| `products[].min_opening_balance_base` | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |

```json
{
  "asset_id": "usdt_trc20",
  "gas": "1.5",
  "gas_dynamic": true,
  "gas_priced_at": "2026-08-31T09:15:00.000Z",
  "issue_fee": "1.5",
  "issue_fee_base": "1500000000",
  "products": [
    {
      "id": 1,
      "rail": "rail_a",
      "core_id": "string",
      "core_name": "string",
      "code": "string",
      "card_nature": "VIRTUAL",
      "ccy": "string",
      "region": "string",
      "card_bin": "string",
      "card_brand": "string",
      "card_mode": "string",
      "title": "string",
      "requires_kyc": true,
      "active_min_limit": "string",
      "recharge_min_limit": "string",
      "withdraw_min_limit": "string",
      "issue_fee": "1.5",
      "issue_fee_base": "1500000000",
      "merchant_issue_fee": "1.5",
      "issue_total": "1.5",
      "issue_total_base": "1500000000",
      "charged_to": "cardholder",
      "load_fee_bps": 1,
      "load_fee_min": "1.5",
      "load_fee_flat": "1.5",
      "close_fee": "1.5",
      "close_fee_base": "1500000000",
      "cardholder_fees": {
        "per_transaction": "string",
        "three_d_secure": "string",
        "foreign_currency_pct": "string",
        "atm_flat": "string",
        "atm_pct": "string",
        "cancellation": "string",
        "dispute": "string",
        "monthly_service": "string"
      },
      "limits": {
        "per_transaction": "string",
        "daily": "string",
        "monthly": "string",
        "atm_single": "string",
        "atm_per_day": 1,
        "atm_monthly": "string",
        "cards_per_user": 1,
        "validity_years": 1
      },
      "min_opening_balance": "5.000000",
      "min_opening_balance_base": "1500000000"
    }
  ]
}
```

#### Example request

```bash
curl -X GET 'https://api.cryptoinfra.build/v1/cards/products' \
  -H 'Authorization: Bearer ck_live_your_key'
```

## GET /v1/cards

**List cards**  ·  _Auth: bearer API key_  ·  `operationId: listCards`

With `user_id`: that user's cards across all their provider identities
(404 when the user has no card holder). Without it: every card on your
account, newest first — each row additionally carries `user_id`,
`cardholder_name`, the holder's `program`, and a cached (≤60 s) live
`balance` / `pending` (null when unreadable, never a fake zero), so any
per-card endpoint can be called from a row. `core_id` narrows either
form to one programme.

#### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `user_id` | query | `string` | no | — |
| `core_id` | query | `string` | no | One programme's cards only. |

#### Responses

| Status | Body | Meaning |
| --- | --- | --- |
| `200` | `object` | The cards. |
| `401` | `Error` | Missing, malformed or unknown API key. |
| `404` | `Error` | No matching card holder (`holder_not_found`), or an unknown user/payer (`user_not_found`), for this tenant. |
| `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. |

**`200` response body**

| Field | Type | Description |
| --- | --- | --- |
| `user_id` | `string` | Echoed only on the one-user form. |
| `cards` **required** | `object[]` | — |
| `cards[].id` **required** | `integer` | — |
| `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. |
| `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. |
| `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. |
| `cards[].product_id` **required** | `string | null` | Provider-scoped product CODE (kept as an alias of product_code); null when the card was listed untagged. |
| `cards[].product_code` **required** | `string | null` | Provider-scoped product code; null when the card was listed untagged. |
| `cards[].card_nature` **required** | `string` |  _(one of `VIRTUAL`, `PHYSICAL`)_ |
| `cards[].status` **required** | `integer` | The provider's RAW status code, verbatim — interpret via status_normalized. |
| `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`)_ |
| `cards[].currency` **required** | `string | null` | — |
| `cards[].last4` **required** | `string | null` | — |
| `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. |
| `cards[].issued_at` **required** | `string<date-time>` | — |
| `cards[].user_id` | `string` | The cardholder's external_user_id. |
| `cards[].cardholder_name` | `string | null` | — |
| `cards[].program` | `string | null` | — |
| `cards[].balance` | `string | null` | Live available balance (≤60 s cache); null when unreadable. |
| `cards[].pending` | `string | null` | Holds in flight; null when the rail does not expose them or the read failed. |

```json
{
  "user_id": "string",
  "cards": [
    {
      "id": 1,
      "rail": "rail_a",
      "core_id": "string",
      "bin": "string",
      "product_id": "string",
      "product_code": "string",
      "card_nature": "VIRTUAL",
      "status": 1,
      "status_normalized": "issuing",
      "currency": "string",
      "last4": "string",
      "name_on_card": "string",
      "issued_at": "2026-08-31T09:15:00.000Z",
      "user_id": "string",
      "cardholder_name": "string",
      "program": "string",
      "balance": "string",
      "pending": "string"
    }
  ]
}
```

#### Example request

```bash
curl -X GET 'https://api.cryptoinfra.build/v1/cards' \
  -H 'Authorization: Bearer ck_live_your_key'
```

## POST /v1/cards

**Issue a card**  ·  _Auth: bearer API key_  ·  `operationId: issueCard`

**A card is issued FUNDED.** `initial_amount` is required and must be at
least the product's `min_opening_balance` from GET /v1/cards/products
(read that value, do not hard-code a minimum — it tracks platform policy
and the issuer's own floor, whichever is higher). The cardholder is
debited `total` from GET /v1/cards/issue-quote — our issue fee, the
opening balance priced as a load (our load fee + gas), and your own fees
if set — at request time, and refunded together if the application
definitively fails. Read `issue_total` on the shelf or `total` on the
quote; never add the parts yourself.

Pass one of `product_id` / `product_code`, taken from
GET /v1/cards/products rather than hard-coded. `provider` is almost
never needed: the product code names its own rail.
`delivery_address_id` (from POST /v1/cards/delivery/address) is required
for PHYSICAL products. Requires an `approved` holder. `idempotency_key`
is required and replay-safe. The holder pays: our issue fee, the opening
balance priced as a load, gas, and your own fees if you set any — see
`issue_total` on GET /v1/cards/products.

**201** = the card exists (`card` populated). **202** = the application
is still resolving (`card` is null; a `card.status` webhook or the
reconciler links it later). Treat 202 as PENDING.

**The normal answer is 202.** The opening balance is sent on-chain to
the rail first, so the response is `202` with `order.status: funding`
(then `requested`/`sent`); the card is applied for once the rail shows
the credit and arrives on the `card.status` webhook, usually within
minutes.

**A 201 card is not spendable yet.** It is returned with
`status_normalized: issuing`, and turns `active` only once the opening
balance has landed at the issuer — usually minutes. An integrator that
polls for `active` immediately after the 201 will see `issuing` first;
that is the normal path, not an error. Watch `GET /v1/cards/{id}` or
the `card.status` webhook for the flip.

#### Request body

`application/json` · required

| Field | Type | Description |
| --- | --- | --- |
| `user_id` **required** | `string` |  _(minLength `1`; maxLength `255`)_ |
| `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`)_ |
| `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`)_ |
| `product_id` | `integer` | Numeric product id, kept for back-compat. One of product_id / product_code is required. _(min `1`)_ |
| `product_code` | `string` | Provider-scoped product code (preferred; wins when both are sent). _(minLength `1`; maxLength `64`)_ |
| `delivery_address_id` | `integer` | Required for PHYSICAL products (from POST /v1/cards/delivery/address). _(min `1`)_ |
| `idempotency_key` **required** | `string` | Required; replay-safe. _(minLength `1`; maxLength `255`)_ |
| `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 .'-]*$`)_ |
| `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+)?$`)_ |

#### Responses

| Status | Body | Meaning |
| --- | --- | --- |
| `201` | `CardIssueResult` | Card issued. `card.status_normalized` is `issuing` — it becomes `active` once the opening balance lands (see the description). |
| `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. |
| `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. |
| `401` | `Error` | Missing, malformed or unknown API key. |
| `404` | `Error` | No matching card holder (`holder_not_found`), or an unknown user/payer (`user_not_found`), for this tenant. |
| `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). |
| `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. |

**`201` response body** — `CardIssueResult`

| Field | Type | Description |
| --- | --- | --- |
| `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. |
| `order.id` **required** | `integer` | — |
| `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`)_ |
| `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`)_ |
| `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. |
| `order.asset_id` **required** | `string` | — |
| `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. |
| `order.amount_base` **required** | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |
| `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. |
| `order.fee_base` **required** | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |
| `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. |
| `order.merchant_fee` | `string` | Your own fee on this order, credited to your balance. |
| `order.total` | `string` | Everything the cardholder was debited for this order. |
| `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. |
| `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. |
| `order.amount_credited_base` **required** | `string | null` | Integer-string base units of `amount_credited` (`null` until a withdraw confirms). |
| `order.card_id` **required** | `integer | null` | — |
| `order.request_order_id` **required** | `string | null` | Server-minted correlation handle (`ct-`/`cw-`/`ci-` prefix). |
| `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`. |
| `order.error` **required** | `string | null` | — |
| `order.idempotency_key` **required** | `string | null` | — |
| `order.created_at` **required** | `string<date-time>` | — |
| `card` **required** | `object | null` | The issued card, or null while the application is still resolving (202). |
| `card.id` **required** | `integer` | — |
| `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. |
| `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. |
| `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. |
| `card.product_id` **required** | `string | null` | Provider-scoped product CODE (kept as an alias of product_code); null when the card was listed untagged. |
| `card.product_code` **required** | `string | null` | Provider-scoped product code; null when the card was listed untagged. |
| `card.card_nature` **required** | `string` |  _(one of `VIRTUAL`, `PHYSICAL`)_ |
| `card.status` **required** | `integer` | The provider's RAW status code, verbatim — interpret via status_normalized. |
| `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`)_ |
| `card.currency` **required** | `string | null` | — |
| `card.last4` **required** | `string | null` | — |
| `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. |
| `card.issued_at` **required** | `string<date-time>` | — |

```json
{
  "order": {
    "id": 1,
    "kind": "topup",
    "status": "funding",
    "txid": "string",
    "asset_id": "usdt_trc20",
    "amount": "1.5",
    "amount_base": "1500000000",
    "fee": "1.5",
    "fee_base": "1500000000",
    "gas": "1.5",
    "merchant_fee": "1.5",
    "total": "1.5",
    "sent_on_chain": "1.5",
    "amount_credited": "1.5",
    "amount_credited_base": "1500000000",
    "card_id": 1,
    "request_order_id": "string",
    "provider_status": "string",
    "error": "string",
    "idempotency_key": "string",
    "created_at": "2026-08-31T09:15:00.000Z"
  },
  "card": {
    "id": 1,
    "rail": "rail_a",
    "core_id": "string",
    "bin": "string",
    "product_id": "string",
    "product_code": "string",
    "card_nature": "VIRTUAL",
    "status": 1,
    "status_normalized": "issuing",
    "currency": "string",
    "last4": "string",
    "name_on_card": "string",
    "issued_at": "2026-08-31T09:15:00.000Z"
  }
}
```

#### Example request

```bash
curl -X POST 'https://api.cryptoinfra.build/v1/cards' \
  -H 'Authorization: Bearer ck_live_your_key' \
  -H 'Content-Type: application/json' \
  -d '{
  "user_id": "string",
  "idempotency_key": "string",
  "initial_amount": "string"
}'
```

## GET /v1/cards/topup-quote

**Preview a top-up's fee and total**  ·  _Auth: bearer API key_  ·  `operationId: cardTopupQuote`

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

#### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `amount` | query | `string` | yes | Decimal amount to top up (≤6 decimal places). |
| `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. |
| `card_id` | query | `integer` | no | Price for THIS card — its rail, BIN and product. Send with `user_id`. _(min `1`)_ |
| `user_id` | query | `string` | no | The card's holder (your external user id). Send with `card_id`. |

#### Responses

| Status | Body | Meaning |
| --- | --- | --- |
| `200` | `object` | The fee preview — every component the cardholder is debited. |
| `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. |
| `401` | `Error` | Missing, malformed or unknown API key. |
| `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. |

**`200` response body**

| Field | Type | Description |
| --- | --- | --- |
| `asset_id` **required** | `string` | — |
| `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. |
| `amount_base` **required** | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |
| `fee` **required** | `string` | Our fee. |
| `fee_base` **required** | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |
| `gas` **required** | `string` | The network fee for the on-chain send behind this load. |
| `gas_base` **required** | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |
| `merchant_fee` **required** | `string` | Your own fee (PUT /v1/merchant/card-fees/load). "0" until you set one. |
| `merchant_fee_base` **required** | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |
| `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". |
| `credited_base` **required** | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |
| `total` **required** | `string` | What the cardholder's balance is debited — amount + fee + gas + merchant_fee. |
| `total_base` **required** | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |
| `charged_to` **required** | `string` |  _(one of `cardholder`)_ |
| `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. |

```json
{
  "asset_id": "usdt_trc20",
  "amount": "1.5",
  "amount_base": "1500000000",
  "fee": "1.5",
  "fee_base": "1500000000",
  "gas": "1.5",
  "gas_base": "1500000000",
  "merchant_fee": "1.5",
  "merchant_fee_base": "1500000000",
  "credited": "1.5",
  "credited_base": "1500000000",
  "total": "1.5",
  "total_base": "1500000000",
  "charged_to": "cardholder",
  "dynamic": true
}
```

#### Example request

```bash
curl -X GET 'https://api.cryptoinfra.build/v1/cards/topup-quote?amount=string' \
  -H 'Authorization: Bearer ck_live_your_key'
```

## GET /v1/cards/issue-quote

**Preview what issuing a card costs the cardholder**  ·  _Auth: bearer API key_  ·  `operationId: cardIssueQuote`

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

#### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `product_code` | query | `string` | yes | A `code` from GET /v1/cards/products. |
| `core_id` | query | `string` | no | The programme to issue from. Optional with one programme; send it when you hold several. |
| `initial_amount` | query | `string` | no | Opening balance (≤6 decimal places). Omitted = the product's `min_opening_balance`; below it is a 400 invalid_amount. |

#### Responses

| Status | Body | Meaning |
| --- | --- | --- |
| `200` | `object` | The preview — every component the cardholder is debited. |
| `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. |
| `401` | `Error` | Missing, malformed or unknown API key. |
| `404` | `Error` | Record not found (or belongs to another tenant). |
| `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. |

**`200` response body**

| Field | Type | Description |
| --- | --- | --- |
| `asset_id` **required** | `string` | — |
| `core_id` **required** | `string` | — |
| `product_code` **required** | `string` | — |
| `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. |
| `initial_amount_base` **required** | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |
| `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. |
| `fee` **required** | `string` | Our fee — the issue fee plus our load fee on the opening balance. |
| `fee_base` **required** | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |
| `gas` **required** | `string` | The network fee for the on-chain send behind the opening balance. |
| `gas_base` **required** | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |
| `merchant_fee` **required** | `string` | Your own fees on this issue (PUT /v1/merchant/card-fees/issue and /load). "0" until you set one. |
| `merchant_fee_base` **required** | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |
| `opening_credited` **required** | `string` | What the opening balance puts on the card — initial_amount itself; its load fee and gas are charged on top. |
| `opening_credited_base` **required** | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |
| `total` **required** | `string` | initial_amount + fee + gas + merchant_fee — what the cardholder's balance is debited. |
| `total_base` **required** | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |
| `charged_to` **required** | `string` |  _(one of `cardholder`)_ |
| `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). |

```json
{
  "asset_id": "usdt_trc20",
  "core_id": "core-1",
  "product_code": "string",
  "initial_amount": "1.5",
  "initial_amount_base": "1500000000",
  "min_opening_balance": "1.5",
  "fee": "1.5",
  "fee_base": "1500000000",
  "gas": "1.5",
  "gas_base": "1500000000",
  "merchant_fee": "1.5",
  "merchant_fee_base": "1500000000",
  "opening_credited": "1.5",
  "opening_credited_base": "1500000000",
  "total": "1.5",
  "total_base": "1500000000",
  "charged_to": "cardholder",
  "dynamic": true
}
```

#### Example request

```bash
curl -X GET 'https://api.cryptoinfra.build/v1/cards/issue-quote?product_code=string' \
  -H 'Authorization: Bearer ck_live_your_key'
```

## GET /v1/cards/orders/{order_id}

**Read a card order's status (the authoritative poll)**  ·  _Auth: bearer API key_  ·  `operationId: getCardOrder`

Return a card order by its `id`. This is the **authoritative outcome
read** behind a "webhooks are hints, the poll is truth" reconcile model:
a top-up / withdrawal / issue-fee order is created `202 processing`
(`status: funding`, `requested` or `sent`) and reaches `confirmed` or `failed` as the
platform's reconciler and provider webhooks settle it. Poll this when a
`card.topup` / `card.withdraw` webhook was missed (delivery exhausted, or
your endpoint was down) so a debited operation can always be resolved
without a manual investigation. Tenant-scoped: another tenant's order
returns `404` and is never disclosed.

Refund discipline (unchanged): refund the customer only on
`status: failed` (definitive — the money did not move); never on
`requested`/`sent` (outcome still unknown).

#### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `order_id` | path | `integer` | yes | The order `id` returned by issue / top-up / withdraw. _(min `1`)_ |

#### Responses

| Status | Body | Meaning |
| --- | --- | --- |
| `200` | `CardOrder` | The card order in its current (possibly terminal) state. |
| `401` | `Error` | Missing, malformed or unknown API key. |
| `404` | `Error` | Unknown card (`card_not_found`), or an unknown payer user (`user_not_found`), for this tenant. |
| `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. |

**`200` response body** — `CardOrder`

| Field | Type | Description |
| --- | --- | --- |
| `id` **required** | `integer` | — |
| `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`)_ |
| `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`)_ |
| `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. |
| `asset_id` **required** | `string` | — |
| `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. |
| `amount_base` **required** | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |
| `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. |
| `fee_base` **required** | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |
| `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. |
| `merchant_fee` | `string` | Your own fee on this order, credited to your balance. |
| `total` | `string` | Everything the cardholder was debited for this 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. |
| `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. |
| `amount_credited_base` **required** | `string | null` | Integer-string base units of `amount_credited` (`null` until a withdraw confirms). |
| `card_id` **required** | `integer | null` | — |
| `request_order_id` **required** | `string | null` | Server-minted correlation handle (`ct-`/`cw-`/`ci-` prefix). |
| `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`. |
| `error` **required** | `string | null` | — |
| `idempotency_key` **required** | `string | null` | — |
| `created_at` **required** | `string<date-time>` | — |

```json
{
  "id": 1,
  "kind": "topup",
  "status": "funding",
  "txid": "string",
  "asset_id": "usdt_trc20",
  "amount": "1.5",
  "amount_base": "1500000000",
  "fee": "1.5",
  "fee_base": "1500000000",
  "gas": "1.5",
  "merchant_fee": "1.5",
  "total": "1.5",
  "sent_on_chain": "1.5",
  "amount_credited": "1.5",
  "amount_credited_base": "1500000000",
  "card_id": 1,
  "request_order_id": "string",
  "provider_status": "string",
  "error": "string",
  "idempotency_key": "string",
  "created_at": "2026-08-31T09:15:00.000Z"
}
```

#### Example request

```bash
curl -X GET 'https://api.cryptoinfra.build/v1/cards/orders/1' \
  -H 'Authorization: Bearer ck_live_your_key'
```

## GET /v1/cards/{id}

**Secure card details**  ·  _Auth: bearer API key_  ·  `operationId: getCard`

Local card + live balance + a link to this card's hosted card-number
page (`card_pan_url` — drop it into an `<iframe src>`; it works for
about ten minutes, so fetch it fresh each time you show the number).
This endpoint never returns PAN/CVV — the full number is a separate,
explicit call: POST /v1/cards/{id}/reveal. A live provider read failure
DEGRADES to stored state (`provider_read_failed: true`, null balance —
never fake zeros) rather than failing the call. When the read succeeded
but there is no card-number page, `reveal_unavailable: true` is set.

#### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | `integer` | yes | The card's numeric id (from GET /v1/cards). _(min `1`)_ |
| `user_id` | query | `string` | yes | — |

#### Responses

| Status | Body | Meaning |
| --- | --- | --- |
| `200` | `CardDetails` | Card details (possibly degraded — see CardDetails). |
| `401` | `Error` | Missing, malformed or unknown API key. |
| `404` | `Error` | Unknown card (`card_not_found`), or an unknown payer user (`user_not_found`), for this tenant. |
| `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. |

**`200` response body** — `CardDetails`

| Field | Type | Description |
| --- | --- | --- |
| `card` **required** | `object` | — |
| `card.id` **required** | `integer` | — |
| `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. |
| `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. |
| `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. |
| `card.product_id` **required** | `string | null` | Provider-scoped product CODE (kept as an alias of product_code); null when the card was listed untagged. |
| `card.product_code` **required** | `string | null` | Provider-scoped product code; null when the card was listed untagged. |
| `card.card_nature` **required** | `string` |  _(one of `VIRTUAL`, `PHYSICAL`)_ |
| `card.status` **required** | `integer` | The provider's RAW status code, verbatim — interpret via status_normalized. |
| `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`)_ |
| `card.currency` **required** | `string | null` | — |
| `card.last4` **required** | `string | null` | — |
| `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. |
| `card.issued_at` **required** | `string<date-time>` | — |
| `balance` **required** | `object | null` | Live card balance, or null when the provider read failed (never a fake zero). |
| `balance.available` **required** | `string` | Spendable balance |
| `balance.pending` **required** | `string | null` | Held amount; null (NOT 0) when the provider does not expose holds. |
| `balance.currency` **required** | `string` | — |
| `currency` **required** | `string | null` | — |
| `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. |
| `expire_date` **required** | `string | null` | `YYYY-MM`. |
| `masked_number` | `string | null` | Masked PAN as the issuer renders it. |
| `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. |
| `cardholder_name` | `string | null` | — |
| `bin` | `string | null` | — |
| `decline_count` | `integer | null` | Declines counted by the issuer; null when it exposes none. |
| `decline_limit` | `integer | null` | Declines allowed before the issuer acts; null when it exposes none. |
| `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. |
| `otp_waiting` | `object` | Present only while `otp_available` is true: what the waiting code is for. Never the code itself. |
| `otp_waiting.kind` **required** | `string` |  _(one of `payment_3ds`, `wallet_provisioning`)_ |
| `otp_waiting.merchant_name` | `string` | — |
| `otp_waiting.amount` | `string` | — |
| `otp_waiting.currency` | `string` | — |
| `otp_waiting.expires_in_seconds` **required** | `integer` |  _(min `0`)_ |
| `provider_read_failed` | `boolean` | Present and true only when the live provider read failed (response degraded to stored state). |
| `note` | `string` | Present alongside provider_read_failed. |
| `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). |
| `reveal_note` | `string` | Present alongside reveal_unavailable. |

```json
{
  "card": {
    "id": 1,
    "rail": "rail_a",
    "core_id": "string",
    "bin": "string",
    "product_id": "string",
    "product_code": "string",
    "card_nature": "VIRTUAL",
    "status": 1,
    "status_normalized": "issuing",
    "currency": "string",
    "last4": "string",
    "name_on_card": "string",
    "issued_at": "2026-08-31T09:15:00.000Z"
  },
  "balance": {
    "available": "string",
    "pending": "string",
    "currency": "USD"
  },
  "currency": "string",
  "card_pan_url": "string",
  "expire_date": "string",
  "masked_number": "string",
  "name_on_card": "string",
  "cardholder_name": "string",
  "bin": "string",
  "decline_count": 1,
  "decline_limit": 1,
  "otp_available": true,
  "otp_waiting": {
    "kind": "payment_3ds",
    "merchant_name": "string",
    "amount": "string",
    "currency": "string",
    "expires_in_seconds": 0
  },
  "provider_read_failed": true,
  "note": "string",
  "reveal_unavailable": true,
  "reveal_note": "string"
}
```

#### Example request

```bash
curl -X GET 'https://api.cryptoinfra.build/v1/cards/1?user_id=string' \
  -H 'Authorization: Bearer ck_live_your_key'
```

## DELETE /v1/cards/{id}

**Close (terminate) a card**  ·  _Auth: bearer API key_  ·  `operationId: closeCard`

Terminates the card. **One call is enough.**

1. If the card still holds a balance, this call returns the residual to
   us (a card→wallet withdrawal order) and answers
   `202 card_close_in_progress` with `details.card_id` and
   `details.order_id`. The card is not closed yet — **a 202 is not a
   closed card**. Until it is, `GET /v1/cards/{id}` carries
   `close_in_progress: true`.
2. Within about a minute of that order settling we finish the close
   ourselves: the close fee — ours plus yours, see
   GET /v1/cards/{id}/close-quote — is debited from the cardholder and
   the card is terminated. The `card.status` webhook confirms it, and
   the card reads `status_normalized: closed`. The fee shows up as a
   `close_fee` order. You may also call DELETE again yourself once the
   order is confirmed (`card.withdraw` webhook or
   GET /v1/cards/orders/{order_id}); that finishes it at once, and a
   DELETE on a card we already closed answers `200` with the card.

The close is **always accepted**: it never fails because a refund cannot be paid yet.
Read `close_refund.state` on `GET /v1/cards/{id}` for where the money is
(`waiting` → `approved` → `sending` → `paid`, or `held` with a reason).

A card holding **less than the issuer's cancellation fee**, or nothing at all,
closes like any other (since 20 September 2026): the issuer keeps what is on
the card, there is nothing to return, and `409 card_close_underfunded` is no
longer raised. `minimum_card_balance` on the close quote is always `0`.

Money still **pending** on the card blocks the close with
`409 card_state` (the error names the amount) — wait for it to settle.

The card's balance goes back to the holder's own address on-chain, from
a wallet cryptoinfra funds by hand, within **7 working days** of the issuer
releasing it. The close is never refused while that is arranged
(`409 card_refund_wallet_empty` is retired): read `close_refund.state` on
`GET /v1/cards/{id}` — `waiting`, `approved`, `sending`, `paid`, or `held`
with a reason.
Closing an already-closed or expired card also answers `409 card_state`.
If the balance cannot be read the close is refused (`400`) — an
unreadable balance is never treated as zero.

**Residual-on-close rails** refund the residual as part of the close.
The settlement that credits that residual back to the user arrives in a
later release; until then close for these providers answers
`400 not_supported_by_provider`.

#### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | `integer` | yes | The card's numeric id (from GET /v1/cards). _(min `1`)_ |

#### Request body

`application/json` · required · schema `CardUserRef`

| Field | Type | Description |
| --- | --- | --- |
| `user_id` **required** | `string` |  _(minLength `1`; maxLength `255`)_ |

#### Responses

| Status | Body | Meaning |
| --- | --- | --- |
| `200` | `object` | Card closed; the close fee was debited from the cardholder. |
| `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`. |
| `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. |
| `401` | `Error` | Missing, malformed or unknown API key. |
| `404` | `Error` | Unknown card (`card_not_found`), or an unknown payer user (`user_not_found`), for this tenant. |
| `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). |
| `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. |

**`200` response body**

| Field | Type | Description |
| --- | --- | --- |
| `card` **required** | `object` | — |
| `card.id` **required** | `integer` | — |
| `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. |
| `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. |
| `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. |
| `card.product_id` **required** | `string | null` | Provider-scoped product CODE (kept as an alias of product_code); null when the card was listed untagged. |
| `card.product_code` **required** | `string | null` | Provider-scoped product code; null when the card was listed untagged. |
| `card.card_nature` **required** | `string` |  _(one of `VIRTUAL`, `PHYSICAL`)_ |
| `card.status` **required** | `integer` | The provider's RAW status code, verbatim — interpret via status_normalized. |
| `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`)_ |
| `card.currency` **required** | `string | null` | — |
| `card.last4` **required** | `string | null` | — |
| `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. |
| `card.issued_at` **required** | `string<date-time>` | — |

```json
{
  "card": {
    "id": 1,
    "rail": "rail_a",
    "core_id": "string",
    "bin": "string",
    "product_id": "string",
    "product_code": "string",
    "card_nature": "VIRTUAL",
    "status": 1,
    "status_normalized": "issuing",
    "currency": "string",
    "last4": "string",
    "name_on_card": "string",
    "issued_at": "2026-08-31T09:15:00.000Z"
  }
}
```

#### Example request

```bash
curl -X DELETE 'https://api.cryptoinfra.build/v1/cards/1' \
  -H 'Authorization: Bearer ck_live_your_key' \
  -H 'Content-Type: application/json' \
  -d '{
  "user_id": "string"
}'
```

## GET /v1/cards/{id}/balance

**Live card balance**  ·  _Auth: bearer API key_  ·  `operationId: getCardBalance`

`pending` is null (not 0) when the provider does not expose holds.

While the card is still being issued (`status_normalized: issuing`, the
minute or two after POST /v1/cards) this answers `409 card_issuing` —
poll GET /v1/cards/{id} or wait for the `card.status` webhook, then retry.

#### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | `integer` | yes | The card's numeric id (from GET /v1/cards). _(min `1`)_ |
| `user_id` | query | `string` | yes | — |

#### Responses

| Status | Body | Meaning |
| --- | --- | --- |
| `200` | `CardBalance` | The card's balance. |
| `401` | `Error` | Missing, malformed or unknown API key. |
| `404` | `Error` | Unknown card (`card_not_found`), or an unknown payer user (`user_not_found`), for this tenant. |
| `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). |
| `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. |

**`200` response body** — `CardBalance`

| Field | Type | Description |
| --- | --- | --- |
| `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. |
| `available_base` **required** | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |
| `pending` **required** | `string | null` | Held amount (decimal string); null (NOT 0) when the provider does not expose holds. |
| `pending_base` **required** | `string | null` | — |
| `currency` **required** | `string` | — |

```json
{
  "available": "1.5",
  "available_base": "1500000000",
  "pending": "string",
  "pending_base": "string",
  "currency": "string"
}
```

#### Example request

```bash
curl -X GET 'https://api.cryptoinfra.build/v1/cards/1/balance?user_id=string' \
  -H 'Authorization: Bearer ck_live_your_key'
```

## POST /v1/cards/{id}/reveal

**Reveal the full card number**  ·  _Auth: bearer API key_  ·  `operationId: revealCard`

Full card details for the merchant that OWNS the card, on explicit
request. POST (never GET) so the card id never lands in an access-log
query string, and the body is the only place the secrets ever appear.

**The issuer decides the shape of the answer, and exactly ONE of the two
forms is populated — always branch on which:**

- **Raw secrets** — `pan`, `cvv` and `expiry` (`MM/YY`) are filled and
  `reveal_url` is `null`.
- **Hosted page** — the issuer reveals only through its own page: `pan`
  and `cvv` come back `null` and `reveal_url` carries a URL to open.
  The link is SHORT-LIVED (about a minute) and single-purpose: hand it
  straight to the cardholder's browser. Do not cache it, store it, put
  it in an email, or re-serve it later — a stale link is simply dead,
  and mint a fresh one by calling this endpoint again.

A client that reads only `pan` will therefore show a blank card number
for every card on a hosted-reveal product. Handle both.

While the card is still being issued (`status_normalized: issuing`) this
answers `409 card_issuing` instead of a body with neither form — poll
GET /v1/cards/{id} or wait for the `card.status` webhook, then retry.

**Handling rules (both forms).** The response is sent with
`Cache-Control: no-store`. Never log, persist, or forward these values —
not to your database, not to analytics, not into a support ticket.
Render them to the cardholder and drop them.

**This is the way to show a card number.** Both enabled rails answer it
with raw secrets, so you can render your own card face — you do not need
to embed anything. `card_pan_url` on `GET /v1/cards/{id}` remains for
clients that would rather not handle a PAN at all, and `reveal_url` here
covers the rare product whose issuer insists on showing the number
itself. A rail with no reveal at all answers
`400 not_supported_by_provider`.

Scoped exactly like every other per-card call: the card must belong to
this tenant AND to the `user_id` in the body, or the call 404s.

#### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | `integer` | yes | The card's numeric id (from GET /v1/cards). _(min `1`)_ |

#### Request body

`application/json` · required · schema `CardUserRef`

| Field | Type | Description |
| --- | --- | --- |
| `user_id` **required** | `string` |  _(minLength `1`; maxLength `255`)_ |

#### Responses

| Status | Body | Meaning |
| --- | --- | --- |
| `200` | `CardSecrets` | The card details, returned once. Sent with `Cache-Control: no-store` — do not cache, log, or persist any field. |
| `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. |
| `401` | `Error` | Missing, malformed or unknown API key. |
| `404` | `Error` | Unknown card (`card_not_found`), or an unknown payer user (`user_not_found`), for this tenant. |
| `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). |
| `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. |

**`200` response body** — `CardSecrets`

| Field | Type | Description |
| --- | --- | --- |
| `pan` **required** | `string | null` | Full card number; null when the issuer reveals via `reveal_url` instead. |
| `cvv` **required** | `string | null` | Security code; null when the issuer reveals via `reveal_url` instead. |
| `expiry` **required** | `string | null` | `MM/YY`; null when the issuer does not disclose it here. |
| `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. |

```json
{
  "pan": "string",
  "cvv": "string",
  "expiry": "string",
  "reveal_url": "string"
}
```

#### Example request

```bash
curl -X POST 'https://api.cryptoinfra.build/v1/cards/1/reveal' \
  -H 'Authorization: Bearer ck_live_your_key' \
  -H 'Content-Type: application/json' \
  -d '{
  "user_id": "string"
}'
```

## GET /v1/cards/{id}/otp

**Fetch the card's live one-time code**  ·  _Auth: bearer API key_  ·  `operationId: getCardOtp`

Returns the verification code the ISSUER relayed for this card — either a
3DS code that approves a payment, or a wallet code that adds the card to
Apple/Google Pay.

**The code never reaches the cardholder's own phone or email.** For an
issued-on-your-program card the issuer sends it to us, so whichever
channel the wallet or checkout offers your customer, the code arrives
here. This endpoint is how you hand it to them.

Three properties to build against:

- **Single read.** Fetching CONSUMES the code — a second call returns 404.
  Never poll this endpoint; call it when your user is ready to type the
  code. To know a code is waiting, read `otp_available` on
  `GET /v1/cards/{id}` (a peek that does not consume) or listen for the
  `card.wallet_otp` webhook. Your API key and your dashboard each have
  their own read: fetching a code from your server does not take it away
  from someone looking at the card in the dashboard, and the other way
  round.
- **Short-lived.** Codes expire in about ten minutes; `expires_in_seconds`
  is the remaining life at the moment of the read.
- **Never stored.** The code is held in memory only — not in the database,
  not in a webhook payload, not in a log. If it is lost, the customer must
  trigger a new one.

A 404 is deliberately uniform: it does not distinguish "no code arrived"
from "already read", "expired", or "not your card".

#### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | `integer` | yes | The card's numeric id (from GET /v1/cards). _(min `1`)_ |
| `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. |

#### Responses

| Status | Body | Meaning |
| --- | --- | --- |
| `200` | `object` | The code, returned once. Sent with `Cache-Control: no-store` — do not cache, log, or persist it. |
| `401` | `Error` | Missing, malformed or unknown API key. |
| `404` | `object` | No code is available. Uniform by design — no code arrived, it was already read, it expired, or the card is not yours. |
| `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. |

**`200` response body**

| Field | Type | Description |
| --- | --- | --- |
| `card_id` **required** | `integer` | — |
| `code` **required** | `string` | The one-time code, exactly as the issuer sent it. |
| `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`)_ |
| `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. |
| `amount` | `string` | `payment_3ds` only, when sent: the amount of that payment, as a decimal string. |
| `currency` | `string` | `payment_3ds` only, when sent: the currency of `amount`. |
| `expires_in_seconds` **required** | `integer` |  _(min `0`)_ |

```json
{
  "card_id": 1,
  "code": "441524",
  "kind": "payment_3ds",
  "merchant_name": "EXAMPLE STORE",
  "amount": "12.50",
  "currency": "USD",
  "expires_in_seconds": 477
}
```

#### Example request

```bash
curl -X GET 'https://api.cryptoinfra.build/v1/cards/1/otp' \
  -H 'Authorization: Bearer ck_live_your_key'
```

## POST /v1/cards/{id}/topup

**Top up a card**  ·  _Auth: bearer API key_  ·  `operationId: cardTopup`

Debits `total` from the cardholder's balance immediately (see
GET /v1/cards/topup-quote: amount + our fee + gas + your fee), sends what
the rail needs on-chain, and funds the card once that send is credited.
Returns **202** with the order in `funding`; it moves to `requested`
when the send confirms, `sent` when the rail is asked, `confirmed` when
the card is funded — a `card.topup` webhook or the reconciler tells you.
A definitive failure at any stage refunds the charge in full. Treat 202
as pending; refund your user ONLY on a definitive failure
(`409 provider_rejected`, or a webhook with order status `failed`), never
on the 202 itself. `idempotency_key` is required.

#### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | `integer` | yes | The card's numeric id (from GET /v1/cards). _(min `1`)_ |

#### Request body

`application/json` · required · schema `CardMoneyRequest`

| Field | Type | Description |
| --- | --- | --- |
| `user_id` **required** | `string` |  _(minLength `1`; maxLength `255`)_ |
| `amount` **required** | `string` | Decimal amount in major units (USD/USDT), at most 6 decimal places. _(pattern `^\d+(\.\d+)?$`)_ |
| `idempotency_key` **required** | `string` | Required; replay-safe. _(minLength `1`; maxLength `255`)_ |

#### Responses

| Status | Body | Meaning |
| --- | --- | --- |
| `202` | `CardOrder` | Top-up accepted; provider outcome pending. |
| `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. |
| `401` | `Error` | Missing, malformed or unknown API key. |
| `404` | `Error` | Unknown card (`card_not_found`), or an unknown payer user (`user_not_found`), for this tenant. |
| `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). |
| `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. |

**`202` response body** — `CardOrder`

| Field | Type | Description |
| --- | --- | --- |
| `id` **required** | `integer` | — |
| `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`)_ |
| `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`)_ |
| `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. |
| `asset_id` **required** | `string` | — |
| `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. |
| `amount_base` **required** | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |
| `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. |
| `fee_base` **required** | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |
| `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. |
| `merchant_fee` | `string` | Your own fee on this order, credited to your balance. |
| `total` | `string` | Everything the cardholder was debited for this 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. |
| `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. |
| `amount_credited_base` **required** | `string | null` | Integer-string base units of `amount_credited` (`null` until a withdraw confirms). |
| `card_id` **required** | `integer | null` | — |
| `request_order_id` **required** | `string | null` | Server-minted correlation handle (`ct-`/`cw-`/`ci-` prefix). |
| `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`. |
| `error` **required** | `string | null` | — |
| `idempotency_key` **required** | `string | null` | — |
| `created_at` **required** | `string<date-time>` | — |

```json
{
  "id": 1,
  "kind": "topup",
  "status": "funding",
  "txid": "string",
  "asset_id": "usdt_trc20",
  "amount": "1.5",
  "amount_base": "1500000000",
  "fee": "1.5",
  "fee_base": "1500000000",
  "gas": "1.5",
  "merchant_fee": "1.5",
  "total": "1.5",
  "sent_on_chain": "1.5",
  "amount_credited": "1.5",
  "amount_credited_base": "1500000000",
  "card_id": 1,
  "request_order_id": "string",
  "provider_status": "string",
  "error": "string",
  "idempotency_key": "string",
  "created_at": "2026-08-31T09:15:00.000Z"
}
```

#### Example request

```bash
curl -X POST 'https://api.cryptoinfra.build/v1/cards/1/topup' \
  -H 'Authorization: Bearer ck_live_your_key' \
  -H 'Content-Type: application/json' \
  -d '{
  "user_id": "string",
  "amount": "string",
  "idempotency_key": "string"
}'
```

## GET /v1/cards/{id}/close-quote

**What closing this card would charge the cardholder**  ·  _Auth: bearer API key_  ·  `operationId: cardCloseQuote`

Read-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}.

#### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | `integer` | yes | The card's numeric id (from GET /v1/cards). _(min `1`)_ |
| `user_id` | query | `string` | yes | — |

#### Responses

| Status | Body | Meaning |
| --- | --- | --- |
| `200` | `object` | The close preview. |
| `401` | `Error` | Missing, malformed or unknown API key. |
| `404` | `Error` | Unknown card (`card_not_found`), or an unknown payer user (`user_not_found`), for this tenant. |

**`200` response body**

| Field | Type | Description |
| --- | --- | --- |
| `asset_id` **required** | `string` | — |
| `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. |
| `fee_base` **required** | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |
| `gas` | `string` | The live network figure for returning the balance to the holder's address on-chain. Part of `total`. |
| `gas_base` | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |
| `dynamic` | `boolean` | true when `gas` was priced live now; false on the fallback. |
| `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. |
| `merchant_fee_base` **required** | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |
| `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. |
| `total_base` **required** | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |
| `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`)_ |
| `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. |
| `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. |
| `issuer_fee_base` | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |
| `minimum_card_balance_base` **required** | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |
| `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. |
| `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. |
| `refund_quote.card_balance_base` **required** | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |
| `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. |
| `refund_quote.issuer_fee_base` **required** | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |
| `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. |
| `refund_quote.rail_return_fee_base` **required** | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |
| `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. |
| `refund_quote.close_fee_base` **required** | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |
| `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. |
| `refund_quote.gas_base` **required** | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |
| `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. |
| `refund_quote.merchant_fee_base` **required** | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |
| `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. |
| `refund_quote.refund_base` **required** | `string` | Exact integer amount in the asset's smallest unit. _(pattern `^[0-9]+$`)_ |
| `refund_quote.live` **required** | `boolean` | — |
| `refund_eta_typical` | `string` | What a refund usually takes once the card is closed (e.g. "24–48 hours"). |
| `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. |
| `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. |

```json
{
  "asset_id": "usdt_trc20",
  "fee": "1.5",
  "fee_base": "1500000000",
  "gas": "1.5",
  "gas_base": "1500000000",
  "dynamic": true,
  "merchant_fee": "1.5",
  "merchant_fee_base": "1500000000",
  "total": "1.5",
  "total_base": "1500000000",
  "charged_to": "refund",
  "minimum_card_balance": "1.5",
  "issuer_fee": "1.5",
  "issuer_fee_base": "1500000000",
  "minimum_card_balance_base": "1500000000",
  "refund_quote": {
    "card_balance": "1.5",
    "card_balance_base": "1500000000",
    "issuer_fee": "1.5",
    "issuer_fee_base": "1500000000",
    "rail_return_fee": "1.5",
    "rail_return_fee_base": "1500000000",
    "close_fee": "1.5",
    "close_fee_base": "1500000000",
    "gas": "1.5",
    "gas_base": "1500000000",
    "merchant_fee": "1.5",
    "merchant_fee_base": "1500000000",
    "refund": "1.5",
    "refund_base": "1500000000",
    "live": true
  },
  "refund_eta_typical": "string",
  "refund_eta_max_days": 1,
  "refund_eta_working_days": 1
}
```

#### Example request

```bash
curl -X GET 'https://api.cryptoinfra.build/v1/cards/1/close-quote?user_id=string' \
  -H 'Authorization: Bearer ck_live_your_key'
```

## GET /v1/merchant/card-fees

**Your own card fees, on top of ours**  ·  _Auth: bearer API key_  ·  `operationId: merchantCardFeesGet`

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

#### Responses

| Status | Body | Meaning |
| --- | --- | --- |
| `200` | `object` | Our prices and yours, per operation. |
| `401` | `Error` | Missing, malformed or unknown API key. |

**`200` response body**

| Field | Type | Description |
| --- | --- | --- |
| `max_percent_bps` | `integer` | — |
| `charged_to` | `string` |  _(one of `cardholder`)_ |
| `ours` | `object` | — |
| `ours.issue` | `object` | — |
| `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. |
| `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. |
| `ours.load` | `object` | — |
| `ours.load.percent_bps` | `integer` | — |
| `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. |
| `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. |
| `ours.load.gas_dynamic` | `boolean` | true = priced live now; false = the live price could not be read and a conservative fallback applies. |
| `ours.load.gas_priced_at` | `string<date-time>` | When this figure was priced — the moment of this response. |
| `ours.close` | `object` | — |
| `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. |
| `yours` | `object[]` | — |
| `yours[].operation` | `string` |  _(one of `issue`, `load`, `close`)_ |
| `yours[].percent_bps` | `integer` | 100 = 1%. |
| `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. |
| `yours[].updated_at` | `string<date-time> | null` | — |

```json
{
  "max_percent_bps": 1000,
  "charged_to": "cardholder",
  "ours": {
    "issue": {
      "flat": "1.5",
      "flat_physical": "1.5"
    },
    "load": {
      "percent_bps": 1,
      "min": "1.5",
      "gas": "1.5",
      "gas_dynamic": true,
      "gas_priced_at": "2026-08-31T09:15:00.000Z"
    },
    "close": {
      "flat": "1.5"
    }
  },
  "yours": [
    {
      "operation": "issue",
      "percent_bps": 1,
      "flat": "1.5",
      "updated_at": "2026-08-31T09:15:00.000Z"
    }
  ]
}
```

#### Example request

```bash
curl -X GET 'https://api.cryptoinfra.build/v1/merchant/card-fees' \
  -H 'Authorization: Bearer ck_live_your_key'
```

## PUT /v1/merchant/card-fees/{operation}

**Set your fee on one card operation**  ·  _Auth: bearer API key_  ·  `operationId: merchantCardFeeSet`

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

#### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `operation` | path | `string` | yes | — _(one of `issue`, `load`, `close`)_ |

#### Request body

`application/json` · required

| Field | Type | Description |
| --- | --- | --- |
| `percent_bps` | `integer` |  _(min `0`; max `1000`)_ |
| `flat` | `string` |  _(pattern `^\d+(\.\d+)?$`)_ |

#### Responses

| Status | Body | Meaning |
| --- | --- | --- |
| `200` | `object` | The fee as saved. |
| `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. |
| `401` | `Error` | Missing, malformed or unknown API key. |
| `409` | `Error` | `no_own_account` — open an own-balance account first. |

**`200` response body**

| Field | Type | Description |
| --- | --- | --- |
| `operation` | `string` |  _(one of `issue`, `load`, `close`)_ |
| `percent_bps` | `integer` | — |
| `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. |

```json
{
  "operation": "issue",
  "percent_bps": 1,
  "flat": "1.5"
}
```

#### Example request

```bash
curl -X PUT 'https://api.cryptoinfra.build/v1/merchant/card-fees/issue' \
  -H 'Authorization: Bearer ck_live_your_key' \
  -H 'Content-Type: application/json' \
  -d '{}'
```

## DELETE /v1/merchant/card-fees/{operation}

**Remove your fee on one card operation**  ·  _Auth: bearer API key_  ·  `operationId: merchantCardFeeClear`

#### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `operation` | path | `string` | yes | — _(one of `issue`, `load`, `close`)_ |

#### Responses

| Status | Body | Meaning |
| --- | --- | --- |
| `204` | — | Removed; the cardholder pays exactly our rate again. |
| `401` | `Error` | Missing, malformed or unknown API key. |

#### Example request

```bash
curl -X DELETE 'https://api.cryptoinfra.build/v1/merchant/card-fees/issue' \
  -H 'Authorization: Bearer ck_live_your_key'
```

## POST /v1/cards/{id}/withdraw

**Retired — moving part of a card balance back to the wallet**  ·  **Deprecated.**  ·  _Auth: bearer API key_  ·  `operationId: cardWithdraw`

**Retired on 2026-09-14.** Always answers `410 card_unload_retired`. The
only way value leaves a card is `DELETE /v1/cards/{id}`, which returns
the whole balance to the holder's own address on-chain. An unload would
credit the holder's balance with coins that never reach their address.

#### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | `integer` | yes | The card's numeric id (from GET /v1/cards). _(min `1`)_ |

#### Request body

`application/json` · required · schema `CardMoneyRequest`

| Field | Type | Description |
| --- | --- | --- |
| `user_id` **required** | `string` |  _(minLength `1`; maxLength `255`)_ |
| `amount` **required** | `string` | Decimal amount in major units (USD/USDT), at most 6 decimal places. _(pattern `^\d+(\.\d+)?$`)_ |
| `idempotency_key` **required** | `string` | Required; replay-safe. _(minLength `1`; maxLength `255`)_ |

#### Responses

| Status | Body | Meaning |
| --- | --- | --- |
| `401` | `Error` | Missing, malformed or unknown API key. |
| `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). |
| `410` | `Error` | Retired — `card_unload_retired`. Close the card instead. |
| `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. |

#### Example request

```bash
curl -X POST 'https://api.cryptoinfra.build/v1/cards/1/withdraw' \
  -H 'Authorization: Bearer ck_live_your_key' \
  -H 'Content-Type: application/json' \
  -d '{
  "user_id": "string",
  "amount": "string",
  "idempotency_key": "string"
}'
```

## POST /v1/cards/{id}/freeze

**Freeze a card**  ·  _Auth: bearer API key_  ·  `operationId: freezeCard`

Freezes an `active` card (`409 card_state` otherwise). The authoritative status is confirmed by a `card.status` webhook.

#### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | `integer` | yes | The card's numeric id (from GET /v1/cards). _(min `1`)_ |

#### Request body

`application/json` · required · schema `CardUserRef`

| Field | Type | Description |
| --- | --- | --- |
| `user_id` **required** | `string` |  _(minLength `1`; maxLength `255`)_ |

#### Responses

| Status | Body | Meaning |
| --- | --- | --- |
| `200` | `object` | Card frozen. |
| `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. |
| `401` | `Error` | Missing, malformed or unknown API key. |
| `404` | `Error` | Unknown card (`card_not_found`), or an unknown payer user (`user_not_found`), for this tenant. |
| `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). |
| `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. |

**`200` response body**

| Field | Type | Description |
| --- | --- | --- |
| `card` **required** | `object` | — |
| `card.id` **required** | `integer` | — |
| `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. |
| `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. |
| `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. |
| `card.product_id` **required** | `string | null` | Provider-scoped product CODE (kept as an alias of product_code); null when the card was listed untagged. |
| `card.product_code` **required** | `string | null` | Provider-scoped product code; null when the card was listed untagged. |
| `card.card_nature` **required** | `string` |  _(one of `VIRTUAL`, `PHYSICAL`)_ |
| `card.status` **required** | `integer` | The provider's RAW status code, verbatim — interpret via status_normalized. |
| `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`)_ |
| `card.currency` **required** | `string | null` | — |
| `card.last4` **required** | `string | null` | — |
| `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. |
| `card.issued_at` **required** | `string<date-time>` | — |

```json
{
  "card": {
    "id": 1,
    "rail": "rail_a",
    "core_id": "string",
    "bin": "string",
    "product_id": "string",
    "product_code": "string",
    "card_nature": "VIRTUAL",
    "status": 1,
    "status_normalized": "issuing",
    "currency": "string",
    "last4": "string",
    "name_on_card": "string",
    "issued_at": "2026-08-31T09:15:00.000Z"
  }
}
```

#### Example request

```bash
curl -X POST 'https://api.cryptoinfra.build/v1/cards/1/freeze' \
  -H 'Authorization: Bearer ck_live_your_key' \
  -H 'Content-Type: application/json' \
  -d '{
  "user_id": "string"
}'
```

## POST /v1/cards/{id}/unfreeze

**Unfreeze a card**  ·  _Auth: bearer API key_  ·  `operationId: unfreezeCard`

Unfreezes a `frozen` card (`409 card_state` otherwise).

#### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | `integer` | yes | The card's numeric id (from GET /v1/cards). _(min `1`)_ |

#### Request body

`application/json` · required · schema `CardUserRef`

| Field | Type | Description |
| --- | --- | --- |
| `user_id` **required** | `string` |  _(minLength `1`; maxLength `255`)_ |

#### Responses

| Status | Body | Meaning |
| --- | --- | --- |
| `200` | `object` | Card unfrozen. |
| `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. |
| `401` | `Error` | Missing, malformed or unknown API key. |
| `404` | `Error` | Unknown card (`card_not_found`), or an unknown payer user (`user_not_found`), for this tenant. |
| `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). |
| `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. |

**`200` response body**

| Field | Type | Description |
| --- | --- | --- |
| `card` **required** | `object` | — |
| `card.id` **required** | `integer` | — |
| `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. |
| `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. |
| `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. |
| `card.product_id` **required** | `string | null` | Provider-scoped product CODE (kept as an alias of product_code); null when the card was listed untagged. |
| `card.product_code` **required** | `string | null` | Provider-scoped product code; null when the card was listed untagged. |
| `card.card_nature` **required** | `string` |  _(one of `VIRTUAL`, `PHYSICAL`)_ |
| `card.status` **required** | `integer` | The provider's RAW status code, verbatim — interpret via status_normalized. |
| `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`)_ |
| `card.currency` **required** | `string | null` | — |
| `card.last4` **required** | `string | null` | — |
| `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. |
| `card.issued_at` **required** | `string<date-time>` | — |

```json
{
  "card": {
    "id": 1,
    "rail": "rail_a",
    "core_id": "string",
    "bin": "string",
    "product_id": "string",
    "product_code": "string",
    "card_nature": "VIRTUAL",
    "status": 1,
    "status_normalized": "issuing",
    "currency": "string",
    "last4": "string",
    "name_on_card": "string",
    "issued_at": "2026-08-31T09:15:00.000Z"
  }
}
```

#### Example request

```bash
curl -X POST 'https://api.cryptoinfra.build/v1/cards/1/unfreeze' \
  -H 'Authorization: Bearer ck_live_your_key' \
  -H 'Content-Type: application/json' \
  -d '{
  "user_id": "string"
}'
```

## GET /v1/cards/{id}/transactions

**Card transaction history**  ·  _Auth: bearer API key_  ·  `operationId: listCardTransactions`

Normalized live passthrough from the provider (nothing is stored in v1).

#### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | `integer` | yes | The card's numeric id (from GET /v1/cards). _(min `1`)_ |
| `user_id` | query | `string` | yes | — |
| `limit` | query | `integer` | no | Rows per page. _(min `1`; max `100`)_ |
| `page` | query | `integer` | no | 1-based page number. _(min `1`; max `100000`)_ |
| `page_size` | query | `integer` | no | Deprecated alias of `limit`; `limit` wins when both are sent. _(min `1`; max `100`)_ |
| `page_num` | query | `integer` | no | Deprecated alias of `page`; `page` wins when both are sent. _(min `1`; max `100000`)_ |

#### Responses

| Status | Body | Meaning |
| --- | --- | --- |
| `200` | `object` | Transactions. |
| `401` | `Error` | Missing, malformed or unknown API key. |
| `404` | `Error` | Unknown card (`card_not_found`), or an unknown payer user (`user_not_found`), for this tenant. |
| `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. |

**`200` response body**

| Field | Type | Description |
| --- | --- | --- |
| `transactions` **required** | `object[]` | — |
| `transactions[].id` **required** | `string` | — |
| `transactions[].occurred_at` | `string | null` | — |
| `transactions[].amount` | `string | null` | Decimal string; null when the provider omits it. |
| `transactions[].amount_base` | `string | null` | — |
| `transactions[].currency` | `string | null` | — |
| `transactions[].fee` | `string | null` | — |
| `transactions[].fee_base` | `string | null` | — |
| `transactions[].merchant_name` | `string | null` | — |
| `transactions[].mcc` | `string | null` | — |
| `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. |
| `transactions[].status` | `string | null` | 1 processing, 2 success, 3 failed. |
| `transactions[].authorization_code` | `string | null` | — |
| `transactions[].related_authorization_code` | `string | null` | — |

```json
{
  "transactions": [
    {
      "id": "string",
      "occurred_at": "string",
      "amount": "string",
      "amount_base": "string",
      "currency": "string",
      "fee": "string",
      "fee_base": "string",
      "merchant_name": "string",
      "mcc": "string",
      "type": "string",
      "status": "string",
      "authorization_code": "string",
      "related_authorization_code": "string"
    }
  ]
}
```

#### Example request

```bash
curl -X GET 'https://api.cryptoinfra.build/v1/cards/1/transactions?user_id=string' \
  -H 'Authorization: Bearer ck_live_your_key'
```

## GET /v1/cards/delivery/regions

**Physical-card delivery regions**  ·  _Auth: bearer API key_  ·  `operationId: listCardDeliveryRegions`

Shipping regions for physical cards. Supported on one rail in v1 — returns
`400 not_supported_by_provider` on the others.

#### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `local` | query | `string` | no | Display locale (default en_US). _(one of `zh_CN`, `zh_HK`, `en_US`, `ko_KR`)_ |

#### Responses

| Status | Body | Meaning |
| --- | --- | --- |
| `200` | `object` | Regions. |
| `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. |
| `401` | `Error` | Missing, malformed or unknown API key. |
| `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. |

**`200` response body**

| Field | Type | Description |
| --- | --- | --- |
| `regions` **required** | `object[]` | — |
| `regions[].id` | `string` | Region id; pass its numeric value as country_region_id to POST /v1/cards/delivery/address. |
| `regions[].country_region` | `string` | — |
| `regions[].country_region_zh` | `string` | — |
| `regions[].national_code` | `string` | Dialing code. |
| `regions[].price` | `string | null` | Postage as a decimal string; currency unspecified. null = unknown (NOT free). |

```json
{
  "regions": [
    {
      "id": "string",
      "country_region": "string",
      "country_region_zh": "string",
      "national_code": "string",
      "price": "string"
    }
  ]
}
```

#### Example request

```bash
curl -X GET 'https://api.cryptoinfra.build/v1/cards/delivery/regions' \
  -H 'Authorization: Bearer ck_live_your_key'
```

## POST /v1/cards/delivery/address

**Add a physical-card delivery address**  ·  _Auth: bearer API key_  ·  `operationId: addCardDeliveryAddress`

Creates a mailing address at the provider AND records its ownership
locally. The returned `delivery_address_id` is passed as
`delivery_address_id` when issuing a PHYSICAL card (issuance only accepts
an id recorded for the same holder).

#### Request body

`application/json` · required

| Field | Type | Description |
| --- | --- | --- |
| `user_id` **required** | `string` |  _(minLength `1`; maxLength `255`)_ |
| `country_region_id` **required** | `integer` | Region id from GET /v1/cards/delivery/regions. _(min `1`)_ |
| `country` **required** | `string` |  _(minLength `1`; maxLength `128`)_ |
| `city` **required** | `string` |  _(minLength `1`; maxLength `128`)_ |
| `receiver_name` **required** | `string` |  _(minLength `1`; maxLength `128`)_ |
| `receiver_mobile` **required** | `string` |  _(minLength `1`; maxLength `32`)_ |
| `receiver_address` **required** | `string` |  _(minLength `1`; maxLength `512`)_ |
| `post_code` **required** | `string` |  _(minLength `1`; maxLength `32`)_ |

#### Responses

| Status | Body | Meaning |
| --- | --- | --- |
| `201` | `object` | Address created. |
| `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. |
| `401` | `Error` | Missing, malformed or unknown API key. |
| `404` | `Error` | No matching card holder (`holder_not_found`), or an unknown user/payer (`user_not_found`), for this tenant. |
| `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. |

**`201` response body**

| Field | Type | Description |
| --- | --- | --- |
| `delivery_address_id` **required** | `integer` | — |

```json
{
  "delivery_address_id": 1
}
```

#### Example request

```bash
curl -X POST 'https://api.cryptoinfra.build/v1/cards/delivery/address' \
  -H 'Authorization: Bearer ck_live_your_key' \
  -H 'Content-Type: application/json' \
  -d '{
  "user_id": "string",
  "country_region_id": 1,
  "country": "string",
  "city": "string",
  "receiver_name": "string",
  "receiver_mobile": "string",
  "receiver_address": "string",
  "post_code": "string"
}'
```
