API Reference
Issue and manage crypto-funded virtual cards. Depending on your account, you either onboard the cardholder through an identity-verification (KYC) step first, or issue a card directly with a name you supply — see which path is mine.
https://api.cryptocard.la. Every request must be HTTPS.api-sandbox.cryptocard.la): create real cards and try the whole flow before taking the next step.Authentication
Send your API key as a bearer token on every request except /health:
Authorization: Bearer ck_live_...
Keys are issued when you're onboarded: get in touch to request one.
Errors
Errors are JSON with a machine-readable error code and, when useful, a human-readable message:
{ "error": "invalid_fields", "message": "client_reference_id is required" }
| HTTP | error | Meaning |
|---|---|---|
| 400 | invalid_fields | Missing or malformed field in the request body. |
| 400 | bad_json | Request body is not valid JSON. |
| 400 | onboarding_incomplete | KYCSome required data or a document is still missing, or a document was rejected on quality/identity grounds. See Create a user. |
| 400 | user_not_approved | KYCThe user_id you gave doesn't reference a user whose KYC is approved yet. |
| 401 | unauthorized | Missing, invalid, or revoked API key. |
| 404 | not_found | No such card/user, or it doesn't belong to your account. |
| 502 | provider_error | Temporary failure completing the operation. See the notes on each endpoint for whether it's safe to just retry. |
| 500 | internal | Unexpected error on our side. Safe to retry. |
Idempotency
Card creation accepts an optional Idempotency-Key header — it's the only endpoint that reads this header. Retry a request with the same key and you'll get back the same card instead of a duplicate:
Idempotency-Key: signup-8841-attempt-1
Use a new key for a genuinely new card. This header only applies to card creation — onboarding (POST /v1/users, an endpoint that only exists on KYC accounts) is a separate call, and doesn't take a header like this. It's still safe to retry, though: client_reference_id is unique per account, so retrying with the same one returns the existing user's current status instead of opening a second KYC case. If something was still missing, sending documents again on that same call picks up the same case instead of starting over — see Create a user. A genuinely rejected case needs a new client_reference_id — see current limitations.
Which path is mine?
Everything past this point is one API, on one base URL, but there are two ways into it, and your account only has access to one of them:
404 on your account.user_id whose KYC has been approved — you can't skip to card creation. The flow:- Create the user —
POST /v1/userswith their personal data and identity documents. Comes backin_reviewin the normal case (KYC takes time), or straight toapproved/400 onboarding_incompletein the edge cases. - Wait for a decision — either poll
GET /v1/users/{user_id}, or listen for theuser.approved/user.rejectedwebhook. We recommend doing both: poll as the source of truth, treat the webhook as a faster notification when it arrives. - Create the card — once the user's
statusisapproved,POST /v1/cardswith thatuser_id.
in_review — we can't tell you whether a given case is in the fast path or the slow one.KYCPOST /v1/users
Submits a person's data and identity documents for KYC review, in one request.
curl https://api.cryptocard.la/v1/users \
-X POST \
-H "Authorization: Bearer ck_live_..." \
-H "Content-Type: application/json" \
-d '{
"client_reference_id": "user_8841",
"person": {
"first_name": "MARIA",
"last_name": "BARBER",
"email": "[email protected]",
"birth_date": "1989-03-21",
"address": {
"street_line1": "Florida 9860",
"city": "Del Viso",
"postal_code": "1669",
"state": "ARG-B",
"country": "ARG"
},
"phone_country_code": 54,
"phone_number": 1168860622
},
"documents": [
{
"document_type": "PASSPORT",
"image_front": "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQ...",
"metadata": { "number": "AB1234567", "expires_at": "2030-01-01" }
},
{
"document_type": "SELFIE",
"image_front": "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQ..."
}
]
}'
| Field | Type | Required | Notes |
|---|---|---|---|
client_reference_id | string | yes | Your own user id. Opaque to us, up to 120 characters. |
person.first_name | string | yes | |
person.last_name | string | yes | |
person.second_name | string | no | |
person.email | string | yes | |
person.birth_date | string | yes | YYYY-MM-DD. |
person.address | object | yes | street_line1, city, postal_code, state, country (all strings; country as a 3-letter ISO code, e.g. ARG). |
person.phone_country_code / phone_number | number | yes | The KYC review asks for it on essentially every case anyway, so we require it upfront instead of finding that out later via onboarding_incomplete — with no way to add it to that same submission afterwards (see limitations). |
documents | array | yes | See Document requirements below. |
201 — everything required was present and no document was rejected on the spot:
{ "user_id": "user_803f99acfcb74eeb", "status": "in_review" }
400 onboarding_incomplete — something is still missing or was rejected:
{
"error": "onboarding_incomplete",
"message": "Some data or documents are still missing or were rejected.",
"user_id": "user_803f99acfcb74eeb",
"missing_requirements": { "data": [], "documents": [ /* ... */ ] },
"rejected_items": [ { "type": "document", "key": "passport", "code": "DOCUMENT_QUALITY_REJECTED", "reason": "Part of the document is outside the photo..." } ]
}
user_id is returned even on a 400 — save it. status is one of in_review, approved or rejected; in practice you'll almost always see in_review here, since KYC review itself happens after this call, not during it.client_reference_id never opens a second KYC case. What it does depends on the current status: if it's still in_review with something outstanding (missing_requirements non-empty), sending documents again attempts to complete that same case — corrected images, added metadata, whatever was missing. If it's already approved, or genuinely rejected, we just return the current status and ignore anything else in the call — use a new client_reference_id to start a fresh attempt.Document requirements
Each entry in documents has a document_type, one or two base64-encoded images, and (for identity documents) metadata:
| document_type | Images | Metadata |
|---|---|---|
PASSPORT | image_front only | number, expires_at (required) · issuing_country, issued_at (optional) |
NATIONAL_ID | image_front + image_back | same as passport |
DRIVERS_LICENSE | image_front + image_back | same as passport |
SELFIE | image_front only | none |
documents entry needs an identity document (one of the three above) and a SELFIE — two entries minimum, three when the identity document needs a back image too (see below).NATIONAL_ID and DRIVERS_LICENSE need both sides — send them as two separate entries in documents, both with the same document_type: one with only image_front (plus the metadata), the other with only image_back. Sending both images in a single entry has been observed to fail silently on the provider's side instead of being accepted."documents": [
{
"document_type": "NATIONAL_ID",
"image_front": "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQ...",
"metadata": { "number": "34480171", "expires_at": "2034-01-02" }
},
{
"document_type": "NATIONAL_ID",
"image_back": "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQ..."
},
{
"document_type": "SELFIE",
"image_front": "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQ..."
}
]
Image rules, straight from the identity provider — a photo that breaks these gets auto-rejected before a human ever sees it:
- JPG or PNG only, up to 10 MB. No PDF, no HEIC/HEIF (the default photo format on iPhone and Mac).
- A real photo taken with a camera — not a scan, a screenshot, or an image that passed through an editor (even unedited: exporting from Photos/Preview/Word adds metadata that reads as "edited"). This includes cropping: send the photo as the camera produced it, framed correctly to begin with — don't crop it afterward to "clean it up".
- All four corners of the document inside the frame, in focus, no glare.
- Digital-only IDs from government apps (e.g. Argentina's Mi Argentina, a digital driver's license, an EU digital identity wallet) are not accepted — needs the physical document.
A rejected document is never saved — the requirement stays open until you send a corrected one. See the rejected_items shape in the Create a user response above.
KYCGET /v1/users/{user_id}
Checks the current KYC status of a user. Always queries the identity provider live — never a stale cache.
curl https://api.cryptocard.la/v1/users/user_803f99acfcb74eeb \
-H "Authorization: Bearer ck_live_..."
{ "user_id": "user_803f99acfcb74eeb", "status": "in_review", "missing_requirements": null }
| Field | Notes |
|---|---|
status | in_review · approved · rejected. |
missing_requirements | Present when still in_review and something is outstanding; otherwise null. |
rejection_code / rejection_description | Present only when status is rejected. |
This is the reliable way to find out whether a user can now have a card issued — poll it rather than relying only on the webhook below. Every few minutes is plenty: review isn't instant, and there's no faster signal than the webhook when it arrives anyway.
POST /v1/cards
Issues a new card. The body is different depending on your account type — send the one that matches yours; the other field is rejected.
No-KYC
curl https://api.cryptocard.la/v1/cards \
-X POST \
-H "Authorization: Bearer ck_live_..." \
-H "Idempotency-Key: signup-8841" \
-H "Content-Type: application/json" \
-d '{
"client_reference_id": "user_8841",
"holder_name": "MARIA BARBER",
"monthly_limit": 5000,
"phone_country_code": 57,
"phone_number": 3001234567,
"email": "[email protected]"
}'
KYC
curl https://api.cryptocard.la/v1/cards \
-X POST \
-H "Authorization: Bearer ck_live_..." \
-H "Idempotency-Key: signup-8841" \
-H "Content-Type: application/json" \
-d '{
"client_reference_id": "user_8841",
"user_id": "user_803f99acfcb74eeb",
"monthly_limit": 5000
}'
| Field | Type | Required | Notes |
|---|---|---|---|
client_reference_id | string | yes | Your own user/account id. Opaque to us, up to 120 characters. |
holder_name | string | No-KYC yes | Name printed on the card, up to 26 characters. A longer one is rejected with invalid_fields, never truncated. Not accepted on KYC accounts — the printed name comes from the verified person instead. |
user_id | string | KYC yes | From Create a user. Must reference a user whose status is approved, or this returns 400 user_not_approved. Not accepted on no-KYC accounts. |
phone_country_code / phone_number / email | number / string / string | No-KYC yes | Required so the 3-D Secure OTP reaches the person actually using the card: missing any of these three returns invalid_fields. Not sent on KYC accounts — this already came in at onboarding, so it's not part of this request. |
monthly_limit | number | no | Spend cap in USD. Falls back to your account's default and is capped at your account's max. |
daily_limit / weekly_limit / per_transaction_limit | number | no | Independent spend caps in USD. Any of these you don't send falls back to monthly_limit — send them only if you actually want a different number per window. Change any of the four later with Update limits. |
201
{
"card_id": "card_0179c6dd1fcf97a5",
"client_reference_id": "user_8841",
"status": "ACTIVE",
"last4": "4242",
"funding_address": "0x8c1a2b3c4d5e6f7089ab1234cd5678ef90123456",
"network": "POLYGON",
"limits": { "daily": 5000, "weekly": 5000, "monthly": 5000, "per_transaction": 5000 },
"created_at": "2026-01-14T20:00:00.000Z"
}
network to funding_address to fund it. Balance updates automatically; there's no separate "confirm deposit" call.GET /v1/cards/{card_id}
Fetches a card, including its live balance.
curl https://api.cryptocard.la/v1/cards/card_0179c6dd1fcf97a5 \
-H "Authorization: Bearer ck_live_..."
{
"card_id": "card_0179c6dd1fcf97a5",
"client_reference_id": "user_8841",
"status": "ACTIVE",
"last4": "4242",
"funding_address": "0x8c1a2b3c4d5e6f7089ab1234cd5678ef90123456",
"network": "POLYGON",
"limits": { "daily": 5000, "weekly": 5000, "monthly": 5000, "per_transaction": 5000 },
"created_at": "2026-01-14T20:00:00.000Z",
"balance": 187.42
}
balance, status, last4, limits and funding_address are all fetched live on every call to this endpoint, so none of them are ever stale here. balance and funding_address come back null if they couldn't be retrieved at that moment (safe to just retry).
GET /v1/cards
Returns every card belonging to your account, newest first. status, last4 and limits reflect our last-known values here, not a live check; there's no balance or funding_address field either. Fetch a specific card if you need the current, authoritative state.
{ "data": [ { "card_id": "card_0179c6dd1fcf97a5", "client_reference_id": "user_8841", "...": "..." } ] }
POST /v1/cards/{card_id}/freeze · POST /v1/cards/{card_id}/unfreeze
Freezing blocks new authorizations immediately; unfreezing re-enables them. No request body. Returns the updated card (same shape as Get a card, without balance):
curl https://api.cryptocard.la/v1/cards/card_0179c6dd1fcf97a5/freeze \
-X POST -H "Authorization: Bearer ck_live_..."
{ "card_id": "card_0179c6dd1fcf97a5", "status": "FROZEN", "...": "..." }
PATCH /v1/cards/{card_id}/limits
Changes any subset of a card's four spend limits, already issued, no reissue. Fields you don't send keep their current value — you're never required to send all four just to change one.
curl https://api.cryptocard.la/v1/cards/card_0179c6dd1fcf97a5/limits \
-X PATCH \
-H "Authorization: Bearer ck_live_..." \
-H "Content-Type: application/json" \
-d '{ "daily_limit": 250, "per_transaction_limit": 200 }'
| Field | Type | Required | Notes |
|---|---|---|---|
daily_limit / weekly_limit / monthly_limit / per_transaction_limit | number | no | Send at least one. Each one you send must be a positive number, capped at your account's max; each one you don't send is left exactly as it was. |
200
{
"card_id": "card_0179c6dd1fcf97a5",
"status": "ACTIVE",
"limits": { "daily": 250, "weekly": 5000, "monthly": 5000, "per_transaction": 200 },
"...": "..."
}
balance). Changes take effect on the card's next authorization.POST /v1/cards/{card_id}/pan-session
Gives your user a one-time, one-view link to see their full card number, expiry and CVV. The card number never appears in an API response: this returns a URL, not the number itself.
curl https://api.cryptocard.la/v1/cards/card_0179c6dd1fcf97a5/pan-session \
-X POST -H "Authorization: Bearer ck_live_..."
{
"url": "https://api.cryptocard.la/pan/9f2a1c...",
"expires_at": "2026-01-14T20:05:00.000Z"
}
url (a new tab, not an iframe). We render the card number directly on a page hosted by us; it never reaches your backend or your frontend code. The link works once: reloading it, or opening it a second time, shows an expired-link message instead.This is what keeps card data out of your PCI scope: your servers request a link, your user's browser sees the number, and that's the whole path.
GET /v1/cards/{card_id}/transactions
Query params: limit (default 20, max 100) and cursor (from the previous page's next_cursor).
curl "https://api.cryptocard.la/v1/cards/card_0179c6dd1fcf97a5/transactions?limit=20" \
-H "Authorization: Bearer ck_live_..."
{
"data": [
{
"tx_id": "tx_77ab19f2c3d4",
"type": "purchase",
"amount": -12.50,
"currency": "USD",
"merchant": "NETFLIX.COM",
"mcc": "4899",
"mcc_category": "Cable, Satellite, and Other Pay Television and Radio Services",
"merchant_country": "USA",
"transaction_currency": "USD",
"created_at": "2026-01-14T21:03:11.000Z"
}
],
"next_cursor": null
}
| Field | Notes |
|---|---|
type | purchase · deposit · withdrawal · adjustment |
amount | Negative = money out (a purchase, already net of any reversal/refund on it). Positive = money in. |
merchant | null when the network doesn't provide one (deposits, adjustments, and some purchases). |
mcc | Purchases only. The merchant category code (ISO 18245), e.g. "5411" for supermarkets. null when the network didn't send one. |
mcc_category | Purchases only. The network's name for that category, e.g. "Grocery Stores, Supermarkets". Keep in mind the code is chosen by the merchant, so it doesn't always match what was bought. |
merchant_country | Purchases only. ISO 3166 alpha-3 country of the merchant, e.g. "USA", or null. |
transaction_currency | Purchases only. The currency the merchant charged in (e.g. "EUR"). amount is always in the card's currency (currency, USD); when they differ, the purchase went through currency conversion. |
KYCThese four fields come filled in the list, the single-transaction lookup and the transaction.* webhooks. No-KYCFor now they're filled in the webhooks only, and are null in the list.
No-KYCThere's no status field in this version. A declined purchase attempt can currently still appear in this list as a purchase, indistinguishable from an approved one — known gap, being worked on; listen for the transaction.declined webhook if you need this live. KYCThis account type does not have that problem: a declined purchase is already filtered out of this list.
GET /v1/cards/{card_id}/transactions/{tx_id}
Looks up one transaction by the tx_id you got from a previous list call or webhook.
tx_id you've never seen before (even a well-formed one) returns 404, same as a made-up one. We can only resolve transactions we've shown you at least once; there's no way to reverse-derive one on demand, and that's intentional: it means this endpoint can't be used to probe whether some transaction exists.Webhooks
If you've given us a webhook URL, we POST an event as soon as it happens:
| Event | When |
|---|---|
user.approved | KYCKYC review approved this user — they can now have a card issued. |
user.rejected | KYCKYC review rejected this user. |
card.created | Right after a card is issued. |
card.active | The card is usable (today, immediately after card.created). |
card.frozen | After a successful freeze. |
card.unfrozen | After a successful unfreeze. |
card.blocked | The card was automatically blocked by a fraud/velocity rule (not something you triggered). |
card.deposit | A crypto deposit was credited to the card's funding address. |
transaction.authorized | A purchase was approved. Never fires for a declined purchase. |
transaction.declined | A purchase was declined. Carries a decline_reason (code, description) — not a real charge, but useful if you want to show your user a complete history including attempts that didn't go through. |
user.approved/user.rejected fire against a real, completed KYC review end-to-end — the wiring is in place and tested up to the point of our identity provider's own decision webhook, but we'd rather tell you that plainly than imply it's battle-tested. Until we've confirmed it with a real case, treat polling as the source of truth and this webhook as a bonus if it arrives.{
"id": "evt_3f9a1c7e2b4d5678",
"type": "card.deposit",
"created_at": "2026-01-14T21:03:11.000Z",
"data": { "card_id": "card_0179c6dd1fcf97a5", "amount": 19.95, "currency": "USD" }
}
Every event has the same envelope; data varies per event type as shown in the table above.
Verifying signatures
Every webhook request carries an X-Signature header: the hex-encoded HMAC-SHA256 of the raw request body, using the webhook secret we gave you at onboarding.
const crypto = require('crypto');
function isValid(rawBody, signatureHeader, secret) {
const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signatureHeader));
}
A failed delivery is retried automatically with increasing backoff (1 min, 5 min, 30 min, 2 h, 12 h) — up to 5 attempts in total before we give up on that one. Every event is logged on our side regardless of delivery outcome, so nothing is lost if your endpoint was down longer than that; ask us to replay a range if you missed one.
Current limitations
Being upfront about what isn't there yet. Some of this applies to every account, some only to one side:
- Only SMS-based 3-D Secure is supported. It's the default: the code goes straight to the phone on the card, nothing needed from you. We're working on a feature to send the challenge to your own backend instead, so you (or your end user) can approve it directly.
- Only
transaction.authorized/transaction.declinedfire for purchases. A later clearing, reversal or refund on the same purchase doesn't generate its own webhook yet, on either account type. - KYCA genuinely rejected onboarding can't be corrected under the same
client_reference_id. Use a new one to submit a fresh attempt. An incomplete one (stillin_review, something outstanding) is different: callPOST /v1/usersagain with the sameclient_reference_idand corrected/additionaldocuments, and we retry against that same case — see Create a user. - KYCReview timing is opaque. Usually minutes; can take up to a few weeks if it needs a human look. No in-between status is exposed.
- KYCBuild your integration around polling, not around the webhook. Wait a few minutes after creating the user, then poll
GET /v1/users/{id}— that's the reliable way to get the final status. Theuser.approved/user.rejectedwebhook is a nice-to-have when it arrives, not something to wait on. - No-KYCA declined purchase can still appear as a real one in the transaction listing. The listing endpoint itself is the known gap, still being worked on. Listen for
transaction.declinedinstead of polling the list if you need this distinguished correctly today.