AThe Block External Gateway
v1.0.0

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.

📘
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 Core owns the provider integration, 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 fundingBetaPOST /accounts/{id}/register-bank-account
Transactions & balancesBetaGET /cards/{id}/transactions · GET /cards/{id}
WebhooksPreviewPOST /v1/webhooks/endpoints + delivery management
3-D Secure (CNP OTP)In developmentNot 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:

BadgeWhat it means
LiveGenerally available. Stable contract, production-ready, supported.
BetaAvailable and usable; the contract may still change. Fine to integrate — track the changelog.
PreviewEarly access. May change or be withdrawn without notice — don't depend on it in production.
In developmentNot 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.

Getting started

Onboarding is a short checklist. Steps 1–3 are handled with your Axys contact; step 4 is your first call.

#StepResult
1Generate an RSA key pair + CSR; submit the CSR.You receive a signed mTLS client certificate.
2Share the source IPs you'll call from.Your egress IPs are allowlisted at the edge.
3Receive your gateway host and your consumer identity.Your base URL (per-consumer stack).
4Confirm connectivity with a signed read.A 200 with an empty list on a fresh stack.
step 4 · confirm connectivity
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/accounts
👍
A 200 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).

EnvironmentBase URLPurpose
Staginghttps://theblock.staging.core.api.axysbank.ioIntegration testing.
Productionhttps://gateway.<you>.axyscore.exampleLive 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.

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
Card providerExternal GatewayEdge (IP allowlist + mTLS)Your serverCard providerExternal GatewayEdge (IP allowlist + mTLS)Your serverBuild canonical stringMETHOD · path?query · ts · nonce · sha256(body)1Sign with RSA private key → X-Signature2HTTPS + client certX-Signature, X-Timestamp, X-Nonce3Verify source IP + client certificate4Forward (mTLS terminated)5Verify signature, timestamp skew, nonce replay6Provider call (if needed)7Provider result8{ success, data, requestId, timestamp }9
Anatomy of an authenticated request.

The signature headers

Send these three headers on every 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 the gateway 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.

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 — 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):

minor → major
major = Number(amount_minor) / 10 ** exponent      # USD: 149 / 10**2 = 1.49
CurrencyExponentMinor unitStatus
USD2centEnabled
EUR · GBP · CAD2cent / pennyPlanned
BTC8satoshiPlanned
ETH18weiPlanned

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.

approved

rejected

fix & resubmit

POST /accounts
cardholder created

status = draft

PUT /accounts/{id}/kyc-submit
(no body)

status = pending
→ under_review

status = approved
webhook: account.activated

status = compliance_decline
webhook: account.declined

POST /accounts/{id}/cards
issue virtual / physical

card status = not_activated

PUT /cards/{id}/activate
→ status = active

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://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"
    }
  }'
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. 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 statusMeaningWebhook
approvedKYC passed; you can issue cards.account.activated
compliance_declineRejected by compliance.account.declined
admin_declineRejected by an operator.account.declined

Full status set: draft · pending · under_review · approved · compliance_decline · admin_decline · 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.

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.

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

⚠️
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.

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.

Your webhook endpointExternal GatewayYour serverCardholder / payerYour webhook endpointExternal GatewayYour serverCardholder / payerCrypto railBank railRead stateGET /cards/{id}/deposit-address1{ deposit_addresses: [{chain, address}] }2Send funds to the address3webhook deposit.pending4webhook deposit.cleared5POST /accounts/{id}/register-bank-account6{ registered: true }7Wire funds from that bank account8webhook deposit.pending → deposit.cleared9GET /cards/{id} (balances)10GET /cards/{id}/transactions11
Crypto and bank funding both settle via deposit.* webhooks.

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 receive deposit.pending then deposit.cleared.

200 · data
{ "deposit_addresses": [ { "chain": "ETH", "address": "0xAbc…" } ] }

Bank rail

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

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

GET/cards/{cardId}/transactions

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.

200 · data
{
  "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
}
💵
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.

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.

statusMeaning
authorizedApproved and held against the balance; not yet cleared.
clearing · partially_capturedCapture in progress (possibly partial).
settledFinalized — funds have moved. settled_at is set.
reversed · refundedAn earlier authorization/settlement was undone or returned.
declined · expired · errorNot 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_balanceWhat it is
availableSpendable right now.
pendingIn-flight deposits not yet cleared.
authorizationsHeld 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.

🔒
3-D Secure / card-not-present OTP In development — not exposed on the External Gateway today. If your program needs CNP OTP, raise it during onboarding; it's tracked separately from this API surface.

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.

Your webhook endpointExternal GatewayYour serverYour webhook endpointExternal GatewayYour serverStore the secretconsecutive failures ≥ threshold → auto_pausedalt[endpoint returns non-2xx / times out]POST /v1/webhooks/endpoints{ url, enabled_events }1endpoint id + secret (whsec_…) shown once2POST eventAxys-Signature: t=…,k=…,v1=…3Recompute HMAC-SHA256, compare (≤5 min skew)42xx (accepted)5retry with backoff6POST …/resume (optionally replay dead-letters)7
Registration, signed delivery, retry/backoff, and 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.

verify.mjs
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);
}
Respond 2xx to accept. Non-2xx, timeouts (5s), DNS or TLS failures count as failed deliveries.

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

AggregateEvents
accountcreated · kyc_submitted · activated · declined · resubmitted · suspended · reinstated · offboarded
depositpending · cleared · failed
cardblocked · unblocked
event envelope
{
  "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.

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
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
429RATE_LIMIT_EXCEEDED (observe Retry-After)
502PROVIDER_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.