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.
Platform overview
Axys Card Program presents a single program-level contract for partner products. Use this overview to assess audience, capability scope, and maturity; the later Guides define the implementation requirements, and the API reference defines the exhaustive request and response contract.
Who this documentation is for
| Audience | What this documentation helps you decide or deliver |
|---|---|
| Product & program teams | Understand the cardholder journey, available capabilities, maturity, and operating dependencies. |
| Engineering teams | Implement authentication, lifecycle flows, money movement, reconciliation, and signed event handling. |
| Security & compliance teams | Assess the control model, sensitive-data boundaries, and responsibilities shared between Axys and your organization. |
| Operations teams | Prepare onboarding inputs, observe asynchronous outcomes, and design recovery and support procedures. |
What you can build
| 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} |
| Staging emulation | Preview | POST /emulation/* · unavailable in production |
| Webhooks | Preview | POST /v1/webhooks/endpoints + delivery management |
Status is indicative — confirm with your Axys contact before depending on a capability in production.
What makes the platform different
- One card-centric money model. Enabled funding paths converge on the card's balance and transaction surfaces instead of exposing separate balance contracts by rail. See Funding & money and Transactions & balances.
- Funding destinations arrive with issuance. Blockchain deposit addresses are provisioned with the card and retrieved when needed; there is no address-registration flow. See Funding & money.
- Onboarding and issuance are programmatic. Straightforward cardholder reviews can complete in minutes and approved cardholders can proceed directly to card issuance; manual and exception reviews remain asynchronous. See Onboard a cardholder and KYC.
- Currency conversion is part of the money flow. Supported deposits and transactions are converted into the card's base currency. Axys can support additional program currencies, while this published Card Program contract currently enables USD only.
- Issuing reach is program-led rather than region-locked. Cardholder onboarding and card issuance can span approved jurisdictions, subject to sanctions screening, program enablement, and case-specific compliance review.
- Configured controls define load and spend boundaries. Axys does not add a separate platform ceiling beyond enabled program, cardholder, and card limits and applicable card-network constraints.
- Current state and events work together. Read endpoints provide the latest observable state, while signed webhooks carry asynchronous lifecycle changes. See Webhooks.
- Sensitive card data follows a separate path. Routine operations remain masked; the dedicated reveal flow has its own controls and obligations. See Sensitive card details & PCI.
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. |
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
Base URL for your organisation in development:
https://<your-org>.dev.gw.axysbank.ioThe environment is usable only after Axys confirms that it has been provisioned.
Your organisation receives its own dedicated subdomain in each enabled environment:
<your-org>.stg.gw.axysbank.io for staging and
<your-org>.gw.axysbank.io for production. The <your-org> value is named after
the organisation in your CSR. Each 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.
For every retry, generate a fresh timestamp, nonce, and signature. When retrying a mutation that
declares an Idempotency-Key, reuse the same key. GET /accounts does not use one.
6 · Confirm connectivity
BASE_URL="https://<your-org>.dev.gw.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:{
"success": true,
"data": {
"items": [],
"next_cursor": null,
"has_more": false
},
"requestId": "...",
"timestamp": "..."
}Base URLs & environments
Every consumer receives its own dedicated subdomain. Public DNS uses stg for
staging and no environment label for production. The <your-org> value 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 |
|---|---|---|
| your organisation | Staging | https://<your-org>.stg.gw.axysbank.io |
| your organisation | Production | https://<your-org>.gw.axysbank.io |
A generic preview shows unmistakable placeholders. Publishable builds fail unless the consumer, environment, and assigned gateway host are supplied together.
Platform callback ingress
Card-program callbacks use a separate, platform-operated hostname family. This is not your consumer API base URL, and it is not the receiver URL that you register under Webhooks.
The callback hostname family is symmetric with the consumer API family: staging uses
stg and production omits the environment label.
| Environment | Callback URL |
|---|---|
| Staging | https://<your-org>.stg.callback.axysbank.io/cards |
| Production | https://<your-org>.callback.axysbank.io/cards |
POST /cards. The origin root
/ is not a catch-all and does not route callback requests.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'),
});Security & data protection
Axys applies security controls across API access, card-data handling, event delivery, and digital-asset signing. The control map below points to the implementation requirements elsewhere in these Guides and keeps this section focused on security posture and responsibility boundaries.
Security control map
| Control area | Implementation requirements |
|---|---|
| API access and request integrity | See Authentication for source-IP allowlisting, mTLS, request signatures, timestamps, and nonces. |
| Environment separation | See Base URLs & environments for environment-specific hosts, credentials, and enablement. |
| Card-data boundaries | See Sensitive card details & PCI for storage boundaries, masking, auditability, rate limits, and integration obligations. |
| Event integrity | See Webhooks for signing, verification, replay handling, retry, and endpoint controls. |
Digital-asset signing architecture
Axys digital-asset signing uses an HSM-backed Multi-Party Computation (MPC) model with a Threshold Signature Scheme (TSS). Key shares are generated and used inside hardware-isolated Trusted Execution Environments (TEEs). No complete private key is assembled in a single location, and one share alone is insufficient to authorize a transaction.
Compliance framework context
| Framework | How to use it during review |
|---|---|
| PCI DSS | Use the documented network, cryptography, access, audit, and card-data boundaries when determining your integration's PCI DSS scope. |
| ISO/IEC 27001 | Map applicable controls and evidence to your information-security management requirements during security review. |
| SOC 2 Trust Services Criteria | Map applicable controls and evidence to the security, availability, and confidentiality criteria relevant to your review. |
Current evidence for the applicable control scope and any formal attestations is handled through the security-review process. This avoids treating a static documentation page as assurance evidence.
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/endpoints/{public_id}/deliveries | status — a delivery outcome · since / until — ISO-8601 date-times · limit (1–1000) · cursor |
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 and ledger) 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. |
Zero to a funded card
The shortest path from an empty environment to a card with a spendable balance, on staging.
Every step below is a real endpoint in this contract; the steps marked staging only use the
/emulation/* surface, which is not mounted in production. Each section links to the guide that
covers the same endpoint in depth.
set -e or chain dependent steps with &&. The steps that depend on an
earlier result also assert it up front, so a missing id stops the block instead of building a request
around an empty value.1 · Authenticate and sign the request
Every call needs your mTLS client certificate plus the three signature headers. Build the five-line canonical string — method, path with query, timestamp, nonce, lowercase hex SHA-256 of the raw body — and sign it with your request-signing key. See Authentication for the full rules.
Save this helper as sign-request.sh and source it once — every later step calls
sign_and_send with its own method, path and body, and a fresh timestamp, nonce and
signature are computed per call.
--fail-with-body, so a failed request exits non-zero while still returning the error body that
carries the requestId. Older curl rejects the option and every request fails before reaching
the API; the helper checks for it and stops with a clear message. Check yours with
curl --version.# Axys Card Program — request signing helper (POSIX sh).
#
# . ./sign-request.sh
# sign_and_send GET /accounts
#
# Every call recomputes the timestamp, nonce and signature, so the same helper
# is correct for a first attempt and for a retry. Reuse an Idempotency-Key only
# when retrying the SAME logical mutation — see the note under each step.
BASE_URL="${BASE_URL:-https://your-org.stg.gw.axysbank.io}"
SIGNING_KEY="${SIGNING_KEY:-request-signing-private.pem}"
CLIENT_CERT="${CLIENT_CERT:-client-cert.pem}"
CLIENT_KEY="${CLIENT_KEY:-client-key.pem}"
# A POSIX pipeline reports only the LAST command's status. Every function below
# is written so an openssl failure reaches the caller instead of being masked by
# a later command that happily succeeds on empty input — the difference between
# stopping and sending a request with an EMPTY signature.
# Lowercase hex SHA-256 of the exact request bytes. A bodyless request hashes
# the empty byte string.
axys_body_hash() {
# openssl is last in this pipeline, so its status IS the pipeline's.
_axys_digest="$(printf '%s' "${1-}" | openssl dgst -sha256 -hex)" || return 1
# Take the field after the last space (parameter expansion, so no extra
# process whose exit status could mask the failure above).
_axys_hash="${_axys_digest##* }"
[ -n "$_axys_hash" ] || return 1
printf '%s' "$_axys_hash"
}
# The canonical string: exactly five LF-separated fields, no trailing newline.
# axys_canonical_string METHOD PATH_WITH_QUERY TIMESTAMP NONCE [BODY]
axys_canonical_string() {
# Command substitution does not propagate failure on its own — check it.
_axys_body_hash="$(axys_body_hash "${5-}")" || return 1
printf '%s\n%s\n%s\n%s\n%s' "$1" "$2" "$3" "$4" "$_axys_body_hash"
}
# Base64 RSA-SHA256 signature over the canonical string.
axys_sign() {
# Split rather than piped: `openssl dgst -sign | openssl base64` returns the
# BASE64 status, so a missing or unreadable key produced an empty signature
# that looked like success. The signature is not secret (it ships in a
# header), so a temp file is safe here; the private key never leaves openssl.
_axys_sig_file="$(mktemp)" || return 1
if ! printf '%s' "$1" | openssl dgst -sha256 -sign "$SIGNING_KEY" -out "$_axys_sig_file"; then
rm -f "$_axys_sig_file"
return 1
fi
_axys_sig="$(openssl base64 -A -in "$_axys_sig_file")" || {
rm -f "$_axys_sig_file"
return 1
}
rm -f "$_axys_sig_file"
[ -n "$_axys_sig" ] || return 1
printf '%s' "$_axys_sig"
}
# Sign and send one request.
# sign_and_send METHOD PATH_WITH_QUERY [BODY] [IDEMPOTENCY_KEY]
#
# PATH_WITH_QUERY must be the EXACT target sent on the wire, query string
# included — the signature covers it.
sign_and_send() {
# Checked here, not at source time: this file is sourced, so a hard failure
# while sourcing would be easy to miss. On curl older than 7.76.0 the
# --fail-with-body option below is rejected and every request would die with
# "option --fail-with-body: is unknown" before reaching the API.
if ! curl --help all 2>/dev/null | grep -q -- '--fail-with-body'; then
echo 'curl 7.76 or later is required.' >&2
# `return` in a sourced function, `exit` only if somehow run as a script —
# never kill the reader's interactive shell.
return 1 2>/dev/null || exit 1
fi
method="$1"
request_path="$2"
body="${3-}"
idempotency_key="${4-}"
# Every generated value is checked before anything is dispatched. A failure
# here must stop the request, never send a half-built one.
timestamp="$(date +%s)" || return 1
nonce="$(openssl rand -hex 16)" || return 1
canonical="$(axys_canonical_string "$method" "$request_path" "$timestamp" "$nonce" "$body")" || return 1
signature="$(axys_sign "$canonical")" || return 1
# Belt and braces: a tool that fails with status 0 would leave an empty value.
# Refuse to sign or send anything incomplete.
if [ -z "$timestamp" ] || [ -z "$nonce" ] || [ -z "$canonical" ] || [ -z "$signature" ]; then
echo 'Could not build a signed request (timestamp, nonce, canonical string or signature was empty); no request was sent.' >&2
return 1
fi
# --fail-with-body: a 4xx/5xx exits non-zero so `&&` chains and `set -e`
# scripts stop, while the ERROR BODY still reaches stdout — that body carries
# the requestId you quote when contacting Axys.
#
# The status line goes to stderr via %{stderr}: status and method ONLY,
# deliberately not the URL. A path or query string can carry personal data (an
# email in a search filter, for example) and this line lands in terminals and
# CI logs. -sS drops the progress meter but keeps transport errors.
set -- -sS --fail-with-body -w '%{stderr}HTTP %{http_code} %{method}\n' \
-X "$method" "${BASE_URL}${request_path}" \
-H "X-Timestamp: $timestamp" \
-H "X-Nonce: $nonce" \
-H "X-Signature: $signature"
# Present the client certificate unless mTLS is terminated ahead of this hop.
if [ -n "$CLIENT_CERT" ]; then
set -- "$@" --cert "$CLIENT_CERT" --key "$CLIENT_KEY"
fi
if [ -n "$idempotency_key" ]; then
set -- "$@" -H "Idempotency-Key: $idempotency_key"
fi
if [ -n "$body" ]; then
# Send the body on STDIN, never as an argument. `-d "$body"` would put the
# request JSON — card number, CVV, PIN, names, emails — into curl's argv,
# where any user on the host can read it with `ps`. `--data-binary @-`
# reads stdin verbatim, so the bytes sent are byte-for-byte the bytes that
# were hashed into the signature.
printf '%s' "$body" | curl "$@" -H 'Content-Type: application/json' --data-binary @-
else
curl "$@"
fi
}
# Your assigned host, plus the mTLS pair and signing key from onboarding.
BASE_URL="https://<your-org>.dev.gw.axysbank.io"
. ./sign-request.shsign_and_send does that for you. The only value you reuse on a retry is the
Idempotency-Key — which is what the next step is for.2 · Check your prerequisites
The walkthrough needs three tools: curl (7.76 or later) to send the requests,
openssl to generate keys and signatures, and jq to
read ids out of the responses. Confirm all three before sending anything — a missing
openssl would silently produce empty idempotency keys, and a missing jq would
create a cardholder whose id you then cannot capture.
MISSING=""
for tool in curl openssl jq; do
command -v "$tool" >/dev/null 2>&1 || MISSING="$MISSING $tool"
done
if [ -n "$MISSING" ]; then
echo "Missing required tool(s):$MISSING — no request was sent." >&2
false
fi3 · Generate your idempotency keys — once
Every mutation below declares an Idempotency-Key. Reusing the same key is what makes a retry
safe; minting a fresh one creates a second operation — a second account, a second card. Generate one
key per logical operation now, and run every block of this walkthrough in the same shell session,
once — the keys and the captured ids live in that session's variables, so re-running any request block below
reuses its key instead of minting a new one. If you lose the shell state, do not resend with fresh
keys: that would create duplicate resources. Contact Axys instead.
# Keys must match ^[a-z0-9._~-]{8,128}$ — lowercase. `openssl rand -hex`
# is safe; `uuidgen` is NOT (it emits uppercase on most systems).
ACCOUNT_IDEM_KEY="${ACCOUNT_IDEM_KEY:-account-$(openssl rand -hex 8)}"
KYC_SUBMIT_IDEM_KEY="${KYC_SUBMIT_IDEM_KEY:-kyc-submit-$(openssl rand -hex 8)}"
KYC_VALIDATE_IDEM_KEY="${KYC_VALIDATE_IDEM_KEY:-kyc-validate-$(openssl rand -hex 8)}"
CARD_IDEM_KEY="${CARD_IDEM_KEY:-card-$(openssl rand -hex 8)}"
ACTIVATE_IDEM_KEY="${ACTIVATE_IDEM_KEY:-activate-$(openssl rand -hex 8)}"
DEPOSIT_IDEM_KEY="${DEPOSIT_IDEM_KEY:-deposit-$(openssl rand -hex 8)}"${VAR:-default} form only assigns
when the variable is still unset, so re-running this block keeps the keys you already have. Each
request block below only reads its key, making a re-run a safe retry of the same operation.To start a genuinely new run, clear all run state — the keys and the ids captured from the
previous run. Clearing only the keys is unsafe: the capture steps below assign only on success, so a failed
create would leave the previous run's ACCOUNT_ID/CARD_ID in place and the
later steps would target the old resources with fresh keys.
unset ACCOUNT_IDEM_KEY KYC_SUBMIT_IDEM_KEY KYC_VALIDATE_IDEM_KEY
unset CARD_IDEM_KEY ACTIVATE_IDEM_KEY DEPOSIT_IDEM_KEY
unset ACCOUNT_ID CARD_ID DEPOSIT_ADDRESS NEW_ID NEW_ADDRESS
unset ACCOUNT_RESPONSE CARD_RESPONSE ADDRESS_RESPONSE REQUEST_ID4 · Create the cardholder account
Full field reference in Onboard a cardholder.
ACCOUNT_BODY='{
"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"
}
}'
# Keep the RAW response: on a non-2xx (e.g. a same-key retry returning 409)
# the error envelope carries the requestId you need when contacting Axys.
# Piping straight into jq would consume and lose it. `|| true` keeps the
# non-zero curl status from aborting before the check below reports it.
ACCOUNT_RESPONSE="$(sign_and_send POST /accounts "$ACCOUNT_BODY" "$ACCOUNT_IDEM_KEY")" || true
NEW_ID="$(printf '%s' "$ACCOUNT_RESPONSE" | jq -r '.data.id // empty')"
REQUEST_ID="$(printf '%s' "$ACCOUNT_RESPONSE" | jq -r '.requestId // empty')"
if [ -z "$NEW_ID" ]; then
echo "No account id returned (requestId: ${REQUEST_ID:-unknown}) — stop here; do not run the next step." >&2
# Return a non-zero block status so `&&` chains and scripts using
# `set -e` stop here. Deliberately `false`, not `exit`, because this
# block may be pasted into an interactive shell.
false
else
ACCOUNT_ID="$NEW_ID"
echo "$ACCOUNT_ID"
fi409 REQUEST_IN_PROGRESS and does not replay the original
response, so retry with the same ACCOUNT_IDEM_KEY — a fresh key would create a second
cardholder. If the id still cannot be confirmed, contact Axys with the requestId printed in the stop
message rather than re-submitting.201 · data.status is
draft and data.id is the accountId used from here on — captured into
ACCOUNT_ID by the block above.{
"id": "a3f1c9b7-8a4d-4c2a-9f3e-1d6b0a5c7e21",
"status": "draft",
"kyc_url": null,
"kyc_generation": null,
"kyc_revision": null,
"created": true
}5 · Submit KYC, then resolve the review (staging only)
No request body. The response is the account summary; a fresh submission carries a single-use
kyc_url and the status moves through pending → under_review. See
KYC.
: "${ACCOUNT_ID:?ACCOUNT_ID is not confirmed; stop before KYC}"
# Bodyless: the empty body hashes the empty byte string.
sign_and_send PUT "/accounts/${ACCOUNT_ID}/kyc-submit" "" "$KYC_SUBMIT_IDEM_KEY"On staging you resolve the review yourself instead of waiting for a real decision:
: "${ACCOUNT_ID:?ACCOUNT_ID is not confirmed; stop before KYC}"
# Emulation routes require an Idempotency-Key like any other mutation.
sign_and_send POST /emulation/kyc/validate \
"{\"account_id\": \"${ACCOUNT_ID}\", \"result\": \"pass\"}" "$KYC_VALIDATE_IDEM_KEY"{
"status": "success",
"result": { }
}400 IDEMPOTENCY_KEY_REQUIRED; retrying with the same key returns
409 REQUEST_IN_PROGRESS rather than replaying the original response, so confirm the outcome
with the account read in the next step. See Staging emulation.404 can mean that the emulation
surface is not enabled, or that the submitted account_id does not exist. Confirm the original
account with sign_and_send GET "/accounts/${ACCOUNT_ID}". If the account read succeeds but KYC
emulation still returns 404 RESOURCE_NOT_FOUND, stop and contact Axys with the
requestId. Do not probe availability by submitting a KYC result for another account —
that either trips 409 IDEMPOTENCY_KEY_REUSED_WITH_DIFFERENT_PAYLOAD or, with a fresh key,
applies a real KYC decision to someone else's account.6 · Poll until the account is approved
Review is asynchronous, so read the account until status leaves
under_review. Poll until the review is approved or declined. If it remains unresolved after
48 hours, contact Axys support through the documented escalation process.
: "${ACCOUNT_ID:?ACCOUNT_ID is not confirmed; stop before this step}"
sign_and_send GET "/accounts/${ACCOUNT_ID}"200 · data.status is
approved. Only an approved account can be issued a card.7 · Issue a card
: "${ACCOUNT_ID:?ACCOUNT_ID is not confirmed; stop before this step}"
CARD_RESPONSE="$(sign_and_send POST "/accounts/${ACCOUNT_ID}/cards" \
'{"card_type": "virtual", "name_on_card": "JORDAN REYES"}' "$CARD_IDEM_KEY")" || true
NEW_ID="$(printf '%s' "$CARD_RESPONSE" | jq -r '.data.id // empty')"
REQUEST_ID="$(printf '%s' "$CARD_RESPONSE" | jq -r '.requestId // empty')"
if [ -z "$NEW_ID" ]; then
echo "No card id returned (requestId: ${REQUEST_ID:-unknown}) — stop here; do not run the next step." >&2
false
else
CARD_ID="$NEW_ID"
echo "$CARD_ID"
fi{
"id": "c1a2f6d0-3b45-4e18-9a27-8f0e5d1c4b93",
"account_id": "a3f1c9b7-8a4d-4c2a-9f3e-1d6b0a5c7e21",
"card_type": "virtual", "status": "not_activated",
"masked_pan": "************4242", "currency_code": "USD",
"card_display": { "last4": "4242" }, "created": true
}data.id is the cardId the remaining steps use; the block above captures it into
CARD_ID.
CARD_IDEM_KEY; a fresh key would issue a second
card. If the id still cannot be confirmed, contact Axys with the requestId printed in the stop message
rather than re-submitting.not_activated and cannot spend until step 8.8 · Activate the card
: "${CARD_ID:?CARD_ID is not confirmed; stop before card operations}"
ACTIVATE_BODY='{
"activation_request_id": "activation-01",
"pan": "9999999999999999",
"expiry_month": 8, "expiry_year": 2029,
"cvv": "123", "pin": "1234"
}'
sign_and_send PUT "/cards/${CARD_ID}/activate" "$ACTIVATE_BODY" "$ACTIVATE_IDEM_KEY"200 · data.status is
active and data.operation is activate.
data.updated is false on an idempotent replay.pan of
12 through 19 digits is accepted — any length in that range. The format is validated but the value is not
matched against a real card, so a successful activation here does not prove the card number is correct.
The other fields keep their documented formats. The card number, CVV and PIN are used transiently and
never persisted by Axys.9 · Read the card's deposit addresses
Addresses are provisioned with the card; there is no address-registration call. See Funding & money.
: "${CARD_ID:?CARD_ID is not confirmed; stop before card operations}"
# Capture the first address to fund in the next step. This is a read, so it is
# safe to re-run — but a transient failure must not overwrite a good address,
# so assign only on success.
ADDRESS_RESPONSE="$(sign_and_send GET "/cards/${CARD_ID}/deposit-address")" || true
NEW_ADDRESS="$(printf '%s' "$ADDRESS_RESPONSE" | jq -r '.data.deposit_addresses[0].address // empty')"
REQUEST_ID="$(printf '%s' "$ADDRESS_RESPONSE" | jq -r '.requestId // empty')"
if [ -z "$NEW_ADDRESS" ]; then
echo "Deposit address was not returned (requestId: ${REQUEST_ID:-unknown}); stop and contact Axys." >&2
false
else
DEPOSIT_ADDRESS="$NEW_ADDRESS"
echo "$DEPOSIT_ADDRESS"
fi{ "deposit_addresses": [ { "chain": "ETH", "address": "0xAbc…" } ] }10 · Simulate a deposit (staging only)
Send the address captured in step 9, a token_id, and a positive decimal-string
amount with at most two places. token_id is the token identifier for the
simulated deposit (e.g. chain-specific USDT ID).
: "${DEPOSIT_ADDRESS:?DEPOSIT_ADDRESS is not confirmed; stop before deposit}"
TOKEN_ID="<chain-specific token id>"
DEPOSIT_BODY="{\"address\": \"${DEPOSIT_ADDRESS}\", \"token_id\": \"${TOKEN_ID}\", \"amount\": \"100.00\"}"
sign_and_send POST /emulation/simulate-crypto-deposit "$DEPOSIT_BODY" "$DEPOSIT_IDEM_KEY"{
"status": "success",
"message": "Deposit simulated",
"tx_id": "…"
}Idempotency-Key; reuse $DEPOSIT_IDEM_KEY on a retry rather
than minting a new one, which would be a second deposit.11 · Poll the card until the balance appears
There is no dedicated balance endpoint: read the card and use its balance object. See
Transactions & balances.
: "${CARD_ID:?CARD_ID is not confirmed; stop before card operations}"
sign_and_send GET "/cards/${CARD_ID}"{
"available": "10000", "ledger": "10000",
"currency_code": "USD", "observed_at": "2026-07-08T12:00:00.000Z"
}"10000"
on a USD card is $100.00. The whole balance object is null while a card is not in a
balance-bearing state, so poll until it is present and available has moved.12 · Know the current limitations
deposit.pending, deposit.cleared, or deposit.failed until Axys
confirms general availability./emulation/* routes are not mounted
in production, and a staging activation does not prove the card number you sent is correct. Do not build
production logic around either behaviour.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.
Signed with the sign_and_send helper from
Zero to a funded card — each request needs its own signature over its own method,
path and body, so never reuse one computed for a different call.
ACCOUNT_IDEM_KEY="${ACCOUNT_IDEM_KEY:-account-$(openssl rand -hex 8)}" # kept across re-runs
ACCOUNT_BODY='{
"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"
}
}'
sign_and_send POST /accounts "$ACCOUNT_BODY" "$ACCOUNT_IDEM_KEY"{
"id": "a3f1…",
"status": "draft",
"kyc_url": null,
"kyc_generation": null,
"kyc_revision": 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.
Account reads and successful submissions also return kyc_generation and
kyc_revision as decimal strings; both are null before the first submission.
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 single-use and return-only.
A fresh submission returns it; it is never included in webhook payloads. Hand it to the cardholder
promptly. If the link lapses, contact Axys support.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. A successful correction keeps the current generation, increments its
revision, returns the updated pair, and emits account.resubmitted. The next successful
kyc-submit starts the next generation at revision 1.
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.
The card currency is inherited from the Card Program configuration and cannot be selected by the
caller. This request accepts no currency field; under a fresh Idempotency-Key, a request still
sending one fails validation with a 400. The issued card's currency_code is
returned on the response.
{ "card_type": "virtual", "name_on_card": "JORDAN REYES" }Cards — virtual and physical alike — are issued, not activated. Activate one with
PUT /cards/{cardId}/activate, sending activation_request_id (a non-secret
idempotency discriminator), the full pan,
expiry_month/expiry_year, cvv, and a 4-digit pin.
The card number, CVV and PIN are used transiently and never persisted by Axys.
pan of
12 through 19 digits is accepted — any length in that range, not only the endpoints of it. The format
is validated, but the value is not matched against a real card, so a successful activation on staging does
not prove the card number is correct. The other fields keep their documented formats:
expiry_month 1–12, a four-digit expiry_year, and a 4-digit pin.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 the masked number, CVV and expiry — 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 masked_pan, the cvv, and the
expiry, for when a cardholder needs to see their own card.
{
"id": "c1a2…", "masked_pan": "************4242",
"cvv": "123", "expiry_month": 8, "expiry_year": 2029
}masked_pan, the
cvv, and the expiry.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 CVV or expiry 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 | Card number, CVV, PIN | The card number is never returned; CVV and expiry 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. The currently available crypto funding API retrieves the destination; there is no create-deposit or list-deposits endpoint.
deposit.pending, deposit.cleared, or deposit.failed until Axys
confirms general availability.Planned webhook lifecycle. Once generally available, deposit.pending will represent
committed but unspendable funds and deposit.cleared will indicate that funds are 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. Deposit event notifications are not currently available; use supported read surfaces and the Axys
support process for current-state questions rather than waiting for a deposit.* webhook.
{ "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. Under the planned webhook contract, a deposit that cannot be confirmed would
typically become 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.
Planned deposit.failed reasons In dev
| 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 are planned to surface as deposit.* webhooks once those events are generally
available.
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. Once generally available, the planned deposit webhook contract will identify a deposit fee when one is available.
GET /cards/{id}.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. |
currency_code | Currency for both balance amounts. |
observed_at | When Axys observed this balance. It does not say when the balance last changed. |
The whole balance object may be null while a card is not in a balance-bearing
state. When present, all four fields are supplied; use available for spend decisions.
category is a display hint for the completed transaction kind, such as purchase, refund, or
reversal.
Staging emulation Preview
Use these operations to advance test scenarios in an assigned staging stack.
The routes are not mounted in production. They use the same authentication and per-consumer
rate limits as other business operations. All four require an Idempotency-Key;
reuse the same key for every retry of one logical test action. One of them,
prepare-spend-otp, is still in development — see the note below.
| Operation | Use it for | Precondition |
|---|---|---|
POST /emulation/kyc/validate | Resolve an account review as pass or fail. | The public account_id must currently be under review. |
POST /emulation/simulate-crypto-deposit | Simulate funding to a crypto address returned for a card in this stack. | Supply address, token_id, and decimal-string amount. |
POST /emulation/simulate-spend | Simulate a card-present spend. | The public card_id must be active and sufficiently funded. |
POST /emulation/prepare-spend-otp In development | Generate a test 3-DS code for the normal listener flow. | The public card_id must be active and have a pending listener. |
POST /emulation/prepare-spend-otp is
in development. The 3-DS flow it supports is not yet available: this contract exposes no operation to
register an OTP listener or to read a delivered code, so the precondition above cannot be met through the
API and the helper cannot be exercised end-to-end. It is documented so you can design ahead. Do not build
a 3-DS integration against it until Axys confirms availability.Each command has a durable claim recorded before dispatch. A matching in-flight or completed
retry returns 409 REQUEST_IN_PROGRESS; the original response is not replayed. Confirm
KYC through the account read, deposits and spends through card balance or transactions, and OTP
through the normal listener flow. If dispatch may have happened but its outcome cannot be proven,
Axys returns 503 EMULATION_OUTCOME_UNKNOWN. Keep using the same key and verify through
reads; a fresh key is a deliberate new action and can duplicate the unresolved command.
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.
These deliveries to your receiver are distinct from the platform-operated callback ingress described under Base URLs & environments.
1 · Register an endpoint
{
"url": "https://hooks.your-app.example/axys",
"enabled_events": ["account.activated", "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.
deposit.pending, deposit.cleared, or deposit.failed until Axys
confirms general availability.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.
4 · Inspect delivery history
Delivery history is available through this API — it is the consumer-facing record of what was attempted and how it ended. Results are cursor-paginated and accept these filters:
| Query | Accepts |
|---|---|
limit | Integer, 1–1000. |
cursor | Continuation token from the previous response's next_cursor. |
status | A delivery outcome; an unrecognised value returns 400 VALIDATION_ERROR. |
since / until | ISO-8601 date-times bounding attempted_at. |
Each row is bounded attempt metadata: outbox_event_id, attempt_number,
attempted_at, delivered_to_url, delivery_status,
response_status, response_latency_ms, and error_category, alongside
next_cursor and has_more.
outbox_event_id and attempted_at.5 · Verify your receiver on staging
Confirm the whole path end-to-end before relying on events:
- Register the endpoint with
POST /v1/webhooks/endpoints. Retain the returnedpublic_idand the signing secret — the secret is shown once. - Wait about a minute. The endpoint is
activeas soon as it is registered, but a test delivery is rejected with409for the first 60 seconds after registration. Sending one earlier returns that conflict, not a delivery. - Send the test delivery with
POST /v1/webhooks/endpoints/{public_id}/test. It uses the same authentication as every other operation — mTLS plus a request signature. Axys delivers a synthetic_test.pingevent carryinglivemode: false; your receiver must be reachable over HTTPS and answer2xxfor the attempt to count as delivered. The endpoint must beactive; a paused or auto-paused endpoint returns409. - Confirm it was recorded with
GET /v1/webhooks/endpoints/{public_id}/deliveries. The attempt is persisted whether it succeeded or failed, so this read is what proves the path end-to-end — the response to the test call gives you the immediate outcome, the delivery list gives you the durable record.
X-Confirm-Production-Test: yes header — a guard against a
staging checklist being run against production.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.
Every KYC lifecycle event also carries decimal-string kycGeneration and
kycRevision. Compare generation first and revision second as arbitrary-precision decimal
integers (do not coerce them to JavaScript Number) to discard stale delivery;
revision increases within a submission cycle and resets to 1 when generation increases.
| Event | payload (beyond accountPublicId, status) |
|---|---|
account.created | + email · status draft |
account.kyc_submitted | + lifecycle pair + submissionId · status under_review |
account.kyc_recovery_required | + lifecycle pair + reason · status error; support review is required before retry |
account.activated | + lifecycle pair · status approved |
account.declined | + lifecycle pair + verdict (compliance_decline|admin_decline), reason (compliance_decline|administrative_decline) |
account.resubmitted | + lifecycle pair · status draft |
account.suspended · reinstated · offboarded | status suspended / approved / offboarded |
Planned deposit events In dev
This payload contract is planned and unavailable for production use. Once generally available,
amounts will be integer strings in minor units and channel will be blockchain or
bank. On cleared, consumers will treat a null feeAmount as zero;
otherwise the three values will be 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 |
Card transaction events
The event catalogue includes the four immutable activity observations below. Each
activity_id identifies only that observation: do not infer a parent transaction,
authorization-to-posting relationship, or delivery order from matching amounts or nearby timestamps.
Amounts are decimal strings in minor units.
| Event | payload |
|---|---|
card.transaction.authorized | activity_id, card_id, account_id, authorization_amount, authorization_currency, merchant_name (nullable), observed_at |
card.transaction.posted | activity_id, card_id, account_id, amount, currency, direction: "debit", merchant_name (nullable), occurred_at |
card.transaction.refunded | activity_id, card_id, account_id, amount, currency, direction: "credit", merchant_name (nullable), occurred_at |
card.transaction.reversed | activity_id, card_id, account_id, amount, currency, direction: "credit", merchant_name (nullable), occurred_at |
In staging, POST /emulation/simulate-spend can produce independent
card.transaction.authorized and card.transaction.posted observations in either
order. It does not generate refund or reversal events. Register all events you need explicitly in
enabled_events; existing subscriptions are not changed automatically. Continue to use
GET /cards/{id}/transactions for transaction history.
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 · EMULATION_OUTCOME_UNKNOWN |
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 coordination 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. Operations backed by
a durable claim, including staging emulation, continue enforcing the same key after the coordination
window or a coordination-store outage. 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.