TDAB Partner API
One integration lets a partner issue and redeem bank-backed stablecoins for their own users: a deposit turns dollars in a user's external bank account into tokens in their wallet, a withdrawal turns tokens back into dollars, and every step reports back over webhooks or a status endpoint.
You are reading v0.1, the current release. This page describes the current production API. Confirm the capabilities enabled for your sandbox environment during onboarding. The v0.2 nightly page documents the next release ahead of time.
The integration has five parts, presented in the order you would build them:
- Registering a user: create the record and complete KYC verification.
- Opening the account: TDAB provisions the user's own US bank account and tells you its details.
- Deposits: pull dollars from the user's external bank account; tokens mint to their wallet on settlement, with no second call from you.
- Withdrawals: redeem tokens back to dollars, paid out to the external bank account you supply.
- Status updates for all of it: webhooks pushed to your server, plus pull endpoints for reconciliation.
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, the X25519 public key you seal bank details against, and separately the host and the RFC 9421 signing secret for the KYC service, which your API key is also used against. You send back the public egress addresses your calls will come from: the sandbox admits allowlisted sources only, so nothing works until yours are registered. There is no self-serve signup yet.
- 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). The user then completes KYC (know-your-customer verification); money movement is blocked until their KYC status is verified. - 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. Verification runs on its own
host, https://kyc.sandbox.tdab.tech, and adds a request signature on top of the
same API key: see KYC verification.
Examples on this page carry those hosts literally, so they can be copied directly.
The only variable is your key: export TDAB_API_KEY first. There is no single gateway in
front of the two hosts, so treat each as its own base rather than assuming one prefix covers both.
Both hosts sit behind an IP allowlist. A call from an address TDAB has not registered is refused at the edge with 403 before it reaches the API, so it carries none of the error bodies documented below. If every request fails with an empty 403, check your egress addresses first, not your key.
curl -s -X POST "https://api.sandbox.tdab.tech/api/create-user" \
-H "Authorization: $TDAB_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "externalId":"3f9c81a0-7d2e-4b8a-9c1d-5e6f7a8b9c0d" }'
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 | Your own user id; required, non-empty |
walletAddress | string, optional | EIP-55 checksummed; can be added later, required before any deposit mints |
sumsubApplicantId | string, optional | If KYC already ran on Sumsub elsewhere and you are importing the applicant |
Returns 201 on create and 200 on update, with created, updatedFields, and noopFields telling you which happened. A conflict (for example a wallet address already owned by another user) returns 409 with a conflicts array; a malformed wallet address returns 400.
The response userId is TDAB's internal record ID. Keep your original externalId: use it as user_id for KYC and money movement, and as userId for account lookups. Do not substitute the internal response ID in those requests.
curl -s -X POST "https://api.sandbox.tdab.tech/api/create-user" \
-H "Authorization: $TDAB_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "externalId":"3f9c81a0-7d2e-4b8a-9c1d-5e6f7a8b9c0d",
"walletAddress":"0x9F2A1c3De45b8F001B7C4a2D9E6f1234ABCD4B7C" }'
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",
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",
"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
Verification runs on a separate service with its own base URL, and it authenticates in two layers. Your API key goes in the Authorization header exactly as it does everywhere else on this page, raw, with no Bearer prefix. On top of that, every request is signed with RFC 9421 HTTP Message Signatures, using the signing secret you are handed together with the host at onboarding. A request that carries only the key, or only the signature, is refused with 401.
| Header | What goes in it |
|---|---|
Authorization | Your API key, raw. It identifies your environment and selects the secret your signature is checked against |
Content-Digest | RFC 9530 digest of the exact body bytes you send, as sha-256=:<base64>:. Required on every request with a body |
Signature-Input | Exactly one signature, covering @method, @request-target, content-type and content-digest, with alg="hmac-sha256", a keyid of your choosing, and created as a Unix timestamp |
Signature | The HMAC-SHA256 over those components, keyed with your signing secret, base64 between colons |
A signature is accepted for 30 seconds from created, with 5 seconds of tolerance for clock skew, so sign at send time rather than ahead of it. Any RFC 9421 library produces these two headers from the same inputs; the accompanying example shows the shape produced without one.
Start verification
/partner/kyc-new
Returns a short-lived token you hand to the Sumsub Web SDK inside your own app, so the user never leaves your interface.
| 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 |
Returns { "sdk_token": "…" }. The applicant is created on the first call and reused after that, so calling again for the same user_id resumes the same verification rather than starting a second one.
Importing a verified user
/partner/kyc-existing
If the user already verified with the same provider elsewhere and you hold a share token for them, send it as share_token alongside user_id (same UUID rule). You get the same sdk_token back and the user is not asked to verify twice.
Verification outcome
Verification is not instant, and the outcome reaches you as an account event: once the user is approved, TDAB opens their bank account and pushes its routing and account details to your base URL, see Opening the account. Until then, money-movement calls return 400 with User KYC not verified, so gating your own UI on that push is safe.
# BODY_DIGEST_B64 is base64(sha256(body)); the two RFC 9421 headers are
# produced by your signing library from the components listed on the left.
curl -s -X POST "https://kyc.sandbox.tdab.tech/partner/kyc-new" \
-H "Authorization: $TDAB_API_KEY" \
-H "Content-Type: application/json" \
-H "Content-Digest: sha-256=:$BODY_DIGEST_B64:" \
-H "Signature-Input: $RFC9421_SIG_INPUT" \
-H "Signature: $RFC9421_SIG" \
-d '{ "user_id":"3f9c81a0-7d2e-4b8a-9c1d-5e6f7a8b9c0d" }'
import { createHash, createHmac } from "node:crypto";
// Sign at send time: created is checked against a 30 second window.
const body = JSON.stringify({ user_id: "3f9c81a0-7d2e-4b8a-9c1d-5e6f7a8b9c0d" });
const digest = "sha-256=:" + createHash("sha256").update(body).digest("base64") + ":";
const created = Math.floor(Date.now() / 1000);
const params = `("@method" "@request-target" "content-type" "content-digest")` +
`;created=${created};keyid="your-key-id";alg="hmac-sha256"`;
const base = [
`"@method": POST`,
`"@request-target": /partner/kyc-new`,
`"content-type": application/json`,
`"content-digest": ${digest}`,
`"@signature-params": ${params}`,
].join("\n");
const sig = createHmac("sha256", process.env.TDAB_KYC_SIGNING_SECRET)
.update(base).digest("base64");
await fetch("https://kyc.sandbox.tdab.tech/partner/kyc-new", {
method: "POST",
headers: {
Authorization: process.env.TDAB_API_KEY,
"Content-Type": "application/json",
"Content-Digest": digest,
"Signature-Input": `sig1=${params}`,
Signature: `sig1=:${sig}:`,
},
body,
});
import base64, hashlib, hmac, json, os, time, requests
# Sign at send time: created is checked against a 30 second window.
body = json.dumps({"user_id": "3f9c81a0-7d2e-4b8a-9c1d-5e6f7a8b9c0d"}, separators=(",", ":")).encode()
digest = "sha-256=:" + base64.b64encode(hashlib.sha256(body).digest()).decode() + ":"
params = ('("@method" "@request-target" "content-type" "content-digest")'
f';created={int(time.time())};keyid="your-key-id";alg="hmac-sha256"')
base = "\n".join([
'"@method": POST',
'"@request-target": /partner/kyc-new',
'"content-type": application/json',
f'"content-digest": {digest}',
f'"@signature-params": {params}',
]).encode()
sig = base64.b64encode(hmac.new(
os.environ["TDAB_KYC_SIGNING_SECRET"].encode(), base, hashlib.sha256).digest()).decode()
requests.post(
"https://kyc.sandbox.tdab.tech/partner/kyc-new",
headers={
"Authorization": os.environ["TDAB_API_KEY"],
"Content-Type": "application/json",
"Content-Digest": digest,
"Signature-Input": f"sig1={params}",
"Signature": f"sig1=:{sig}:",
},
data=body,
)
{ "sdk_token": "_act-sbx-jwt-..." }
curl -s -X POST "https://kyc.sandbox.tdab.tech/partner/kyc-existing" \
-H "Authorization: $TDAB_API_KEY" \
-H "Content-Type: application/json" \
-H "Content-Digest: sha-256=:$BODY_DIGEST_B64:" \
-H "Signature-Input: $RFC9421_SIG_INPUT" \
-H "Signature: $RFC9421_SIG" \
-d '{ "user_id":"3f9c81a0-7d2e-4b8a-9c1d-5e6f7a8b9c0d",
"share_token":"_act-sbx-share-..." }'
{ "error": "Missing HMAC signature header" }
{ "error": "Invalid HMAC signature" }
user_id not a UUID 400{ "error": "Invalid JSON payload" }
Opening the account
Registration is the identity step. The account is what follows: the user completes KYC (the same Sumsub-backed flow TDAB's own wallet uses), and TDAB provisions their own US bank account on the core ledger. You are notified as each step completes.
- Account registration push. Once the account exists, TDAB POSTs the user's routing and account details to your registered base URL, once. See status updates for the payload.
- Re-fetch on demand.
GET /api/account-info?userId=…returns the same routing and account number any time after that, so a missed push is recoverable.
To change a user's preferredNetwork or preferredCurrency later, call POST /api/user/preferences with their userId (your externalId) and whichever field is changing; at least one is required.
Every end user completes their own TDAB KYC verification and receives their own TDAB-held bank account before they can receive tokens, so signing an agreement does not by itself enable minting for any user immediately. Contact your partnerships representative about the KYC model for your integration.
curl -s "https://api.sandbox.tdab.tech/api/account-info?userId=3f9c81a0-7d2e-4b8a-9c1d-5e6f7a8b9c0d" \
-H "Authorization: $TDAB_API_KEY"
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 |
Returns 200 with an intentId and a status of pending. The user must already be registered under your environment, KYC-verified, and have a wallet address on file; each of those failing returns a 400 with a plain-English error.
Deposit lifecycle
The ACH pull is submitted, posts at the bank, the funds are swept into the reserve, and the tokens mint to the user's wallet. You see that as a sequence of deposit-status events:
unposted → posted → swept → minted or failed
minted is the terminal success and the only status that carries the on-chain txHash; failed carries an errorMessage. ACH means minutes to days depending on the rail, so build for the pending window rather than a synchronous result.
Deposit lookup and recovery. GET /api/account/deposit?userId=…&id=… returns one deposit. Supply your original externalId as userId and the depositId from a deposit-status event as id. Both parameters are required. This endpoint does not list deposits or accept an intentId or transferId in place of the deposit ID.
Deposit-status events do not contain a traceId, so GET /deposit/status?traceId=… is not a recovery path from those events. If you never received the deposit ID, contact TDAB with your environment, external user ID, transfer_id, and the intentId returned by POST /deposit. Keep those identifiers and do not submit a new transfer ID while the outcome is unknown.
No further action is required for the money to arrive. Once the bank leg clears, the sweep and the mint proceed automatically and you are notified at each step, so the user's balance updates without a second call from you.
Standard ACH settles in about two banking days. Faster handling is possible and depends on the risk and collateral arrangement agreed with you, so treat two days as the default to design against rather than a fixed number.
| 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..." }'
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...",
}),
});
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...",
},
)
{ "intentId": "di_1755781200000_k3f9x", "status": "pending" }
{ "success": false, "error": "User KYC not verified" }
Encrypting bank details
sealed_bank_details is a libsodium sealed box (crypto_box_seal: X25519 with a fresh ephemeral keypair per message, XSalsa20-Poly1305), base64-encoded with standard RFC 4648 padding. You seal the user's bank details against TDAB's public key, so only TDAB can open them. No proprietary cryptography is involved, so any standard libsodium binding interoperates.
Seal a compact UTF-8 JSON object with exactly these two string fields. Both are strings so leading zeros survive:
| 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 starts it.
/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 |
Returns 200 with an intentId, your transferId echoed if you sent one, and a status of pending. An insufficient on-chain balance returns a 400 up front.
curl -s -X POST "https://api.sandbox.tdab.tech/withdraw" \
-H "Authorization: $TDAB_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "user_id":"3f9c81a0-7d2e-4b8a-9c1d-5e6f7a8b9c0d",
"token_symbol":"eUSD",
"network":"polygon",
"amount":10000,
"transfer_id":"your-idempotency-key-002" }'
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",
}),
});
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",
},
)
{
"intentId": "wi_1755781260000_p7q2m",
"transferId": "your-idempotency-key-002",
"status": "pending"
}
Serving the payout beneficiary
A withdrawal pays out to the user's bank account, and you are the source of truth for those details. TDAB asks you for them per withdrawal, at the moment of payout, by calling an endpoint you serve.
Serve it at this exact path on your registered base URL. TDAB calls it with your environment's outbound auth header:
{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 asks you for the payout beneficiary, burns the tokens, and submits the payout. The beneficiary is resolved before the burn, so a missing payout account fails the withdrawal while the user still holds their tokens. You see the lifecycle as withdrawal-status events:
pending → processing → completed or failed
Several internal steps (beneficiary check, burn, in-flight ACH transfer) all report as processing, so expect more than one processing event. completed means the payout actually settled, not that the burn was confirmed on-chain. If no valid payout account can be resolved, the withdrawal fails before anything irreversible happens.
You can poll the same lifecycle at any time with GET /withdraw/status?intentId=….
Payouts settle over ACH. The rail is chosen on the TDAB side and never changes how you call POST /withdraw, so build against the status events rather than assuming a settlement time.
curl -s "https://api.sandbox.tdab.tech/withdraw/status?intentId=wi_1755781260000_p7q2m" \
-H "Authorization: $TDAB_API_KEY"
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. Your answer decides whether the withdrawal proceeds, so this endpoint must stay available and respond promptly. See Serving the payout beneficiary |
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: 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. 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 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, 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 |
| 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" }