Skip to main content

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.

EnvironmentBase URL
Sandboxhttps://api.sbx.oxen.finance/v1/card
Productionprovided 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:

  1. 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.
  2. Webhooks (service → you): signed event notifications, at-most-once — paired with a pull cursor that is the actual delivery guarantee. ✅
  3. 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

CapabilityStatus
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 documentGET {base}/docs-json, browsable at {base}/docs and 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:

  1. Ask the service to issue a second key (or use the key endpoint when self-service lands).
  2. Deploy the new credential to your systems.
  3. 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

ConventionRule
Content typeContent-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.
IdentifiersService 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.
PaginationTwo 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.
CurrencyISO 4217 alpha-3 ("USD"), never numeric codes.
AmountsStrings, canonical decimals, max 2 fraction digits: "10.50". Scientific/hex notation and empty strings are rejected.
Request tracingOptionally 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:

SituationResponseWhat you do
First requestnormal 201store the result
Retry after success — same key, same bodythe original non-secret result replays; hosted verification links are null and must be read livetreat as success; GET the resource when you need a link
Retry after a definitive failure — same key, same bodya fresh attempt executes; the journal serializes itfix the cause, retry with the same key + same body
Same key, changed body422 IDEMPOTENCY_KEY_REUSED — a key is bound to one body foreveruse a new key for a genuinely new request
Company onboarding returned ambiguous (502 PROVIDER_AMBIGUOUS)may need operator review before it resolvesretry the same key later; do not switch keys or assume failure
Same key while the first attempt is still running409 OPERATION_IN_FLIGHTwait, then retry with the same key
POST /cards/:id/replace returned 502the replace scope stays locked permanently, so every later attempt answers 409 OPERATION_IN_FLIGHTstop. This is the one OPERATION_IN_FLIGHT that never clears — polling it loops forever. Contact support with the requestId
Missing header on a creating endpoint422 IDEMPOTENCY_KEY_REQUIREDadd 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.

StatusCodeMeansRetry
400(various)malformed request, e.g. invalid JSONno
401UNAUTHORIZEDcredentials — uniform by design (§3)no
403ACCESS_RESTRICTEDcredentials valid, account restrictedno — contact support
404CLIENT_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_FOUNDno such id for your account — a foreign or mistyped id is indistinguishable from a nonexistent one. The code names the resource, never the reasonno
409OPERATION_IN_FLIGHTthe same key is still runningyes — 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
409EVENT_NOT_READABLEthe event exists and is not lost; its stored payload cannot be opened right now — the condition is on the service's sideyes — plain backoff, nothing about your request is wrong
409CLIENT_NOT_APPROVEDKYB is still running; it clears when the client reaches APPROVEDyes — poll minutes apart; it is provisioning time, not a backoff loop
409FUNDING_NOT_READYthe issuer is still deploying the funding contractyes — poll minutes apart; escalate if it persists for hours
409CARD_TERMINAL, EXTERNAL_REF_TAKEN, DISPUTE_ALREADY_OPEN, ATTACHMENT_LIMIT_REACHED, ENDPOINT_LIMIT_REACHED, ENDPOINT_URL_ALREADY_REGISTERED, FUNDING_AMBIGUOUS, WITHDRAWAL_AUTH_CONFLICTa state conflict that will not clear on its ownno — retrying 409 as a class retries every one of these
413DOCUMENT_TOO_LARGE, ATTACHMENT_TOO_LARGEthe upload exceeds 20 MBno — send a smaller file
422E_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 payloadno
429REVEAL_RATE_LIMITED, FUNDING_RATE_LIMITEDa per-minute read budget is spentyes — after the current minute window
429PROVIDER_RATE_LIMITEDthe issuer rate-limited an onward callyes — short wait, same key
502PROVIDER_UNAVAILABLE, AUTH_BACKEND_UNAVAILABLE, FUNDING_UPSTREAM_UNAVAILABLE, FUNDING_READ_FAILEDtransient, and the write did not happen. AUTH_BACKEND_UNAVAILABLE means an auth dependency of the service is down — nothing about your key is wrongyes — exponential backoff, same key
502PROVIDER_AMBIGUOUSthe write may or may not have landedsame key only — never re-send under a new one (§6)
502CARD_UNRECORDEDthe card was created at the issuer and is billable, but could not be recorded — so it appears in no list and resolves from no idno. A retry issues a second card with a second PAN. Contact support with the requestId
502PIN_WRITE_UNCONFIRMEDthe PIN write's outcome is unknownno — read the PIN state back first; a blind retry may overwrite a PIN that did land
5xxother transient errorsyes — 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-Id header, not from the parsed body — the body must not be parsed before the signature is checked.

Endpoint management

EndpointResult
POST /webhook-endpoints201 — 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-endpoints200 — 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/pause200 — 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/resume200 — deliver again, same secret, same URL
DELETE /webhook-endpoints/:id204 — 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_ENDPOINT and never pushed, so rotate when you are quiet and run a catch-up after. The new registration mints a new whsec_…; 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 from ENDPOINT_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 301 to 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_FLIGHT and 502 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 requestId logging joined to at least one support round-trip