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 API covers five things, and this page is organized around them:
- Creating and onboarding users: register your user, get them through KYC (know-your-customer verification), and receive their TDAB-assigned bank account details.
- Deposits: pull dollars from a user's external bank account; tokens mint to their wallet automatically once the money settles.
- Withdrawals: redeem tokens back to dollars, paid out to the user's linked bank account.
- Status updates for all three: webhooks pushed to your server, plus pull endpoints for reconciliation.
Quickstart
- Get onboarded. The TDAB team registers an environment for you (your webhook base URL, a signing secret, an outbound auth header) and issues an API key scoped to the endpoints your integration needs. There is no self-serve signup yet.
- Create your user.
POST /api/create-userwith your ownexternalIdand the user's wallet address. 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, or poll
GET /api/account/depositandGET /withdraw/status.
Base URL and key
Both are issued during onboarding, per environment. Examples on this page use
$TDAB_BASE_URL and $TDAB_API_KEY placeholders; your partnerships contact
will provision your sandbox host.
curl -s -X POST "$TDAB_BASE_URL/api/create-user" \
-H "Authorization: $TDAB_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "externalId":"user_48291" }'
await fetch(`${process.env.TDAB_BASE_URL}/api/create-user`, {
method: "POST",
headers: {
Authorization: process.env.TDAB_API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({ externalId: "user_48291" }),
});
import os, requests
requests.post(
f"{os.environ['TDAB_BASE_URL']}/api/create-user",
headers={"Authorization": os.environ["TDAB_API_KEY"]},
json={"externalId": "user_48291"},
)
{
"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. Least privilege by default, not by request.
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.
curl -s -X POST "$TDAB_BASE_URL/withdraw" \
-H "Authorization: Bearer $TDAB_API_KEY" \
-d '{}'
# "Bearer" prefix: the key hashes wrong
{ "error": "Unauthorized" }
Users & accounts
Register each of your users with TDAB before their first deposit or withdrawal. One call creates the record; KYC and bank-account provisioning happen behind it, and the results come back to you as account events.
/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 genuinely 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 genuine clash (for example a wallet address already owned by another user) returns 409 with a conflicts array; a malformed wallet address returns 400.
What happens next
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 piece lands:
- 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. - Bank-account verification. When the user links the external bank account they will deposit from, TDAB sends micro-deposits (under a dollar) and notifies you as they land, so your UI can ask the user to confirm the amounts.
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. Statement retrieval also exists (GET /api/statements and POST /api/statements/retrieve) if your UI shows users their TDAB account statements.
Every end user goes through their own TDAB KYC and gets their own TDAB-held bank account before they can receive tokens, so signing an agreement does not by itself mean minting for any user right away. Talk to your partnerships contact about the KYC model for your integration.
curl -s -X POST "$TDAB_BASE_URL/api/create-user" \
-H "Authorization: $TDAB_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "externalId":"user_48291",
"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": []
}
curl -s "$TDAB_BASE_URL/api/account-info?userId=user_48291" \
-H "Authorization: $TDAB_API_KEY"
{
"success": true,
"userId": "user_48291",
"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 fires 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 | e.g. base |
amount | integer, cents | Positive; a string of digits is also accepted |
transfer_id | string | Your idempotency key; a retry with the same value returns the existing deposit, never a second ACH pull |
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.
What happens next
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.
| Currency | Networks |
|---|---|
eUSD (US dollar, 1:1) | ethereum, base, polygon, arbitrum, optimism, avalanche |
Additional currencies (eMXN, eEUR) and networks are roadmap items, added as demand is confirmed. There is no listing endpoint; this table is the source of truth today.
curl -s -X POST "$TDAB_BASE_URL/deposit" \
-H "Authorization: $TDAB_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "user_id":"user_48291",
"token_symbol":"eUSD",
"network":"base",
"amount":25000,
"transfer_id":"your-idempotency-key-001",
"sealed_bank_details":"c2VhbGVkX2JveF9jaXBoZXJ0ZXh0..." }'
await fetch(`${process.env.TDAB_BASE_URL}/deposit`, {
method: "POST",
headers: {
Authorization: process.env.TDAB_API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({
user_id: "user_48291",
token_symbol: "eUSD",
network: "base",
amount: 25000,
transfer_id: "your-idempotency-key-001",
sealed_bank_details: "c2VhbGVkX2JveF9jaXBoZXJ0ZXh0...",
}),
});
import os, requests
requests.post(
f"{os.environ['TDAB_BASE_URL']}/deposit",
headers={"Authorization": os.environ["TDAB_API_KEY"]},
json={
"user_id": "user_48291",
"token_symbol": "eUSD",
"network": "base",
"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" }
Withdrawals
A withdrawal redeems tokens back to dollars: the tokens leave the user's wallet, and the dollars pay out to their linked 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 | e.g. base |
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.
What happens next
After the response, TDAB verifies the user's payout beneficiary with the bank, burns the tokens, and submits the ACH payout. You see that as withdrawal-status events:
pending → processing → completed or failed / expired
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 landed on-chain. If the beneficiary check finds no valid payout account, the withdrawal fails before anything irreversible happens.
You can poll the same lifecycle at any time with GET /withdraw/status?intentId=….
curl -s -X POST "$TDAB_BASE_URL/withdraw" \
-H "Authorization: $TDAB_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "user_id":"user_48291",
"token_symbol":"eUSD",
"network":"base",
"amount":10000,
"transfer_id":"your-idempotency-key-002" }'
{
"intentId": "wi_1755781260000_p7q2m",
"transferId": "your-idempotency-key-002",
"status": "pending"
}
curl -s "$TDAB_BASE_URL/withdraw/status?intentId=wi_1755781260000_p7q2m" \
-H "Authorization: $TDAB_API_KEY"
{
"success": true,
"intent": {
"intentId": "wi_1755781260000_p7q2m",
"transferId": "your-idempotency-key-002",
"status": "processing",
"amount": 10000,
"tokenSymbol": "eUSD",
"network": "base",
"walletAddress": "0x9F2a1C3dE45B8f001b7C4a2D9e6F1234abCd4b7C",
"createdAt": 1755781260000,
"updatedAt": 1755781295000
}
}
Status updates
Your environment registers one base URL at onboarding. Each event POSTs to that URL plus the path shown below, as JSON, with three headers that matter:
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. 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 every delivery.
| 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 / expired |
| 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, so you can correlate without a lookup table. minted is the only status with a txHash; failed carries errorMessage. Missed one? GET /api/account/deposit?userId=…&id=… returns the identical payload shape, built by the same code, so push and pull can never drift apart.
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 (three attempts, backing off 1s, 2s, 4s). 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. Poll GET /api/account/deposit
and GET /withdraw/status to reconcile anything you might have missed.
Amount is not one format across events, and that is real, not a typo: integer cents on the way in (/deposit, /withdraw), a cents string on deposit status ("700" is $7.00), absent from withdrawal-status events (poll /withdraw/status if you need it), and a decimal dollar number on account.verification (0.31 is 31 cents). Check each integration point against the examples rather than assuming one convention.
{
"depositId": "dep_9f21ac04",
"intentId": "di_1755781200000_k3f9x",
"transferId": "your-idempotency-key-001",
"externalUserId": "user_48291",
"status": "minted",
"amount": "25000",
"currency": "USD",
"txHash": "0xa9c3f21e7b6d4c8a0f1e2d3c4b5a69788f0e1d2c3b4a5968f7e6d5c4b3a29180"
}
{
"success": true,
"intent": {
"intentId": "wi_1755781260000_p7q2m",
"transferId": "your-idempotency-key-002",
"status": "processing",
"network": "base",
"txHash": "0xa9c3f21e7b6d4c8a0f1e2d3c4b5a69788f0e1d2c3b4a5968f7e6d5c4b3a29180",
"fiatTransferId": "ach_9182x"
}
}
{
"current_user_id": "user_48291",
"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": "user_48291",
"routingNumber": "071000301",
"accountLast4": "7734",
"amount": 0.31,
"sendingBank": "CAPITAL ONE",
"createdAt": "2026-08-19T09:14:02Z",
"debitOrCredit": "credit"
}
}
{
"externalUserId": "user_48291",
"fields": {
"walletAddress": "0x9F2a1C3dE45B8f001b7C4a2D9e6F1234abCd4b7C"
},
"updatedAt": 1755781320000
}
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 |
| 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 |
{
"success": false,
"error": "Amount must be a positive integer (cents) as number or string"
}
{ "error": "Unauthorized" }