TDAB Partner API
v0.2 · nightly
Sandbox
Telcoin Digital Asset Bank

You are reading v0.2, the nightly build. This is the next release, published early so you can plan and design against the API before it ships. Anything marked proposed is not live yet and can still change. To integrate today, build against v0.1, the current release, which is verified against what runs in production. When v0.2 ships, it becomes current.

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.

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

  • Registering a user: create the record, complete KYC verification, and for a self-custody wallet, collect the one signature that makes redemption possible later.
  • 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, and the X25519 public key you seal bank details 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). externalId must be a UUID.
  3. Have the user complete KYC. proposed POST /api/kyc-new with the user's UUID from the previous step, then pass what it returns to your frontend: an sdk_token for the Sumsub SDK embedded in your own app, or a websdk_url you deliver however you already reach your users. Verification (KYC) is not instant. POST /withdraw is refused until the status is verified; POST /deposit is accepted earlier and held as pending_kyc until the verdict lands proposed, see Deposits before the KYC verdict. See KYC verification. Until the main-API route ships, verification runs on its own host with a signed request; the current release documents that flow.
  4. 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.
  5. 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. Examples carry it literally, so it can be copied directly. The only variable is your key: export TDAB_API_KEY first.

The sandbox sits 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
externalIdstring, UUIDYour own user id; required. Must be a UUID: the KYC enrollment call keys the verification to this value and rejects anything else
walletAddressstring, optionalEIP-55 checksummed; can be added later, required before any deposit mints
networkstring, optionalThe chain the wallet is on, e.g. polygon. Defaults to the environment's network if omitted proposed
permitobjectRequired with walletAddress if your users hold their own keys. See Self-custody wallets proposed

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.

One network per user. proposed In this release the chain is chosen once, here, with network. Deposits and withdrawals inherit it, so it is not repeated on every money-movement call. One user, one network, one bank account; a user who wants a second chain registers a second user.

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",
       "network":"polygon",
       "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

TDAB verifies users with Sumsub, and the integration reflects that: the credential you get back is a Sumsub session, and if you embed the flow you are embedding the Sumsub Web SDK. Applicants are created under TDAB's Sumsub account against a TDAB-configured verification level, so you need no Sumsub account, contract or credentials of your own.

Every enrollment works the same way: call the API, get a short-lived credential back, and present it to your user. You choose the form it takes:

  • An sdk_token to mount the Sumsub Web SDK inside your app, so the user never leaves your interface and you own the surrounding UX. Sumsub's own Web SDK documentation covers the mount.
  • A websdk_url you deliver yourself. Nothing to integrate, and you control how it is delivered.

/api/kyc-existing is the same call with the applicant pre-populated: a share token from your own Sumsub account seeds the applicant with documents your user has already supplied, so the session asks only for what is still missing. It is not a way to skip the session.

Verification is part of the main API and authenticates exactly like every other call on this page proposed: your API key, raw, in the Authorization header, no Bearer prefix and nothing alongside it. Failures return the same 401 with {"error":"Unauthorized"}. See Authentication.

Until the main-API route ships, verification runs on its own host, https://kyc.sandbox.tdab.tech, under /partner/kyc-new and /partner/kyc-existing, with your API key plus an RFC 9421 request signature and a Content-Digest. The current release documents that flow in full. Everything on this page marked proposed replaces it.

Start verification proposed

POST /api/kyc-new

Creates the verification session and hands you a short-lived credential for it. You choose the form proposed: a token to feed the Sumsub Web SDK embedded in your own app, so the user never leaves your interface, or a hosted URL you deliver however you already reach your users, in-app, by text, by email. Either way the artifact is yours to hand to your frontend. TDAB sends nothing to your users directly, so all communication with them stays in your product's voice.

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
access_methodstring, optionalsdk_token or websdk_url. Selects which one comes back; defaults to sdk_token when omitted proposed
emailstring, optionalPrefills the session so the user does not retype what you already hold proposed
phonestring, optionalSame, in E.164 (+15551234567) proposed

Returns exactly one of the two, never both proposed: { "sdk_token": "…" }, or { "websdk_url": "…" } if you asked for that instead. Both point at the same session and both expire 30 minutes after issue, so fetch one when you are ready to use it. Treat either as a session secret: anyone holding it can open that user's verification.

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. That also means you can call again with the other access_method if the user abandons one route and comes back through another.

Reusing an existing applicant proposed

POST /api/kyc-existing

If your user already verified with Sumsub elsewhere and you hold a share token for them, send it as share_token alongside user_id (same UUID rule), with the same optional access_method. You get back exactly what /api/kyc-new returns.

What transfers is the data, not the decision. The share token hands TDAB's Sumsub the documents and details your user already supplied, so they are not asked for them twice. Everything else is unchanged: your user still opens the session to fill whatever gaps TDAB's level requires, TDAB's Sumsub still verifies the result itself, the status still starts at pending, and the outcome can differ from the approval you already hold.

Coming in this release: onboarding in one call. Send your user id and their wallet address, get back a verification link or SDK token, and present it however your product works: embedded, in-app, or as a link you deliver yourself. TDAB never contacts your users directly. The endpoint name and payload are being finalized and will be documented here first.

Verification outcome

Verification is not instant. Approval reaches you as an account event: TDAB opens the user's bank account and pushes its routing and account details to your base URL, once, see Opening the account. Until it arrives, POST /withdraw returns 400 with User KYC not verified. POST /deposit is different proposed: it accepts the deposit and holds it as pending_kyc, then pulls the funds once the account opens, see Deposits before the KYC verdict. A rejected verdict fails any held deposit with kyc_rejected.

A rejection produces no event of its own. No webhook reports a failed verification directly, so a user with nothing in flight who has been rejected looks exactly like one still in progress. Poll for the outcome rather than waiting on an event that never arrives. The one indirect signal is a held deposit proposed: if the user submitted a deposit before the verdict, rejection fails it and you receive a deposit-status event with status: "failed" and errorMessage: "kyc_rejected", see Deposits before the KYC verdict.

To check at any point, or to render progress in your own UI, poll GET /api/kyc-status?userId=… proposed:

FieldNotes
kycStatusinactive, pending, verified or failed
rejectedByOnly on a failure. Distinguishes a bank rejection from an identity rejection, rather than showing a bare failure
walletAddressOnFileWhether a wallet is attached yet
bankAccountReadyWhether the account exists on the core ledger

The last two matter because both gate money movement: a user can be verified and still not be ready for a deposit.

curl -s -X POST "https://api.sandbox.tdab.tech/api/kyc-new" \
  -H "Authorization: $TDAB_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "user_id":"3f9c81a0-7d2e-4b8a-9c1d-5e6f7a8b9c0d" }'
Response 200
{ "sdk_token": "_act-sbx-jwt-..." }
Ask for a URL instead proposed
curl -s -X POST "https://api.sandbox.tdab.tech/api/kyc-new" \
  -H "Authorization: $TDAB_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "user_id":"3f9c81a0-7d2e-4b8a-9c1d-5e6f7a8b9c0d",
       "access_method":"websdk_url" }'
Response 200
{ "websdk_url": "https://in.sumsub.com/websdk/p/sbx_..." }
Import an existing applicant instead proposed
curl -s -X POST "https://api.sandbox.tdab.tech/api/kyc-existing" \
  -H "Authorization: $TDAB_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "user_id":"3f9c81a0-7d2e-4b8a-9c1d-5e6f7a8b9c0d",
       "share_token":"_act-sbx-share-..." }'
Key missing, unknown or out of scope proposed 401
{ "error": "Unauthorized" }
Body not valid JSON or user_id not a UUID 400
{ "error": "Invalid JSON payload" }
Then check where it landed proposed
curl -s "https://api.sandbox.tdab.tech/api/kyc-status?userId=3f9c81a0-7d2e-4b8a-9c1d-5e6f7a8b9c0d" \
  -H "Authorization: $TDAB_API_KEY"
Response 200
{
  "success": true,
  "userId": "3f9c81a0-7d2e-4b8a-9c1d-5e6f7a8b9c0d",
  "kycStatus": "pending",
  "walletAddressOnFile": true,
  "bankAccountReady": false
}

Self-custody wallets proposed

Proposed for this release. The permit flow below is not live in production yet. It is how self-custody wallets (MetaMask, Ledger, a Safe) will be able to redeem, and it is documented here first.

This section does not apply if TDAB issues the wallets for your users. It applies when your users hold their own keys, for example MetaMask, Ledger or a Safe.

Redeeming burns tokens out of the user's wallet, and the burn contract can only do that against an on-chain ERC-20 allowance. A wallet TDAB issued is recognized and skips that check; a self-custody wallet is not, so without an allowance the burn reverts on-chain and the withdrawal fails late, after the user has already asked for their money.

The allowance is therefore granted once, at linking, rather than per withdrawal. Two calls: ask for the message, then send back the signature.

1. Ask for the message

POST /api/permit-request

TDAB builds the message because the signature must cover the spender and the wallet's current on-chain nonce. The spender is a burner vault resolved per token contract, and the nonce is read from the contract at request time.

FieldTypeNotes
wallet_addressstringThe wallet being linked; 0x-prefixed, 20 bytes
networkstring, optionalDefaults to your environment's network
token_symbolstring, optionalDefaults to eUSD

Returns permitRequired, and when it is true, a permitRequestId, an expiresAt, and a typedData object you can pass straight to eth_signTypedData_v4.

Check permitRequired before prompting. A wallet that already holds the allowance comes back false with nothing to sign, and a user who granted it once should never see the prompt again.

Requests are single-use and expire after 15 minutes. The window is short because the nonce is read at the moment of issue, and any permit accepted for that wallet in between invalidates an outstanding signature. If it lapses, ask for a new one and sign that.

2. Send back the signature

Render the typed data in the user's wallet, then pass the signature to POST /api/create-user as the permit object:

FieldTypeNotes
permitRequestIdstringThe id you were issued. Binds the signature to the message TDAB built, and to this wallet
vinteger27 or 28. Wallets return this form already; a raw secp256k1 signer returns the recovery id, 0 or 1, so add 27 before you send it
rstring0x-prefixed, 32 bytes
sstring0x-prefixed, 32 bytes

If you hold the user's keys, there is no wallet UI to render. Compute the EIP-712 digest of typedData from the domain, types and message exactly as returned, and sign that 32-byte hash directly with the private key; do not sign it as a text message. The types object carries only Permit, which is the shape typed-data encoders expect. Send back v as 27 or 28, and r and s as 0x-prefixed 32-byte hex.

TDAB submits the permit with the exact values it gave you, so nothing you send back can change what is submitted on-chain. Linking without a valid permit is rejected with PERMIT_REQUIRED: onboarding a user who could never redeem is a worse outcome than a failed link, so this fails closed.

The allowance granted is unlimited and non-decrementing, so it survives every later withdrawal and the user signs once.

curl -s -X POST "https://api.sandbox.tdab.tech/api/permit-request" \
  -H "Authorization: $TDAB_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "wallet_address":"0x9F2A1c3De45b8F001B7C4a2D9E6f1234ABCD4B7C" }'
Response 200
{
  "success": true,
  "permitRequired": true,
  "permitRequestId": "k57f2h9a1b0c",
  "expiresAt": 1787900400000,
  "typedData": {
    "domain": {
      "name": "eUSD",
      "version": "1",
      "chainId": 137,
      "verifyingContract": "0x4d3B..."
    },
    "primaryType": "Permit",
    "types": {
      "Permit": [
        { "name": "owner", "type": "address" },
        { "name": "spender", "type": "address" },
        { "name": "value", "type": "uint256" },
        { "name": "nonce", "type": "uint256" },
        { "name": "deadline", "type": "uint256" }
      ]
    },
    "message": {
      "owner": "0x9F2A1c3De45b8F001B7C4a2D9E6f1234ABCD4B7C",
      "spender": "0x77aE...",
      "value": "115792089237316195423570985008687907853269984665640564039457584007913129639935",
      "nonce": "0",
      "deadline": 1787900400
    }
  }
}
Already allowed 200
{ "success": true, "permitRequired": false }
Signing with your own key
// No wallet UI: hash the typed data TDAB returned, then sign the hash.
import { SigningKey, TypedDataEncoder } from "ethers"; // v6

const { domain, types, message } = res.typedData;
const digest = TypedDataEncoder.hash(domain, types, message);
const sig = new SigningKey(userPrivateKey).sign(digest);
// sig.v is already 27 or 28; sig.r and sig.s are 0x-prefixed 32-byte hex.
const permit = { permitRequestId: res.permitRequestId, v: sig.v, r: sig.r, s: sig.s };

// Python (eth_account): encode_typed_data(full_message=res["typedData"]),
// then Account.sign_message(signable, key) gives v (27 or 28), r and s.
Then link the wallet
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",
       "permit": { "permitRequestId":"k57f2h9a1b0c",
                   "v":28, "r":"0x…", "s":"0x…" } }'
Permit missing or stale 400
{ "success": false, "error": "PERMIT_REQUIRED" }
Flows

Opening the account

Registration is the identity step. The account is what follows: the user completes KYC, and TDAB provisions a 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
balanceinteger, cents, optionalThe source account's balance as verified by a real-time balance check (for example Plaid) just before submission; omit when no usable balance was available proposed
balance_timestampstring, optionalRFC 3339 UTC (2026-08-31T18:20:34Z) of when the balance was retrieved; sent exactly when balance is, never one without the other proposed

Real-time balance attestation. proposed When you verify the source account's balance in real time before submitting, pass what you saw as balance with its balance_timestamp. Sending them is an attestation that you ran the check at that moment, and it is what lets TDAB release the ACH pull without the settlement hold. Deposits without them are still accepted and simply wait for settlement. Any real-time balance source works: Plaid, another provider, or your own records.

Returns 200 with an intentId and a status of pending. The user must already be registered under your environment and have a wallet address on file; either of those missing returns a 400 with a plain-English error. KYC does not have to be complete proposed: a user whose verdict is still undecided gets 200 with status: "pending_kyc" and the deposit is held, while a user who has already been rejected gets 400 with code: "kyc_rejected". Withdrawals are different and still require a verified user.

Deposits before the KYC verdict. proposed A user can fund their account before verification finishes. Submit the deposit as normal: while their KYC is undecided the response is 200 with status: "pending_kyc", no money moves, and TDAB keeps the sealed bank details. Once the user is approved and their account opens, the ACH pull is queued automatically and status events arrive as for any other deposit, starting at unposted. If the user is rejected, you receive a deposit-status event with status: "failed" and errorMessage: "kyc_rejected"; its depositId equals the intentId, since no bank-side deposit was ever created. That id works with GET /api/account/deposit like any other, so a missed event can be recovered the same way. A user who is already rejected gets the 400 above with code: "kyc_rejected": do not retry it. The hold is not live in production yet: today a deposit for an unverified user is refused with 400, as the current release documents.

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:

created → unposted → posted → swept → minted or failed

created proposed means TDAB has accepted the deposit but the ACH pull has not yet been initiated. 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...",
       "balance":993770,
       "balance_timestamp":"2026-08-31T18:20:34Z" }'
Response 200
{ "intentId": "di_1755781200000_k3f9x", "status": "pending" }
KYC still pending, held proposed 200
{ "intentId": "di_1755781200000_k3f9x", "status": "pending_kyc" }
KYC rejected proposed 400
{ "success": false, "error": "User KYC rejected", "code": "kyc_rejected" }

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 is enough, and the request has the same shape as a deposit: the amount and the sealed payout account travel together, and TDAB orchestrates everything after that. proposed

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
sealed_bank_detailsstring, optionalA libsodium sealed box (base64) of the user's payout bank details, built exactly as for a deposit (see Encrypting bank details) and forwarded to the bank leg untouched. When you send it, TDAB does not call your beneficiary endpoint for that withdrawal; when you omit it, the endpoint is asked as today proposed
permitobject, optionalEIP-2612 signature. Only for self-custody wallets, and only as a fallback: normally the allowance is already in place from wallet linking. See Self-custody wallets proposed

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.

Withdrawals in one call. proposed In this release the payout account can travel with the request as sealed_bank_details, the same sealed box you already build for deposits. TDAB then runs the beneficiary check, the burn and the payout without further calls from you, and you track progress through the status events. The field is optional: a withdrawal submitted without it still goes through the /withdrawal-auth/batch callback below, so you can migrate at your own pace. The callback is retired only once every integration sends the sealed box.

If your users hold their own keys, the on-chain allowance that makes the burn possible is granted once at wallet linking, not here. See Self-custody wallets. Passing permit on this call is only a fallback for a wallet that somehow has no allowance, and it covers just that one withdrawal.

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",
       "sealed_bank_details":"c2VhbGVkX2JveF9jaXBoZXJ0ZXh0..." }'
Response 200
{
  "intentId": "wi_1755781260000_p7q2m",
  "transferId": "your-idempotency-key-002",
  "status": "pending"
}

Serving the payout beneficiary

Optional from this release. proposed A withdrawal that carries sealed_bank_details on POST /withdraw is never asked about here; TDAB uses the sealed box. A withdrawal submitted without one is asked exactly as the current release describes, so keep serving this endpoint until every withdrawal you submit carries the sealed box. Retirement comes after that, as its own change.

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 resolves the payout account (the sealed box if the request carried one, otherwise your beneficiary endpoint), burns the tokens, and submits the payout. proposed The payout account is validated before the burn, so an unusable one, including a sealed box that fails to open, 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 (payout-account 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 the payout account cannot be used, 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 today. RTP and FedNow are coming as additional rails, and batched withdrawals with them. The rail is chosen on the TDAB side, so none of that changes how you call POST /withdraw: build against the status events rather than assuming ACH timing, and you will not need to revisit this when the faster rails become available.

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. Skipped for withdrawals that carry sealed_bank_details on POST /withdraw; still called for the rest. See Serving the payout beneficiary proposed
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: created → 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, which is kyc_rejected when a deposit held for KYC fails because the user was rejected (that event's depositId is the intentId) proposed. 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 requests (including the optional balance attestation) 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 on a withdrawal, a rejected KYC verdict on a deposit (code: "kyc_rejected" proposed), 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
422The request was valid but a downstream step refused it, for example a permit that could not be established. The action did not happen; nothing partial was left behind proposed
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" }