ResourcesAPI ReferenceIntegration Guide
API Reference

Integration Guide

The card business data flow end to end — which endpoints to call, which webhooks arrive, and how to wire them together into an event-driven integration.

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:

  1. Mint an API token (choose its abilities), then verify it with GET /ping.
  2. Register a webhook endpoint: URL + the event codes you subscribe to. The signing secret is shown only once, at creation — store it immediately.
  3. (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-Key within 24 hours returns the same order — no double charge. After a timeout, just resend.
  • order.completed is the only acknowledgement there is. There is no card.issued or card.topped_up webhook — 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 authorized is 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 captured or reversed push; 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

EventOne-line understandingSuggested 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 business uuid); 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 2xx fast 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).