Designing Against a Card Issuing API: What Matters
The patterns that separate a card integration that survives production from one that does not: idempotency, state machines, fee snapshots and reconciliation.
In short
Integrating a card API is mostly about handling the cases where things go wrong: duplicate requests, partial failures, and states that are not what your model assumed. Six patterns cover almost all of it.
The happy path of a card API is trivial. POST /cards, get a card. What separates a robust integration from a fragile one is entirely in the failure modes — and in payments, failure modes cost money rather than just annoying people.
Six patterns cover most of it.
1. Idempotency, done properly
Any call that moves money must be idempotent. The reason is simple: when a request times out, you cannot tell whether it succeeded. Without idempotency your only options are to retry and risk charging twice, or not retry and risk losing an operation.
The rule that matters: one key per logical operation, not per attempt.
// Wrong — a new key per attempt defeats the entire mechanism
for (let i = 0; i < 3; i++) {
await issueCard({ idempotencyKey: crypto.randomUUID() });
}
// Right — the key identifies the operation, and survives a retry
const key = `card-issue:${order.id}`;
await retry(() => issueCard({ idempotencyKey: key }));
Derive the key from something stable in your own domain — an order id, a user id plus a purpose — and store it. If your process restarts mid-retry, you need to be able to reconstruct the same key.
2. Model the state machine honestly
A card is not created-or-not. It moves through states, and at least one of them surprises people:
| State | Meaning | What you can do |
|---|---|---|
PENDING | Accepted, not yet sent to the network | Wait |
CREATING | The network is provisioning it. No card number yet. | Wait |
ACTIVE | Usable. Details readable. | Everything |
FROZEN | Authorisations blocked, reversibly | Unfreeze, read |
FAILED | Creation failed, funds returned | Investigate |
TERMINATED | Permanently dead | Read only |
The CREATING state is where integrations break. A card returned from POST /cards genuinely has no number for a short period. If your UI says "your card is ready" and then shows blanks, you have built the most common avoidable support ticket in card programmes. Treat issued and usable as separate facts, because they are.
3. Snapshot the fee at the time of the transaction
Fees change. A statement from six months ago must still show what was actually charged, not what the rate is today. Any competent issuing API snapshots the fee onto the transaction; store your own copy anyway, because reconciling your books against a provider's is far easier when you have the number on both sides.
Store the fee as its components — fixed and percentage — not just the total. A total of $6.00 tells you nothing about whether the rate changed; $5.00 + 1% on a $100 load tells you everything.
4. Never trust the client for anything that costs money
This is obvious and still gets violated. The amount to load, the card product, the user the card belongs to — all of it is decided server-side from your own records. A request that arrives with a userId in the body is a request you should not honour.
The corollary: every read of a card must be scoped to its owner. GET /cards/:id should be SELECT ... WHERE id = $1 AND user_id = $2, always, with no exceptions for admin paths that somehow forget.
5. Handle rate limits as a queue, not an error
ON5 limits reads to 120 per minute and card operations to 60, counted per account rather than per key — adding keys does not add capacity. Bulk issuing must therefore be a queue with bounded concurrency, not a Promise.all over a thousand users.
// A bounded queue with backoff beats parallel fire-and-hope
const queue = new PQueue({ concurrency: 4, interval: 1000, intervalCap: 1 });
for (const user of users) {
queue.add(() => issueCard({
idempotencyKey: `onboarding:${user.id}`,
cardholderEmail: user.email,
initialAmount: '25.00',
}));
}
This is also faster in practice than parallelism that trips a limiter and forces you to work out which of a thousand requests actually landed.
6. Reconcile, on a schedule
However careful the integration, your view and the provider's will diverge eventually — a webhook missed, a process killed mid-write, a retry that landed twice at a layer without idempotency. The only defence is a job that compares them.
- List cards from the provider, compare against yours: any card they have that you do not, and vice versa.
- Compare balances and statuses on a sample, or on everything if your volume allows.
- Alert on a difference rather than auto-correcting it. A silent auto-fix hides the bug that caused the divergence.
On webhooks
If your provider sends webhooks, three rules are non-negotiable. Verify the signature against the raw request bytes, before parsing — re-serialising JSON can change key order and break verification. Deduplicate on the event id, because every provider retries. And acknowledge fast, process asynchronously: persist the event, return 200, and do the work in a worker. A webhook handler that does real work inline will time out and be retried, and then you are handling the same event three times under load.
A short checklist
- Idempotency keys derived from your domain, stored, reused across retries.
- Provisioning state handled in the UI as its own thing.
- Fees stored as components, snapshotted at transaction time.
- Every card read scoped to its owner.
- Bulk work queued with bounded concurrency.
- A reconciliation job that alerts rather than silently corrects.
- Webhooks verified on raw bytes, deduplicated, acknowledged fast.
Frequently asked questions
Why do card issuing APIs require idempotency keys?
Because a timed-out request cannot be distinguished from a failed one. Retrying with the same idempotency key returns the original result rather than issuing a second card or charging twice.
Should the idempotency key be per attempt or per operation?
Per operation. A fresh key on each retry defeats the mechanism entirely. Derive the key from something stable in your own domain, such as an order id, and store it.
Why is a newly created card not immediately usable?
The card network provisions the number after accepting the request, so the card exists in a CREATING state with no card number for a short period. Treat issued and usable as separate states in your own model.
How should bulk card issuing be handled?
As a queue with bounded concurrency, not parallel requests. Rate limits are counted per account rather than per key, so adding API keys does not add capacity.
Issue your first card on ON5
Fund an account with USDT or USDC and issue a branded Visa or Mastercard virtual card. The minimum is $5.
Open the dashboard