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.

Base URL: https://api.cryptocard.la. Every request must be HTTPS.
Want to start integrating right away? Ask for a test key for our sandbox environment (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" }
HTTPerrorMeaning
400invalid_fieldsMissing or malformed field in the request body.
400bad_jsonRequest body is not valid JSON.
400onboarding_incompleteKYCSome required data or a document is still missing, or a document was rejected on quality/identity grounds. See Create a user.
400user_not_approvedKYCThe user_id you gave doesn't reference a user whose KYC is approved yet.
401unauthorizedMissing, invalid, or revoked API key.
404not_foundNo such card/user, or it doesn't belong to your account.
502provider_errorTemporary failure completing the operation. See the notes on each endpoint for whether it's safe to just retry.
500internalUnexpected 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:

No-KYCYour account issues cards directly. You supply the cardholder's name yourself. Skip straight to Create a card — the "Onboarding" section below doesn't apply to you, and calling it returns 404 on your account.
KYCYour account requires identity verification first. A card always belongs to a user_id whose KYC has been approved — you can't skip to card creation. The flow:
  1. Create the user — POST /v1/users with their personal data and identity documents. Comes back in_review in the normal case (KYC takes time), or straight to approved/400 onboarding_incomplete in the edge cases.
  2. Wait for a decision — either poll GET /v1/users/{user_id}, or listen for the user.approved / user.rejected webhook. We recommend doing both: poll as the source of truth, treat the webhook as a faster notification when it arrives.
  3. Create the card — once the user's status is approved, POST /v1/cards with that user_id.
KYCThere is no fixed timeline for step 2. Automated review is usually done in minutes; anything that needs a human look can take anywhere from hours to a few weeks. There's no intermediate status beyond 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..."
      }
    ]
  }'
FieldTypeRequiredNotes
client_reference_idstringyesYour own user id. Opaque to us, up to 120 characters.
person.first_namestringyes 
person.last_namestringyes 
person.second_namestringno 
person.emailstringyes 
person.birth_datestringyesYYYY-MM-DD.
person.addressobjectyesstreet_line1, city, postal_code, state, country (all strings; country as a 3-letter ISO code, e.g. ARG).
person.phone_country_code / phone_numbernumberyesThe 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).
documentsarrayyesSee 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..." } ]
}
The 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.
A later call with the same 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_typeImagesMetadata
PASSPORTimage_front onlynumber, expires_at (required) · issuing_country, issued_at (optional)
NATIONAL_IDimage_front + image_backsame as passport
DRIVERS_LICENSEimage_front + image_backsame as passport
SELFIEimage_front onlynone
Every 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:

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 }
FieldNotes
statusin_review · approved · rejected.
missing_requirementsPresent when still in_review and something is outstanding; otherwise null.
rejection_code / rejection_descriptionPresent 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
  }'
FieldTypeRequiredNotes
client_reference_idstringyesYour own user/account id. Opaque to us, up to 120 characters.
holder_namestringNo-KYC yesName 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_idstringKYC yesFrom 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 / emailnumber / string / stringNo-KYC yesRequired 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_limitnumbernoSpend cap in USD. Falls back to your account's default and is capped at your account's max.
daily_limit / weekly_limit / per_transaction_limitnumbernoIndependent 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"
}
A card is usable immediately after creation but starts with zero balance. Send USDT/USDC on 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 }'
FieldTypeRequiredNotes
daily_limit / weekly_limit / monthly_limit / per_transaction_limitnumbernoSend 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 },
 "...": "..."
}
Returns the updated card (same shape as Get a card, without 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"
}
Redirect your user's browser to 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
}
FieldNotes
typepurchase · deposit · withdrawal · adjustment
amountNegative = money out (a purchase, already net of any reversal/refund on it). Positive = money in.
merchantnull when the network doesn't provide one (deposits, adjustments, and some purchases).
mccPurchases only. The merchant category code (ISO 18245), e.g. "5411" for supermarkets. null when the network didn't send one.
mcc_categoryPurchases 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_countryPurchases only. ISO 3166 alpha-3 country of the merchant, e.g. "USA", or null.
transaction_currencyPurchases 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.

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

EventWhen
user.approvedKYCKYC review approved this user — they can now have a card issued.
user.rejectedKYCKYC review rejected this user.
card.createdRight after a card is issued.
card.activeThe card is usable (today, immediately after card.created).
card.frozenAfter a successful freeze.
card.unfrozenAfter a successful unfreeze.
card.blockedThe card was automatically blocked by a fraud/velocity rule (not something you triggered).
card.depositA crypto deposit was credited to the card's funding address.
transaction.authorizedA purchase was approved. Never fires for a declined purchase.
transaction.declinedA 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.
KYCWe haven't yet observed 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));
}
Use the raw request body (before any JSON parsing): re-serializing it can reorder keys or change whitespace and break the comparison.

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: