#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 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_idis your own reference for them, 5–10 digits) with a balance — see Static Wallet Address. 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. - 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.
#2. Verify identity, when the product needs it
Read requires_kyc on the product you intend to issue (step 3). When it is true:
POST /v1/kyc/startwithuser_id(optionallanguage) → a hosted verificationurl. Send the user there. An already-verified user getsstatus: approvedand no URL — nobody is sent around twice.- Poll
GET /v1/kyc/status?user_id=…untilapproved. Acard.kycwebhook also tells you.declinedandrevieware 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), 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 (cardpopulated,status_normalized: issuing). It turnsactiveonce the opening balance lands at the issuer, a few minutes later; acard.statuswebhook tells you. Do not readissuingon a 201 as an error. Until it turnsactive,GET /v1/cards/{id}/balanceandPOST /v1/cards/{id}/revealanswer409 card_issuing— wait for the webhook or poll, then retry.202— the application is still resolving (cardis null); acard.statuswebhook or the reconciler links it. Treat as pending.409 insufficient_balance— the holder's balance is short oftotal. Nothing is charged or applied for.400 card_core_required— you hold more than one programme and sent nocore_id;details.coreslists 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.
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.
- When your user asks to see the card, call
POST /v1/cards/{id}/reveal(POST, never GET) withuser_id. It answersreveal_url, withpan,cvvandexpirynull. - Put
reveal_urlin this iframe:
<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
sandboxattribute on the iframe. - If your page sends a Content-Security-Policy, its
frame-srcmust allow HTTPS frames (frame-src https:). - Do not send
Cross-Origin-Embedder-Policy: require-corpon 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 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 addressShow 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.
- 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_idis 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}carriesclose_in_progress: true. - 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.statustells you, and the card then readsstatus_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 thecard.statuswebhook), 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/productsat run time - [ ]
core_idsent on issue; (core_id,bin) used as the key for prices - [ ] Every money call carries an
idempotency_keyand retries with the same one - [ ]
202handled as pending; the user refunded only onfailedor409 provider_rejected - [ ]
totalfrom the quote shown before every issue, load and close - [ ] Secrets from
revealandotpshown once, never logged or stored - [ ] Webhooks verified over the raw body and deduplicated