Axys Card Program
v1.0.0

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.

📘
These Guides cover the critical flows end-to-end. For the exhaustive contract — every endpoint, field, and schema — open the API reference.

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.

CapabilityStatusEndpoints
Cardholder onboarding & KYCBetaPOST /accounts · PUT /accounts/{id}/kyc-submit
Card issuance & lifecycleBetaPOST /accounts/{id}/cards · activate · block/unblock · PIN
Crypto fundingBetaGET /cards/{id}/deposit-address
Bank fundingIn developmentPOST /accounts/{id}/register-bank-account
Transactions & balancesPreviewGET /cards/{id}/transactions · GET /cards/{id}
WebhooksPreviewPOST /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:

BadgeWhat it means
LiveGenerally available contract. Confirm enablement for your assigned environment.
BetaThe contract may still change. Confirm enablement before integrating.
PreviewEarly contract access. May change or be withdrawn; confirm enablement before use.
In devNot yet callable. Documented so you can design ahead.
DeprecatedStill works but scheduled for removal. Migrate to the named replacement.
🧭
Scope. The platform is intentionally focused on card creation and its money movement. There is no broad consumer surface here — the flows below are the whole API.

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.

request-signing keys
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.pem

Send 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:

assigned base URL
https://stg.<your-org>.axysbank.io

Your 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

signed GET /accounts
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}"
👍
A 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 subdomainstg.<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).

ConsumerEnvironmentBase URLStatus
your organisationStaginghttps://stg.<your-org>.axysbank.ioPending enablement
your organisationProductionhttps://<your-org>.axysbank.ioPending 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.

LayerWhat it isWhere
1 · IP allowlistYour source IPs are allowlisted; anything else is dropped.Edge
2 · mTLSYou present a client certificate on the TLS handshake.Load balancer
3 · RSA signatureA per-request signature over a canonical string, plus timestamp & nonce.Application
Sequence: sign the canonical string, present client cert at the edge, Axys Card Program verifies the signature, enveloped response.
Anatomy of an authenticated request.

The signature headers

Send these three headers on each authenticated request:

HeaderValue
X-SignatureBase64 RSA-SHA256 signature of the canonical string, signed with your private key.
X-TimestampUnix time in seconds. Rejected outside a ±30s window (TIMESTAMP_EXPIRED).
X-NonceUnique 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):

canonical string
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 buffer

Reference: signing a request in Node.js

sign.mjs
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'),
});
🔏
Sign the exact target you send, including the query string. Any rewrite by a proxy between you and Axys Card Program will change the path and fail verification.

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:

EndpointFilters
GET /accountsstatus (an account status) · search — an exact id (uuid) or a name/email substring (≥3 chars)
GET /cards/{id}/transactionsbefore / after — a Unix-seconds time window
GET /v1/webhooks/endpointsstatusactive · paused · auto_paused
GET /v1/webhooks/{id}/deliveriesstatus · 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.

ClassLimitApplies to
Read300 / minGET endpoints
Write60 / minCreate/update mutations
Admin30 / minWebhook endpoint management
Sensitive10 / minGET /cards/{id}/sensitive
Recovery10 / minSecret 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):

minor → major
major = Number(amount_minor) / 10 ** exponent      # USD: 149 / 10**2 = 1.49
CurrencyExponentMinor unitStatus
USD2centEnabled

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.

🌍
The merchant side is not USD-bound. On a transaction, 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:

FieldExpectsExample
residential_address.countryISO-3166 α-2 (2 letters)US · GB · AE · DE
identity_provenance.place_of_birthISO-3166 α-3 (3 letters)USA · GBR · ARE · DEU
identity_provenance.country_calling_codeISO-3166 α-2US · GB · AE
⚠️
Two of those three are α-2 and one (place_of_birth) is α-3 — confirm the form per field before you submit.
Countryα-2α-3
United StatesUSUSA
United KingdomGBGBR
United Arab EmiratesAEARE
GermanyDEDEU
FranceFRFRA
CanadaCACAN
SingaporeSGSGP

Full lookup table: iban.com/country-codes · standard: ISO 3166-1.

Phone numbers

Phone data appears two ways in the create-cardholder body:

FieldFormatExample
phone (top level)E.164, with the leading + (≤ 20 chars)+14155551234
identity_provenance.calling_codeIntl dialing codedigits only, no + (1–3 digits)1 · 44 · 971
identity_provenance.cell_numThe mobile number's local digits4155551234
identity_provenance.country_calling_code⚠️ ISO-3166 α-2 country codenot a dialing codeUS
📞
The mobile is the 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

FieldFormat
nationalityFree-text legal nationality as shown on the government ID (2–50 chars), e.g. American — not a code.
genderInteger enum — 0 male · 1 female · 2 unspecified.
date_of_birthISO-8601 date YYYY-MM-DD.
Timestamps (created_at, settled_at, occurred_at…)ISO-8601 date-time in UTC.
currency / currency_codeISO-4217 α-3 string (USD) — the string code, not an integer. Amounts are minor units.
stateUS → 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_codeUS → 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 idsAccount, 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.

Flow: create account (draft), submit KYC (pending, under review), approved or declined, issue card, activate.
Cardholder onboarding & card issuance lifecycle.
POST/accounts

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.

POST /accounts
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"
    }
  }'
200 · data
{
  "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

PUT/accounts/{accountId}/kyc-submit

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:

🔒
Review identity data before submission. Submission cannot be withdrawn. Once review begins, legal-name, birth, address, nationality, and identity fields lock; an attempted full-profile edit returns 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 statusMeaningWebhook
approvedKYC passed; you can issue cards.account.activated
compliance_declineRejected by compliance.account.declined
admin_declineRejected by an operator. Obtain Axys support clearance before retrying or correcting the profile.account.declined
errorKYC 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

POST/accounts/{accountId}/cards

Issues a virtual or physical card against an approved account (otherwise 409 ACCOUNT_NOT_APPROVED). Both types are born not_activated.

POST /accounts/{id}/cards
{ "card_type": "virtual", "name_on_card": "JORDAN REYES", "currency_code": "USD" }
PUT/cards/{cardId}/activate

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.

GET/cards/{cardId}/sensitive
200 · data
{
  "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

⚠️
Calling this endpoint brings full card data into your systems, which places them in PCI DSS scope. Only call it to reveal details to the cardholder — never for display you could satisfy with masked_pan / last4.
DoDon'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:

ClassExamplesHow this API exposes it
SensitiveFull PAN, CVV, PINOnly PAN/CVV, only via /sensitive. PIN is write-only.
Restrictedlast4, name, email, phoneReturned where relevant (masked PAN, account profile).
Internalstatus, limits, transactionsFreely 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.

Sequence: get a crypto deposit address; a pending deposit may clear, fail, or remain held for review; the bank rail is not yet available; read balances and completed transactions.
Deposits can clear, fail, or remain pending for review; the bank rail is in development.

Crypto rail

GET/cards/{cardId}/deposit-address

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.

200 · data
{ "deposit_addresses": [ { "chain": "ETH", "address": "0xAbc…" } ] }
⚠️
Addresses are specific to an address format; some compatible networks may share the same address. Send only a supported asset on a supported network and format. A wrong-chain transfer may be permanently unrecoverable; an unsupported asset may not be credited. Funding instructions are available only for active cards. Funds sent after a card becomes inactive may be held pending review.

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

ReasonMeaningRecommended action
expiredConfirmation did not arrive within the expected window.Check the sending network or bank, then contact Axys support with the deposit details above.
card_service_failureThe 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_rejectedThe deposit was definitively rejected.Correct the funding prerequisite before beginning a new deposit.
supersededA later observation became the surviving record.Use the surviving deposit id from subsequent events; do not count both records.
recovery_unacknowledgedAxys could not confirm acceptance during recovery.Verify the sender outcome and contact Axys support before trying again.

Bank rail In development

POST/accounts/{accountId}/register-bank-account

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.

Bank OTP / reply-to-message confirmation. When this rail is enabled, registration cannot complete through the API alone. The bank account must be in the cardholder's own name and pre-registered once; include the reference Axys supplies on each transfer. The cardholder receives a phone message and must reply exactly as instructed. Delivery may take around ten minutes; contact Axys support to reset a lost confirmation. Your server does not submit the OTP. Keep the funding state pending until a deposit.* webhook arrives.

Transactions & balances

GET/cards/{cardId}/transactions

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.

200 · data
{
  "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
}
💵
Amounts are integer minor units of their 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.

balanceWhat it is
availableSpendable right now.
ledgerCommitted balance when supplied; otherwise null.
pendingPending balance when supplied; otherwise null.
authorizationsAuthorized 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.

Sequence: register endpoint and receive secret once; signed delivery is deduplicated by event id; 4xx and signing-key failures dead-letter immediately while retryable failures back off; weighted failures may auto-pause.
Registration, duplicate-safe handling, failure classification, and weighted auto-pause.

1 · Register an endpoint

POST/v1/webhooks/endpoints
request
{
  "url": "https://hooks.your-app.example/axys",
  "enabled_events": ["account.activated", "deposit.cleared", "card.blocked"]
}
🔐
The response returns the signing secret (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 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.

verify-webhook.mjs
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)
      );
    }),
  );
}
The verifier preserves each ordered (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 outcomeDelivery behaviorAuto-pause score
2xxSucceeded; no retry.Reset to 0
Any 4xxImmediately dead-lettered; no automatic retry.+2
5xx or other non-2xx HTTP failureRetried with backoff, then dead-lettered after the attempt limit.+1
Connection refused or other transport failureRetried with backoff, then dead-lettered after the attempt limit.+1
Timeout, DNS, or TLS failureRetried with backoff, then dead-lettered after the attempt limit.+0
Signing-key decryption failureImmediately 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).

envelope · example: deposit.cleared
{
  "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"
  }
}
🐫
Envelope keys are 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.

Eventpayload (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.activatedstatus approved
account.declined+ verdict (compliance_decline|admin_decline), reason (compliance_decline|administrative_decline)
account.resubmittedstatus draft
account.suspended · reinstated · offboardedstatus 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).

Eventpayload
deposit.pendingdepositPublicId, cardPublicId, channel, initiatedAmount, currencyCode, status (may also carry clearedAmount: null)
deposit.cleared… + clearedAmount, feeAmount (nullable), feeCategory ("deposit"|null)
deposit.faileddepositPublicId, cardPublicId, channel, initiatedAmount, currencyCode, reason (expired · card_service_failure · card_service_rejected · superseded · recovery_unacknowledged), status

card events

Eventpayload
card.blockedcardPublicId, accountPublicId, status on_hold, reason (lost · stolen · customer_request · fraud_review · compliance · other)
card.unblockedcardPublicId, 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.

error envelope
{
  "success": false,
  "error": { "code": "VALIDATION_ERROR", "message": "…", "details": { "errors": ["…"] } },
  "requestId": "…", "timestamp": "2026-07-08T…"
}
HTTPRepresentative codes
400VALIDATION_ERROR · IDEMPOTENCY_KEY_REQUIRED · UNSUPPORTED_CURRENCY · UNSUPPORTED_EVENT_TYPE · CURRENCY_MISMATCH · UNKNOWN_CHART_ACCOUNT
401SIGNATURE_MISSING · SIGNATURE_INVALID · TIMESTAMP_EXPIRED · NONCE_REPLAYED
403CONSUMER_NOT_ALLOWED
404RESOURCE_NOT_FOUND
409INVALID_STATE_TRANSITION · ACCOUNT_NOT_APPROVED · CARD_NOT_ACTIVE · IDEMPOTENCY_KEY_REUSED_WITH_DIFFERENT_PAYLOAD · REQUEST_IN_PROGRESS
429RATE_LIMIT_EXCEEDED (observe Retry-After)
501FEATURE_NOT_AVAILABLE
502CARD_SERVICE_UNAVAILABLE · CARD_SERVICE_REJECTED
503SERVICE_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.