TDAB Partner API
v0.1 · current
Sandbox
Telcoin Digital Asset Bank

TDAB Partner API

One integration lets a partner issue and redeem bank-backed stablecoins for their own users: a deposit turns dollars in a user's external bank account into tokens in their wallet, a withdrawal turns tokens back into dollars, and every step reports back over webhooks or a status endpoint.

You are reading v0.1, the current release. This page describes the current production API. Confirm the capabilities enabled for your sandbox environment during onboarding. The v0.2 nightly page documents the next release ahead of time.

The integration has five parts, presented in the order you would build them:

  • Registering a user: create the record and complete KYC verification.
  • Opening the account: TDAB provisions the user's own US bank account and tells you its details.
  • Deposits: pull dollars from the user's external bank account; tokens mint to their wallet on settlement, with no second call from you.
  • Withdrawals: redeem tokens back to dollars, paid out to the external bank account you supply.
  • Status updates for all of it: webhooks pushed to your server, plus pull endpoints for reconciliation.
Start

Quickstart

  1. Get onboarded. The TDAB team registers an environment for you and hands over everything the integration needs: an API key scoped to your endpoints, your webhook base URL, a signing secret and an outbound auth header, the X25519 public key you seal bank details against, and separately the host and the RFC 9421 signing secret for the KYC service, which your API key is also used against. You send back the public egress addresses your calls will come from: the sandbox admits allowlisted sources only, so nothing works until yours are registered. There is no self-serve signup yet.
  2. Create your user. POST /api/create-user with your own externalId, adding the user's wallet address then or later (it must be on file before any deposit mints). The user then completes KYC (know-your-customer verification); money movement is blocked until their KYC status is verified.
  3. Move money. POST /deposit to pull dollars from the user's external bank account (tokens mint automatically when it settles), or POST /withdraw to redeem tokens back to dollars.
  4. Track status. Receive webhooks at your registered base URL. With a known deposit ID, poll GET /api/account/deposit?userId=…&id=…; for a withdrawal, poll GET /withdraw/status?intentId=…. See deposit recovery if you missed the deposit ID.

Base URL and key

The sandbox base URL is https://api.sandbox.tdab.tech. Verification runs on its own host, https://kyc.sandbox.tdab.tech, and adds a request signature on top of the same API key: see KYC verification.

Examples on this page carry those hosts literally, so they can be copied directly. The only variable is your key: export TDAB_API_KEY first. There is no single gateway in front of the two hosts, so treat each as its own base rather than assuming one prefix covers both.

Both hosts sit behind an IP allowlist. A call from an address TDAB has not registered is refused at the edge with 403 before it reaches the API, so it carries none of the error bodies documented below. If every request fails with an empty 403, check your egress addresses first, not your key.

curl -s -X POST "https://api.sandbox.tdab.tech/api/create-user" \
  -H "Authorization: $TDAB_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "externalId":"3f9c81a0-7d2e-4b8a-9c1d-5e6f7a8b9c0d" }'
Response 201
{
  "success": true,
  "userId": "usr_2f9c81a0",
  "created": true,
  "updatedFields": [],
  "noopFields": []
}
Start

Authentication

Every request carries your API key, raw, in the Authorization header:

Authorization: 51h8qzby4uwn…

No Bearer prefix. The server hashes the header value exactly as sent and compares it to the stored key hash. Authorization: Bearer <key> hashes differently and gets a 401 on every call.

Keys are scoped by endpoint, not all-or-nothing. Each key carries an allow-list matched against the path you call, with exactly three pattern forms:

PatternGrants
*Every endpoint
/depositOnly that exact path
/api/*Everything starting with /api/

A deposits-only partner might hold a key scoped to /deposit and /api/account/deposit, with no access to withdrawals or account info. Keys are issued with least privilege by default.

Every authentication failure, whether the key is missing, unknown, disabled, out of scope for the path, or tied to a disabled environment, returns the same response: 401 with {"error":"Unauthorized"}. The reason is logged server-side but never returned, so a failing integration should check the key value and its scope with the TDAB team rather than parse the body.

# Incorrect: the Bearer prefix causes a 401.
curl -s "https://api.sandbox.tdab.tech/api/account-info?userId=3f9c81a0-7d2e-4b8a-9c1d-5e6f7a8b9c0d" \
  -H "Authorization: Bearer $TDAB_API_KEY"
Incorrect Bearer prefix 401
{ "error": "Unauthorized" }
Flows

Registering a user

Register each of your users with TDAB before their first deposit or withdrawal. This call creates the identity record only. KYC and the bank account follow from it, covered in Opening the account.

POST /api/create-user

Upserts by your own externalId, scoped to your environment. Calling it again with new fields updates the record rather than erroring, unless a field conflicts with what is already on file.

FieldTypeNotes
externalIdstringYour own user id; required, non-empty
walletAddressstring, optionalEIP-55 checksummed; can be added later, required before any deposit mints
sumsubApplicantIdstring, optionalIf KYC already ran on Sumsub elsewhere and you are importing the applicant

Returns 201 on create and 200 on update, with created, updatedFields, and noopFields telling you which happened. A conflict (for example a wallet address already owned by another user) returns 409 with a conflicts array; a malformed wallet address returns 400.

The response userId is TDAB's internal record ID. Keep your original externalId: use it as user_id for KYC and money movement, and as userId for account lookups. Do not substitute the internal response ID in those requests.

curl -s -X POST "https://api.sandbox.tdab.tech/api/create-user" \
  -H "Authorization: $TDAB_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "externalId":"3f9c81a0-7d2e-4b8a-9c1d-5e6f7a8b9c0d",
       "walletAddress":"0x9F2A1c3De45b8F001B7C4a2D9E6f1234ABCD4B7C" }'
Response 201
{
  "success": true,
  "userId": "usr_2f9c81a0",
  "created": true,
  "updatedFields": [],
  "noopFields": []
}
On field conflict 409
{
  "success": false,
  "error": "Field conflict — use separate update endpoints or resolve the conflicting value",
  "userId": "usr_2f9c81a0",
  "conflicts": [{ "field": "walletAddress", "ownerUserId": "usr_7a12ef90" }],
  "noopFields": []
}

KYC verification

Verification runs on a separate service with its own base URL, and it authenticates in two layers. Your API key goes in the Authorization header exactly as it does everywhere else on this page, raw, with no Bearer prefix. On top of that, every request is signed with RFC 9421 HTTP Message Signatures, using the signing secret you are handed together with the host at onboarding. A request that carries only the key, or only the signature, is refused with 401.

HeaderWhat goes in it
AuthorizationYour API key, raw. It identifies your environment and selects the secret your signature is checked against
Content-DigestRFC 9530 digest of the exact body bytes you send, as sha-256=:<base64>:. Required on every request with a body
Signature-InputExactly one signature, covering @method, @request-target, content-type and content-digest, with alg="hmac-sha256", a keyid of your choosing, and created as a Unix timestamp
SignatureThe HMAC-SHA256 over those components, keyed with your signing secret, base64 between colons

A signature is accepted for 30 seconds from created, with 5 seconds of tolerance for clock skew, so sign at send time rather than ahead of it. Any RFC 9421 library produces these two headers from the same inputs; the accompanying example shows the shape produced without one.

Start verification

POST /partner/kyc-new

Returns a short-lived token you hand to the Sumsub Web SDK inside your own app, so the user never leaves your interface.

FieldTypeNotes
user_idstring, UUIDYour id for the user, and it must be a UUID: anything else is rejected with 400. Send the same value you used as externalId on /api/create-user, because the outcome is matched to your user by that id. Choose a UUID for externalId from the start

Returns { "sdk_token": "…" }. The applicant is created on the first call and reused after that, so calling again for the same user_id resumes the same verification rather than starting a second one.

Importing a verified user

POST /partner/kyc-existing

If the user already verified with the same provider elsewhere and you hold a share token for them, send it as share_token alongside user_id (same UUID rule). You get the same sdk_token back and the user is not asked to verify twice.

Verification outcome

Verification is not instant, and the outcome reaches you as an account event: once the user is approved, TDAB opens their bank account and pushes its routing and account details to your base URL, see Opening the account. Until then, money-movement calls return 400 with User KYC not verified, so gating your own UI on that push is safe.

# BODY_DIGEST_B64 is base64(sha256(body)); the two RFC 9421 headers are
# produced by your signing library from the components listed on the left.
curl -s -X POST "https://kyc.sandbox.tdab.tech/partner/kyc-new" \
  -H "Authorization: $TDAB_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Content-Digest: sha-256=:$BODY_DIGEST_B64:" \
  -H "Signature-Input: $RFC9421_SIG_INPUT" \
  -H "Signature: $RFC9421_SIG" \
  -d '{ "user_id":"3f9c81a0-7d2e-4b8a-9c1d-5e6f7a8b9c0d" }'
Response 200
{ "sdk_token": "_act-sbx-jwt-..." }
Import an existing applicant instead
curl -s -X POST "https://kyc.sandbox.tdab.tech/partner/kyc-existing" \
  -H "Authorization: $TDAB_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Content-Digest: sha-256=:$BODY_DIGEST_B64:" \
  -H "Signature-Input: $RFC9421_SIG_INPUT" \
  -H "Signature: $RFC9421_SIG" \
  -d '{ "user_id":"3f9c81a0-7d2e-4b8a-9c1d-5e6f7a8b9c0d",
       "share_token":"_act-sbx-share-..." }'
API key or signature headers missing 401
{ "error": "Missing HMAC signature header" }
API key unknown or signature invalid 401
{ "error": "Invalid HMAC signature" }
Body not valid JSON or user_id not a UUID 400
{ "error": "Invalid JSON payload" }
Flows

Opening the account

Registration is the identity step. The account is what follows: the user completes KYC (the same Sumsub-backed flow TDAB's own wallet uses), and TDAB provisions their own US bank account on the core ledger. You are notified as each step completes.

  • Account registration push. Once the account exists, TDAB POSTs the user's routing and account details to your registered base URL, once. See status updates for the payload.
  • Re-fetch on demand. GET /api/account-info?userId=… returns the same routing and account number any time after that, so a missed push is recoverable.

To change a user's preferredNetwork or preferredCurrency later, call POST /api/user/preferences with their userId (your externalId) and whichever field is changing; at least one is required.

Every end user completes their own TDAB KYC verification and receives their own TDAB-held bank account before they can receive tokens, so signing an agreement does not by itself enable minting for any user immediately. Contact your partnerships representative about the KYC model for your integration.

curl -s "https://api.sandbox.tdab.tech/api/account-info?userId=3f9c81a0-7d2e-4b8a-9c1d-5e6f7a8b9c0d" \
  -H "Authorization: $TDAB_API_KEY"
Response 200
{
  "success": true,
  "userId": "3f9c81a0-7d2e-4b8a-9c1d-5e6f7a8b9c0d",
  "accountNumber": "4821009273",
  "routingNumber": "071000301"
}
Flows

Deposits

A deposit pulls dollars from your user's external bank account over ACH (Automated Clearing House) and mints tokens to their wallet once the money settles. You make one call; the mint is never a second call, it happens automatically when the bank leg clears.

POST /deposit
FieldTypeNotes
user_idstringYour externalId for the user
token_symbolstringe.g. eUSD
networkstringRequired and recorded on the deposit. The mint itself goes to the network on the user's record, polygon unless changed with POST /api/user/preferences, so send the same value
amountinteger, centsPositive; a string of digits is also accepted
transfer_idstringUnique across your partner environment, including all users. Reuse only for the same deposit; a repeat returns the existing intent without resubmitting the ACH pull. Changed request fields are not applied or compared for a conflict
sealed_bank_detailsstringA libsodium sealed box (base64) of the user's external bank details, forwarded to the bank leg untouched; TDAB never sees it in the clear

Returns 200 with an intentId and a status of pending. The user must already be registered under your environment, KYC-verified, and have a wallet address on file; each of those failing returns a 400 with a plain-English error.

Deposit lifecycle

The ACH pull is submitted, posts at the bank, the funds are swept into the reserve, and the tokens mint to the user's wallet. You see that as a sequence of deposit-status events:

unposted → posted → swept → minted or failed

minted is the terminal success and the only status that carries the on-chain txHash; failed carries an errorMessage. ACH means minutes to days depending on the rail, so build for the pending window rather than a synchronous result.

Deposit lookup and recovery. GET /api/account/deposit?userId=…&id=… returns one deposit. Supply your original externalId as userId and the depositId from a deposit-status event as id. Both parameters are required. This endpoint does not list deposits or accept an intentId or transferId in place of the deposit ID.

Deposit-status events do not contain a traceId, so GET /deposit/status?traceId=… is not a recovery path from those events. If you never received the deposit ID, contact TDAB with your environment, external user ID, transfer_id, and the intentId returned by POST /deposit. Keep those identifiers and do not submit a new transfer ID while the outcome is unknown.

No further action is required for the money to arrive. Once the bank leg clears, the sweep and the mint proceed automatically and you are notified at each step, so the user's balance updates without a second call from you.

Standard ACH settles in about two banking days. Faster handling is possible and depends on the risk and collateral arrangement agreed with you, so treat two days as the default to design against rather than a fixed number.

CurrencyNetworks
eUSD (US dollar, 1:1)polygon (default); confirm any other enabled network with TDAB before use

There is no currency or network listing endpoint. Confirm supported currencies and networks for your production and sandbox environments during onboarding before setting user preferences or initiating money movement.

curl -s -X POST "https://api.sandbox.tdab.tech/deposit" \
  -H "Authorization: $TDAB_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "user_id":"3f9c81a0-7d2e-4b8a-9c1d-5e6f7a8b9c0d",
       "token_symbol":"eUSD",
       "network":"polygon",
       "amount":25000,
       "transfer_id":"your-idempotency-key-001",
       "sealed_bank_details":"c2VhbGVkX2JveF9jaXBoZXJ0ZXh0..." }'
Response 200
{ "intentId": "di_1755781200000_k3f9x", "status": "pending" }
Missing KYC 400
{ "success": false, "error": "User KYC not verified" }

Encrypting bank details

sealed_bank_details is a libsodium sealed box (crypto_box_seal: X25519 with a fresh ephemeral keypair per message, XSalsa20-Poly1305), base64-encoded with standard RFC 4648 padding. You seal the user's bank details against TDAB's public key, so only TDAB can open them. No proprietary cryptography is involved, so any standard libsodium binding interoperates.

Seal a compact UTF-8 JSON object with exactly these two string fields. Both are strings so leading zeros survive:

FieldRule
routing_numberExactly 9 digits
account_number4 to 17 digits

An acknowledgement is not bank-detail validation. Missing or empty sealed_bank_details is rejected with 400. A nonempty payload that cannot be opened or fails the decrypted-field checks can still receive 200 with status: "pending", although it has not produced an ACH pull. Repeating the same transfer_id does not resubmit corrected details. If an intent remains pending without progress, use the deposit recovery procedure before submitting another transfer.

TDAB's public key

TDAB gives you its X25519 public key as a base64 string, 44 characters decoding to exactly 32 bytes. Validate it on load and fail closed if it is missing or malformed: never fall back to sending details unencrypted. Rotation is a configuration change on your side, and because every message uses a fresh ephemeral keypair it needs no coordination beyond the cutover.

Sealed boxes are randomized, so identical details seal to different ciphertexts every time. To check your integration, seal the sample payload and have TDAB confirm it round-trips.

import base64, json
from nacl.public import PublicKey, SealedBox

def seal_bank_details(account_number, routing_number, tdab_pk_b64):
    pk = PublicKey(base64.b64decode(tdab_pk_b64))
    plaintext = json.dumps({
        "account_number": account_number,
        "routing_number": routing_number,
    }, separators=(",", ":")).encode()
    return base64.b64encode(SealedBox(pk).encrypt(plaintext)).decode()
Plaintext, before sealing
{"account_number":"1234567890","routing_number":"021000021"}
Flows

Withdrawals

A withdrawal redeems tokens back to dollars: the tokens leave the user's wallet, and the dollars pay out to their external bank account over ACH. One call starts it.

POST /withdraw
FieldTypeNotes
user_idstringYour externalId for the user
token_symbolstringe.g. eUSD
networkstringThe network the tokens are burned on, e.g. polygon
amountinteger, centsPositive; the user's wallet must already hold at least this much (checked on-chain at call time)
transfer_idstring, optionalYour idempotency key (transferId also accepted); a retry with the same value returns the existing withdrawal, never a second burn

Returns 200 with an intentId, your transferId echoed if you sent one, and a status of pending. An insufficient on-chain balance returns a 400 up front.

curl -s -X POST "https://api.sandbox.tdab.tech/withdraw" \
  -H "Authorization: $TDAB_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "user_id":"3f9c81a0-7d2e-4b8a-9c1d-5e6f7a8b9c0d",
       "token_symbol":"eUSD",
       "network":"polygon",
       "amount":10000,
       "transfer_id":"your-idempotency-key-002" }'
Response 200
{
  "intentId": "wi_1755781260000_p7q2m",
  "transferId": "your-idempotency-key-002",
  "status": "pending"
}

Serving the payout beneficiary

A withdrawal pays out to the user's bank account, and you are the source of truth for those details. TDAB asks you for them per withdrawal, at the moment of payout, by calling an endpoint you serve.

Serve it at this exact path on your registered base URL. TDAB calls it with your environment's outbound auth header:

POST {your_base_url}/withdrawal-auth/batch

The current callback sends an array containing one withdrawal identifier. How you source the account details is up to you: pull them live from whatever bank-linking provider you use, or read them from your own records. TDAB requires only the routing and account number.

FieldTypeNotes
transaction_idstringEcho the intentId you were asked about
routing_numberstringExactly 9 digits
account_numberstringThe destination account
errorstring, optionalSet it instead of the numbers when you have no payout account for that withdrawal

This runs before the burn, as a pre-flight check. If you respond that no payout account is available, the withdrawal fails while the user still holds their tokens, so nothing irreversible has happened. A 5xx or a timeout is treated as transient and retried: the withdrawal is held, not failed.

What you return is recorded against that withdrawal, encrypted, so a return or a dispute can be traced back to the account that was actually paid. It is not reused as the destination for a later withdrawal: you are asked again every time, because only you know whether the user has changed banks.

POST {your_base_url}/withdrawal-auth/batch

{ "tx_identifiers": [
    "wi_1755781260000_p7q2m"
] }
You respond 200
{ "results": [
    { "transaction_id": "wi_1755781260000_p7q2m",
      "routing_number": "021000021",
      "account_number": "123456789" }
] }

Withdrawal lifecycle

After the response, TDAB asks you for the payout beneficiary, burns the tokens, and submits the payout. The beneficiary is resolved before the burn, so a missing payout account fails the withdrawal while the user still holds their tokens. You see the lifecycle as withdrawal-status events:

pending → processing → completed or failed

Several internal steps (beneficiary check, burn, in-flight ACH transfer) all report as processing, so expect more than one processing event. completed means the payout actually settled, not that the burn was confirmed on-chain. If no valid payout account can be resolved, the withdrawal fails before anything irreversible happens.

You can poll the same lifecycle at any time with GET /withdraw/status?intentId=….

Payouts settle over ACH. The rail is chosen on the TDAB side and never changes how you call POST /withdraw, so build against the status events rather than assuming a settlement time.

curl -s "https://api.sandbox.tdab.tech/withdraw/status?intentId=wi_1755781260000_p7q2m" \
  -H "Authorization: $TDAB_API_KEY"
Response 200
{
  "success": true,
  "intent": {
    "intentId": "wi_1755781260000_p7q2m",
    "transferId": "your-idempotency-key-002",
    "status": "processing",
    "amount": 10000,
    "tokenSymbol": "eUSD",
    "network": "polygon",
    "walletAddress": "0x9F2A1c3De45b8F001B7C4a2D9E6f1234ABCD4B7C",
    "createdAt": 1755781260000,
    "updatedAt": 1755781295000
  }
}
Flows

Status updates

Your environment registers one base URL at onboarding. Event notifications POST JSON to the paths below. Callback questions and the diagnostic probe have different header requirements.

Everything TDAB calls on your base URL

You register one baseUrl. The paths below distinguish blocking questions, push notifications, and the diagnostic probe:

PathKindWhat it is
POST /withdrawal-auth/batchBlockingTDAB asks for the payout beneficiary and waits. Your answer decides whether the withdrawal proceeds, so this endpoint must stay available and respond promptly. See Serving the payout beneficiary
POST /user/walletBlockingAsks you for a user's wallet address, given { "user_id": … }. Answer with { "wallet_id": "0x…" }
GET /status/readyDiagnosticReachability probe. Answer 200 when healthy. It is not automatically checked before each money movement
POST / (bare base URL)PushAccount registration: the user's routing and account details, in snake_case, once
POST /accounts/updatedPushThe user's wallet address changed
POST /webhook/deposit-statusPushDeposit lifecycle
POST /webhook/withdrawal-statusPushWithdrawal lifecycle
POST /webhook/account-verificationPushA micro-deposit landed

A push is a notification: TDAB does not read your response body, and a failure to deliver does not stop anything. A blocking call is a question TDAB waits on, and the answer determines what happens next. If a blocking call fails, the money movement behind it stops rather than proceeding on a guess.

The /webhook/* notifications and /accounts/updated carry an Idempotency-Key derived from the event. Deduplicate those deliveries on that key. The header is not sent on the original account-registration push or the wallet and beneficiary callbacks.

Registration and /user/wallet use your configured outbound auth header and include X-Signature when a signing secret is configured. The beneficiary callback uses the configured outbound auth header and does not generate X-Signature. The diagnostic probe sends neither auth nor event headers. Configure validation separately for each path; the signature example below applies to signed deliveries.

Make account registration idempotent using current_user_id and tdab_partner_account_id. Wallet and beneficiary callbacks must return their JSON answer again when repeated; a duplicate acknowledgement without that answer does not satisfy the callback.

  • Idempotency-Key: a stable key per event, so a duplicate delivery is detectable. Dedupe on it rather than assuming exactly-once.
  • X-Signature: an HMAC-SHA256 hex digest of the exact request body, keyed with your environment's signing secret. Verify it before trusting the payload: compute the digest over the raw bytes you received (not re-serialized JSON) and compare in constant time; the accompanying example includes a Node implementation. If your environment has no signing secret configured, events arrive unsigned; ask for one.
  • Your environment's configured auth header, if one was registered, echoed on event deliveries and the blocking callbacks.
PathFires when
Money movement
/webhook/deposit-statusA deposit progresses: unposted → posted → swept → minted / failed
/webhook/withdrawal-statusA withdrawal progresses: pending → processing (several internal steps collapse here) → completed / failed
Account events
(none, posts to your base URL)TDAB finishes creating the user's own bank account: routing and account details, pushed once
/webhook/account-verificationA micro-deposit verifying a linked external bank account lands
/accounts/updatedThe user's wallet address changes (the one field that fires this today)

Deposits

Deposit events carry your transfer_id back as transferId, plus the externalUserId and depositId. Store these identifiers for reconciliation. minted is the only status with a txHash; failed carries errorMessage. With a known depositId, GET /api/account/deposit?userId=…&id=… returns the same payload shape. If you never received that ID, follow deposit recovery.

Withdrawals

Withdrawal events wrap the intent in the same shape the /withdraw/status poll returns. Because several internal steps all map to processing, you will receive multiple processing events with distinct idempotency keys; treat them as heartbeats, not duplicates. completed means the fiat payout settled.

Users & accounts

The account-registration push POSTs to your bare base URL (no sub-path) with the user's routing and account details in snake_case; it is also the only event with automatic retries today (up to three total attempts, with waits of 1s and 2s before the second and third attempts; a 4xx response is treated as permanent and not retried). account.verification reports micro-deposits: trust sendingBank for the counterparty; routingNumber in that payload is TDAB's own receiving account by design, not the sender's.

Deliveries are not retried automatically. Apart from the account-registration push, a failed delivery is logged and can be replayed by the TDAB team, but nothing retries it on its own. With a known deposit ID, poll GET /api/account/deposit?userId=…&id=…; for withdrawals, poll GET /withdraw/status?intentId=…. If the deposit ID is missing, follow deposit recovery.

Amount is not one format across events. Check each integration point against the examples rather than assuming one convention.

WhereFormat
/deposit and /withdraw requestsInteger cents
Deposit status eventsCents as a string ("700" is $7.00)
Withdrawal status eventsAbsent; poll /withdraw/status if you need it
account.verification eventsDecimal dollars (0.31 is 31 cents)
Deposit status
{
  "depositId": "dep_9f21ac04",
  "intentId": "di_1755781200000_k3f9x",
  "transferId": "your-idempotency-key-001",
  "externalUserId": "3f9c81a0-7d2e-4b8a-9c1d-5e6f7a8b9c0d",
  "status": "minted",
  "amount": "25000",
  "currency": "USD",
  "txHash": "0xa9c3f21e7b6d4c8a0f1e2d3c4b5a69788f0e1d2c3b4a5968f7e6d5c4b3a29180"
}
Withdrawal status
{
  "success": true,
  "intent": {
    "intentId": "wi_1755781260000_p7q2m",
    "transferId": "your-idempotency-key-002",
    "status": "processing",
    "network": "polygon",
    "txHash": "0xa9c3f21e7b6d4c8a0f1e2d3c4b5a69788f0e1d2c3b4a5968f7e6d5c4b3a29180",
    "fiatTransferId": "ach_9182x"
  }
}
Account registration (to base URL)
{
  "current_user_id": "3f9c81a0-7d2e-4b8a-9c1d-5e6f7a8b9c0d",
  "tdab_partner_account_id": "j97f2b1c4e8a09d3",
  "beneficiary_name": "Jordan Lee",
  "account_number_last4": "4821",
  "full_routing_number": "071000301",
  "bank_name": "Battle Creek State Bank",
  "account_type": "Primary"
}
Account verification
{
  "type": "account_verification",
  "verification": {
    "traceId": "achtrc_20260819_0091",
    "externalUserId": "3f9c81a0-7d2e-4b8a-9c1d-5e6f7a8b9c0d",
    "routingNumber": "071000301",
    "accountLast4": "7734",
    "amount": 0.31,
    "sendingBank": "CAPITAL ONE",
    "createdAt": "2026-08-19T09:14:02Z",
    "debitOrCredit": "credit"
  }
}
Accounts updated
{
  "externalUserId": "3f9c81a0-7d2e-4b8a-9c1d-5e6f7a8b9c0d",
  "fields": {
    "walletAddress": "0x9F2A1c3De45b8F001B7C4a2D9E6f1234ABCD4B7C"
  },
  "updatedAt": 1755781320000
}
Verify X-Signature (Node)
import crypto from "node:crypto";

// rawBody: the exact bytes received.
// Re-serializing the parsed JSON breaks the digest.
function verifySignature(rawBody, header, secret) {
  const expected = crypto
    .createHmac("sha256", secret)
    .update(rawBody)
    .digest("hex");
  const a = Buffer.from(expected, "hex");
  const b = Buffer.from(header ?? "", "hex");
  return a.length === b.length &&
    crypto.timingSafeEqual(a, b);
}
Reference

Errors

There is no cross-endpoint error-code enum. Money-movement and user endpoints return {"success": false, "error": "…"} with a plain-English message; the deposit pull endpoint returns {"error": "…"}. Branch on the HTTP status, and log the message.

StatusWhen
400A missing or malformed field, an unregistered user, unverified KYC, no wallet on file, or insufficient balance; the message says which
401Any authentication failure: missing, unknown, disabled, or out-of-scope key. Always the same body, {"error":"Unauthorized"}, with no further detail
403Refused at the edge because the source address is not on the sandbox allowlist. No JSON body. This is a network-layer block, not an auth failure, so the key is irrelevant to it
403The user exists but belongs to another environment. Body is identical to the not-found case on purpose, so ids cannot be used to probe another partner's users. Also returned when a key is not environment-scoped and the endpoint requires one
404No such deposit or withdrawal. Someone else's, or none at all, look identical, so ids cannot be used to probe another partner's activity
405Wrong HTTP method (plain-text body)
409Create-user field conflict, with a conflicts array naming each clashing field
500{"success":false,"error":"Internal server error"}; safe to retry with the same transfer_id
503A dependency needed for this call is unavailable. Transient by nature, so retry rather than treating it as a rejection
Validation error
{
  "success": false,
  "error": "Amount must be a positive integer (cents) as number or string"
}
Auth failure 401
{ "error": "Unauthorized" }