The Block — External Gateway API
The External Gateway is Axys Core's public, partner-facing API for card issuing as a service. Create cardholders, run KYC, issue cards, fund them over crypto and bank rails, read transactions, and receive lifecycle events over signed webhooks — all through one surface.
Overview
A single card balance can be funded and spent across bank and blockchain rails without you building separate systems for each. Axys Core owns the provider integration, the accounting, and event delivery; you integrate one API.
| Capability | Status | Endpoints |
|---|---|---|
| Cardholder onboarding & KYC | Beta | POST /accounts · PUT /accounts/{id}/kyc-submit |
| Card issuance & lifecycle | Beta | POST /accounts/{id}/cards · activate · block/unblock · PIN |
| Crypto funding | Beta | GET /cards/{id}/deposit-address |
| Bank funding | Beta | POST /accounts/{id}/register-bank-account |
| Transactions & balances | Beta | GET /cards/{id}/transactions · GET /cards/{id} |
| Webhooks | Preview | POST /v1/webhooks/endpoints + delivery management |
| 3-D Secure (CNP OTP) | In development | Not yet exposed on this API |
Status is indicative — confirm with your Axys contact before depending on a capability in production.
API status
Every capability and endpoint carries a maturity badge, so you always know what to build on:
| Badge | What it means |
|---|---|
| Live | Generally available. Stable contract, production-ready, supported. |
| Beta | Available and usable; the contract may still change. Fine to integrate — track the changelog. |
| Preview | Early access. May change or be withdrawn without notice — don't depend on it in production. |
| In development | Not yet callable. Documented so you can design ahead. |
| Deprecated | Still works but scheduled for removal. Migrate to the named replacement. |
Getting started
Onboarding is a short checklist. Steps 1–3 are handled with your Axys contact; step 4 is your first call.
| # | Step | Result |
|---|---|---|
| 1 | Generate an RSA key pair + CSR; submit the CSR. | You receive a signed mTLS client certificate. |
| 2 | Share the source IPs you'll call from. | Your egress IPs are allowlisted at the edge. |
| 3 | Receive your gateway host and your consumer identity. | Your base URL (per-consumer stack). |
| 4 | Confirm connectivity with a signed read. | A 200 with an empty list on a fresh stack. |
curl --cert client-cert.pem --key client-key.pem \
-H "X-Signature: $SIG" -H "X-Timestamp: $TS" -H "X-Nonce: $NONCE" \
https://theblock.staging.core.api.axysbank.io/accounts200 means your certificate, IP allowlist, and request
signature are all correct. A TLS error or 401 → re-check Authentication.Base URLs & environments
Each consumer is issued its own gateway host at onboarding — there is no shared multi-tenant host, and
your credentials scope every request to your own stack. Routes are host-root relative (there is no global
path prefix; only webhook routes carry the /v1 segment).
| Environment | Base URL | Purpose |
|---|---|---|
| Staging | https://theblock.staging.core.api.axysbank.io | Integration testing. |
| Production | https://gateway.<you>.axyscore.example | Live cardholders, cards, and funds. |
Exact hostnames, your IP allowlist, and certificates are provided during onboarding.
Authentication
Every request is authenticated in three layers. The first two are enforced at the network edge; the third is enforced in-application and is what your client must implement on every call.
| 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 every request:
| Header | Value |
|---|---|
X-Signature | Base64 RSA-SHA256 signature of the canonical string, signed with your private key. |
X-Timestamp | Unix time in seconds. Rejected outside a ±30s window (TIMESTAMP_EXPIRED). |
X-Nonce | Unique 16–128 char token per request. A replay is rejected (NONCE_REPLAYED). |
The canonical string
Concatenate exactly these five fields, in order, joined by a single newline (\n):
METHOD
pathWithQuery # exact target incl. query, e.g. /cards/abc/transactions?limit=10
timestamp # the exact X-Timestamp value
nonce # the exact X-Nonce value
sha256(rawBody) in hex # lowercase; empty body → sha256 of the empty bufferReference: signing a request in Node.js
import { createHash, createSign, randomBytes } from 'node:crypto';
import https from 'node:https';
import fs from 'node:fs';
function signedHeaders(method, pathWithQuery, body, privateKeyPem) {
const ts = String(Math.floor(Date.now() / 1000));
const nonce = randomBytes(18).toString('base64url'); // 16–128 chars
const raw = body ? Buffer.from(JSON.stringify(body)) : Buffer.alloc(0);
const bodyHash = createHash('sha256').update(raw).digest('hex');
const canonical = [method.toUpperCase(), pathWithQuery, ts, nonce, bodyHash].join('\n');
const sig = createSign('RSA-SHA256').update(canonical).sign(privateKeyPem, 'base64');
return { 'X-Signature': sig, 'X-Timestamp': ts, 'X-Nonce': nonce };
}
// mTLS: present your client cert + key on the TLS agent.
const agent = new https.Agent({
cert: fs.readFileSync('client-cert.pem'),
key: fs.readFileSync('client-key.pem'),
});Conventions
Pagination
List endpoints are cursor-paginated. Pass limit and, to continue, the
cursor from the previous response. A response carries next_cursor (or
has_more); a null next_cursor means you've reached the last page. Cursors are
opaque — echo them back verbatim, don't construct them.
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 — amounts and balances — is an integer string counting the minor units of its
currency_code. Fields that carry minor units end in _minor. 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 |
EUR · GBP · CAD | 2 | cent / penny | Planned |
BTC | 8 | satoshi | Planned |
ETH | 18 | wei | Planned |
Today USD (exponent 2) is the only enabled currency, so you divide by 100 in practice
right now — but key your integration off the currency's exponent, not a hard-coded 100, and it stays correct
as currencies are added. An unsupported currency_code returns 400 UNSUPPORTED_CURRENCY;
for large exponents (ETH = 18) use a big-decimal/BigInt type — the value overflows a JS number.
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.
Creates the account in draft and emits account.created. All fields the KYC
pipeline needs are required up-front, so you see the contract here rather than discovering it through 400s.
curl --cert client-cert.pem --key client-key.pem \
-X POST https://theblock.staging.core.api.axysbank.io/accounts \
-H "X-Signature: $SIG" -H "X-Timestamp: $TS" -H "X-Nonce: $NONCE" \
-H "Idempotency-Key: $(uuidgen)" -H "Content-Type: application/json" \
-d '{
"first_name": "Jordan", "last_name": "Reyes",
"email": "jordan@example.com", "phone": "+14155551234",
"date_of_birth": "1990-05-14",
"residential_address": {
"adr_line1": "123 Market St", "city": "San Francisco",
"state": "CA", "country": "US", "zip_code": "94105"
},
"identity_provenance": {
"nationality": "American", "place_of_birth": "USA", "gender": 0,
"calling_code": "1", "country_calling_code": "US", "cell_num": "4155551234"
}
}'{
"id": "a3f1…",
"status": "draft",
"kyc_url": null,
"created": true
}gender 0 = male, 1 = female, 2 = unspecified ·
place_of_birth ISO-3166 α-3 ·
country ISO-3166 α-2
KYC
Submits the account to compliance. No request body. The account moves
draft → pending → under_review, and the response returns a single-use kyc_url —
the provider-hosted verification link where the cardholder uploads documents. Emits
account.kyc_submitted.
kyc_url is ephemeral and return-only.
It is never persisted or replayed on webhooks. Hand it to the cardholder promptly; call kyc-submit again to
mint a fresh one if it lapses.KYC state is not a separate field — it is the account status. The terminal outcomes you
observe (and their webhooks) are:
| 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. | account.declined |
Full status set: draft · pending · under_review · approved · compliance_decline · admin_decline · suspended · offboarded
Issue & activate cards
Issues a virtual or physical card against an approved account (otherwise
409 ACCOUNT_NOT_APPROVED). Both types are born not_activated.
{ "card_type": "virtual", "name_on_card": "JORDAN REYES", "currency_code": "USD" }Activates the card. Send activation_request_id (a non-secret idempotency discriminator),
the full pan, expiry_month/expiry_year, cvv, and a 4-digit
pin. PAN, CVV and PIN are used transiently and never persisted by Axys.
Other lifecycle calls: PUT /cards/{id}/status to block (on_hold, a
reason is required) or unblock (active); PUT /cards/{id}/pin to change
the PIN; and GET /cards/{id}/sensitive to reveal full card details — see
Sensitive details & PCI.
Sensitive card details & PCI
Everywhere else in this API, card numbers are masked — you receive masked_pan and
provider.last4. One endpoint returns the full Primary Account Number (PAN) and CVV, for
when a cardholder needs to see their own card.
{
"id": "c1a2…", "pan": "4242424242424242", "masked_pan": "************4242",
"cvv": "123", "expiry_month": 8, "expiry_year": 2029
}What Axys does
- Never stores it. Axys does not persist PAN, CVV, or PIN. This endpoint fetches the details live from the issuer per call and passes them through — 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. - Runs on PCI-DSS infrastructure. TLS 1.2+ in transit, KMS-encrypted at rest.
Your responsibilities
masked_pan / last4.| Do | Don't |
|---|---|
| Fetch on demand, only while the cardholder is viewing their card. | Log, cache, or store the PAN/CVV anywhere. |
| Render client-side and discard right after display. | Put it in analytics, screenshots, error reports, or backups. |
| Serve over TLS; isolate the code path that touches it. | Forward it to any third party outside your PCI boundary. |
How the platform classifies the fields you'll handle:
| Class | Examples | How this API exposes it |
|---|---|---|
| Sensitive | Full PAN, CVV, PIN | Only PAN/CVV, only via /sensitive. PIN is write-only. |
| Restricted | last4, name, email, phone | Returned where relevant (masked PAN, account profile). |
| Internal | status, limits, transactions | Freely readable. |
Funding & money
A card is funded over two rails. In both cases you set up the destination through the API, but the
deposit itself arrives asynchronously as a deposit.* webhook — there is no
create-deposit or list-deposits endpoint.
deposit.* webhooks.Crypto rail
Returns the card's blockchain deposit addresses (auto-provisioned at issuance). Share an address with the
payer; when funds arrive you receive deposit.pending then deposit.cleared.
{ "deposit_addresses": [ { "chain": "ETH", "address": "0xAbc…" } ] }Bank rail
Registers an external bank account for fiat deposits (bank_name, account_number,
optional routing_number/iban, 3-letter currency). Returns
{ "registered": true }. Subsequent wires surface as deposit.* webhooks.
Transactions & balances
Cursor-paginated spend history. Query with limit (1–50), before/after
(Unix seconds), and cursor. A non-null next_cursor means there is another page.
{
"card_id": "c1a2…",
"items": [{
"id": "txn_…", "amount_minor": "149", "currency_code": "USD",
"status": "settled", "trans_type": 1,
"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.Transaction lifecycle
A card spend moves through provider-reported states. status reflects where a transaction
is in that lifecycle; settled_at is set once it finalizes.
| status | Meaning |
|---|---|
authorized | Approved and held against the balance; not yet cleared. |
clearing · partially_captured | Capture in progress (possibly partial). |
settled | Finalized — funds have moved. settled_at is set. |
reversed · refunded | An earlier authorization/settlement was undone or returned. |
declined · expired · error | Not approved, timed out, or a processing error. |
How balances work
Balances come from GET /cards/{id}: the card summary plus
internal_balance and a provider_balance_snapshot, with
balance_display_source naming which is authoritative for display.
| internal_balance | What it is |
|---|---|
available | Spendable right now. |
pending | In-flight deposits not yet cleared. |
authorizations | Held by open card authorizations. |
trans_type is a provider display hint (e.g. authorization / refund / reversal) — Axys passes
it through and does not itself derive debit vs credit.
Webhooks Preview
Register HTTPS endpoints to receive lifecycle events. Deliveries are HMAC-signed, retried with backoff, and auto-paused if your endpoint stays unhealthy. Your receiver URL is independent of your mTLS cert — secure it however your architecture prefers.
1 · Register an endpoint
{
"url": "https://hooks.your-app.example/axys",
"enabled_events": ["account.activated", "deposit.cleared", "card.blocked"]
}whsec_…) exactly once. Store it immediately; if you lose it, rotate with
POST …/rotate-secret.Discover subscribable types at GET /v1/webhooks/event-catalogue. No wildcards — unknown
types are rejected at registration.
2 · Receive & verify
Each delivery is a POST with an Axys-Signature header:
Axys-Signature: t=<unix>,k=<key-hint>,v1=<hex_hmac>The signature is HMAC-SHA256 over
<t>.<endpoint_public_id>.<event_id>.<raw_body>. The endpoint id and event
id are mixed in so a captured body cannot be replayed to another endpoint or event. During secret rotation
two (k,v1) pairs are sent — accept either.
import { createHmac, timingSafeEqual } from 'node:crypto';
// endpointId + eventId come from the event body; rawBody is the exact bytes received.
function verify(header, endpointId, eventId, rawBody, secret, toleranceSec = 300) {
const parts = Object.fromEntries(header.split(',').map(p => p.trim().split('=')));
if (Math.abs(Date.now()/1000 - Number(parts.t)) > toleranceSec) return false;
const signed = `${parts.t}.${endpointId}.${eventId}.${rawBody}`;
const expected = createHmac('sha256', secret).update(signed).digest('hex');
const a = Buffer.from(expected), b = Buffer.from(parts.v1 ?? '');
return a.length === b.length && timingSafeEqual(a, b);
}3 · Delivery, retries & auto-pause
Failed deliveries are retried with backoff. Consecutive failures up to your
auto_pause_threshold move the endpoint to auto_paused; recover with
POST …/resume (optionally replaying dead-lettered events). Inspect history at
GET …/deliveries, and retry / ack individual events.
Event catalogue
| Aggregate | Events |
|---|---|
| account | created · kyc_submitted · activated · declined · resubmitted · suspended · reinstated · offboarded |
| deposit | pending · cleared · failed |
| card | blocked · unblocked |
{
"event_id": "…", "event_type": "deposit.cleared", "aggregate_type": "deposit",
"aggregate_public_id": "…", "correlation_id": "…", "causation_id": null,
"occurred_at": "2026-07-08T…", "payload": { }
}Errors & idempotency
Every error shares one envelope. code is a stable value you can branch on; it never changes
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 |
| 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 |
| 429 | RATE_LIMIT_EXCEEDED (observe Retry-After) |
| 502 | PROVIDER_UNAVAILABLE · PROVIDER_REJECTED |
Idempotency
Every mutating call requires an Idempotency-Key header. Re-sending the same key with an
identical payload replays the original result; re-using it with a different payload returns
409 IDEMPOTENCY_KEY_REUSED_WITH_DIFFERENT_PAYLOAD. Use a fresh UUID per logical operation and
reuse it on retries.