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.
Quickstart
- 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.
- Create your user.
POST /api/create-userwith your ownexternalId, adding the user's wallet address then or later (it must be on file before any deposit mints).externalIdmust be a UUID. - Have the user complete KYC. proposed
POST /api/kyc-newwith the user's UUID from the previous step, then pass what it returns to your frontend: ansdk_tokenfor the Sumsub SDK embedded in your own app, or awebsdk_urlyou deliver however you already reach your users. Verification (KYC) is not instant.POST /withdrawis refused until the status isverified;POST /depositis accepted earlier and held aspending_kycuntil 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. - Move money.
POST /depositto pull dollars from the user's external bank account (tokens mint automatically when it settles), orPOST /withdrawto redeem tokens back to dollars. - Track status. Receive webhooks at your registered base URL. With a known deposit ID, poll
GET /api/account/deposit?userId=…&id=…; for a withdrawal, pollGET /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" }'
await fetch("https://api.sandbox.tdab.tech/api/create-user", {
method: "POST",
headers: {
Authorization: process.env.TDAB_API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({ externalId: "3f9c81a0-7d2e-4b8a-9c1d-5e6f7a8b9c0d" }),
});
import os, requests
requests.post(
"https://api.sandbox.tdab.tech/api/create-user",
headers={"Authorization": os.environ["TDAB_API_KEY"]},
json={"externalId": "3f9c81a0-7d2e-4b8a-9c1d-5e6f7a8b9c0d"},
)
{
"success": true,
"userId": "usr_2f9c81a0",
"created": true,
"updatedFields": [],
"noopFields": []
}
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:
| Pattern | Grants |
|---|---|
* | Every endpoint |
/deposit | Only 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"
await fetch("https://api.sandbox.tdab.tech/api/account-info?userId=3f9c81a0-7d2e-4b8a-9c1d-5e6f7a8b9c0d", {
headers: { Authorization: "Bearer " + process.env.TDAB_API_KEY }, // Incorrect: use the raw key.
});
import os, requests
requests.get(
"https://api.sandbox.tdab.tech/api/account-info?userId=3f9c81a0-7d2e-4b8a-9c1d-5e6f7a8b9c0d",
headers={"Authorization": "Bearer " + os.environ["TDAB_API_KEY"]}, # Incorrect: use the raw key.
)
{ "error": "Unauthorized" }
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.
/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.
| Field | Type | Notes |
|---|---|---|
externalId | string, UUID | Your own user id; required. Must be a UUID: the KYC enrollment call keys the verification to this value and rejects anything else |
walletAddress | string, optional | EIP-55 checksummed; can be added later, required before any deposit mints |
network | string, optional | The chain the wallet is on, e.g. polygon. Defaults to the environment's network if omitted proposed |
permit | object | Required 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" }'
await fetch("https://api.sandbox.tdab.tech/api/create-user", {
method: "POST",
headers: {
Authorization: process.env.TDAB_API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({
externalId: "3f9c81a0-7d2e-4b8a-9c1d-5e6f7a8b9c0d",
network: "polygon",
walletAddress: "0x9F2A1c3De45b8F001B7C4a2D9E6f1234ABCD4B7C",
}),
});
import os, requests
requests.post(
"https://api.sandbox.tdab.tech/api/create-user",
headers={"Authorization": os.environ["TDAB_API_KEY"]},
json={
"externalId": "3f9c81a0-7d2e-4b8a-9c1d-5e6f7a8b9c0d",
"network": "polygon",
"walletAddress": "0x9F2A1c3De45b8F001B7C4a2D9E6f1234ABCD4B7C",
},
)
{
"success": true,
"userId": "usr_2f9c81a0",
"created": true,
"updatedFields": [],
"noopFields": []
}
{
"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_tokento 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_urlyou 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
/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.
| Field | Type | Notes |
|---|---|---|
user_id | string, UUID | Your 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_method | string, optional | sdk_token or websdk_url. Selects which one comes back; defaults to sdk_token when omitted proposed |
email | string, optional | Prefills the session so the user does not retype what you already hold proposed |
phone | string, optional | Same, 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
/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:
| Field | Notes |
|---|---|
kycStatus | inactive, pending, verified or failed |
rejectedBy | Only on a failure. Distinguishes a bank rejection from an identity rejection, rather than showing a bare failure |
walletAddressOnFile | Whether a wallet is attached yet |
bankAccountReady | Whether 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" }'
await fetch("https://api.sandbox.tdab.tech/api/kyc-new", {
method: "POST",
headers: {
Authorization: process.env.TDAB_API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({ user_id: "3f9c81a0-7d2e-4b8a-9c1d-5e6f7a8b9c0d" }),
});
import os, requests
requests.post(
"https://api.sandbox.tdab.tech/api/kyc-new",
headers={"Authorization": os.environ["TDAB_API_KEY"]},
json={"user_id": "3f9c81a0-7d2e-4b8a-9c1d-5e6f7a8b9c0d"},
)
{ "sdk_token": "_act-sbx-jwt-..." }
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" }'
{ "websdk_url": "https://in.sumsub.com/websdk/p/sbx_..." }
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-..." }'
{ "error": "Unauthorized" }
user_id not a UUID 400{ "error": "Invalid JSON payload" }
curl -s "https://api.sandbox.tdab.tech/api/kyc-status?userId=3f9c81a0-7d2e-4b8a-9c1d-5e6f7a8b9c0d" \
-H "Authorization: $TDAB_API_KEY"
{
"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
/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.
| Field | Type | Notes |
|---|---|---|
wallet_address | string | The wallet being linked; 0x-prefixed, 20 bytes |
network | string, optional | Defaults to your environment's network |
token_symbol | string, optional | Defaults 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:
| Field | Type | Notes |
|---|---|---|
permitRequestId | string | The id you were issued. Binds the signature to the message TDAB built, and to this wallet |
v | integer | 27 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 |
r | string | 0x-prefixed, 32 bytes |
s | string | 0x-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" }'
await fetch("https://api.sandbox.tdab.tech/api/permit-request", {
method: "POST",
headers: {
Authorization: process.env.TDAB_API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({ wallet_address: "0x9F2A1c3De45b8F001B7C4a2D9E6f1234ABCD4B7C" }),
});
import os, requests
requests.post(
"https://api.sandbox.tdab.tech/api/permit-request",
headers={"Authorization": os.environ["TDAB_API_KEY"]},
json={"wallet_address": "0x9F2A1c3De45b8F001B7C4a2D9E6f1234ABCD4B7C"},
)
{
"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
}
}
}
{ "success": true, "permitRequired": false }
// 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.
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…" } }'
{ "success": false, "error": "PERMIT_REQUIRED" }
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"
await fetch("https://api.sandbox.tdab.tech/api/account-info?userId=3f9c81a0-7d2e-4b8a-9c1d-5e6f7a8b9c0d", {
headers: { Authorization: process.env.TDAB_API_KEY },
});
import os, requests
requests.get(
"https://api.sandbox.tdab.tech/api/account-info?userId=3f9c81a0-7d2e-4b8a-9c1d-5e6f7a8b9c0d",
headers={"Authorization": os.environ["TDAB_API_KEY"]},
)
{
"success": true,
"userId": "3f9c81a0-7d2e-4b8a-9c1d-5e6f7a8b9c0d",
"accountNumber": "4821009273",
"routingNumber": "071000301"
}
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.
/deposit
| Field | Type | Notes |
|---|---|---|
user_id | string | Your externalId for the user |
token_symbol | string | e.g. eUSD |
network | string | Required 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 |
amount | integer, cents | Positive; a string of digits is also accepted |
transfer_id | string | Unique 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_details | string | A libsodium sealed box (base64) of the user's external bank details, forwarded to the bank leg untouched; TDAB never sees it in the clear |
balance | integer, cents, optional | The 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_timestamp | string, optional | RFC 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.
| Currency | Networks |
|---|---|
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" }'
await fetch("https://api.sandbox.tdab.tech/deposit", {
method: "POST",
headers: {
Authorization: process.env.TDAB_API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({
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",
}),
});
import os, requests
requests.post(
"https://api.sandbox.tdab.tech/deposit",
headers={"Authorization": os.environ["TDAB_API_KEY"]},
json={
"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",
},
)
{ "intentId": "di_1755781200000_k3f9x", "status": "pending" }
{ "intentId": "di_1755781200000_k3f9x", "status": "pending_kyc" }
{ "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:
| Field | Rule |
|---|---|
routing_number | Exactly 9 digits |
account_number | 4 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()
const sodium = require('libsodium-wrappers');
async function sealBankDetails(accountNumber, routingNumber, tdabPkB64) {
await sodium.ready;
const pk = sodium.from_base64(tdabPkB64, sodium.base64_variants.ORIGINAL);
const plaintext = JSON.stringify({
account_number: accountNumber,
routing_number: routingNumber,
});
const sealed = sodium.crypto_box_seal(sodium.from_string(plaintext), pk);
return sodium.to_base64(sealed, sodium.base64_variants.ORIGINAL);
}
{"account_number":"1234567890","routing_number":"021000021"}
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
/withdraw
| Field | Type | Notes |
|---|---|---|
user_id | string | Your externalId for the user |
token_symbol | string | e.g. eUSD |
network | string | The network the tokens are burned on, e.g. polygon |
amount | integer, cents | Positive; the user's wallet must already hold at least this much (checked on-chain at call time) |
transfer_id | string, optional | Your idempotency key (transferId also accepted); a retry with the same value returns the existing withdrawal, never a second burn |
sealed_bank_details | string, optional | A 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 |
permit | object, optional | EIP-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..." }'
await fetch("https://api.sandbox.tdab.tech/withdraw", {
method: "POST",
headers: {
Authorization: process.env.TDAB_API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({
user_id: "3f9c81a0-7d2e-4b8a-9c1d-5e6f7a8b9c0d",
token_symbol: "eUSD",
network: "polygon",
amount: 10000,
transfer_id: "your-idempotency-key-002",
sealed_bank_details: "c2VhbGVkX2JveF9jaXBoZXJ0ZXh0...",
}),
});
import os, requests
requests.post(
"https://api.sandbox.tdab.tech/withdraw",
headers={"Authorization": os.environ["TDAB_API_KEY"]},
json={
"user_id": "3f9c81a0-7d2e-4b8a-9c1d-5e6f7a8b9c0d",
"token_symbol": "eUSD",
"network": "polygon",
"amount": 10000,
"transfer_id": "your-idempotency-key-002",
"sealed_bank_details": "c2VhbGVkX2JveF9jaXBoZXJ0ZXh0...",
},
)
{
"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:
{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.
| Field | Type | Notes |
|---|---|---|
transaction_id | string | Echo the intentId you were asked about |
routing_number | string | Exactly 9 digits |
account_number | string | The destination account |
error | string, optional | Set 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"
] }
{ "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"
await fetch("https://api.sandbox.tdab.tech/withdraw/status?intentId=wi_1755781260000_p7q2m", {
headers: { Authorization: process.env.TDAB_API_KEY },
});
import os, requests
requests.get(
"https://api.sandbox.tdab.tech/withdraw/status?intentId=wi_1755781260000_p7q2m",
headers={"Authorization": os.environ["TDAB_API_KEY"]},
)
{
"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
}
}
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:
| Path | Kind | What it is |
|---|---|---|
POST /withdrawal-auth/batch | Blocking | TDAB 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/wallet | Blocking | Asks you for a user's wallet address, given { "user_id": … }. Answer with { "wallet_id": "0x…" } |
GET /status/ready | Diagnostic | Reachability probe. Answer 200 when healthy. It is not automatically checked before each money movement |
POST / (bare base URL) | Push | Account registration: the user's routing and account details, in snake_case, once |
POST /accounts/updated | Push | The user's wallet address changed |
POST /webhook/deposit-status | Push | Deposit lifecycle |
POST /webhook/withdrawal-status | Push | Withdrawal lifecycle |
POST /webhook/account-verification | Push | A 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.
| Path | Fires when |
|---|---|
| Money movement | |
/webhook/deposit-status | A deposit progresses: created → unposted → posted → swept → minted / failed |
/webhook/withdrawal-status | A 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-verification | A micro-deposit verifying a linked external bank account lands |
/accounts/updated | The 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.
| Where | Format |
|---|---|
/deposit requests (including the optional balance attestation) and /withdraw requests | Integer cents |
| Deposit status events | Cents as a string ("700" is $7.00) |
| Withdrawal status events | Absent; poll /withdraw/status if you need it |
account.verification events | Decimal dollars (0.31 is 31 cents) |
{
"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"
}
{
"success": true,
"intent": {
"intentId": "wi_1755781260000_p7q2m",
"transferId": "your-idempotency-key-002",
"status": "processing",
"network": "polygon",
"txHash": "0xa9c3f21e7b6d4c8a0f1e2d3c4b5a69788f0e1d2c3b4a5968f7e6d5c4b3a29180",
"fiatTransferId": "ach_9182x"
}
}
{
"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"
}
{
"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"
}
}
{
"externalUserId": "3f9c81a0-7d2e-4b8a-9c1d-5e6f7a8b9c0d",
"fields": {
"walletAddress": "0x9F2A1c3De45b8F001B7C4a2D9E6f1234ABCD4B7C"
},
"updatedAt": 1755781320000
}
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);
}
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.
| Status | When |
|---|---|
| 400 | A 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 |
| 401 | Any authentication failure: missing, unknown, disabled, or out-of-scope key. Always the same body, {"error":"Unauthorized"}, with no further detail |
| 403 | Refused 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 |
| 403 | The 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 |
| 404 | No such deposit or withdrawal. Someone else's, or none at all, look identical, so ids cannot be used to probe another partner's activity |
| 405 | Wrong HTTP method (plain-text body) |
| 409 | Create-user field conflict, with a conflicts array naming each clashing field |
| 422 | The 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 |
| 503 | A dependency needed for this call is unavailable. Transient by nature, so retry rather than treating it as a rejection |
{
"success": false,
"error": "Amount must be a positive integer (cents) as number or string"
}
{ "error": "Unauthorized" }