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 job | From your code? | What it takes |
|---|---|---|
| Issue a virtual card | Yes | One POST, $2 off the balance, one of 5 slots |
| Read the number and CVV | Yes | One route, one scope, and that scope is off until you turn it on |
| Freeze, cap, delete | Yes | Granted by default — worth knowing before a key leaks |
| Pass a 3-D Secure check | Yes | Two scopes, both off by default. This is the unusual one |
| Open a crypto deposit | Yes | One scope off by default, then poll until it is credited |
| Get a webhook callback | Not yet | Endpoints register, but delivery is not live — poll instead |
| Issue cards for users | No | The key drives one account, and there are no sub-accounts |
| Move money back out | No | Value 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.
# 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.
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.
- Build a deterministic label for the thing the card is for.
GET /cards. If the label already exists, use that card and stop.- Otherwise
POST /cards. - 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:
# 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:
# 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
/3dsfirst marks the stale pending challenges asexpired. A challenge can therefore be gone by the time you look at it — poll well inside the window, which each challenge states inexpires_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:
| Status | error | What the body also carries |
|---|---|---|
401 | unauthorized / invalid_key | Nothing. Malformed header, or a key that is not ours |
403 | insufficient_scope | required_scope — name it in your own error message |
402 | insufficient_funds | required_usd — the card fee, read from the source |
400 | amount_too_low | minimum_usd — this account's minimum, first deposit or not |
409 | no_slot_left | Nothing. Delete or reuse a card before retrying |
409 | not_pending | Nothing. The challenge was already resolved, or it expired |
429 | rate_limited | Nothing. 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.


