# Cards, end to end

A card is a VISA/Mastercard-network debit card issued to one of **your users**, funded
from **that user's own balance** on cryptoinfra, and priced per programme and BIN. Every
cost — issuing, each load, closing — is charged to the cardholder's balance and to nobody
else; your own fee, if you set one, lands in your balance in the same movement. This page
walks the flow once, in order. The exact numbers on every step come from the API, never
from this page: see [Fees and pricing](fees) for today's price sheet and the reasoning.

**Everything here is server-to-server.** Your API key operates every card on your
account — proxy from your backend, never call from a browser or an app.

## Before you start

- A user (`user_id` is your own reference for them, 5–10 digits) with a balance — see
  [Static Wallet Address](deposit-addresses). A card is issued only from what that user holds.
- Webhook endpoints registered (`POST /v1/developer/webhooks`) — card events are how you
  learn that an asynchronous step finished. See [Webhooks](webhooks).
- The product you will issue, read live from `GET /v1/cards/products` (step 3), never
  hard-coded: codes, minimums and prices change without a deploy on your side.

## 1. Register the holder

`POST /v1/cards/holders` with `user_id`, `email`, and as much of `first_name`, `last_name`,
`phone`, `date_of_birth`, `country`, `address`, `city`, `state`, `zip_code` as you have.
Answers `201` with the holder and a `kyc_status`. Idempotent per user: calling it again for
the same `user_id` returns the same holder. The same customer may already hold a card
through another merchant with the same email: that never blocks yours. Nor does the
number of cards they already hold with you — see
[How many cards one user can hold](#how-many-cards-one-user-can-hold).

## 2. Verify identity, when the product needs it

Read `requires_kyc` on the product you intend to issue (step 3). When it is true:

1. `POST /v1/kyc/start` with `user_id` (optional `language`) → a hosted verification `url`.
   Send the user there. An already-verified user gets `status: approved` and no URL —
   nobody is sent around twice.
2. Poll `GET /v1/kyc/status?user_id=…` until `approved`. A `card.kyc` webhook also tells
   you. `declined` and `review` are terminal until the user tries again.

Issuing a product that needs verification before it is `approved` answers
`409 kyc_not_approved` with the current status in `details.kyc_status`.

Some programmes take a document dossier instead of the hosted flow —
`GET /v1/cards/kyc/countries`, `POST /v1/cards/kyc/upload`, `POST /v1/cards/kyc`,
`GET /v1/cards/kyc/status`. The product tells you which applies; you never need both.

## 3. Read the shelf and quote the issue

`GET /v1/cards/products` is the live catalogue. Each row carries the programme it belongs
to (`core_id`), its `bin`, `code`, `card_nature` (VIRTUAL or PHYSICAL), and what the
cardholder pays:

| Field | What it is |
| --- | --- |
| `issue_fee` | our one-time fee to issue this product, on this programme |
| `min_opening_balance` | the smallest `initial_amount` the issue accepts — a card is issued **funded** |
| `load_fee_bps` / `load_fee_min` / `load_fee_flat` | our fee on every load: basis points of the amount (with its floor) plus a flat platform part, plus gas |
| `close_fee` | our fee to close the card |
| `issue_total` | everything the cardholder is debited to open the card at the minimum — budget from this |
| `cardholder_fees` | what the card itself charges when used: per purchase, 3-D Secure, foreign currency, ATM, cancellation, dispute |

**Read `core_id` and `bin` together.** A BIN number is unique only inside its issuer, so
the same number under another programme is a different card with different prices.

For any other opening balance, `GET /v1/cards/issue-quote?product_code=…&initial_amount=…`
returns `total` — our issue fee, yours, the opening balance and its load fee and gas,
charged **on top** of it. At the minimum that is `issue_fee + min_opening_balance + its
load fee + gas` (`issue_total_at_minimum` on the public [price sheet](fees#card-fees)), and the
card opens holding the full minimum. The quote is priced by the same code that charges, so
it cannot differ from the debit.

## 4. Issue the card

```
POST /v1/cards
{
  "user_id": "804080379",
  "core_id": "core-1",
  "product_code": "100096",
  "initial_amount": "5.00",
  "idempotency_key": "issue-804080379-2026-09-03-a",
  "name_on_card": "A HOLDER"          // optional; not every card is printed with one
}
```

- `201` — the card exists (`card` populated, `status_normalized: issuing`). It turns
  `active` once the opening balance lands at the issuer, a few minutes later; a
  `card.status` webhook tells you. **Do not read `issuing` on a 201 as an error.**
  Until it turns `active`, `GET /v1/cards/{id}/balance` and `POST /v1/cards/{id}/reveal`
  answer `409 card_issuing` — wait for the webhook or poll, then retry.
- `202` — the application is still resolving (`card` is null); a `card.status` webhook or
  the reconciler links it. Treat as pending.
- `409 insufficient_balance` — the holder's balance is short of `total`. Nothing is
  charged or applied for.
- `400 card_core_required` — you hold more than one programme and sent no `core_id`;
  `details.cores` lists them.
- `409 provider_rejected` — definitive; retrying the identical request will not help.

`idempotency_key` is required and replay-safe: a network failure is retried with the
**same** key, and the second call returns the first result. See [Idempotency](idempotency).

`delivery_address_id` (from `POST /v1/cards/delivery/address`) is required for PHYSICAL
products; `GET /v1/cards/delivery/regions` lists where they can be mailed.

### How many cards one user can hold

As many as you issue. We set no limit per user: a user who already holds cards gets the
next one the same way, under the same `user_id`.

`limits.cards_per_user` on a product is a different number: the rail's ceiling for **one
account** on that BIN. Every card ever issued under that account counts towards it, closed
ones included. When the next card would go over it, we open a further account for the
same user and issue the card under that one, inside the same `POST /v1/cards`. There is
nothing for you to call first. `GET /v1/cards?user_id=…` lists the user's cards across
every account they have.

## 5. Show the card

`GET /v1/cards/{id}?user_id=…` returns everything a screen needs and **no secrets**:
`last4`, `status_normalized`, `balance.available` / `balance.pending`, `expire_date`, and
`card_pan_url` — a link on our domain to that card's secure card page, good for about ten
minutes.

**To show the card number, CVV and expiry, put one iframe on your page.** For current cards
(BINs 493 and 404) no API returns the raw details — the issuer's security policy does not
allow it — so the card is shown on our card page, inside your app.

1. When your user asks to see the card, call `POST /v1/cards/{id}/reveal` (POST, never GET)
   with `user_id`. It answers `reveal_url`, with `pan`, `cvv` and `expiry` null.
2. Put `reveal_url` in this iframe:

```html
<iframe src="REVEAL_URL" title="Card details"
        style="display:block;width:100%;max-width:400px;aspect-ratio:1.589/1;border:0;border-radius:14px;color-scheme:light"
        referrerpolicy="no-referrer" allow="clipboard-write"></iframe>
```

That is the whole integration. Our page shows a card in your company name and logo while it
loads, then the card itself, with a pointer to the eye that shows the number. Your user never
leaves your app, and there is nothing to style or size on your side.

Closing the card and opening it again on the same screen? Hide the iframe instead of removing
it, and it reopens instantly for the link's ten minutes — no new request, no loading.

`card_pan_url` from `GET /v1/cards/{id}` opens the same page. Should a card ever carry raw
details, reveal fills `pan`, `cvv` and `expiry` and leaves `reveal_url` null — branch on
`pan`.

Checklist:

- Fetch a fresh link each time you show the card; it expires within ten minutes. Keep it
  exactly as received.
- Your page must be served over **HTTPS**.
- No `sandbox` attribute on the iframe.
- If your page sends a Content-Security-Policy, its `frame-src` must allow HTTPS frames
  (`frame-src https:`).
- Do not send `Cross-Origin-Embedder-Policy: require-corp` on that page.
- In a mobile app, show the link in a WebView.

Never log, cache or persist these links.

**One-time codes.** A 3-D Secure challenge at checkout, and adding the card to a phone
wallet, both ask the cardholder for a code — and the code comes from **us**, not by SMS.
When a `card.wallet_otp` event arrives (it carries no code), or the cardholder is at a
checkout, read `GET /v1/cards/{id}/otp?user_id=…` and show `code` while they are looking:
it is single-read and short-lived (`expires_in_seconds`), and `kind` says which of the two
it is for. A payment code also carries `merchant_name` and `amount` when the issuer sends
them, so your customer can check it is their own checkout. Your server and your dashboard
each get one read: fetching the code from your server does not hide it from the card's
page in the dashboard, where it also appears live as it arrives.

## 6. Load the card

Quote first: `GET /v1/cards/topup-quote?amount=100&card_id=…&user_id=…` returns `fee`
(ours), `gas`, `merchant_fee` (yours), `credited` and `total`. **`total` is the number to
show.** `gas` is the network's price for the send, computed when you ask, and `dynamic`
says whether it was live (`true`) or a conservative fallback (`false`) — label it "live" or
"approx", never print it as a fixed charge. The tariff on `GET /v1/merchant/card-fees`
carries the same `gas`, `gas_dynamic` and `gas_priced_at`. The order and its `card.topup`
webhook carry the `gas` actually charged. **Fees are on top:**
`credited` equals `amount` and is what lands on the card; `total` is what the balance is
debited. Loading `amount` puts exactly `amount` on the card and charges `amount + fee + gas` (`total` on the quote).

```
POST /v1/cards/{id}/topup
{ "user_id": "804080379", "amount": "100", "idempotency_key": "load-804080379-0042" }
```

Answers **`202`** with a card order. The cardholder is charged now; the money is sent
on-chain to the card rail and the card is funded once the rail confirms it, so the order
goes `funding` → `requested` → `sent` → `confirmed` and takes minutes. A `card.topup`
webhook carries the outcome; `GET /v1/cards/orders/{order_id}` is the authoritative poll.
**202 is pending, never failure.** Refund your user only on a definitive failure — a
`409 provider_rejected` at request time, or an order whose `status` is `failed`; the
charge is reversed automatically then.

A load below the product's minimum answers `400` naming it; a balance short of `total`
answers `409 insufficient_balance`; nothing is charged in either case. Show `total`
before the user confirms, and check it against their balance first.

### When a load fails

The **whole** charge — `amount`, `fee`, `gas` and your `merchant_fee`, i.e. `total` — goes
back to the cardholder's balance, in the same moment the order turns `failed`. Nothing
lands anywhere else: not on the card, not in your treasury. `provider_status` says where
it failed: `send_failed` means the on-chain send to the rail reverted — that is on our
side, and no coins left the cardholder's address either, so a block explorer and the
balance agree. `sent_on_chain` on the order is what the send was *for*, not what moved.

Every load and every issue with an opening balance carries `txid` — the hash of its
on-chain send — on `GET /v1/cards/orders/{id}` and on the `card.topup` / `card.status`
webhook, so your receipt can link to a block explorer the way it does for a deposit or a
payout. It is null until the send is broadcast; a failed order keeps the reverted hash.

So after a failure the money is exactly where it was before the load: on the cardholder.
The cleanest next step is to load again from that balance — nothing has to be moved
first. If you funded the cardholder from your own treasury just for this load and want
it back, the only path is a payout (`POST /v1/withdrawals` from the cardholder to your
treasury address); like any payout its fee is charged on top, so a retry is cheaper than a
round trip.

## 7. Watch it spend

`GET /v1/cards/{id}/transactions?user_id=…` is the live history — purchases, refunds,
ATM withdrawals, the card's own fees — and a `card.transaction` webhook arrives for every
purchase, refund, fee, ATM withdrawal and pre-authorization as it happens.

The history's `type` / `status` and the webhook's `transaction.transType` /
`transaction.transStatus` carry the same codes (a string in the history, a number in the
webhook):

| Type | Meaning |
| --- | --- |
| `170` | card payment |
| `171` | refund — `originalTransactionId` is the payment it refunds |
| `172` | card fee |
| `173` | ATM cash withdrawal |
| `174` | pre-authorization |
| `150` | card top-up (history only) |
| `140` | card issue (history only) |

| Status | Meaning |
| --- | --- |
| `1` | processing |
| `2` | success |
| `3` | failed — the webhook's `respCodeDesc` says why |

A refund is its own transaction (`171`) pointing at the payment; the payment itself is not
rewritten. These codes cover every card we issue today. The card's own charges (per purchase, 3-D Secure, foreign currency, ATM) are
**inside** the transaction amount, not added to it: a $3.86 purchase with a $0.25 fee takes
$3.86 from the card. `cardholder_fees` on the product is the schedule; the
[fees page](fees) explains each line.

`POST /v1/cards/{id}/freeze` and `/unfreeze` (with `user_id`) stop and resume spending
without closing the card.

## 8. Money only leaves a card by closing it

There is no partial unload. `POST /v1/cards/{id}/withdraw` is retired and answers
`410 card_unload_retired`: an unload would credit the holder's balance with coins that never
reach their own address. To return a card's balance, close the card (next section) — the
whole balance comes back to the holder's own address on-chain.

## 9. Close the card

A close is the only way value leaves a card, and it is **always accepted**: whatever the card
holds, and whatever state our refund wallet is in, `DELETE /v1/cards/{id}` never fails for
money reasons. Everything a close costs comes out of the balance the card returns — nothing
is taken from the holder's wallet balance, which is usually empty by the time somebody closes
a card.

> **These terms belong to one rail and one BIN.** Every number in this section — our close
> fee, the issuer's cancellation charge, the rail's release charge, the settlement period, the
> limits and the features — is what the card's own `(core_id, bin)` carries today. A card on a
> different BIN, or on a different rail, can differ in all of them. Never copy a figure from
> here into your app: read the close quote for the card in front of you, and show your
> customer what comes back.

### What it costs, and what your user gets back

`GET /v1/cards/{id}/close-quote?user_id=…` answers both questions. `fee` is our flat part,
`gas` the live network figure for the on-chain refund (`dynamic` says whether it was priced
now), `merchant_fee` yours, and `total` the three together. Beside them, `refund_quote` does
the arithmetic on the card's balance read live at that moment:

```
card_balance
  − issuer_fee        the issuer's cancellation charge, kept on the card
  − rail_return_fee   what the card rail charges to release the rest to us
  − close_fee         ours
  − gas               the on-chain send
  − merchant_fee      yours, if you set one
  = refund            reaches the holder's own address
```

Show `refund` before they confirm. When `live` is `false` (and `card_balance` is `null`) the
rail could not be asked at that moment: show no figure rather than a guessed one, and try
again.

`minimum_card_balance` is always `0` — there is no amount a card must hold to be closed. A
card holding less than the issuer's charge, or nothing at all, closes like any other and the
issuer keeps whatever is there.

### Making the close

`DELETE /v1/cards/{id}` with `user_id` in the body does the whole thing. **One call is enough.**

1. A card holding more than the issuer's amount answers **`202 card_close_in_progress`**: its
   balance is on its way to the rail (`details.order_id` is that withdraw order). **The card is
   not closed yet** — a 202 is a close that has started, not one that has finished. Until it
   finishes, `GET /v1/cards/{id}` carries `close_in_progress: true`.
2. Within about a minute of that order settling, we finish the close ourselves: the fees are
   charged once, the card terminates and the issuer keeps its amount. `card.status` tells you,
   and the card then reads `status_normalized: closed`.

A card at or below the issuer's amount closes on the first call (`200`). You may still call
close again yourself once the order is confirmed — that finishes it at once — and a `DELETE` on
a card we already closed answers `200` with the card, never an error. Nothing is ever charged
twice.

> **If your client treats every 2xx as success**, make the close screen wait for
> `status_normalized: closed` (or the `card.status` webhook), not for the first response. Only
> then show the refund state below.

### Where the refund is

Paying the holder on-chain is a **state you can read**, never an error. `GET /v1/cards/{id}`
carries `close_refund` for a closed card:

| `state` | What it means |
| --- | --- |
| `waiting` | The card's money is at the rail; we are bringing it on-chain. |
| `approved` | Released for payment; the send is being made. |
| `sending` | On-chain, waiting for confirmations. `txid` is set. |
| `paid` | At the holder's own address. |
| `held` | We have held it back; `reason` says why. Rare, and we will have contacted you. |

The block also carries `amount` (exactly the `refund` the quote showed), `closed_at`, and the
two timings below. Show the state to your user; there is nothing for them to retry.

### How long it takes

Cancelling a card opens the **issuer's settlement period**: the balance is not released until
it ends, and the issuer states this can run to **45 days** (`refund_eta_max_days`). In
practice it has settled within 24 to 48 hours (`refund_eta_typical`). Quote your customer the
issuer's limit and let a fast refund be a pleasant surprise.

Once released, the balance reaches the holder's own address within
**`refund_eta_working_days` working days** (7), paid from a wallet cryptoinfra funds by hand.
Until it lands the holder cannot load another card with that money — a load needs the coins at
their own address, rule 2 above.

## Your own fee, on top of ours

`GET /v1/merchant/card-fees` shows ours, yours and what the cardholder pays;
`PUT /v1/merchant/card-fees/{issue|load|close}` sets a percentage (`percent_bps`, 100 = 1%,
at most 1000), a flat amount, or both; `DELETE` removes it. Every quote already includes
it as `merchant_fee`, and it is credited to your balance in the same movement that charges
the cardholder.

## Webhooks you will receive

| Event | When |
| --- | --- |
| `card.kyc` | the identity verdict changed |
| `card.status` | the card's status changed — issued, active, frozen, closed |
| `card.topup` | a load settled or failed |
| `card.withdraw` | a card→wallet withdrawal settled or failed — including the return that precedes a close |
| `card.transaction` | the card was used |
| `card.wallet_otp` | a one-time code is waiting — fetch it from `GET /v1/cards/{id}/otp`; the event never carries it |

Delivery is at-least-once: deduplicate on the event name and the entity id
(`card_id`, `order_id`). Verify `x-cryptoinfra-signature` over the raw body before parsing.

## When a call answers 503

`503` on any card call means **your request was valid and nothing was charged** — retry
with the same idempotency key. `error` says why: `provider_unavailable` (the rail did not
answer), `card_rail_unavailable` (that card's rail is down; other cards are unaffected),
`rail_unfunded` (the platform could not fund the load right now), `cards_disabled` (the
feature is off for this environment — the sandbox has no card rail). A `409` is the
opposite: definitive, and retrying the identical request will answer the same.

## Checklist

- [ ] Products, minimums and prices read from `GET /v1/cards/products` at run time
- [ ] `core_id` sent on issue; (`core_id`, `bin`) used as the key for prices
- [ ] Every money call carries an `idempotency_key` and retries with the same one
- [ ] `202` handled as pending; the user refunded only on `failed` or `409 provider_rejected`
- [ ] `total` from the quote shown before every issue, load and close
- [ ] Secrets from `reveal` and `otp` shown once, never logged or stored
- [ ] Webhooks verified over the raw body and deduplicated
