Developers

Issuing virtual cards from your own code, over the API

A bearer key, eleven scopes and eight resources. What you can automate, where the API deliberately stops, and the retry that quietly costs you a card slot.

By CryptoCard · 3537 words · about 16 min · updated

Contents

What sits behind the key

Most no-KYC cards end at a dashboard. This one has a REST API over the same account, which changes what you can build: a card per merchant created by your own code, a deposit opened and watched by a job, an online payment carried all the way through 3-D Secure without a human reading an inbox.

Before any of that, the scope of the thing, because getting this wrong wastes a day. The key belongs to one account and drives that account. It is not a card-issuing platform: you cannot create accounts for your users, you cannot issue cards on their behalf, and there is nothing resembling a sub-account. What you get is programmatic control of your own cards and your own balance. That is a smaller promise than "card issuing API", and it is the true one.

The jobFrom your code?What it takes
Issue a virtual cardYesOne POST, $2 off the balance, one of 5 slots
Read the number and CVVYesOne route, one scope, and that scope is off until you turn it on
Freeze, cap, deleteYesGranted by default — worth knowing before a key leaks
Pass a 3-D Secure checkYesTwo scopes, both off by default. This is the unusual one
Open a crypto depositYesOne scope off by default, then poll until it is credited
Get a webhook callbackNot yetEndpoints register, but delivery is not live — poll instead
Issue cards for usersNoThe key drives one account, and there are no sub-accounts
Move money back outNoValue leaves as a card payment or not at all

One thing to check before you write any of it: the key stays hidden until the account has at least one active card. It can issue cards and spend a balance, so handing that to an account that has activated nothing would be strange. Fund the account, activate a card, then read the key from Dashboard → Developers.

The root of the API answers without a key and lists the version, the scopes and every route. It exposes nothing about any account, which makes it the right target for a health check — you find out you are talking to the right service without spending a credential to do it.

One key, 11 scopes, and the 5 that are off

Authentication is one header. The key is cc_live_ followed by 32 hex characters, and it is a bearer credential — whoever holds it is the account. Server-side, in an environment variable, and never in a browser bundle, a mobile binary or a repository. There is no endpoint that rotates a key using itself; rotation is a button in the dashboard, which is the right place for it.

first-call.sh
# Who am I, and what is this key actually allowed to do?
curl https://cryptocard.net/api/v1/account \
  -H "Authorization: Bearer $CC_API_KEY"

Do that call at boot and read granted_scopes back. Every endpoint checks its own scope, so a key without cards:write genuinely cannot create a card — but you would rather find that out in a startup check than at three in the morning.

Of the 11 scopes, 6 are on by default and 5 are off, and the split is not arbitrary:

  • On by default — reading the account, the balance, the ledger and the list of deposits, plus reading and writing cards. Everything here is either harmless or reversible.
  • Off by default — opening deposits, reading a full card number, and both halves of 3-D Secure. 3 of them carry an explicit risk flag in the dashboard.

Now the part worth pausing on, because it is the opposite of what people assume. cards:write is granted by default, and issuing a card takes $2 off your balance. A key you think of as read-only can therefore spend money and burn card slots. If the process only ever needs to read, turn the write scopes off for it — the switches are there precisely so a key can be smaller than an account.

When a call fails on a scope you get 403 with required_scope in the body. Put that value straight into your own log line. "Insufficient scope" sends someone to the docs; "insufficient scope: cards:pan" sends them to the switch.

Issuing a card, and the retry that quietly costs you a slot

A card is one POST. You may send a label (up to 40 characters — longer is truncated, not refused) and a monthly_limit in dollars, or neither.

issue-card.sh
curl -X POST https://cryptocard.net/api/v1/cards \
  -H "Authorization: Bearer $CC_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"label":"sub-netflix-2026","monthly_limit":18}'

# 201 — the card object, with last4 only. The number is a different call.

Three refusals are worth handling explicitly, and all three are ordinary business outcomes rather than bugs: 402 insufficient_funds when the balance does not cover the $2 fee (the body carries required_usd), 409 no_slot_left when all 5 slots are in use, and 403 if the key lost cards:write.

The retry problem, which is the real content of this section

No endpoint in this API takes an idempotency key. That is fine for reads and it is a trap for POST /cards. A request that times out on your side may well have succeeded on ours, and a naive retry gives you two cards, two $2 charges and two of your 5 slots spent on one merchant.

There is a clean way round it that costs nothing, and it is the habit worth taking from this guide: derive the label from something you already have — an order id, a subscription id, a customer reference — and make the list your idempotency key. Before retrying a POST /cards whose outcome you do not know, GET /cards and look for that label. If it is there, the first call worked and you are done.

  1. Build a deterministic label for the thing the card is for.
  2. GET /cards. If the label already exists, use that card and stop.
  3. Otherwise POST /cards.
  4. On a timeout or a 5xx, go back to step 2 rather than reposting.

Two more facts about the shape of the fleet. Deleting a card frees the slot but refunds nothing, so rotating a merchant's card costs $2 every time — budget for rotation, or freeze instead. And the default card cannot be deleted: that call comes back 409 cannot_delete_default by design, so there is always one card left standing.

For everything short of destruction, prefer POST /cards/{id}/freeze. It is instant, it is reversible, it costs nothing, and it is the right response to a card behaving oddly at a merchant — see why a card gets declined before you conclude the card is at fault. The pattern that makes all of this worth automating is one card per merchant with a cap just above the price; the API is simply that pattern without the clicking.

The one call that returns a real card number

Every other endpoint returns last4 and nothing more. The full number lives behind a single route and its own scope:

secure.json
# GET /cards/{id}/secure   — scope: cards:pan
{
  "id": 41,
  "label": "sub-netflix-2026",
  "last4": "4417",
  "expiry": "08/30",
  "number": "…",
  "cvv": "…"
}

Two properties of that response deserve to be known rather than discovered.

The CVV is not stored anywhere. It is derived on the fly from the card number, the card id and the application key, which means a stolen copy of the database does not contain a single CVV. It also means the value is stable — the same card returns the same three digits — so there is nothing to cache and no reason to write it down.

A frozen card refuses to show its number. The call answers 409 card_frozen until you unfreeze it. That is deliberate, and it breaks one tempting design: keeping cards frozen between purchases and thawing them only to pay. If you want that pattern, the order is unfreeze, read, pay, freeze — and the read has to sit inside the thawed window, not before it.

The obvious hygiene, said once because it is the only response in this API worth stealing: it is the one payload you never log, never put in an error report and never keep past the request that needed it. If your logger redacts by key name, add number and cvv to the list before your first call, not after your first incident.

The half of checkout that normally needs a human

This is the section that has no equivalent elsewhere, so it is worth being precise about what it buys you.

An online payment above a certain risk threshold triggers 3-D Secure: the issuer sends a six-digit code and the checkout will not settle until someone types it. Because this card has no phone number attached, the code goes to your account email rather than by SMS. For a person that is a detail. For a program it is the wall — and it is where automated spending on every other no-KYC card stops.

Here the challenge is a resource. You list the open ones, you read the code, and you resolve it:

approve.sh
# 1. anything waiting?   scope: 3ds:read
curl "https://cryptocard.net/api/v1/3ds?status=pending" \
  -H "Authorization: Bearer $CC_API_KEY"

# -> data[].code is present while the challenge is pending, and only then.
#    data[].merchant, .amount_usd and .expires_at are your decision inputs.

# 2. let it through     scope: 3ds:write
curl -X POST https://cryptocard.net/api/v1/3ds/$REF/approve \
  -H "Authorization: Bearer $CC_API_KEY"

Four behaviours to build around, in the order they will bite you:

  • The code exists only while the challenge is pending. Once it is approved, declined or expired the field is simply absent from the payload. There is no reading it back afterwards, so if you need it, take it on the pass that found the challenge.
  • Expiry is swept on every read. Any request to /3ds first marks the stale pending challenges as expired. A challenge can therefore be gone by the time you look at it — poll well inside the window, which each challenge states in expires_at.
  • A resolved challenge cannot be re-resolved. Approving twice returns 409 not_pending. Treat it as terminal and re-read the challenge to learn which way it went, rather than retrying.
  • Approving is a decision, not a formality. The payload names the merchant and the amount before you approve. A loop that approves everything it sees has thrown away the only check 3-D Secure was there to provide.

Which leads to the warning this section has to carry. A key holding both 3ds:read and 3ds:write can complete online payments on its own. Those two, with the full card number, are the 3 scopes the dashboard flags as risky, and they are off by default for exactly this reason. Grant them to the one process that needs them, keep that process small, and leave every other key without them.

Funding from code, and reading the balance honestly

Deposits are the other half of an automated setup: a card with no balance is an ornament. Opening one needs topups:write, which is off by default.

You POST the dollar amount you want credited and a coin from BTC, XMR, ETH, USDT, USDTTRC, LTC, TRX. Note that Tether appears twice on purpose — USDT is the Ethereum rail and USDTTRC is the TRON one, and they are not interchangeable. The response carries deposit_address, deposit_amount, deposit_network, an expires_at, and a deposit_tag that is null on chains which do not use one.

deposit_amount is a string, and it has to stay one. The trailing digits are part of the identifier, a JSON float will quietly lose them, and on a chain where the amount is the whole of the identification that is the difference between a deposit that credits itself and one that needs a conversation. Pass it through as text, all the way to whatever sends the coin.

Then poll GET /topups/{reference} until credited_at stops being null. Do not compute the arithmetic yourself: the object already carries amount_usd, fee_usd and credit_usd. On a $500 top-up at our 1% that is $5.00 of fee and $495.00 landing on the balance — but read the fields, because a number you recomputed is a number that can disagree with ours.

The bounds are worth reading from the errors rather than hard-coding: the first deposit on an account is $100 and every one after that is $100, so 400 amount_too_low comes back with minimum_usd set to whichever applies to this account right now. A single deposit tops out at $100,000.

Two things GET /balance gives you beyond a number. It returns the spendable balance, and a yield block with the current rate, the compounded APY, the basis, what is accruing today and when the next payment lands. The balance earns 4% a year, paid daily — and because the endpoint reports the rate, your code should never contain the figure 4%. Read it; it is variable, and a hard-coded rate is a bug with a delay fuse.

On webhooks, plainly: you can register up to ten HTTPS endpoints today against topup.confirmed, card.created, payment.settled, and nothing is dispatched to them yet. Delivery is not live. Until it ships, poll GET /topups/{reference} and GET /3ds?status=pending — and write the handler as a function your poller calls, so the day delivery ships you change the caller and nothing else.

A client that survives contact with production

Six behaviours that are easy to get wrong from the reference alone, because each is a consequence rather than a field.

The rate limit is a calendar minute, not a rolling window. It is 120 requests per minute per key, counted in a bucket named by the current minute. The consequence is unintuitive: 120 calls at 10:59.9 and 120 more at 11:00.1 both pass, while a client that spreads its load evenly gets no credit for doing so. Do not design around the edge — just back off on 429 instead of spinning. A refused call costs nothing but the round trip.

Switch on error, never on message. Every failure is the same shape: a stable machine-readable error and a human message. The message is prose and prose gets rewritten. Some errors also carry the number you were missing, which is the single best reason not to hard-code our constants:

StatuserrorWhat the body also carries
401unauthorized / invalid_keyNothing. Malformed header, or a key that is not ours
403insufficient_scoperequired_scopename it in your own error message
402insufficient_fundsrequired_usd — the card fee, read from the source
400amount_too_lowminimum_usd — this account's minimum, first deposit or not
409no_slot_leftNothing. Delete or reuse a card before retrying
409not_pendingNothing. The challenge was already resolved, or it expired
429rate_limitedNothing. Back off — a refusal costs nothing else

Out-of-range parameters are clamped, not rejected. Ask GET /transactions for 5,000 rows and you get 200 with no error at all; ask GET /interest for 4,000 days and you get 365. Nothing tells you it happened. Read the length of the array you received rather than assuming you got the length you asked for — a paginator built on that assumption loops forever.

One key per process. The limiter and the request log are both per key. A runaway batch job sharing a key with your checkout path will rate-limit your checkout path, and the log becomes unreadable at exactly the moment you need it.

The request log holds the last forty calls — method, path, status, timestamp, in Dashboard → Developers. That is a debugging aid, and a good one for "did my call even arrive". It is not an audit trail, and forty goes by fast at 120 a minute, so keep your own log of anything you would need to reconstruct later.

Every timestamp is ISO-8601 in UTC, or null. And null means the thing has not happened yet, not that it is unknown — credited_at: null is a deposit still in flight, which is precisely why it is the field to poll on.

What this does not do

Everything above is a real capability. Being straight about the edges is what makes the rest worth trusting.

There is no sandbox and no test key. Keys are cc_live_ because there is only one kind. Every call is the live account, and the $2 card fee is real money. Develop against a small balance and keep an eye on your slot count.

Nothing is idempotent. No idempotency keys, no request ids to replay against. Design your writes to be checkable after the fact — the deterministic-label trick above is the pattern that works.

It is your account, not a platform. No sub-accounts, no issuing on behalf of your users, no transfers between accounts. If you need to give cards to other people, this is not the shape of API that does it.

5 virtual cards, and that is the cap. Deleting frees a slot, so the fleet rotates — but it does not grow, and each rotation costs $2.

There is no way back out. Value enters as a deposit and leaves as a payment to a merchant. No withdrawal endpoint exists because no withdrawal exists. Top up what you intend to spend.

Webhook delivery is not live. Said twice on purpose, because it is the assumption most likely to be made from the endpoint list alone.

And the rules still apply. An API key does not change what the account is: deposits are screened, our AML and sanctions policy is enforced the same way, and automating your spending does not automate away your own tax and reporting obligations. The full API reference has every endpoint, every field and every error in detail — this guide only covers the parts you would otherwise learn by hitting them.

Frequently asked questions

How do I get an API key?

From Dashboard → Developers, once the account has at least one active card. The key is hidden until then, because a key that can issue cards and spend a balance has no business existing on an account that has activated neither. It looks like cc_live_ followed by 32 hex characters, and you rotate it from the same panel — there is no endpoint that rotates a key using itself.

Can I use this to issue cards for my own customers?

No. The key drives one account: your cards, your balance, your deposits. There are no sub-accounts and no way to create an account through the API, so this is not a card-issuing platform you can build a product on top of. What it is good at is automating a fleet you own — one card per merchant, opened, capped, frozen and retired by your own code.

Is there a sandbox or test mode?

No. There is one kind of key and it is live, so every call touches the real account and the $2 card fee is real money. The safe way to develop is a small balance and a key with only the scopes you are actually exercising. The API root answers without a key at all, which is enough to smoke-test connectivity for free.

Can a script complete a 3-D Secure payment without me?

Yes, and that is unusual enough to be the reason most people find this API. With 3ds:read you list pending challenges and read the six-digit code; with 3ds:write you approve or decline. Both are off by default, and a key holding the pair can settle online payments on its own — so grant them to one small process and nowhere else. The code is only in the payload while the challenge is pending; after that it is gone.

What happens if I retry a POST that timed out?

You get a second one. No endpoint accepts an idempotency key, so a retried POST /cards means two cards, two fees and two of your 5 slots. The fix is on your side and it is cheap: build the label from an id you already have, GET /cards before you retry, and treat the presence of that label as proof the first call landed.

Do webhooks work?

You can register up to ten HTTPS endpoints and they will be stored, but delivery is not live yet — nothing is dispatched to them for the moment. Until it ships, poll GET /topups/{reference} for deposits and GET /3ds?status=pending for payments. We would rather tell you than let you wait for a call that never comes.

What is the rate limit, and what does hitting it cost?

120 requests per minute per key, counted over a calendar minute rather than a rolling window. Going over returns 429 with rate_limited and costs nothing else — no penalty, no lockout. Back off and retry in the next minute. If two workloads share a key they share the limit, so give each process its own.

If the CVV is not stored, where does it come from?

It is derived when you ask for it, from the card number, the card id and our application key. That is why it is stable — the same card always returns the same three digits — and why a stolen copy of the database contains no CVVs at all. It comes back only from GET /cards/{id}/secure, only with the cards:pan scope, and only while the card is not frozen.

Product references and further reading

Published by CryptoCard. Product terms, eligibility and third-party features can change; use the linked reference for the current details.

Get your card

An email address, a first top-up from $100, and the card is live. No document, no phone number, and the first virtual card is free.

Keep reading