Skip to content

Merchant API · v1

Everything needed to make the first call, and nothing you have to guess.

Fourteen operations over one signing scheme. Below: the canonical string with a worked example you can reproduce, the four client implementations of it, every request field and response field, every error code, and the webhook contract.

Start with the quickstart Download the OpenAPI 3.1 spec

Base path
https://business.scarabpay.com/api/v1

# every request carries these four:
X-Api-Key:    the key id (26-char ULID)
X-Timestamp:  unix seconds, ±300s
X-Nonce:      single-use per key
X-Signature:  base64 HMAC-SHA256

# and on issue / recharge / terminate:
Idempotency-Key: yours, max 255 chars
Money
integer minor units
Identifiers
26-char ULIDs
Lists
cursor, 1–100 per page
Content type
application/json

Quickstart

Five steps. The first two happen once, in the portal; the last three are the API. Every example below uses the same key id, secret, timestamp and nonce, so the signatures shown are the signatures those inputs actually produce — you can reproduce them before you own a key, and check your signer against them.

  1. Step 1

    Create an API key in the portal

    /portal/api-keys → create. Creating a key requires a fresh two-factor code, and the secret is shown exactly once — it is stored encrypted and cannot be displayed again. If you lose it, roll the secret rather than opening a support ticket.

    Three things are worth setting at creation: test mode (see Test mode), the scopes the key may use, and an optional IP allowlist. A key created with no scopes has full access; a key with scopes must hold the one its route declares.

    You receive Shape Notes
    key id 26-char ULID Public. Sent as X-Api-Key and visible in the portal forever.
    secret 44-char base64 32 random bytes. Shown once. Signs every request; never send it in one.
  2. Step 2

    Get the product id you are issuing against

    A card is issued against a product — the currency, the fees and the minimum initial load. Products are enabled for your account by us, and there is no endpoint that lists them: ask for the ULIDs of the products enabled for you, or read them off the card-creation form in the portal. This is a known gap in the programmatic surface, not an omission from this page.

  3. Step 3

    Register the cardholder

    Cards are issued to a customer in your registry, not to a bag of fields on the issue call. Register the person once with POST /customers, keep the returned id, and issue as many cards to them as you need.

  4. Step 4

    Issue the card — the complete signed request

    This script is the whole contract in one place: hash the body, build the canonical string, sign it, send it. It runs as written (bash + openssl + curl); the only things to change are the key id, the secret and the two ids.

    issue-card.sh
    #!/usr/bin/env bash
    set -euo pipefail
    
    BASE="https://business.scarabpay.com/api/v1"
    KEY_ID="01KA7Y2M6Q4RXHB9WD3F5NTC8V"
    SECRET="qZ9m4TbN1sVfK7hLpR2dXwE5yA8cJ3uG6oI0nB4tS1k="
    
    METHOD="POST"
    # PATH is the FULL request path, including /api/v1 — this is the
    # single most common signing mistake.
    REQ_PATH="/api/v1/cards"
    QUERY=""
    BODY='{"product_id":"01KA7XQ8V3ZP5M2CJ9B6TDNF4H","customer_id":"01KA7XR4W9YT6N3DK8C2QFMH5J","amount_minor":2500}'
    
    TS=$(date -u +%s)
    NONCE=$(uuidgen | tr '[:upper:]' '[:lower:]')
    
    BODY_HASH=$(printf '%s' "$BODY" | openssl dgst -sha256 -hex | awk '{print $NF}')
    
    CANONICAL=$(printf '%s\n%s\n%s\n%s\n%s\n%s\n%s' \
      "$METHOD" "$REQ_PATH" "$QUERY" "$KEY_ID" "$TS" "$NONCE" "$BODY_HASH")
    
    SIGNATURE=$(printf '%s' "$CANONICAL" \
      | openssl dgst -sha256 -hmac "$SECRET" -binary \
      | openssl base64 -A)
    
    curl -sS -X "$METHOD" "$BASE/cards" \
      -H "Content-Type: application/json" \
      -H "X-Api-Key: $KEY_ID" \
      -H "X-Timestamp: $TS" \
      -H "X-Nonce: $NONCE" \
      -H "X-Signature: $SIGNATURE" \
      -H "Idempotency-Key: $NONCE" \
      --data-raw "$BODY"

    Reusing the nonce as the idempotency key, as above, is a reasonable default for a one-shot script: both must be unique per attempt. In a real client they differ — the nonce changes on every retry, the idempotency key stays the same across retries of the same intent. That difference is the whole point of idempotency.

  5. Step 5

    Handle 201 and 202 differently

    201 means the card exists and is active. 202 means the issuer is provisioning it: the card is pending, and it becomes active later. Poll GET /cards/{card}, or register a webhook and wait to be told. A client that treats 202 as a failure will double-issue.

Authentication

There is no bearer token and no OAuth redirect. Every request is signed with your key's secret, stamped with a timestamp, and spent with a single-use nonce. A leaked bearer token is a standing licence to move money; a signed request is one request.

Header Value Rejected when
X-Api-Key string The key id — a 26-character ULID, not the secret. Unknown, revoked or expired → 401 invalid_api_key.
X-Timestamp integer Unix seconds, UTC. More than 300 seconds away from server time (either direction) → 401 timestamp_out_of_window. Sync your clock.
X-Nonce string Unique per key per request; a UUID v4 is ideal. Remembered for 600 seconds. A repeat → 409 replay_detected.
X-Signature string Base64 of the HMAC-SHA256 below, compared in constant time. Mismatch → 401 invalid_signature.

The canonical string

Seven lines, joined with a single \n, with no trailing newline. The body is hashed rather than included, which keeps the string small and avoids binary ambiguity.

Structure
1  METHOD              uppercase: POST
2  PATH                full path, no query, no trailing slash
3  QUERY_STRING        sorted by key; empty string if none
4  X-Api-Key           same value as the header
5  X-Timestamp         same value as the header
6  X-Nonce             same value as the header
7  hex(sha256(body))   lowercase hex; sha256("") if no body
Rules that bite
# PATH includes the API prefix.
/api/v1/cards          ✓
/cards                 ✗  the usual first bug

# QUERY_STRING is rebuilt, not copied.
# Sort by key, then urlencode each side:
?per_page=50&cursor=abc
  → cursor=abc&per_page=50

# Repeated params are key[]=v, values sorted:
?status=b&status=a
  → status[]=a&status[]=b

# Spaces encode as + (not %20).
# Empty body hashes the empty string:
e3b0c44298fc1c149afbf4c8996fb924
27ae41e4649b934ca495991b7852b855

A worked example you can reproduce

Feed these exact inputs to your signer. If you get the same signature, your implementation is correct; if you do not, the canonical string is where to look. (The timestamp is long past, so the request itself would be refused — the point is the arithmetic.)

Inputs
secret       qZ9m4TbN1sVfK7hLpR2dXwE5yA8cJ3uG6oI0nB4tS1k=
method       POST
path         /api/v1/cards
query        (none)
X-Api-Key    01KA7Y2M6Q4RXHB9WD3F5NTC8V
X-Timestamp  1787000400
X-Nonce      f47ac10b-58cc-4372-a567-0e02b2c3d479
body         {"product_id":"01KA7XQ8V3ZP5M2CJ9B6TDNF4H","customer_id":"01KA7XR4W9YT6N3DK8C2QFMH5J","amount_minor":2500}
Canonical string (⏎ marks each \n)
POST⏎
/api/v1/cards⏎
⏎
01KA7Y2M6Q4RXHB9WD3F5NTC8V⏎
1787000400⏎
f47ac10b-58cc-4372-a567-0e02b2c3d479⏎
97dda525220128a1b9a3e8bbf0e3000b6aab4751cd86df8b427b652729e54062
Result
hex(sha256(body))
97dda525220128a1b9a3e8bbf0e3000b6aab4751cd86df8b427b652729e54062

X-Signature
f2HPicoyBZ26PG1LT6B97MDk/JdbfmUVcUyqDf5vNGg=

The third line is empty because there is no query string. It is still a line — six newlines separate seven fields, always.

The signer, in four languages

Each of these produces f2HPicoyBZ26PG1LT6B97MDk/JdbfmUVcUyqDf5vNGg= for the inputs above. Copy one and move on.

PHP
php
function canonicalQuery(array $params): string
{
    if ($params === []) { return ''; }

    ksort($params);
    $parts = [];

    foreach ($params as $key => $value) {
        if (is_array($value)) {
            sort($value);
            foreach ($value as $v) {
                $parts[] = urlencode((string) $key).'[]='.urlencode((string) $v);
            }
        } else {
            $parts[] = urlencode((string) $key).'='.urlencode((string) $value);
        }
    }

    return implode('&', $parts);
}

function signature(
    string $keyId, string $secret,
    string $method, string $path, array $query, string $body,
    int $timestamp, string $nonce,
): string {
    $canonical = implode("\n", [
        strtoupper($method),
        $path,
        canonicalQuery($query),
        $keyId,
        (string) $timestamp,
        $nonce,
        hash('sha256', $body),
    ]);

    return base64_encode(hash_hmac('sha256', $canonical, $secret, true));
}
Node (ESM, no dependencies)
node
import { createHash, createHmac, randomUUID } from 'node:crypto';

function canonicalQuery(params) {
  const parts = [];

  for (const key of Object.keys(params).sort()) {
    const value = params[key];

    if (Array.isArray(value)) {
      for (const v of [...value].sort()) {
        parts.push(`${encodeURIComponent(key)}[]=${encodeURIComponent(v)}`);
      }
    } else {
      parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(value)}`);
    }
  }

  return parts.join('&');
}

function signature({ keyId, secret, method, path, query = {}, body = '', timestamp, nonce }) {
  const bodyHash = createHash('sha256').update(body, 'utf8').digest('hex');

  const canonical = [
    method.toUpperCase(), path, canonicalQuery(query),
    keyId, String(timestamp), nonce, bodyHash,
  ].join('\n');

  return createHmac('sha256', secret).update(canonical, 'utf8').digest('base64');
}

// encodeURIComponent renders a space as %20; the server rebuilds it as +.
// No documented parameter contains a space, but if you ever send one,
// replace %20 with + before signing.
Python 3
python
import base64, hashlib, hmac
from urllib.parse import quote_plus


def canonical_query(params):
    parts = []

    for key in sorted(params):
        value = params[key]

        if isinstance(value, (list, tuple)):
            for v in sorted(value):
                parts.append(f"{quote_plus(str(key))}[]={quote_plus(str(v))}")
        else:
            parts.append(f"{quote_plus(str(key))}={quote_plus(str(value))}")

    return "&".join(parts)


def signature(key_id, secret, method, path, timestamp, nonce, query=None, body=""):
    canonical = "\n".join([
        method.upper(),
        path,
        canonical_query(query or {}),
        key_id,
        str(timestamp),
        nonce,
        hashlib.sha256(body.encode()).hexdigest(),
    ])

    mac = hmac.new(secret.encode(), canonical.encode(), hashlib.sha256).digest()

    return base64.b64encode(mac).decode()
bash + openssl
bash
# Signs a request with no query string. See the quickstart for
# the full curl call around it.
sign() {
  local method="$1" req_path="$2" query="$3" body="$4"
  local key_id="$5" secret="$6" ts="$7" nonce="$8"

  local body_hash
  body_hash=$(printf '%s' "$body" | openssl dgst -sha256 -hex | awk '{print $NF}')

  printf '%s\n%s\n%s\n%s\n%s\n%s\n%s' \
    "$method" "$req_path" "$query" "$key_id" "$ts" "$nonce" "$body_hash" \
    | openssl dgst -sha256 -hmac "$secret" -binary \
    | openssl base64 -A
}

Scopes

Each route requires at most one scope. A key created without scopes has full access; a key with scopes must hold the exact one its route declares, or the request is refused with 403 out_of_scope — after the signature has been verified, so a caller without your secret still only ever sees a 401.

Scope Grants Notes
customers.read GET List and fetch registry customers.
customers.write POST · PATCH Register and edit customers.
cards.read GET Fetch a card and list its transactions.
cards.write POST Issue, recharge, freeze, unfreeze, terminate.
deposits.read GET List deposit addresses and received deposits.
deposits.write POST Provision a deposit address.

Read and write are separate on purpose: a reporting integration that only reconciles should hold cards.read and nothing else, so a leak of that key cannot issue a card.

IP allowlists, rotation, revocation

IP allowlist
Single addresses or CIDR ranges, v4 or v6. Off-list → 403 ip_not_allowed
Roll the secret
Same key id, new secret. The old one keeps verifying for 24h
Rotate the key
New key id and secret; scopes and allowlist are inherited
Revoke
Immediate. Every later request → 401 invalid_api_key

Rolling beats rotating. A roll keeps the key id and overlaps the two secrets for a day, so you can deploy the new secret without a flag day. Rotation mints a new key id, which means every caller has to change two values at once.

Conventions

These hold everywhere, so the operation sections do not repeat them.

Convention Rule Why it is worth knowing
money integer minor units Never a float, never a decimal string. 2500 is $25.00. A field carrying money says so: amount_minor, balance_minor.
identifiers 26-char ULID Every id you send or receive. Internal database ids and the issuing partner's own references never leave the platform.
timestamps ISO 8601 Always with an offset, e.g. 2026-08-17T21:00:00+00:00. May be null where the event has not happened yet.
content type application/json Send it on every request with a body. The signature covers the raw bytes, so serialise once and sign what you send.
versioning in the path /api/v1. A breaking change arrives as /api/v2; new optional fields can appear in v1 responses, so parse tolerantly.
unknown fields ignored Extra request keys are dropped by validation rather than rejected — but they are still signed, so they must be in the body you hash.

Envelopes

Three shapes, and only three. success is present on every response, including errors — branch on it, not on the status code alone.

Success
// one resource
{
  "success": true,
  "data": { … }
}

// a page of resources
{
  "success": true,
  "data": [ … ],
  "meta": { … }
}
Error
{
  "success": false,
  "message": "Insufficient wallet balance.",
  "errors": {
    "balance": ["insufficient_funds"]
  }
}

// errors is {} when there is no
// field-level detail to give.
Processing (202)
{
  "success": true,
  "message": "Card issue is processing.",
  "data": {
    "status": "pending"
  }
}

// success-shaped, not an error.
// See Idempotency.

Program against errors, not message. The codes inside it (insufficient_funds, invalid_state) are a stable contract; the human-readable message is not, and may be reworded.

Pagination

Cursor, not offset. Your tables grow while you are walking them, and an offset page shifts under concurrent inserts — so an offset client silently skips rows. A cursor keys off an ordered value, so a page is stable no matter what is inserted behind it.

Query parameters
?per_page=50        # default 20, clamped to 1–100
&cursor="…"        # opaque; from a previous response

# Both are part of QUERY_STRING, so they
# are signed. Sorted: cursor before per_page.
meta
{
  "per_page":        50,
  "has_more":        true,
  "next_cursor":     "eyJpZCI6MjB9",
  "previous_cursor": null
}

Walk forward until has_more is false. Cursors are opaque: pass them back verbatim and never parse or construct one. Every list is newest-first. GET /deposit-addresses is the one list with no meta at all — it is bounded to one address per network, so there is nothing to page.

Idempotency

Three operations move money and therefore require an Idempotency-Key header: issue, recharge and terminate. It is required, not an optimisation — a request without one is rejected before anything happens. The same key is also forwarded to the issuing partner as the client reference, so a retry is deduplicated at both ends rather than only at ours.

Situation Response Notes
same key, same body the original response Replayed verbatim from the stored snapshot — no second charge, no second card. This is what makes a network timeout safe to retry.
same key, still running <code class='site-code'>409</code> <code class='site-code'>idempotency: [in_flight]</code> A concurrent duplicate. Wait and retry the same key; do not mint a new one.
same key, different body <code class='site-code'>422</code> <code class='site-code'>idempotency: [payload_mismatch]</code> Refused rather than silently applied. Comparison is a SHA-256 of the raw body.
missing header <code class='site-code'>422</code> <code class='site-code'>idempotency_key</code> It is a header, but validation reports it as a field name — do not go looking for a field you never sent.
key age 24 hours Keys are scoped to your account and remembered for 24 hours. After that the same string is a fresh request.

Choosing keys

  • One key per intent, not per attempt. If your job retries an issue three times, all three carry the same key.
  • A UUID v4 is fine. So is a deterministic string of your own — your order id, say — which is better, because a crash before you stored the key does not lose it.
  • Never reuse a key for a different amount or a different card. That is the payload mismatch above, and it is a bug on your side that the API will catch.

The payload is hashed byte-for-byte

The comparison is a hash of the exact bytes you sent, not of a parsed object. These two bodies are the same intent to a human and two different payloads to the API:

Same key, mismatched
// first attempt
{"product_id":"01K…","amount_minor":2500}

// retry, keys reordered → 422
{"amount_minor":2500,"product_id":"01K…"}

So serialise the body once, keep the string, and reuse that same string on every retry — which you have to do anyway, because the signature covers those same bytes.

When the answer is 202

A 202 on issue, recharge or terminate means the request was accepted and the outcome is not yet known — either the issuer provisions asynchronously, or a response was lost and the platform will not guess. It is success-shaped on purpose: the work is under way and reconciliation will finish it.

Do not retry a 202 as though it failed. Either poll GET /cards/{card} until the status settles, or replay the same idempotency key — which returns the current state rather than starting anything new — or wait for the webhook.

Rate limits

Limits are per API key. Every response carries the current budget, so a well-behaved client never has to guess at one.

Limit Value Notes
requests 600 per minute Per key. Exceeding it → 429 with rate_limit: [exceeded].
X-RateLimit-Limit header On every response — the ceiling in force.
X-RateLimit-Remaining header On every response — what is left in the current window.
Retry-After header On a 429 only, in seconds. Honour it; do not spin.
timestamp window ±300 seconds Not a rate limit, but the reason a queued request that sat for ten minutes fails: sign at send time, not at enqueue time.
nonce memory 600 seconds A nonce cannot be reused inside that window. Every attempt — including every retry — needs a fresh one.

Retrying well

  • Back off on 429 using Retry-After, then resume — with a new nonce and a new timestamp, but the same idempotency key.
  • Re-sign every retry. The timestamp and nonce change, so the signature changes; a cached signature will fail.
  • Do not retry a 4xx that is not a 409 or a 429. A 422 means the request is wrong, and sending it again will not make it right.

What a 429 looks like

429 Too Many Requests
Retry-After: 37
X-RateLimit-Limit: 600
X-RateLimit-Remaining: 0

{
  "success": false,
  "message": "Too many requests.",
  "errors": {
    "rate_limit": ["exceeded"]
  }
}

Every authenticated request is logged — method, path, status, source address, and nothing else. No bodies, no headers, no signatures. Those logs are kept for 90 days and then pruned.

Errors

Every error a client can receive, in the order it can happen: the gateway rejects before the operation runs, then validation, then the operation itself. Branch on the code inside errors — those are stable. The message is for your logs and your support team.

1 · At the gateway

These fire before your request reaches an operation. The messages are deliberately terse (Unauthorized., Conflict.) — they give an attacker nothing. The precise reason is in errors.gateway, which is safe to show you and useless to anyone without your secret.

HTTP errors Cause and fix
401 gateway: [missing_signature_headers] One of the four headers is absent. Check for a proxy that strips X-* headers.
401 gateway: [timestamp_out_of_window] Your clock is more than 300s off, or the request was signed long before it was sent. Run NTP; sign at send time.
401 gateway: [invalid_api_key] Unknown, revoked or expired key id. Note this is the id, not the secret — a common mix-up is sending the secret here.
401 gateway: [invalid_signature] The canonical string you signed is not the one the server rebuilt. Compare against the worked example first — nine times in ten it is the missing /api/v1 prefix.
403 gateway: [ip_not_allowed] The signature was valid but the source address is not on the key's allowlist. Add the address, or drop the allowlist.
403 gateway: [out_of_scope] The signature was valid but the key does not hold the scope this route requires. Widen the scopes, or use the right key.
409 gateway: [replay_detected] This nonce was already used with this key. Generate a fresh nonce for every attempt, including retries.
429 rate_limit: [exceeded] Over 600 requests in a minute. Honour Retry-After.

The two 403s only ever appear after a valid signature. That ordering is deliberate: a caller who does not hold your secret can never tell the difference between a key that exists and one that does not.

2 · Validation

HTTP 422, message always Validation failed., and errors is a map of field name → list of human-readable messages (not codes — this is the one place the strings are the detail). Per-field rules live with each operation below.

422 Unprocessable Content
{
  "success": false,
  "message": "Validation failed.",
  "errors": {
    "customer_id": ["The customer id field is required."],
    "amount_minor": ["The amount minor field must be an integer."]
  }
}

3 · Operation outcomes

HTTP errors Cause and fix
404 {} Resource not found. — the id does not exist, belongs to another account, or belongs to the other mode (a test key cannot see a live card). Scoping is fail-closed, so a foreign id is indistinguishable from a missing one, on purpose.
404 {} Endpoint not found. — an unmatched route, or a product that is not enabled for you. Two different messages for a 404 is a known wart; the envelope shape is identical.
409 idempotency: [in_flight] A request with this Idempotency-Key is still running. Wait, then retry the same key.
422 idempotency: [payload_mismatch] This key was already used with a different body. See Idempotency.
422 balance: [insufficient_funds] The wallet cannot cover the amount plus fees. Fund it, or lower the amount.
422 product: [no_default] You left product_id out and there is no default product to fall back on. Send an explicit product_id.
422 product: [unavailable] The product has no enabled route to an issuer right now. This is ours to fix, not yours — retry later or contact us.
422 card: [issue_failed] The issuer refused the card. Nothing was charged. Safe to retry with a new idempotency key.
422 card: [invalid_state] The operation does not apply in the card's current state — recharging a terminated card, freezing a frozen one. Fetch the card and check status first.
422 card: [termination_blocked] The card is too newly issued to terminate. Freeze it now and terminate later — the message says as much.
422 customer: [invalid] The customer is disabled, or cannot back an issue. Re-enable them with PATCH /customers/{customer}.
422 amount_minor: [below_minimum] The initial load is under the product's minimum. The minimum is a property of the product; ask us for it, or raise the amount.
422 network: [not_enabled] The deposit network was turned off between your reading the list and asking for an address. Re-read the enabled set.
403 {} Deposits are live-only. / Deposit addresses are live-only. — a test key called a funding endpoint. See Test mode.

4 · Anything else

HTTP errors Cause and fix
500 {} An unexpected error occurred. — never any detail in production. If a money operation 500s, do not assume it failed: replay the same idempotency key to learn the truth, and tell us the time and the operation.

No error message anywhere in this API names an issuing partner, a partner reference or an internal identifier. That is a deliberate constraint, not an oversight — so when card: [issue_failed] is all you get, the extra detail exists on our side and reaching us is the way to it.

Test mode

A key is created either live or test, and that never changes. A test key exercises the same endpoints, the same signing, the same validation and the same response shapes — against a simulated issuer and a segregated wallet of play money. Nothing a test key does touches a real card, a real balance or a platform report.

Behaviour Test key Notes
issuing simulated Cards issue against a simulated issuer regardless of which product you name. Responses are shaped identically to live — including the occasional 202.
wallet segregated A test wallet is provisioned automatically the first time you create a test key, pre-funded with play money. It cannot be spent live, and live funds cannot be spent by it.
visibility mode-scoped A test key sees only test cards; a live key sees only live cards. A cross-mode id is a 404, not a 403.
customers shared The registry is not mode-scoped: a customer you register with a test key is visible to a live key, and can be issued a live card. Register test people with obviously fake details.
funding unavailable Deposit addresses and deposits are live-only — a test key gets 403. There is no simulated blockchain — the test wallet is funded for you instead.
reporting excluded Test activity never appears in platform revenue or volume figures.

Telling them apart in a response

A card carries its own mode. It is the merchant-facing test/live vocabulary — it never names which issuer is behind a live card.

Card, abbreviated
{
  "id":      "01KA7Y5T8B2VQD6MJ4H9CFNX3P",
  "mode":    "test",
  "status":  "active",
  "last4":   "4242"
}

Going live

  • Create a second, live key — you cannot promote a test key, and you should not want to.
  • Keep the mode in configuration, never in code. The only differences are the key id and the secret.
  • Re-run your integration against the live key with the smallest amount your product allows before you run real volume.
  • Expect nothing new: same paths, same fields, same errors. If live behaves differently from test in any way other than money being real, tell us.

Customers

The cardholder registry. A card is issued to a customer, so the person exists in your registry before their first card and is reused for every card after it. Identity fields are encrypted at rest — all except the cardholder's first and last name, which are stored in the clear so our support staff can find a person by name. The registry is scoped to your account either way: another merchant's customer id is a 404, not a permission error.

Registering a cardholder starts a verification.

Creating a customer returns 202, not 201: the record exists, but the person is not verified yet. Every customer carries a kyc_status, and only approved can back a card issue — issuing sooner returns 409 kyc_not_approved.

Don't poll in a loop waiting for it. Subscribe to customer.kyc.approved and issue when it arrives. Most verifications settle in seconds; some take a few minutes.

In test mode customers are approved immediately, so a test integration never waits.

POST /api/v1/customers

Register a cardholder

Returns
202 · Customer
Scope
customers.write

All fields below are required except customer_type and place_of_birth. There is no partial create: a customer either has a complete identity or does not exist. The response is 202 — the record is saved and verification has started.

Field Type Rules
customer_type string Optional. individual (default) or business. A business needs the two fields below as well as everything else — see the note.
business_name string Max 150. Required when customer_type is business.
registration_number string Max 64. The business or RC number. Required for a business.
first_name string Max 100.
last_name string Max 100.
email string A valid address, max 254.
phone_number string Digits only, 4–15 of them, without the country code.
dial_code string The country code on its own, with the plus: +234.
date_of_birth string YYYY-MM-DD. The cardholder must be at least 18 years old.
id_type string One of bvn, nin, passport, drivers_license, national_id, vnin, voters_card, ghana_card. See the note below — the choice changes what else you must send.
id_number string Max 64. Stored encrypted; only the last four are ever echoed back.
line1 string Street address, max 120.
city string Max 120.
state string Max 120. Required even where the country has no states — use the city or region.
postal_code string Max 20.
country string ISO 3166-1 alpha-3: USA, NGA. Letters only; case-insensitive on input.
occupation string Max 100. Lowercase with underscores, e.g. software_engineer.
employment_status string Max 50, e.g. employed, self_employed, student.
account_purpose string Max 100. What the card is for, e.g. personal_spending.
annual_salary string Declared annual income in whole units, digits only — not minor units, no decimal point.
expected_monthly_volume string Anticipated monthly spend, same format as above.
place_of_birth string Optional. Max 120.
terms_of_service_accepted boolean Must be true. We record when the cardholder accepted; verification cannot be submitted without it.
id_front_image string Base64 photo of the document front. Required for document ID types and rejected for bvn/nin. Send the photo you have — anything oversized is resized for you.

A business is verified through a person.

business_name and registration_number are additions, not replacements. Send them alongside the full personal identity — name, date of birth and an ID document — of the natural person behind the account. That person is who actually gets verified; without their details a business submission is rejected.

The ID type changes what else you send.

bvn and nin are checked against a Nigerian national database. They verify in seconds, country must be NGA, and you must not send id_front_image — sending one is rejected.

Every other type is a document. It requires id_front_image, and a result comes back within about 10 minutes. Send the front of the document in full, in focus and in date — a blurred, cropped or expired photo is the most common reason a verification is rejected.

first_name and last_name are checked against the document, so send them exactly as they appear on it.

Don't resize the photo yourself. A phone camera produces several megabytes, which is far more than the card network accepts — we recompress anything oversized, dropping quality before resolution so the document stays readable, and rotate it upright if the camera recorded it sideways. The document is held on private storage only until verification settles, then deleted.

The last six describe the cardholder rather than identify them, and the verification will not run without them. Send what the cardholder actually told you — these are declarations you are passing on, and a profile that does not match the person is the most common reason a verification comes back rejected.

Request
{
  "first_name":    "Amina",
  "last_name":     "Bello",
  "email":         "[email protected]",
  "phone_number":  "8031234567",
  "dial_code":     "+234",
  "date_of_birth": "1994-03-11",
  "id_type":       "bvn",
  "id_number":     "12345678901",
  "line1":         "14 Adeola Odeku Street",
  "city":          "Lagos",
  "state":         "Lagos",
  "postal_code":   "101241",
  "country":       "NGA",
  "occupation":              "software_engineer",
  "employment_status":       "employed",
  "account_purpose":         "personal_spending",
  "annual_salary":           "4800000",
  "expected_monthly_volume": "150000",
  "terms_of_service_accepted": true
}
202 Accepted
{
  "success": true,
  "data": {
    "id":              "01KA7XR4W9YT6N3DK8C2QFMH5J",
    "customer_type":   "individual",
    "first_name":      "Amina",
    "last_name":       "Bello",
    "email":           "[email protected]",
    "phone_number":    "8031234567",
    "dial_code":       "+234",
    "date_of_birth":   "1994-03-11",
    "id_type":         "bvn",
    "id_number_last4": "8901",
    "line1":           "14 Adeola Odeku Street",
    "city":            "Lagos",
    "state":           "Lagos",
    "postal_code":     "101241",
    "country":         "NGA",
    "status":          "active",
    "kyc_status":      "unsubmitted",
    "kyc_failure_reason": null,
    "created_at":      "2026-08-17T21:00:00+00:00"
  }
}

Note what comes back: id_number_last4, never the id number you sent. The registry is not a place to look things up from later — keep your own record of what you submitted.

kyc_status starts at unsubmitted and moves to pending within moments, then to approved or rejected. Note that status and kyc_status are separate things: status is your own switch for whether a cardholder may be used at all, and disabling an approved customer does not throw their verification away.

GET /api/v1/customers

List your customers

Returns
200 · Customer[]
Scope
customers.read

Newest first, cursor-paginated. Accepts per_page and cursor — see Pagination. Not mode-scoped: the same customers are visible to test and live keys alike.

200 OK
{
  "success": true,
  "data": [ { /* Customer */ }, … ],
  "meta": {
    "per_page":        20,
    "has_more":        false,
    "next_cursor":     null,
    "previous_cursor": null
  }
}
GET /api/v1/customers/{customer}

Fetch one customer

Returns
200 · Customer
Scope
customers.read

{customer} is the ULID from the create response. Unknown, or belonging to another account → 404.

PATCH /api/v1/customers/{customer}

Update a customer, or disable them

Returns
200 · Customer
Scope
customers.write

Send any subset of the create fields — each keeps the same rule as on create — plus status, which accepts active or disabled.

Request — correct an address
{
  "line1": "9 Ozumba Mbadiwe Avenue",
  "city":  "Lagos"
}
Request — stop new issues
{
  "status": "disabled"
}

Two things this does not do. Disabling a customer blocks new card issues for them; it does not freeze or terminate the cards they already have — do that per card. And an edit does not propagate to cards already issued: the identity submitted to the card network at issue time stays as it was.

Editing an identity field voids the verification. A verification belongs to a person, not to a record — so changing a name, date of birth, id number, address or any part of the profile sends the customer back through verification, even if they were already approved. Their kyc_status returns to pending and they cannot back a new card issue until it passes again. Cards they already hold are unaffected.

So treat this endpoint as "correct a record that is not yet approved", not as a way to touch up an approved one — a fixed postcode costs a re-verification. Changing status alone does not: enabling and disabling a cardholder is your own switch and never affects their verification.

POST /api/v1/customers/{customer}/kyc

Re-run a verification

Returns
202 · Customer
Scope
customers.write

Submits the customer's stored details for verification. The request body is empty.

You need this in two situations. A customer whose verification was rejected: fix what kyc_failure_reason points at with a PATCH, then call this. And a customer you registered before verification became its own step, who has none on record: add occupation, employment_status, account_purpose, annual_salary, expected_monthly_volume and terms_of_service_accepted with a PATCH, then call this.

Safe to call twice. An already-approved customer comes back 200 untouched rather than being re-verified. A customer still missing required fields is a 422 kyc_incomplete — we would rather tell you than send an incomplete verification that comes back rejected.

202 Accepted
{
  "success": true,
  "data": {
    "id":         "01KA7XR4W9YT6N3DK8C2QFMH5J",
    "kyc_status": "pending",
    …
  }
}

Cards

Seven operations: issue, fetch, recharge, freeze, unfreeze, terminate, and list activity. Issue, recharge and terminate move money and require an idempotency key; freeze and unfreeze move none.

Lifecycle
┌──── freeze ────┐
                      ↓                ↑
  issue ──→  pending ──→  active  ←── unfreeze ──  frozen
                │          │                        │
                ↓          └──── terminate ─────┬──────┘
             failed                            ↓
                                             terminated

# `expired` is reached by the passage of time, not by a call.
# `pending` and `failed` only ever follow an asynchronous issue.
# `terminated` is final: nothing moves out of it.
POST /api/v1/cards

Issue a card

Returns
201 · Card · 202 when provisioning
Scope
cards.write
Idempotency-Key
required

Charges your wallet for the initial load plus the product's issue fee, then asks the issuer for a card. Both halves are one transaction as far as your balance is concerned: a refusal leaves nothing charged.

Field Type Rules
product_id string Optional, exactly 26 characters when sent. Omit it and the card is issued against your default product. When sent it must be a product enabled for your account — otherwise 404. Omitted with no default configured is 422 product: [no_default].
customer_id string Required, exactly 26 characters. A customer in your registry; a foreign or unknown one is a 404, a disabled one is 422 customer: [invalid].
amount_minor integer Required. 0 to 100,000,000 minor units, and not below the product's minimum initial load (422 amount_minor: [below_minimum]).

If you only ever issue one product, leave product_id out and we will use the default product configured for the platform. Sending it explicitly always wins, so an integration that names its product keeps behaving exactly as before even if the default later changes.

Request — default product
{
  "customer_id":  "01KA7XR4W9YT6N3DK8C2QFMH5J",
  "amount_minor": 2500
}
Request — explicit product
{
  "product_id":   "01KA7XQ8V3ZP5M2CJ9B6TDNF4H",
  "customer_id":  "01KA7XR4W9YT6N3DK8C2QFMH5J",
  "amount_minor": 2500
}
201 Created
{
  "success": true,
  "data": {
    "id":            "01KA7Y5T8B2VQD6MJ4H9CFNX3P",
    "product_id":    "01KA7XQ8V3ZP5M2CJ9B6TDNF4H",
    "customer_id":   "01KA7XR4W9YT6N3DK8C2QFMH5J",
    "status":        "active",
    "mode":          "live",
    "masked_pan":    "424242••••••1234",
    "last4":         "1234",
    "expiry":        "09/29",
    "currency":      "USD",
    "balance_minor": 2500,
    "created_at":    "2026-08-17T21:00:00+00:00"
  }
}

The 202 case. Some issues provision asynchronously. You then get 202 with either the card in pending state or the bare {"status":"pending"} body — poll GET /cards/{card}, or wait for the card.issued webhook. Replaying the same idempotency key returns the card as it stands, so a replay after activation answers 201.

Card numbers: masked_pan and last4 are all this API will ever give you. The full number and security code are not exposed here and not stored — see Not in the API. Treat masked_pan as a display string only: its exact shape is not a contract and differs between test and live. Anything your code decides on should read last4.

GET /api/v1/cards/{card}

Fetch a card

Returns
200 · Card
Scope
cards.read

The poll target after a 202, and the way to check status and balance_minor at any time. A card belonging to another account — or to the other mode — is a 404.

Poll with restraint: a couple of seconds between attempts, giving up into a slower background check after a minute. A webhook is the better answer, and costs no requests against your rate limit.

POST /api/v1/cards/{card}/recharge

Load more funds onto a card

Returns
200 · Card · 202 when the outcome is unknown
Scope
cards.write
Idempotency-Key
required
Field Type Rules
amount integer Required, at least 1, in minor units. Note the name: amount here, not amount_minor as on issue. The unit is the same.
Request
{
  "amount": 5000
}
Errors worth handling
422  balance: [insufficient_funds]
422  card:    [invalid_state]
409  idempotency: [in_flight]
202  outcome unknown — see Idempotency

The field name asymmetry between issue and recharge is a wart we are carrying rather than a subtlety you are missing. Both are integer minor units.

POST /api/v1/cards/{card}/freeze

Freeze a card

Returns
200 · Card
Scope
cards.write

Blocks spending immediately. No body, no money movement, and the balance stays where it is. Freezing a card that is not active is 422 card: [invalid_state].

This is the right first move on suspected fraud: it is instant and reversible, whereas termination is neither.

POST /api/v1/cards/{card}/unfreeze

Unfreeze a card

Returns
200 · Card
Scope
cards.write

Returns a frozen card to active. No body. A card that is not frozen is 422 card: [invalid_state].

POST /api/v1/cards/{card}/terminate

Terminate a card

Returns
200 · Card · 202 when the outcome is unknown
Scope
cards.write
Idempotency-Key
required

Final. The remaining card balance the issuer reports is refunded to your wallet, and the card can never be used again. There is no un-terminate.

Field Type Rules
reason string Optional, defaults to no_longer_needed. One of lost, stolen, fraud, no_longer_needed, other. A fixed vocabulary, not free text — it is forwarded to the issuer.
Request
{
  "reason": "stolen"
}
If it is too soon
422
{
  "errors": {
    "card": ["termination_blocked"]
  }
}

# A freshly issued card cannot be
# terminated yet. Freeze it and
# terminate later.
GET /api/v1/cards/{card}/transactions

List a card's activity

Returns
200 · CardTransaction[]
Scope
cards.read

What the issuer reported against the card: authorisations, captures, refunds, reversals and declines. Newest first, cursor-paginated. This is a record of spend, not your ledger — your wallet movements are in the portal.

Request
GET /api/v1/cards/01KA7Y5T…/transactions
    ?per_page=50

# signed QUERY_STRING (sorted):
#   per_page=50
200 OK
{
  "success": true,
  "data": [
    {
      "id":           "01KA7Z2M4Q8VXB6DJ3H9CFNT5W",
      "type":         "capture",
      "status":       "completed",
      "amount_minor": 1799,
      "currency":     "USD",
      "occurred_at":  "2026-08-17T20:41:02+00:00"
    }
  ],
  "meta": { … }
}

Funding

Your wallet is funded by stablecoin deposits to a permanent address, one per network. Send to the address, and the deposit is credited to the wallet that pays for card issues and recharges. Withdrawing is not an API operation — see Not in the API.

All three operations are live keys only. A test key receives 403 with Deposits are live-only.: there is no simulated chain to send simulated coins across, so the test wallet is funded for you instead.

POST /api/v1/deposit-addresses

Provision a deposit address

Returns
201 new · 200 existing
Scope
deposits.write
Note
Live keys only

Addresses are permanent and there is at most one per network. Calling this twice for the same network is safe and returns the same address — 201 the first time, 200 every time after. No idempotency key needed: the network is the key.

Field Type Rules
network string Required. One of the networks currently enabled on the platform — which is operational configuration, not a fixed list, so it can change without an API version. Send an unavailable one and the 422 message names the ones that are available.
Request
{
  "network": "trc20"
}
201 Created
{
  "success": true,
  "data": {
    "id":         "01KA7ZB6X4NQ2VT8DJ5H3CFMK9",
    "network":    "trc20",
    "asset":      "USDT",
    "address":    "TQ5n…",
    "memo":       null,
    "created_at": "2026-08-17T21:00:00+00:00"
  }
}

If memo is not null, it is mandatory. A deposit sent without a required memo cannot be attributed automatically and needs manual recovery. Show it beside the address wherever you show one, and never treat it as optional metadata.

GET /api/v1/deposit-addresses

List your deposit addresses

Returns
200 · DepositAddress[]
Scope
deposits.read
Note
Live keys only

Ordered by network. This is the one list with no meta block — the set is bounded to one address per network, so there is nothing to page and a cursor would be theatre. Do not write a pagination loop around it.

200 OK
{
  "success": true,
  "data": [
    { "network": "bep20", "asset": "USDT", … },
    { "network": "erc20", "asset": "USDT", … },
    { "network": "trc20", "asset": "USDT", … }
  ]
}
GET /api/v1/deposits

List received deposits

Returns
200 · Deposit[]
Scope
deposits.read
Note
Live keys only

Deposits credited to your wallet, newest first, cursor-paginated. This is the reconciliation surface: asset_amount is what arrived on chain, credited_amount_minor is what the wallet received, and rate is what converted one into the other.

200 OK
{
  "success": true,
  "data": [
    {
      "id":                    "01KA7ZC8M2QVXB4DJ7H5CFNT3P",
      "network":               "trc20",
      "asset":                 "USDT",
      "asset_amount":          "250.000000",
      "credited_amount_minor": 24997,
      "currency":              "USD",
      "rate":                  "0.999900",
      "status":                "credited",
      "tx_id":                 "9f1c…",
      "confirmed_at":          "2026-08-17T20:12:44+00:00"
    }
  ],
  "meta": { … }
}

status

Value Money Meaning
pending not yet Seen, not yet credited.
credited in the wallet Spendable.
flagged in the wallet Credited and marked for our review — a partial, late or over-payment. Your money is there; expect us to be in touch rather than the other way round.

asset_amount and rate are decimal strings, deliberately — parse them with a decimal type, never a float. credited_amount_minor is the integer that actually moved.

tx_id is the on-chain transaction reference, for matching against your own chain records.

Deposits are also pushed: subscribe to deposit.credited instead of polling this list.

Webhooks

Register an endpoint at /portal/webhooks — it takes a fresh two-factor code, and the signing secret is shown exactly once, like an API secret. From then on, every event your account produces is POSTed to it as JSON.

There is no per-event subscription: an endpoint receives every event type below, so switch on type and ignore what you do not need. Register several endpoints and each gets its own copy and its own retry schedule.

The delivery

Headers
Content-Type:            application/json
X-ScarabPay-Event:       card.issued
X-ScarabPay-Delivery:    01KA7ZD4N8QVXT2BJ6H3CFMK5W
X-ScarabPay-Signature:   t=1787000400, v1=8537115a…

# X-ScarabPay-Delivery is stable across every
# retry of the same delivery — it is the id to
# deduplicate on.
Body
{
  "type":        "card.issued",
  "occurred_at": "2026-08-17T21:00:00+00:00",
  "data": {
    "card_id": "01KA7Y5T8B2VQD6MJ4H9CFNX3P"
  }
}

# Three keys, always. `data` is per event type.

Payloads carry ids and amounts, never the substance of a thing — no card numbers, no cardholder details, no reason strings, no partner references. A webhook is a signal to go and read, so the pattern is: verify, enqueue, return 200, then call the API for the current state.

Verifying the signature

The header is t=<unix seconds>, v1=<hex>, where v1 is an HMAC-SHA256 over the string "<t>.<raw body>" — a literal dot between them — keyed with your endpoint's signing secret. Hash the raw request body, before any parsing or re-serialising: a JSON round-trip changes bytes and breaks the comparison.

Worked example
secret  Yv8Qk3mR7pTz1LbN5wX2dJ9sH6cF4gA0uE8iO2nB4tS=
t       1787000400
body    {"type":"card.issued","occurred_at":"2026-08-17T21:00:00+00:00","data":{"card_id":"01KA7Y5T8B2VQD6MJ4H9CFNX3P"}}

signed  1787000400.<body exactly as above>

v1      8537115a69c4fb3b86c5b472b5123e527c2dd0c7b4b609ed212fe7962f6df75b
Verify in PHP
php
function verifyWebhook(string $header, string $rawBody, string $secret, int $tolerance = 300): bool
{
    $parts = [];

    foreach (explode(',', $header) as $piece) {
        [$k, $v] = array_pad(explode('=', trim($piece), 2), 2, '');
        $parts[$k] = $v;
    }

    if (! isset($parts['t'], $parts['v1'])) {
        return false;
    }

    // Reject a replay of an old, captured delivery.
    if (abs(time() - (int) $parts['t']) > $tolerance) {
        return false;
    }

    $expected = hash_hmac('sha256', $parts['t'].'.'.$rawBody, $secret);

    // Constant time — never ===
    return hash_equals($expected, $parts['v1']);
}
Verify in Node
node
import { createHmac, timingSafeEqual } from 'node:crypto';

export function verifyWebhook(header, rawBody, secret, toleranceSeconds = 300) {
  const parts = {};

  for (const piece of String(header).split(',')) {
    const at = piece.indexOf('=');
    if (at > 0) parts[piece.slice(0, at).trim()] = piece.slice(at + 1).trim();
  }

  if (!parts.t || !parts.v1) return false;

  const age = Math.abs(Math.floor(Date.now() / 1000) - Number(parts.t));
  if (age > toleranceSeconds) return false;

  const expected = createHmac('sha256', secret)
    .update(`${parts.t}.${rawBody}`, 'utf8')
    .digest();

  const received = Buffer.from(parts.v1, 'hex');

  return expected.length === received.length && timingSafeEqual(expected, received);
}

// Express: mount express.raw({ type: 'application/json' }) on this
// route so req.body is the raw Buffer, not a parsed object.

Event types

The complete set. Every amount is integer minor units; every id is a ULID you can pass straight back to the API.

type data Fires when
customer.kyc.pending customer_id · kyc_status A cardholder's verification has been submitted and is under review.
customer.kyc.approved customer_id · kyc_status A cardholder is verified. This is the event that says you can now issue cards for them — the one to act on rather than polling after a 202 from POST /customers.
customer.kyc.rejected customer_id · kyc_status · reason A cardholder's verification failed. reason says what to fix; correct the details and call POST /customers/{customer}/kyc.
card.issued card_id A card reaches active — including after an asynchronous issue that answered 202. This is the event that replaces polling.
card.terminated card_id · refunded_amount_minor A card is terminated and the remaining balance has been returned to your wallet.
deposit.credited deposit_id · credited_amount_minor A deposit has been credited. Your spendable balance just went up.
withdrawal.requested withdrawal_id · amount_minor · fee_minor Someone on your account requested a withdrawal in the portal.
withdrawal.approved withdrawal_id · amount_minor An operator approved it. Not yet sent.
withdrawal.completed withdrawal_id · amount_minor · fee_minor · net_amount_minor Funds have left the platform.
withdrawal.rejected withdrawal_id · amount_minor An operator declined it.
withdrawal.failed withdrawal_id · amount_minor The payout could not be completed.
withdrawal.cancelled withdrawal_id · amount_minor It was cancelled on your side before it was sent.

The customer_id on a customer.kyc.* event is the same ULID you pass to POST /cards, so an approval is directly actionable with no lookup. Each of these fires once, on a real change of verdict.

Withdrawals have no API operations but do emit events — they are requested and approved in the portal, and these five let your systems follow along. Note there is no card.transaction event: cardholder spend is read from GET /cards/{card}/transactions, not pushed.

What your endpoint must do

Requirements, checked when you register

Rule Value
scheme https Plain HTTP is refused at registration.
port 443 The default HTTPS port, or none. Any other port is refused.
host public Must resolve outside private and reserved ranges. The resolved address is pinned for the request, so DNS rebinding does not help.
userinfo none No user:pass@host credentials in the URL.
redirects not followed A 3xx counts as a failed delivery. Publish the final URL.

Delivery behaviour

Aspect Value
success 2xx Anything else is a failure and will be retried.
timeouts 5s connect · 15s total Do the work after you answer. Acknowledge first.
attempts 6 Then the delivery is dead.
backoff 1m · 5m · 30m · 2h · 8h · 24h Plus 0–20% jitter, so a fleet of endpoints failing at once does not retry in lockstep.
on exhaustion endpoint disabled After a delivery dies the endpoint is disabled and we email you. Re-enable it in the portal once it is healthy.
history 30 days Delivery records and payloads are pruned after that. Resend from the portal while they last.

A correct handler, in order

  1. 1 Read the raw body — before any JSON middleware touches it.
  2. 2 Verify the signature, in constant time, and reject the timestamp if it is outside your tolerance.
  3. 3 Deduplicate on X-ScarabPay-Delivery. Retries reuse it, so a delivery you have already processed is one you can drop.
  4. 4 Enqueue the work and return 200 immediately. Fifteen seconds is the whole budget, and a slow handler earns you six retries of the same event.
  5. 5 Then read the API for current state. The payload tells you something changed, not what everything now is.

Objects

Every field of every resource this API returns. Anything not listed here is not returned — internal ids, partner references, partner names, full card numbers and security codes are outside the contract by design, not by omission.

Card

Field Type Notes
id string ULID. The id for every card operation.
product_id string ULID of the product it was issued against.
customer_id string|null ULID of the registry customer. Null only on cards issued before the registry existed.
status enum pending · active · frozen · terminated · expired · failed
mode enum test · live. The mode of the key that created it — see Test mode.
masked_pan string Display only. The shape is not a contract and differs between modes; branch on last4. An empty string while a card is still pending.
last4 string The last four digits.
expiry string MM/YY. An empty string until the issuer has provisioned the card — check for it rather than assuming null.
currency string ISO 4217, e.g. USD.
balance_minor integer Current card balance in minor units. Changes with spend, recharge and refunds.
created_at string|null ISO 8601.

Customer

Field Type Notes
id string ULID. Pass as customer_id when issuing.
customer_type enum individual · business
first_name · last_name string As registered.
email string As registered.
phone_number · dial_code string Digits without the country code, and the country code separately.
date_of_birth string YYYY-MM-DD.
id_type enum national_id · passport · drivers_license
id_number_last4 string Last four only. The full number you submitted is never returned.
line1 · city · state · postal_code · country string The registered address; country is ISO alpha-3.
status enum active · disabled. A disabled customer cannot back a new issue.
created_at string|null ISO 8601.

CardTransaction

Field Type Notes
id string ULID.
type enum authorization · capture · refund · reversal · decline
status enum pending · completed · declined · reversed
amount_minor integer Minor units. Always positive — the type carries the direction.
currency string ISO 4217.
occurred_at string|null ISO 8601. When the issuer says it happened, which is the sort key — not when we recorded it.

DepositAddress

Field Type Notes
id string ULID.
network string The chain the address is on.
asset string The asset it accepts, e.g. USDT. Send only this asset to it.
address string The on-chain address. Permanent.
memo string|null Set only on chains that require a tag or memo. When present it is mandatory on the transfer.
created_at string|null ISO 8601.

Deposit

Field Type Notes
id string ULID.
network · asset string What arrived, and where.
asset_amount string Decimal string — the exact amount received on chain. Parse with a decimal type, never a float.
credited_amount_minor integer What was credited to the wallet, in minor units. This is the authoritative money figure.
currency string The wallet currency the credit landed in.
rate string Decimal string — the rate applied to convert the asset amount into the credit.
status enum pending · credited · flagged. Flagged is credited and under our review.
tx_id string|null The on-chain transaction reference, for matching against your own records.
confirmed_at string|null ISO 8601. The sort key for this list.

Not in the API

Four things you might reasonably look for and will not find. Three are deliberate; one is a gap we are naming rather than hiding.

Retrieving a full card number

Not available, and not something we can add. Full card numbers and security codes are never returned by this API and never stored — a card resource carries a masked number and the last four digits. Revealing a number is a separate, authenticated-user action in the portal behind a fresh two-factor code, and the response is marked no-store.

If your product needs to show a cardholder their own number, the cardholder has to be a person in front of a screen, not a server holding an API key.

Withdrawing funds

Deliberately not an API operation. Withdrawals are requested in the portal and require an operator approval step. The one action that moves money out of the platform is not reachable with an API key — so a leaked key cannot drain a wallet.

Withdrawals do emit webhook events at every stage, so your systems can follow one without being able to start one.

Listing the products you can issue against

This one is a genuine gap. A card is issued against a product id, and there is no endpoint that lists the products enabled for your account. Today the ids come from us or from the card-creation screen in the portal.

In practice this bites once, at integration time, and then the ids sit in your configuration. It is still one call short of a self-service integration, and it is on the list.

Wallet balances, ledger entries and platform administration

Balances and the underlying ledger are portal surfaces, not API ones. Every wallet movement is recorded as balanced legs in an insert-only ledger, and that record is readable — by a person, with an audit trail — rather than queryable by key.

Administrative endpoints exist for platform operators under a separate authentication scheme. They are not part of the merchant contract, are not documented here, and a merchant key cannot reach them.

The machine-readable contract

Everything on this page is also an OpenAPI 3.1 document — import it into a client generator, a request collection, or your own tests. It describes the same fourteen operations, the same schemas and the same error envelopes.

openapi.yaml Back to the integration overview

The specification omits one thing this page includes: the request signing is described there in prose, because OpenAPI has no way to express an HMAC canonicalisation. Generated clients will need the signer from Authentication wired in as a request middleware.

Something missing or wrong?

This page is generated from the same source as the implementation and tested against it. If a response you receive disagrees with what is written here, that is a defect worth reporting — tell us the operation, the status code and the errors object, and leave out your key and signature.

Sign in Contact us