Axys Card Program
The Axys Card Program API is our public, partner-facing surface for card issuing as a service. Create cardholders, run KYC, issue cards, fund them over crypto and bank rails, read transactions, and receive lifecycle events over signed webhooks — all through one surface.
Overview
A single card balance can be funded and spent across bank and blockchain rails without you building separate systems for each. Axys owns the rails, the accounting, and event delivery; you integrate one API.
| Capability | Status | Endpoints |
|---|---|---|
| Cardholder onboarding & KYC | Beta | POST /accounts · PUT /accounts/{id}/kyc-submit |
| Card issuance & lifecycle | Beta | POST /accounts/{id}/cards · activate · block/unblock · PIN |
| Crypto funding | Beta | GET /cards/{id}/deposit-address |
| Bank funding | In development | POST /accounts/{id}/register-bank-account |
| Transactions & balances | Preview | GET /cards/{id}/transactions · GET /cards/{id} |
| Webhooks | Preview | POST /v1/webhooks/endpoints + delivery management |
Status is indicative — confirm with your Axys contact before depending on a capability in production.
API status
Every capability and endpoint carries a maturity badge, so you always know what to build on:
| Badge | What it means |
|---|---|
| Live | Generally available contract. Confirm enablement for your assigned environment. |
| Beta | The contract may still change. Confirm enablement before integrating. |
| Preview | Early contract access. May change or be withdrawn; confirm enablement before use. |
| In dev | Not yet callable. Documented so you can design ahead. |
| Deprecated | Still works but scheduled for removal. Migrate to the named replacement. |
External consumer onboarding
External access uses three independent security layers: source-IP allowlisting, mutual TLS, and RSA request signing. Your Axys contact coordinates setup. Never send private keys to Axys.
1 · Configure network access
Provide every static public IPv4 address used by your outbound NAT or egress path. Requests from other source addresses are rejected at the network edge. Coordinate egress-address changes before using them.
2 · Create the mTLS certificate
Generate a dedicated private key and Certificate Signing Request using the subject and SAN values Axys supplies. Keep the private key under your control and send Axys only the CSR. Axys returns an environment-specific signed client certificate. Present that certificate and private key on each API connection. Staging and production certificates are separate.
3 · Create the request-signing key
Create a separate RSA key pair for request signing. Do not reuse the mTLS private key.
openssl genpkey -algorithm RSA \
-pkeyopt rsa_keygen_bits:2048 \
-out request-signing-private.pem
openssl pkey \
-in request-signing-private.pem \
-pubout \
-out request-signing-public.pemSend Axys request-signing-public.pem. Store
request-signing-private.pem in an appropriate secrets manager.
4 · Use your assigned URL
This artifact is configured for your organisation in staging:
https://stg.<your-org>.axysbank.ioYour organisation receives its own dedicated subdomain per environment
(stg.<your-org>.axysbank.io and <your-org>.axysbank.io, named after
the organisation in your CSR). Each enabled environment has its own host, network allowlist, mTLS
certificate, and request-signing configuration. Complete staging verification before requesting
production enablement.
5 · Sign authenticated API requests
Send X-Timestamp, X-Nonce, and X-Signature on authenticated business
operations. Build the exact five-line canonical string described in Authentication,
sign it with the request-signing private key using RSA-SHA256, then Base64-encode the signature.
6 · Confirm connectivity
BASE_URL="https://stg.<your-org>.axysbank.io"
REQUEST_PATH="/accounts"
TS="$(date +%s)"
NONCE="$(openssl rand -hex 16)"
BODY_HASH="e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
CANONICAL="$(printf 'GET\n%s\n%s\n%s\n%s' "$REQUEST_PATH" "$TS" "$NONCE" "$BODY_HASH")"
SIG="$(printf '%s' "$CANONICAL" | openssl dgst -sha256 -sign request-signing-private.pem | openssl base64 -A)"
curl --cert mtls-client-cert.pem \
--key mtls-client-key.pem \
-H "X-Timestamp: $TS" \
-H "X-Nonce: $NONCE" \
-H "X-Signature: $SIG" \
"${BASE_URL}${REQUEST_PATH}"200 confirms the certificate, allowlist,
and signature configuration. An environment with no accounts returns an empty items list,
next_cursor: null, and has_more: false.Base URLs & environments
Every consumer receives its own dedicated subdomain — stg.<your-org>.axysbank.io
for staging and <your-org>.axysbank.io for production, where <your-org>
is your organisation identifier (it matches the organisation name in your CSR and is fixed during
onboarding). An endpoint becomes available only after Axys confirms environment enablement and supplies
the required certificates and allowlists. Routes are host-root relative (there is no global path prefix;
only webhook routes carry /v1).
| Consumer | Environment | Base URL | Status |
|---|---|---|---|
| your organisation | Staging | https://stg.<your-org>.axysbank.io | Pending enablement |
| your organisation | Production | https://<your-org>.axysbank.io | Pending enablement |
A generic preview shows unmistakable placeholders. Publishable builds fail unless the consumer, environment, and assigned gateway host are supplied together.
Authentication
Authenticated business operations use three layers. The first two are enforced at the network edge; the third is enforced in-application and must be included on each authenticated call. The liveness health check is the sole unauthenticated operation in this contract.
| Layer | What it is | Where |
|---|---|---|
| 1 · IP allowlist | Your source IPs are allowlisted; anything else is dropped. | Edge |
| 2 · mTLS | You present a client certificate on the TLS handshake. | Load balancer |
| 3 · RSA signature | A per-request signature over a canonical string, plus timestamp & nonce. | Application |
The signature headers
Send these three headers on each authenticated request:
| Header | Value |
|---|---|
X-Signature | Base64 RSA-SHA256 signature of the canonical string, signed with your private key. |
X-Timestamp | Unix time in seconds. Rejected outside a ±30s window (TIMESTAMP_EXPIRED). |
X-Nonce | Unique 16–128 char token per request. A replay is rejected (NONCE_REPLAYED). |
The canonical string
Concatenate exactly these five fields, in order, joined by a single newline (\n):
METHOD
pathWithQuery # exact target incl. query, e.g. /cards/abc/transactions?limit=10
timestamp # the exact X-Timestamp value
nonce # the exact X-Nonce value
sha256(rawBody) in hex # lowercase; empty body → sha256 of the empty bufferReference: signing a request in Node.js
import { createHash, createSign, randomBytes } from 'node:crypto';
import https from 'node:https';
import fs from 'node:fs';
function signedHeaders(method, pathWithQuery, body, privateKeyPem) {
const ts = String(Math.floor(Date.now() / 1000));
const nonce = randomBytes(18).toString('base64url'); // 16–128 chars
const raw = body ? Buffer.from(JSON.stringify(body)) : Buffer.alloc(0);
const bodyHash = createHash('sha256').update(raw).digest('hex');
const canonical = [method.toUpperCase(), pathWithQuery, ts, nonce, bodyHash].join('\n');
const sig = createSign('RSA-SHA256').update(canonical).sign(privateKeyPem, 'base64');
return { 'X-Signature': sig, 'X-Timestamp': ts, 'X-Nonce': nonce };
}
// mTLS: present your client cert + key on the TLS agent.
const agent = new https.Agent({
cert: fs.readFileSync('client-cert.pem'),
key: fs.readFileSync('client-key.pem'),
});Conventions
Pagination
List endpoints are cursor-paginated. Pass limit and, to continue, the
cursor from the previous response. A response carries next_cursor (or
has_more); a null next_cursor means you've reached the last page. Cursors are
opaque — echo them back verbatim, don't construct them.
Filtering
A few list endpoints accept optional filters — combine them freely with pagination:
| Endpoint | Filters |
|---|---|
GET /accounts | status (an account status) · search — an exact id (uuid) or a name/email substring (≥3 chars) |
GET /cards/{id}/transactions | before / after — a Unix-seconds time window |
GET /v1/webhooks/endpoints | status — active · paused · auto_paused |
GET /v1/webhooks/{id}/deliveries | status · since / until |
Filtering is per-endpoint — there is no global query language. An invalid filter value returns 400 VALIDATION_ERROR.
Rate limits
Limits are per-consumer, per class. Exceeding one returns 429 RATE_LIMIT_EXCEEDED with a
Retry-After header — back off and retry.
| Class | Limit | Applies to |
|---|---|---|
| Read | 300 / min | GET endpoints |
| Write | 60 / min | Create/update mutations |
| Admin | 30 / min | Webhook endpoint management |
| Sensitive | 10 / min | GET /cards/{id}/sensitive |
| Recovery | 10 / min | Secret rotation, delivery retry/resume |
These are the platform defaults (fixed 60-second window); your stack may be tuned differently. Class is picked by method — GET → Read, mutations → Write — and a few routes opt into a tighter class (above).
Money
Every monetary value — every amount and every balance — is an integer string counting the minor
units of its currency. This includes balance fields (available, pending,
ledger, authorizations) that don't carry a _minor suffix — treat
all of them as minor units regardless. Don't assume a fixed number of decimals — the scale is the
currency's minor-unit exponent (ISO 4217 for fiat; the asset's base unit for digital assets):
major = Number(amount_minor) / 10 ** exponent # USD: 149 / 10**2 = 1.49| Currency | Exponent | Minor unit | Status |
|---|---|---|---|
USD | 2 | cent | Enabled |
Card side (today): a card's own currency_code, its amount_minor, and its
balances are USD (exponent 2) — the only enabled card currency. An unsupported card
currency_code returns 400 UNSUPPORTED_CURRENCY.
merchant_currency and merchant_amount_minor come from the merchant/POS
side and can already be a different, non-2-decimal currency (e.g. a ¥ or KWD purchase on a USD card).
Scale merchant_amount_minor by merchant_currency's exponent — not the card's.
This is exactly why you key off the exponent and never hard-code ÷100.For any high-exponent asset, use a big-decimal / BigInt type — the value may overflow a JS number.
Formats & ISO codes
Several cardholder fields use standardized code systems, and a couple are easy to mix up — the residential
country is 2-letter but place of birth is 3-letter, and country_calling_code is a
country code, not a dialing code. This maps every format-bearing field to its convention.
Country codes — Alpha-2 vs Alpha-3
ISO 3166-1 defines two forms, and we use each in different fields:
| Field | Expects | Example |
|---|---|---|
residential_address.country | ISO-3166 α-2 (2 letters) | US · GB · AE · DE |
identity_provenance.place_of_birth | ISO-3166 α-3 (3 letters) | USA · GBR · ARE · DEU |
identity_provenance.country_calling_code | ISO-3166 α-2 | US · GB · AE |
place_of_birth) is α-3 — confirm the form per field before you submit.| Country | α-2 | α-3 |
|---|---|---|
| United States | US | USA |
| United Kingdom | GB | GBR |
| United Arab Emirates | AE | ARE |
| Germany | DE | DEU |
| France | FR | FRA |
| Canada | CA | CAN |
| Singapore | SG | SGP |
Full lookup table: iban.com/country-codes · standard: ISO 3166-1.
Phone numbers
Phone data appears two ways in the create-cardholder body:
| Field | Format | Example |
|---|---|---|
phone (top level) | E.164, with the leading + (≤ 20 chars) | +14155551234 |
identity_provenance.calling_code | Intl dialing code — digits only, no + (1–3 digits) | 1 · 44 · 971 |
identity_provenance.cell_num | The mobile number's local digits | 4155551234 |
identity_provenance.country_calling_code | ⚠️ ISO-3166 α-2 country code — not a dialing code | US |
calling_code +
cell_num pair. Despite its name, country_calling_code is a country code (α-2),
not a dial prefix. Our calling_code takes 1–3 digits — no zero-padding required.Other field conventions
| Field | Format |
|---|---|
nationality | Free-text legal nationality as shown on the government ID (2–50 chars), e.g. American — not a code. |
gender | Integer enum — 0 male · 1 female · 2 unspecified. |
date_of_birth | ISO-8601 date YYYY-MM-DD. |
Timestamps (created_at, settled_at, occurred_at…) | ISO-8601 date-time in UTC. |
currency / currency_code | ISO-4217 α-3 string (USD) — the string code, not an integer. Amounts are minor units. |
state | US → a real, non-blank 2-letter USPS state code (CA). Elsewhere → the real local region when available; otherwise keep the property present and send null or a blank string. Do not invent a placeholder. |
zip_code | US → a real, non-blank 5-digit USPS ZIP (94105). Elsewhere → the real local postal code when available; otherwise keep the property present and send null or a blank string. Do not invent a placeholder. |
| Resource ids | Account, card, deposit, endpoint, and event ids are UUIDs, e.g. a3f1c9b7-8a4d-4c2a-9f3e-1d6b0a5c7e21. Card transaction ids are opaque txn_… strings. Store identifiers unchanged and do not parse them. |
Onboard a cardholder
A cardholder is a natural person, modeled as an account. Create one, submit it to KYC, and once
it is approved you can issue cards against it.
For corporate programs, the account still represents the responsible natural person—such as a director, partner, shareholder, or sole trader—not the company itself. Use your own reconciliation metadata to associate that person with the organization.
Creates the account in draft and emits account.created. All fields the KYC
pipeline needs are required up-front, so you see the contract here rather than discovering it through 400s.
curl --cert client-cert.pem --key client-key.pem \
-X POST https://stg.<your-org>.axysbank.io/accounts \
-H "X-Signature: $SIG" -H "X-Timestamp: $TS" -H "X-Nonce: $NONCE" \
-H "Idempotency-Key: $(uuidgen)" -H "Content-Type: application/json" \
-d '{
"first_name": "Jordan", "last_name": "Reyes",
"email": "jordan@example.com", "phone": "+14155551234",
"date_of_birth": "1990-05-14",
"residential_address": {
"adr_line1": "123 Market St", "city": "San Francisco",
"state": "CA", "country": "US", "zip_code": "94105"
},
"identity_provenance": {
"nationality": "American", "place_of_birth": "USA", "gender": 0,
"calling_code": "1", "country_calling_code": "US", "cell_num": "4155551234"
}
}'{
"id": "a3f1…",
"status": "draft",
"kyc_url": null,
"created": true
}gender 0 = male, 1 = female, 2 = unspecified ·
place_of_birth ISO-3166 α-3 ·
country ISO-3166 α-2
KYC
Submits the account to compliance. No request body. A new submission can start from
draft, compliance_decline, admin_decline, or error
and moves through pending → under_review. A fresh submission includes a single-use
kyc_url — the hosted verification link where the cardholder
uploads documents. Pure replays and reconstructed terminal outcomes return kyc_url: null.
Emits account.kyc_submitted. Use a fresh Idempotency-Key for each new submission
cycle; retry the same logical request with the same key.
kyc_url is ephemeral and return-only.
It is never persisted or replayed on webhooks. Hand it to the cardholder promptly; call
POST /accounts/{accountId}/kyc-url/refresh to mint a replacement if it lapses.KYC state is not a separate field — it is the account status. The review outcomes and
recovery statuses you observe (and their webhooks) are:
409. Verification is asynchronous:
automated checks commonly finish in minutes, while manual review may take hours. Contact Axys support if
an account remains under_review beyond roughly 48 hours.| Account status | Meaning | Webhook |
|---|---|---|
approved | KYC passed; you can issue cards. | account.activated |
compliance_decline | Rejected by compliance. | account.declined |
admin_decline | Rejected by an operator. Obtain Axys support clearance before retrying or correcting the profile. | account.declined |
error | KYC processing could not continue. Support review is required before retrying. | account.kyc_recovery_required |
For compliance-declined or error accounts, either correct the profile with
PUT /accounts/{accountId} (which returns the account to draft) or submit the
unchanged profile directly after compliance/support review. For admin_decline, obtain
Axys support clearance before either path. KYC URL refresh remains valid only while the account
is under_review.
A compliance-decline resubmission is not a promise of approval. Identity documents are not retrievable through this API. The verification session may record the cardholder's IP address for compliance checks; cover that processing in your privacy notice.
Full status set: draft · pending · under_review · approved · compliance_decline · admin_decline · error · suspended · offboarded
Issue & activate cards
Issues a virtual or physical card against an approved account (otherwise
409 ACCOUNT_NOT_APPROVED). Both types are born not_activated.
{ "card_type": "virtual", "name_on_card": "JORDAN REYES", "currency_code": "USD" }Activates the card. Send activation_request_id (a non-secret idempotency discriminator),
the full pan, expiry_month/expiry_year, cvv, and a 4-digit
pin. PAN, CVV and PIN are used transiently and never persisted by Axys.
Other lifecycle calls: PUT /cards/{id}/status to block (on_hold, a
reason is required) or unblock (active); PUT /cards/{id}/pin to change
the PIN; and GET /cards/{id}/sensitive to reveal full card details — see
Sensitive details & PCI.
Spend is allowed only while a card is active; placing it on_hold blocks new
spend immediately. For a lost or stolen card, block it first and then contact Axys support for replacement.
PIN changes require the current PIN; a forgotten-PIN reset and permanent card closure are support-mediated.
Account closure is also an operational request and completes after remaining card balances are resolved.
Sensitive card details & PCI
Account, card-summary, transaction, and webhook responses expose only masked card details—
masked_pan and
card_display.last4. One endpoint returns the full Primary Account Number (PAN) and CVV, for
when a cardholder needs to see their own card.
{
"id": "c1a2…", "pan": "4242424242424242", "masked_pan": "************4242",
"cvv": "123", "expiry_month": 8, "expiry_year": 2029
}What Axys does
- Never stores it. Axys does not persist PAN, CVV, or PIN. This endpoint fetches the details live per call and returns them — nothing is written to our database or logs.
- Masks everywhere else. Summaries, lists, transactions, and webhooks only ever carry
masked_pan/last4. PIN is never returned by any endpoint. - Audits every read. Each call is recorded (which card, which actor, when).
- Rate-limits it. 10 requests/min. Closed cards return
409. - Protects the path. TLS 1.2+ in transit, managed encryption at rest, and audited access controls.
Your responsibilities
masked_pan / last4.| Do | Don't |
|---|---|
| Fetch on demand, only while the cardholder is viewing their card. | Log, cache, or store the PAN/CVV anywhere. |
| Render client-side and discard right after display. | Put it in analytics, screenshots, error reports, or backups. |
| Serve over TLS; isolate the code path that touches it. | Forward it to any third party outside your PCI boundary. |
These platform controls support — but do not replace — your own PCI DSS scoping and QSA assessment: retrieving card data carries its own obligations regardless of how it is transported.
How the platform classifies the fields you'll handle:
| Class | Examples | How this API exposes it |
|---|---|---|
| Sensitive | Full PAN, CVV, PIN | Only PAN/CVV, only via /sensitive. PIN is write-only. |
| Restricted | last4, name, email, phone | Returned where relevant (masked PAN, account profile). |
| Internal | status, limits, transactions | Freely readable. |
Funding & money
A card is funded over two rails. In both cases you set up the destination through the API, but the
deposit itself arrives asynchronously as a deposit.* webhook — there is no
create-deposit or list-deposits endpoint.
Deposits are two-stage. deposit.pending contributes to the committed balance but is not
spendable; deposit.cleared makes funds available. Crypto deposits commonly complete within
minutes to roughly two hours depending on the network. Bank deposits, once enabled, commonly take 3–5
business days and may take longer. These are typical ranges, not SLAs.
When conversion is required, Axys credits the card currency at the applicable market rate with an embedded conversion spread and network costs; the credit is net and those components are not itemized. Card-network or cross-border assessments may apply separately, including when no currency conversion occurs.
Crypto rail
Returns the card's blockchain deposit addresses (auto-provisioned at issuance). Share an address with the
payer; when funds arrive you normally receive deposit.pending followed by
deposit.cleared.
{ "deposit_addresses": [ { "chain": "ETH", "address": "0xAbc…" } ] }A deposit that cannot be attributed—such as one with an unregistered sender or missing reference—may be
held pending review and may be returned net of intermediary fees. Return is not guaranteed and there is no
guaranteed resolution time. A deposit that cannot be confirmed typically becomes failed with
reason expired after about 25 hours, but rare reorganization or compliance cases may remain
pending longer. Contact Axys support after the expected window with the deposit id, amount, sender details,
reference, and date.
deposit.failed reasons
| Reason | Meaning | Recommended action |
|---|---|---|
expired | Confirmation did not arrive within the expected window. | Check the sending network or bank, then contact Axys support with the deposit details above. |
card_service_failure | The card service could not complete the deposit. | Do not send the funds again blindly; confirm the sending-side outcome and contact Axys support. |
card_service_rejected | The deposit was definitively rejected. | Correct the funding prerequisite before beginning a new deposit. |
superseded | A later observation became the surviving record. | Use the surviving deposit id from subsequent events; do not count both records. |
recovery_unacknowledged | Axys could not confirm acceptance during recovery. | Verify the sender outcome and contact Axys support before trying again. |
Bank rail In development
Not yet available — calls currently return 501 FEATURE_NOT_AVAILABLE. When this rail goes
live, it registers an external bank account for fiat deposits (bank_name,
account_number, optional routing_number/iban, 3-letter
currency); registration completes asynchronously (see the confirmation note below), and
subsequent wires surface as deposit.* webhooks.
deposit.* webhook arrives.Transactions & balances
Cursor-paginated spend history. Query with limit (1–50), before/after
(Unix seconds), and cursor. Pass explicit time windows and paginate until
next_cursor is null for a complete statement period.
The list is the statement of record for completed card transactions; individual pending authorizations are not list rows. Webhooks are real-time alerts and are not a guaranteed-complete statement. A final settled amount may differ from a pending estimate because of network fees, currency conversion, or variable pricing. Pending and settled identifiers are independent—do not rely on a contractual join between them.
{
"card_id": "c1a2…",
"items": [{
"id": "txn_…", "amount_minor": "149", "currency_code": "USD",
"status": "settled", "category": "purchase",
"merchant_name": "Blue Bottle", "created_at": "2026-07-08T…", "settled_at": "2026-07-08T…"
}],
"next_cursor": null
}currency_code. The decimal scale is the currency's exponent — USD = 2, so "149"
= $1.49. Don't hard-code ÷100; see Conventions → Money.The transaction amount is the all-in cost in the card currency and can differ from the merchant receipt; embedded network, conversion, and cross-border components are not itemized on the row.
This API does not yet expose a separate card-fee list or a partner fee schedule. Do not derive a complete balance-reconciliation identity from transaction rows alone; use the displayed balance as the spendable authority. Deposit webhooks identify their own deposit fee when one is available.
A merchant may offer dynamic currency conversion (DCC), which can make the merchant-side currency appear to match the card currency even for a foreign purchase. Advise cardholders to decline DCC when they want Axys's card-currency conversion. Cross-border assessments can still apply without a currency conversion.
Pending authorizations that never settle commonly age off after roughly 5–7 days without a separate cancellation row, and some settled transactions have no prior pending phase. These observations do not create a contractual pending-to-settled link.
Cards carry per-transaction limits configured for the program; a purchase may be declined even when the
available balance is sufficient. An ATM may display the lower of the available balance and the card's
configured transaction limit. merchant_name may be null or generic.
Refunds and reversals appear as distinct credit rows. To report fraud or dispute a transaction, contact
Axys support with both the public transaction id and the card id.
How balances work
Balances come from GET /cards/{id}: the card summary plus a single nullable
balance object for display.
| balance | What it is |
|---|---|
available | Spendable right now. |
ledger | Committed balance when supplied; otherwise null. |
pending | Pending balance when supplied; otherwise null. |
authorizations | Authorized amount when supplied; otherwise null. |
The whole balance object may be null while a card is not in a balance-bearing
state. When present, use available for spend decisions; do not derive a value from nullable
components.
category is a display hint for the completed transaction kind, such as purchase, refund, or
reversal.
Webhooks Preview
Register HTTPS endpoints to receive lifecycle events. Deliveries are HMAC-signed, retried with backoff, and auto-paused if your endpoint stays unhealthy. Your receiver URL is independent of your mTLS cert — secure it however your architecture prefers.
1 · Register an endpoint
{
"url": "https://hooks.your-app.example/axys",
"enabled_events": ["account.activated", "deposit.cleared", "card.blocked"]
}whsec_…) exactly once. Store it immediately; if you lose it, rotate with
POST …/rotate-secret.Discover subscribable types at GET /v1/webhooks/event-catalogue. No wildcards — unknown
types are rejected at registration.
2 · Receive & verify
Each delivery is a POST with an Axys-Signature header:
Axys-Signature: t=<unix>,k=<key-hint>,v1=<hex_hmac>The signature is HMAC-SHA256 over
<t>.<endpoint_public_id>.<event_id>.<raw_body>. The endpoint id and event
id are mixed in so a captured body cannot be replayed to another endpoint or event. During secret rotation
two (k,v1) pairs are sent — accept either.
Persist the endpoint publicId returned during registration alongside its valid
whsec_… secrets. The delivery body contains event_id, but it does not contain the
endpoint id. Retain the exact raw request bytes for verification; parsing and reserializing the JSON changes
the signed bytes.
import { createHmac, timingSafeEqual } from 'node:crypto';
export function parseAxysSignature(header) {
const tokens = header
.split(',')
.map((token) => token.trim())
.filter(Boolean);
let timestamp = null;
let pendingKeyHint = null;
const pairs = [];
for (const token of tokens) {
const separator = token.indexOf('=');
if (separator < 1) return null;
const name = token.slice(0, separator);
const value = token.slice(separator + 1);
if (name === 't') {
if (timestamp !== null || !/^\d+$/.test(value)) return null;
timestamp = Number(value);
} else if (name === 'k') {
if (pendingKeyHint !== null || value === '') return null;
pendingKeyHint = value;
} else if (name === 'v1') {
if (pendingKeyHint === null || !/^[a-f0-9]{64}$/.test(value)) return null;
pairs.push({ keyHint: pendingKeyHint, signature: value });
pendingKeyHint = null;
} else {
return null;
}
}
if (timestamp === null || pendingKeyHint !== null || pairs.length === 0) return null;
return { timestamp, pairs };
}
export function webhookSecretBytes(wireFormat) {
if (!wireFormat.startsWith('whsec_')) throw new Error('invalid webhook secret');
const encoded = wireFormat.slice('whsec_'.length).replaceAll('-', '+').replaceAll('_', '/');
const padded = encoded.padEnd(Math.ceil(encoded.length / 4) * 4, '=');
return Buffer.from(padded, 'base64');
}
export function verifyWebhook({
header,
storedEndpointPublicId,
rawBody,
validSecrets,
toleranceSeconds = 300,
nowUnixSeconds = Math.floor(Date.now() / 1000),
}) {
if (!Buffer.isBuffer(rawBody)) throw new TypeError('rawBody must be the exact request Buffer');
const parsed = parseAxysSignature(header);
if (!parsed || Math.abs(nowUnixSeconds - parsed.timestamp) > toleranceSeconds) return false;
let eventId;
try {
eventId = JSON.parse(rawBody.toString('utf8')).event_id;
} catch {
return false;
}
if (typeof eventId !== 'string' || eventId === '') return false;
const prefix = Buffer.from(`${parsed.timestamp}.${storedEndpointPublicId}.${eventId}.`, 'utf8');
return parsed.pairs.some((pair) =>
validSecrets.some((wireFormat) => {
const expected = createHmac('sha256', webhookSecretBytes(wireFormat))
.update(prefix)
.update(rawBody)
.digest('hex');
const expectedBytes = Buffer.from(expected, 'utf8');
const suppliedBytes = Buffer.from(pair.signature, 'utf8');
return (
expectedBytes.length === suppliedBytes.length &&
timingSafeEqual(expectedBytes, suppliedBytes)
);
}),
);
}
(k,v1) pair and accepts the delivery when any supplied signature matches any currently valid
secret. This supports either the new or old secret during rotation.3 · Delivery, retries & auto-pause
Delivery is at least once and unordered. Durably claim event_id under a unique
constraint before business side effects. Commit the claim and local database effects atomically when
possible; make external effects independently idempotent. A duplicate event_id that was already
processed must receive 2xx without repeating its effects. Use occurred_at when your
domain requires ordering, return 2xx quickly, and process asynchronously.
| Receiver outcome | Delivery behavior | Auto-pause score |
|---|---|---|
2xx | Succeeded; no retry. | Reset to 0 |
Any 4xx | Immediately dead-lettered; no automatic retry. | +2 |
5xx or other non-2xx HTTP failure | Retried with backoff, then dead-lettered after the attempt limit. | +1 |
| Connection refused or other transport failure | Retried with backoff, then dead-lettered after the attempt limit. | +1 |
| Timeout, DNS, or TLS failure | Retried with backoff, then dead-lettered after the attempt limit. | +0 |
| Signing-key decryption failure | Immediately dead-lettered; no automatic retry. | +0 |
auto_pause_threshold is a weighted failure score, not a raw count. For example, a
threshold of 10 is reached by five 4xx responses, ten +1 failures, or a weighted mixture. A successful
delivery resets the score to zero.
After a terminal failure, fix the receiver and recover manually. Use POST …/resume to resume
an endpoint (optionally re-queueing recent dead-lettered events), or use the delivery retry /
ack operations. Inspect history at GET …/deliveries.
Events & payloads
Every delivery uses one envelope; the inner payload differs per event type. Discover the live
set at GET /v1/webhooks/event-catalogue — no wildcards, and new event types are additive (never
broadcast to older subscribers).
{
"event_id": "9b1d…", "event_type": "deposit.cleared", "aggregate_type": "deposit",
"aggregate_public_id": "c1a2…", "correlation_id": "…", "causation_id": null,
"occurred_at": "2026-07-08T12:00:00Z",
"payload": {
"depositPublicId": "d4e5…", "cardPublicId": "c1a2…", "channel": "bank",
"initiatedAmount": "10000", "clearedAmount": "9950", "feeAmount": "50",
"feeCategory": "deposit", "currencyCode": "USD", "status": "cleared"
}
}snake_case; payload keys are
camelCase, and every amount is an integer string in
minor units.account events
Every account event carries accountPublicId + status. Only account.created
relays the contact email; no name, address, DOB, or KYC documents ever appear in a webhook.
| Event | payload (beyond accountPublicId, status) |
|---|---|
account.created | + email · status draft |
account.kyc_submitted | + submissionId · status under_review |
account.kyc_recovery_required | + reason · status error; support review is required before retry |
account.activated | status approved |
account.declined | + verdict (compliance_decline|admin_decline), reason (compliance_decline|administrative_decline) |
account.resubmitted | status draft |
account.suspended · reinstated · offboarded | status suspended / approved / offboarded |
deposit events
Amounts are integer strings in minor units; channel is blockchain or
bank. On cleared, treat a null feeAmount as zero; otherwise the
three values are integer strings and clearedAmount + feeAmount = initiatedAmount
(compare as integers after applying the null-as-zero rule).
| Event | payload |
|---|---|
deposit.pending | depositPublicId, cardPublicId, channel, initiatedAmount, currencyCode, status (may also carry clearedAmount: null) |
deposit.cleared | … + clearedAmount, feeAmount (nullable), feeCategory ("deposit"|null) |
deposit.failed | depositPublicId, cardPublicId, channel, initiatedAmount, currencyCode, reason (expired · card_service_failure · card_service_rejected · superseded · recovery_unacknowledged), status |
card events
| Event | payload |
|---|---|
card.blocked | cardPublicId, accountPublicId, status on_hold, reason (lost · stolen · customer_request · fraud_review · compliance · other) |
card.unblocked | cardPublicId, accountPublicId, status active |
Coming to webhooks In dev
Card-transaction events (authorization, settlement, and reversal) are
In dev. Until they appear in
GET /v1/webhooks/event-catalogue, read spend by polling
GET /cards/{id}/transactions. After a new type appears in the catalogue, explicitly add it to
the endpoint's enabled_events; existing subscriptions are not changed automatically.
Errors & idempotency
Errors from authenticated business operations share one envelope. The liveness health check is the
exception. code is a stable value you can branch on; it does not change meaning once published.
{
"success": false,
"error": { "code": "VALIDATION_ERROR", "message": "…", "details": { "errors": ["…"] } },
"requestId": "…", "timestamp": "2026-07-08T…"
}| HTTP | Representative codes |
|---|---|
| 400 | VALIDATION_ERROR · IDEMPOTENCY_KEY_REQUIRED · UNSUPPORTED_CURRENCY · UNSUPPORTED_EVENT_TYPE · CURRENCY_MISMATCH · UNKNOWN_CHART_ACCOUNT |
| 401 | SIGNATURE_MISSING · SIGNATURE_INVALID · TIMESTAMP_EXPIRED · NONCE_REPLAYED |
| 403 | CONSUMER_NOT_ALLOWED |
| 404 | RESOURCE_NOT_FOUND |
| 409 | INVALID_STATE_TRANSITION · ACCOUNT_NOT_APPROVED · CARD_NOT_ACTIVE · IDEMPOTENCY_KEY_REUSED_WITH_DIFFERENT_PAYLOAD · REQUEST_IN_PROGRESS |
| 429 | RATE_LIMIT_EXCEEDED (observe Retry-After) |
| 501 | FEATURE_NOT_AVAILABLE |
| 502 | CARD_SERVICE_UNAVAILABLE · CARD_SERVICE_REJECTED |
| 503 | SERVICE_UNAVAILABLE · ISSUANCE_RECOVERY_PENDING |
Idempotency
Money- and state-changing operations declare an Idempotency-Key header requirement —
it is enforced on exactly the operations that declare it in the reference (sending the header to other
routes does not add de-duplication). Where declared: within a 5-minute window, a duplicate request with
the same key and an identical payload is de-duplicated — it will not double-apply; an in-flight or
already-completed duplicate returns 409 REQUEST_IN_PROGRESS. If you never received the
original response, confirm the outcome with a read (GET) before re-submitting — de-duplication is
not guaranteed after the window. Re-using a key with a different payload returns
409 IDEMPOTENCY_KEY_REUSED_WITH_DIFFERENT_PAYLOAD. Use a fresh key per logical operation and
reuse the same key on retries — minting a fresh key for a retry creates a second operation.
Versioning and changes
The OpenAPI version and each operation's maturity badge describe the contract you are integrating. New optional fields, new enum members, and new event types are additive; existing webhook subscriptions do not opt into new event types automatically. A removal, rename, required-field change, or semantic change is breaking and will be communicated before it takes effect. Preview and Beta surfaces may change under that notice; do not rely on an unpublished changelog.