Card API — Partner Integration Guide
Audience: engineers at partner companies integrating with the Card API. Maintained by: the card service team — every statement here is verified against the running service before publication.
Version 2026-08-26 — contract finalized. Every ✅ section was verified end to end against the live sandbox environment before publication. Sections marked 🔜 describe contract that is designed and stable but not yet callable — integrate against ✅ sections today, build against 🔜 sections knowing the shapes will not change without being announced first. Your integration manager tells you when a 🔜 section becomes callable, and when guidance below is corrected.
What is in this guide
This guide is the conventions — the things true of every endpoint, whichever one you call. The per-resource shapes, fields and error rows live in the API Contract.
Section numbers never change — they are quoted from the contract and from support tickets — so a new section is appended rather than inserted.
Quickstart — your first call
You need a sandbox API key from your integration manager (§2). Then:
1. Authenticate. One header, on every request:
curl https://api.sbx.oxen.finance/v1/card/clients \
-H "Authorization: Bearer $OXEN_KEY"
A 200 with an empty list means you are in. A 401 means the key is wrong — and
deliberately does not say which check failed (§3).
2. Open a client application. POST /clients starts a KYB review that runs for hours or
days; it does not return an approved client. The
onboarding walkthrough is the five
steps from here to APPROVED, including the two that wait on your end user rather than
on the service.
3. Issue a card. Once the client is APPROVED, add a cardholder, send them through their
own verification link, then issue — cardholders & cards.
4. Fund it, and read the spend. You fund the collateral pool on-chain yourself (collateral & withdrawal); spend appears in transactions.
Two things to set up early, because retrofitting them is worse: log every requestId
from _metadata — it is what support resolves a case with — and decide your retry policy
from error.code, not from the status class (§7), because this API has retryable and
non-retryable codes sharing a status.
1. Overview
The Card API lets your platform issue and manage payment cards for your end clients. You integrate with one API and one credential. The service operates the card program and the issuer relationship; the issuer holds the collateral on-chain, and you control the funding wallet (you fund and withdraw directly). The service orchestrates and observes — it never holds or moves your funds.
| Environment | Base URL |
|---|---|
| Sandbox | https://api.sbx.oxen.finance/v1/card |
| Production | provided at go-live |
New to the API? Read the Integration Flows — the step-by-step operational journey (onboarding, funding, cards, secrets, 3-D Secure), what to prepare, and what to store. This guide covers the conventions those flows rely on.
Three integration surfaces:
- REST API (you → the service): clients, cardholders, cards, transactions. You collect and submit company KYB; cardholder identity verification is completed by the person via an issuer-hosted link — you deliver the link, you cannot complete it for them.
- Webhooks (service → you): signed event notifications, at-most-once — paired with a pull cursor that is the actual delivery guarantee. ✅
- Card secrets (PAN/CVV/PIN): a reveal flow that keeps plaintext card data off the service entirely — it exists only at the card processor and, when you reveal it, inside your own backend under a session key only you hold. ✅
What is live today
| Capability | Status |
|---|---|
| Authentication, key lifecycle | ✅ live |
| Request conventions, error contract, idempotency | ✅ live (enforced from the first business endpoint) |
| Clients, cardholders, cards | ✅ live — API Contract §1–§3 |
| Transactions | ✅ live — §4. Note the object is a discriminated union on type, not the flat shape the draft published. |
| Card secrets & PIN reveal | ✅ live — §5, cipher parameters published |
| Disputes & chargebacks | ✅ live — API Contract §8. Nine endpoints. There is no "replace all evidence" call: the issuer has one, it deletes every attachment already on the dispute, and we do not relay it — attachments are added one at a time. |
Dispute webhooks (dispute.created, dispute.updated, dispute.evidenceRequested, dispute.chargebackCreated) | 🔜 — not yet delivered. The REST endpoints ship first. Until these land, poll GET /disputes with your client_id query parameter for status changes; dispute.evidenceRequested is the one that carries a deadline. |
Client funding (GET /clients/:id/funding) | ✅ Beta — live, shape-stable, and verified end to end on the sandbox environment (funded pool → live deposit address + spending power, 2026-08-26). Beta because budget enforcement awaits production end-to-end verification (see the contract's funding section) |
Withdrawal (GET /clients/:id/collateral-contract + POST /clients/:id/withdrawal-authorization) | ✅ Beta. The service relays the issuer's signature and the contract coordinates; you add your admin EIP-712 signature and submit withdrawAsset yourself. The service never signs your message and never moves funds. One authorization at a time per client, and short-lived — measured 5–7 min, so read expiresAt rather than assuming a duration. The five-step walkthrough carries the working code, verified live. |
kind: INDIVIDUAL clients | 🔜 — the enum is published; COMPANY only until availability is announced. |
| Webhooks | ✅ live — §8. Delivery is at-most-once; GET /events?since= is the guarantee. |
2. Getting access
The service provisions your account and issues your first API key — there is no self-service signup. You receive:
- your partner account on the sandbox environment,
- one API key (see §3 — shown exactly once),
- this guide, and the OpenAPI document —
GET {base}/docs-json, browsable at{base}/docsand published as the API Reference.
The OpenAPI document is the only artefact you need for tooling: Postman, Insomnia and Bruno all import it directly, and a generated client comes from the same file. Import it rather than working from a hand-maintained collection — the document is exported from the running service, so it cannot drift from what the API actually answers.
Contact your integration manager to begin.
3. Authentication
Every request carries one header:
Authorization: Bearer oxc_<env>_<KEY_ID>.<secret>
oxc_stg_01KEYIDABCDEFGHJKMNPQRSTVW.k7fJ2mQ9pL4xR8wN3vB6tY1cD5gH0sZaEuIoP-M_
│ │ │ │
│ │ │ └─ the secret — the only authenticating part
│ │ └─ key id (26 chars) — an identifier, safe to log and quote in support tickets
│ └─ environment label — informational only. Sandbox keys today carry `stg`;
│ the production label is announced at go-live. Never parse or validate it.
└─ product prefix
Handling rules:
- The full credential is shown exactly once, at issuance. The service stores only a one-way hash — it cannot be recovered. Lose it → revoke and reissue.
- Store it in a secrets manager. Never in source control, client-side code, or logs.
- Treat the whole token as opaque — send it verbatim; do not parse or rebuild it.
- Sandbox keys (
oxc_stg_…) and production keys are unrelated credentials on unrelated environments.
Authentication failures always answer the same way:
HTTP 401
{ "success": false,
"error": { "code": "UNAUTHORIZED", "message": "Invalid API credentials" },
"_metadata": { "requestId": "…" } }
Wrong secret, unknown key, revoked key, expired key, and rate-limit exhaustion are
deliberately indistinguishable — the response never reveals which check failed. If you
receive an unexpected 401: verify you sent the exact issued token, then check whether the
key was revoked or your traffic spiked, then contact support quoting the requestId.
A 403 ACCESS_RESTRICTED is different: your credential is valid, but your account is
suspended or write-restricted. Contact support.
4. API key lifecycle
Rotation (zero downtime). Two keys can be active at once, for exactly this purpose:
- Ask the service to issue a second key (or use the key endpoint when self-service lands).
- Deploy the new credential to your systems.
- Confirm traffic on the new key, then have the old one revoked.
Revocation is immediate and irreversible. A third key cannot be issued while two are active — finish the rotation first.
5. Request conventions
| Convention | Rule |
|---|---|
| Content type | Content-Type: application/json on requests with a body; bodyless endpoints accept the header with an empty body. The one exception: document uploads are multipart/form-data. |
| Identifiers | Service ids are opaque strings with a type prefix (card_…, chd_…, cl_…, evt_…). Store up to 40 chars, compare case-sensitively, never parse meaning out of them. Three exceptions, all carrying no prefix because the service mints no id of its own for them: a transaction's id (the service stores no transactions — contract §4), a UBO's id and a dispute attachment's id (both are sub-objects of a parent whose ownership is proved before the route runs — contract §1 and §8). Treat all of them as opaque too, exactly like the prefixed ones. |
| Pagination | Two styles, and they are not interchangeable. GET /clients is offset: _page (from 1) and _limit, with pagination: { currentPage, perPage, totalObject, totalPages } beside data. The four keyset lists take cursor and _limit and return data: { items, nextCursor } — no pagination object, no page number, no total, because the issuer beneath them exposes none and inventing one would mean walking every page on every request. Follow nextCursor and stop when it is null. Sending _page to a keyset list does nothing — unrecognised query keys are dropped, not rejected. (GET /events uses since + limit — its own scheme, see §8.) |
| Client scope | ?client_id=cl_… on the four keyset lists (/cardholders, /cards, /transactions, /disputes). Required — those reads cannot be answered without naming one client, so a request without it is 422 naming client_id in error.details.validationErrors, never a partner-wide page. Every other endpoint names its client in the path and ignores this parameter. Send exactly one; sending it twice is also 422. A client that is not yours → 404 CLIENT_NOT_FOUND, never 403. |
| Currency | ISO 4217 alpha-3 ("USD"), never numeric codes. |
| Amounts | Strings, canonical decimals, max 2 fraction digits: "10.50". Scientific/hex notation and empty strings are rejected. |
| Request tracing | Optionally send X-Request-Id: <your id> (≤64 chars). It is echoed in the response envelope and header and attached to our internal traces — include it (or the one from _metadata.requestId) in every support request. |
Response envelope
Success:
{ "success": true, "statusCode": 200, "message": "Success",
"data": { … },
"pagination": { … }, // OFFSET lists only — absent on keyset lists,
// where the cursor lives in data.nextCursor
"_metadata": {
"timestamp": "2026-08-18T04:40:27.511Z", "timezone": "UTC",
"path": "/v1/card/…", "method": "GET",
"requestId": "1214d219-35fa-4905-b392-583872824c08",
"version": "v1", "duration": 12 } }
Error:
{ "success": false,
"error": { "code": "IDEMPOTENCY_KEY_REUSED",
"message": "This Idempotency-Key was already used with a different request body",
"details": { … } }, // present on validation errors
"_metadata": { "requestId": "…", … } }
error.code is a stable machine string — branch on it, not on message (messages may
be reworded). New codes may be added; unknown codes should fall through to your generic
error handling.
6. Idempotency — the money-path contract
Every creating endpoint (companies, cardholders, cards) requires an
Idempotency-Key header. This is what makes retries safe: a timeout followed by a retry
can never double-issue a card.
Idempotency-Key: <any unique string you generate, e.g. a UUID — one per logical operation>
The rules:
| Situation | Response | What you do |
|---|---|---|
| First request | normal 201 | store the result |
| Retry after success — same key, same body | the original non-secret result replays; hosted verification links are null and must be read live | treat as success; GET the resource when you need a link |
| Retry after a definitive failure — same key, same body | a fresh attempt executes; the journal serializes it | fix the cause, retry with the same key + same body |
| Same key, changed body | 422 IDEMPOTENCY_KEY_REUSED — a key is bound to one body forever | use a new key for a genuinely new request |
Company onboarding returned ambiguous (502 PROVIDER_AMBIGUOUS) | may need operator review before it resolves | retry the same key later; do not switch keys or assume failure |
| Same key while the first attempt is still running | 409 OPERATION_IN_FLIGHT | wait, then retry with the same key |
POST /cards/:id/replace returned 502 | the replace scope stays locked permanently, so every later attempt answers 409 OPERATION_IN_FLIGHT | stop. This is the one OPERATION_IN_FLIGHT that never clears — polling it loops forever. Contact support with the requestId |
| Missing header on a creating endpoint | 422 IDEMPOTENCY_KEY_REQUIRED | add the header |
The one habit that matters: generate the key when the user intent is created (your job record, your order row), not per HTTP attempt — every retry of that intent reuses it.
7. Error handling
error.code is the dispatch, never the status class — this API has retryable and
never-retryable codes sharing 409, and again sharing 502. Allow-list the codes marked
retryable below; treat every other code as "the request itself needs changing", because
retrying it unchanged fails identically.
| Status | Code | Means | Retry |
|---|---|---|---|
| 400 | (various) | malformed request, e.g. invalid JSON | no |
| 401 | UNAUTHORIZED | credentials — uniform by design (§3) | no |
| 403 | ACCESS_RESTRICTED | credentials valid, account restricted | no — contact support |
| 404 | CLIENT_NOT_FOUND, CARDHOLDER_NOT_FOUND, CARD_NOT_FOUND, TRANSACTION_NOT_FOUND, DISPUTE_NOT_FOUND, ATTACHMENT_NOT_FOUND, UBO_NOT_FOUND, EVENT_NOT_FOUND, ENDPOINT_NOT_FOUND | no such id for your account — a foreign or mistyped id is indistinguishable from a nonexistent one. The code names the resource, never the reason | no |
| 409 | OPERATION_IN_FLIGHT | the same key is still running | yes — same key, short wait. ⚠ Except after a 502 on POST /cards/:id/replace: there the lock is permanent, polling loops forever — stop and contact support |
| 409 | EVENT_NOT_READABLE | the event exists and is not lost; its stored payload cannot be opened right now — the condition is on the service's side | yes — plain backoff, nothing about your request is wrong |
| 409 | CLIENT_NOT_APPROVED | KYB is still running; it clears when the client reaches APPROVED | yes — poll minutes apart; it is provisioning time, not a backoff loop |
| 409 | FUNDING_NOT_READY | the issuer is still deploying the funding contract | yes — poll minutes apart; escalate if it persists for hours |
| 409 | CARD_TERMINAL, EXTERNAL_REF_TAKEN, DISPUTE_ALREADY_OPEN, ATTACHMENT_LIMIT_REACHED, ENDPOINT_LIMIT_REACHED, ENDPOINT_URL_ALREADY_REGISTERED, FUNDING_AMBIGUOUS, WITHDRAWAL_AUTH_CONFLICT | a state conflict that will not clear on its own | no — retrying 409 as a class retries every one of these |
| 413 | DOCUMENT_TOO_LARGE, ATTACHMENT_TOO_LARGE | the upload exceeds 20 MB | no — send a smaller file |
| 422 | E_CPX_VAL_4201, IDEMPOTENCY_KEY_* | validation or idempotency misuse; details.validationErrors names what failed. property is not always a body field — a missing or malformed client_id query parameter reports client_id there, so read it before re-checking your payload | no |
| 429 | REVEAL_RATE_LIMITED, FUNDING_RATE_LIMITED | a per-minute read budget is spent | yes — after the current minute window |
| 429 | PROVIDER_RATE_LIMITED | the issuer rate-limited an onward call | yes — short wait, same key |
| 502 | PROVIDER_UNAVAILABLE, AUTH_BACKEND_UNAVAILABLE, FUNDING_UPSTREAM_UNAVAILABLE, FUNDING_READ_FAILED | transient, and the write did not happen. AUTH_BACKEND_UNAVAILABLE means an auth dependency of the service is down — nothing about your key is wrong | yes — exponential backoff, same key |
| 502 | PROVIDER_AMBIGUOUS | the write may or may not have landed | same key only — never re-send under a new one (§6) |
| 502 | CARD_UNRECORDED | the card was created at the issuer and is billable, but could not be recorded — so it appears in no list and resolves from no id | no. A retry issues a second card with a second PAN. Contact support with the requestId |
| 502 | PIN_WRITE_UNCONFIRMED | the PIN write's outcome is unknown | no — read the PIN state back first; a blind retry may overwrite a PIN that did land |
| 5xx | other transient errors | — | yes — exponential backoff, same key |
Backoff means exponential with a cap (1s/2s/4s…), always under the same Idempotency-Key.
Network errors follow the same rule as a transient 5xx.
8. Webhooks
The service delivers events to one HTTPS endpoint you register. It is issued its own signing secret,
whsec_…, shown once at registration and never again — store it before you close the
response.
Verifying a delivery
Every delivery carries two headers:
X-Oxen-Signature : t=1755300842,v1=<hex hmac-sha256>
X-Oxen-Event-Id : evt_01M0MTVS6D66QXX7RER99X005B
The signature covers "{t}.{eventId}.{rawBody}" with your whsec_… as the HMAC key:
const crypto = require('node:crypto');
// Returns false on ANY malformed input — never throws on hostile deliveries.
function verifyCardWebhook(rawBody, signatureHeader, eventId, secret, toleranceSec = 300) {
if (typeof signatureHeader !== 'string') return false;
const parts = {};
for (const kv of signatureHeader.split(',')) {
const i = kv.indexOf('=');
if (i > 0) parts[kv.slice(0, i).trim()] = kv.slice(i + 1).trim();
}
const t = Number(parts.t);
if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
if (!/^[0-9a-f]+$/i.test(parts.v1 || '')) return false; // valid hex only
const expected = crypto.createHmac('sha256', secret)
.update(`${parts.t}.${eventId}.${rawBody}`).digest('hex');
const a = Buffer.from(expected, 'hex');
const b = Buffer.from(parts.v1, 'hex');
if (a.length !== b.length) return false; // equal length first
return crypto.timingSafeEqual(a, b);
}
Take eventId from the X-Oxen-Event-Id header, not from the parsed body — the body must
not be parsed before the signature is checked.
⚠ Verify over the raw request bytes. Re-serialized JSON will not match: key order, whitespace and number formatting all change the HMAC.
⚠ The tolerance is 300 seconds, and t is part of the signed string. A delivery your queue
sat on for six minutes will fail verification — verify at the edge, then queue.
Delivery model — read this twice
Push is at-most-once: one attempt per event, no retry. If your endpoint is down, slow, or returns non-2xx at that moment, that push is gone and will not come back on its own.
event happens ──▶ recorded ──▶ ONE push attempt
│ │
│ ├─ 2xx ──▶ done
│ └─ anything else ──▶ recorded FAILED, never re-pushed
│
└──▶ GET /events?since= ← your safety net. Poll it.
The pull path is your delivery guarantee, not the push. Poll GET /events?since= on a
schedule (every few minutes). A partner who treats push as reliable will lose events; a partner
who polls cannot.
Catching up
GET /events?since=<cursor>&limit=50
since is an integer sequence number, not an event id. Start at 0; thereafter pass back
the nextCursor you were given. A non-numeric cursor is rejected with INVALID_CURSOR rather
than being treated as 0 — that would replay your entire history.
{ "events": [ … ], "nextCursor": "12" }
nextCursor is null when, and only when, you are caught up. Stop polling that loop when
you see it; a non-null cursor always means there is more.
GET /events/:id— one event, same projection as the push.POST /events/:id/resend— ask for one event to be pushed again, with a fresh signature. Asking twice while one is pending is rejected; it is an explicit request, not a retry knob.
⚠ If the cursor stops advancing while nextCursor stays non-null, stop and contact support.
That is a deliberate signal, not a bug in your loop: an event that cannot be read is never
skipped over, so the cursor parks at the last event you actually received rather than silently
stepping past one. Retrying is correct and harmless; assuming you are caught up is not.
The envelope
The shape, the field-by-field meaning, the data allowlist and the event types delivered
today are contract §6 — one place, so they cannot drift
apart. Two rules belong here rather than there, because they are about delivery:
- Deduplicate on
id. Push, resend and your own catch-up overlap by design, so the same event reaches you more than once. This is the guarantee that makes the overlap safe. - Read the event id from the
X-Oxen-Event-Idheader, not from the parsed body — the body must not be parsed before the signature is checked.
Endpoint management
| Endpoint | Result |
|---|---|
POST /webhook-endpoints | 201 — register. Body: url (HTTPS, publicly resolvable). The response is the only time secret (whsec_…) is ever returned — store it before you close the connection; no later read replays it. 400 ENDPOINT_URL_REJECTED if the URL is not HTTPS or not publicly routable; 409 ENDPOINT_LIMIT_REACHED if you already hold an ACTIVE endpoint; 409 ENDPOINT_URL_ALREADY_REGISTERED if any non-deleted endpoint of yours — including a PAUSED one — already claims that URL |
GET /webhook-endpoints | 200 — your endpoints as a plain array in data (no pagination; you can hold at most one active). Never returns secret. Use it to find the endpoint holding a URL you are trying to re-register |
POST /webhook-endpoints/:id/pause | 200 — stop delivering, keep the registration. The URL claim and the secret both survive, so resume needs neither. Events arriving while paused are recorded and not pushed — catch-up is how you get them |
POST /webhook-endpoints/:id/resume | 200 — deliver again, same secret, same URL |
DELETE /webhook-endpoints/:id | 204 — permanent. The secret dies with it; re-registering the same URL mints a new one |
404 ENDPOINT_NOT_FOUND for an id that is not yours — foreign and nonexistent answer
identically. 400 ENDPOINT_STATE_INVALID for a transition that does not exist (resuming an
endpoint that is not paused).
One ACTIVE endpoint per partner, enforced — which has three consequences worth knowing before you meet them:
- Rotation is delete-then-register, with a gap of a few seconds. Events in that gap are
recorded
NO_ENDPOINTand never pushed, so rotate when you are quiet and run a catch-up after. The new registration mints a newwhsec_…; the old secret dies with the old endpoint. - Pausing does not release the URL. Re-registering a URL your own paused endpoint still
holds answers
ENDPOINT_URL_ALREADY_REGISTERED— list, then delete the one holding it. That is a different error fromENDPOINT_LIMIT_REACHED, which means you already have an active one. - The URL must be HTTPS and publicly resolvable, re-checked at delivery time, and redirects
are not followed — a
301to your real handler is a failed delivery.
Respond 2xx under 5s and process asynchronously. There is no auto-pause: a persistently
failing endpoint stays registered and keeps being attempted, one attempt per event.
Retention
Events are retained for 180 days — how far back GET /events?since= reaches after an
outage, and how long the service can answer "what did you send us?". Reconcile within it.
⚠ Today nothing is pruned, so history reaches further in practice. Do not build on that:
it is the archive milestone not having landed, not a longer guarantee. Once it does, a cursor
older than the window answers 410 naming the oldest event still available — resynchronize
from live state and that event.
9. Card secrets & PIN (PAN / CVV / PIN)
Your backend creates the encryption session; encrypted card data transits the service
opaque; it never holds a key that could decrypt it. Your backend generates a 32-hex session
secret, wraps it with the RSA public key for your environment into a sessionId, and sends
it with the reveal request — the response fields come back encrypted under your secret.
Plaintext card data exists only at the card processor and inside your backend.
Endpoints and the full cipher parameters — key derivation, AES mode, tag handling, PIN-block
format — are in API Contract §5.
Sessions are single-use; never log sessionId or encrypted fields; decrypt in memory,
render, discard. Reveals are rate-limited per card.
10. Rate limits
Fair-use limits protect the platform; sustained excess answers the standard 401 (§3). Design for steady request rates, cache reads where sensible, and talk to us before launches that change your volume profile — limits can be raised per partner.
11. Sandbox testing checklist
Before requesting production access, demonstrate in sandbox:
- Successful authenticated call, and a clean 401 handling path
- Idempotent create retried with the same key returning the original result
- Correct handling of
409 OPERATION_IN_FLIGHTand502 PROVIDER_AMBIGUOUS - Key rotation completed without downtime
- Webhook endpoint verifying signatures over raw bytes, deduplicating by event
id - Scheduled
GET /events?since=catch-up recovering an event your endpoint missed — verify this works before go-live; it is your delivery guarantee, not the push - A rotation rehearsal: delete + register, then catch up on the gap
- A card-secrets round trip: session generated in your backend, PAN decrypted there, and nothing decrypted written to a log or a file
- Your
requestIdlogging joined to at least one support round-trip