Integration Guide
This guide walks the complete journey of a card integration — from the first /ping to a closed card's refund landing back in your wallet. The endpoint reference tells you what each API does; the webhook reference gives you the envelope and payloads. This page connects them into the flows you will actually build.
The journey in one view:
Stage 0 Setup token → webhook endpoint (+ optional RSA key)
Stage 1 Write & match POST /cards → 202 + order_uuid → order.completed (carries card_uuid)
Stage 2 Card secrets card.issued.secrets → decrypt JWE with your private key
Stage 3 Transactions card.transaction.* streams every statement row as it happens
Stage 4 Lifecycle freeze / two-stage close / platform-initiated events
Stage 0 — Setup (one-time)
Three things, all in the workspace Developer Settings:
- Mint an API token (choose its abilities), then verify it with
GET /ping. - Register a webhook endpoint: URL + the event codes you subscribe to. The signing secret is shown only once, at creation — store it immediately.
- (Optional — only if you need card secrets) Upload an RSA public key and subscribe to
card.issued.secrets.
Stage 1 — Writes and order matching: the async core
Every write on this platform is asynchronous. Issue, top-up, close, freeze, unfreeze — all five return 202 Accepted plus an order, never a synchronous result. The correct integration shape:
send the request (with X-Idempotency-Key)
→ store the order_uuid from the 202 (status: pending)
→ wait for the webhook: order.completed or order.failed
→ completed + it was an issue order: the payload carries card_uuid — the match chain closes
→ failed: the payload carries failure_reason (stable snake_case key); held funds are already back in your wallet
Three semantics worth internalizing:
- The idempotency key is your network insurance. Re-sending the same
X-Idempotency-Keywithin 24 hours returns the same order — no double charge. After a timeout, just resend. order.completedis the only acknowledgement there is. There is nocard.issuedorcard.topped_upwebhook — one receipt pair (order.completed/order.failed) covers all five write operations, so you write exactly one handler.- Polling is the fallback, not the main path.
GET /orders/{uuid}is always available to catch up after webhook downtime, but a healthy integration never needs a polling loop.
The full matching chain: your X-Idempotency-Key → order_uuid (from the 202) → card_uuid (from order.completed).
Stage 2 — Card secrets (JWE)
Only workspaces with an RSA public key configured receive card.issued.secrets. Card secrets (PAN / CVV / expiry) travel exclusively through this webhook — no REST response ever contains them in plaintext.
Think of it as a lockbox: uploading your public key hands the platform a box only you can open; the webhook delivers the locked box (secrets_jwe); your private key opens it (RSA-OAEP-256 + A256GCM). The key_fingerprint field tells you which key was used, so key rotation stays unambiguous. Decryption example: see the webhook reference.
Stage 3 — The transaction stream: one purchase, one lifeline
Once the cardholder starts spending, the issuer's statement is pushed row by row: one statement row = one event, and data.transaction is byte-for-byte the same shape as a row from GET /cards/{uuid}/transactions. Upsert by transaction.uuid and push/poll can never diverge.
A real purchase's typical trajectory (rows of the same purchase share a supplier_txn_id):
card.transaction.authorized hold placed, $30 (available balance −30; money not moved yet)
│
├─→ card.transaction.captured settled (money actually moved, usually 1–3 days later)
│ ├─→ card.transaction.refunded merchant refund (possibly days later)
│ └─→ card.transaction.chargeback_opened dispute opened
│
└─→ card.transaction.reversed merchant released the hold (money back)
Side path: card.transaction.declined — a rejected authorization. It never enters the lifeline above; transaction.decline_reason carries a stable reason key.
Three boundaries that are easy to misread:
- A $0
authorizedis a card-verification probe, not a purchase. No funds are held. Production traffic contains many of these (any subscription platform binding a card sends one). Classify them as "verification", not "spend". - Hold expiry pushes no event. Every real-money hold terminates in a
capturedorreversedpush; only $0 verification probes expire silently. You do not need an "expired" branch. - 3-D Secure OTP codes never arrive by webhook. They go by email directly to the cardholder. Do not wait for an OTP event — it does not exist, by security ruling.
Stage 4 — Freeze and close: including platform-initiated actions
Freeze / unfreeze
POST /cards/{uuid}/freeze is also 202 + order, acknowledged by order.completed. But card.frozen is not just an echo of your own action — platform risk controls and the card issuer can freeze unilaterally, and they push the same event. Read data.origin to see who acted, and treat the event as the authoritative "this card stopped spending" signal.
Close is two-stage
POST /cards/{uuid}/close → 202 + order_uuid
→ card.close_initiated stage 1: spending stops now; in-flight transactions settle first
→ order.completed the close order itself is done
→ card.closed terminal: residual balance refunded to wallet (payload carries refund_amount)
Days may pass between close_initiated and closed (waiting for in-flight transactions to land). refund_amount exists only in card.closed — that is the moment the money is back in your wallet.
Platform-moved money: card.risk.penalty_applied
When a confirmed-fraud ruling deducts card funds, this event fires with penalty_amount and balance_after. It exists because the action is not initiated by you, moves money on the card, and appears in no transaction feed — without the push, your books could not reconcile.
Event → meaning quick reference
| Event | One-line understanding | Suggested handling |
|---|---|---|
order.completed | "Your operation succeeded; here is the result" | Close out the pending order; for issue orders, link card_uuid |
order.failed | "Operation failed; funds already back in wallet" | Map failure_reason to a human message; retry with a new idempotency key |
card.issued.secrets | "The secrets arrived — open with your private key" | Decrypt JWE; match key by key_fingerprint |
card.transaction.authorized | "Funds held ($0 = card verification)" | Start a purchase lifeline; classify $0 as verification |
card.transaction.captured | "The money actually moved" | Advance the lifeline to settled |
card.transaction.reversed | "Hold released, money back" | Close the lifeline |
card.transaction.declined | "Authorization rejected" | Log with decline_reason |
card.transaction.refunded | "Merchant refunded to the card" | Attach to the original purchase |
card.transaction.chargeback_opened | "Dispute opened" | Alert / escalate |
card.frozen / card.unfrozen | "Card stopped / resumed (check origin for who did it)" | Update card state; surface the origin |
card.close_initiated | "Spending stopped; refund on the way" | Mark card as closing |
card.closed | "Close complete; refund_amount is in the wallet" | Terminal state; book the refund |
card.risk.penalty_applied | "Platform deducted card funds after a fraud ruling" | Alert; reconcile with balance_after |
Integration self-check
- Signature: compute the HMAC over the raw body; reject
|now − t| > 300s. - Idempotency: deduplicate by
event_id(or the businessuuid); redeliveries must not double-process. - Transactions: upsert by
transaction.uuid; never assume state from event arrival order. - After
order.failed, do not retry with the same idempotency key (you would get the same failed order back) — use a fresh key. - Reply
2xxfast and queue heavy work — endpoints that keep failing are auto-disabled. - Do not wait for
card.issued,card.topped_up, or an OTP webhook — they do not exist (Stages 1 & 3).