TDAB Partner API
v1.0 · Sandbox
Telcoin Digital Asset Bank

TDAB Partner API

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

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.
Start

Quickstart

  1. 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.
  2. Create your user. POST /api/create-user with your own externalId and the user's wallet address. The user then completes KYC (know-your-customer verification); money movement is blocked until their KYC status is verified.
  3. Move money. POST /deposit to pull dollars from the user's external bank account (tokens mint automatically when it settles), or POST /withdraw to redeem tokens back to dollars.
  4. Track status. Receive webhooks at your registered base URL, or poll GET /api/account/deposit and GET /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" }'
Response 201
{
  "success": true,
  "userId": "usr_2f9c81a0",
  "created": true,
  "updatedFields": [],
  "noopFields": []
}
Start

Authentication

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

Authorization: 51h8qzby4uwn…

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

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

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

A deposits-only partner might hold a key scoped to /deposit and /api/account/deposit, with no access to withdrawals or account info. 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
Response 401
{ "error": "Unauthorized" }
Flows

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.

POST /api/create-user

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

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

Returns 201 on create and 200 on update, with created, updatedFields, and noopFields telling you which happened. A 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" }'
Response 201
{
  "success": true,
  "userId": "usr_2f9c81a0",
  "created": true,
  "updatedFields": [],
  "noopFields": []
}
On field conflict 409
{
  "success": false,
  "error": "Field conflict, use separate update endpoints or resolve the conflicting value",
  "userId": "usr_2f9c81a0",
  "conflicts": [{ "field": "walletAddress", "ownerUserId": "usr_7a12ef90" }],
  "noopFields": []
}
Re-fetch account details
curl -s "$TDAB_BASE_URL/api/account-info?userId=user_48291" \
  -H "Authorization: $TDAB_API_KEY"
Response 200
{
  "success": true,
  "userId": "user_48291",
  "accountNumber": "4821009273",
  "routingNumber": "071000301"
}
Flows

Deposits

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

POST /deposit
FieldTypeNotes
user_idstringYour externalId for the user
token_symbolstringe.g. eUSD
networkstringe.g. base
amountinteger, centsPositive; a string of digits is also accepted
transfer_idstringYour idempotency key; a retry with the same value returns the existing deposit, never a second ACH pull
sealed_bank_detailsstringA libsodium sealed box (base64) of the user's external bank details, forwarded to the bank leg untouched; TDAB never sees it in the clear

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

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:

unpostedpostedsweptminted 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.

CurrencyNetworks
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..." }'
Response 200
{ "intentId": "di_1755781200000_k3f9x", "status": "pending" }
Missing KYC 400
{ "success": false, "error": "User KYC not verified" }
Flows

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.

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

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

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:

pendingprocessingcompleted 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" }'
Response 200
{
  "intentId": "wi_1755781260000_p7q2m",
  "transferId": "your-idempotency-key-002",
  "status": "pending"
}
Poll status
curl -s "$TDAB_BASE_URL/withdraw/status?intentId=wi_1755781260000_p7q2m" \
  -H "Authorization: $TDAB_API_KEY"
Response 200
{
  "success": true,
  "intent": {
    "intentId": "wi_1755781260000_p7q2m",
    "transferId": "your-idempotency-key-002",
    "status": "processing",
    "amount": 10000,
    "tokenSymbol": "eUSD",
    "network": "base",
    "walletAddress": "0x9F2a1C3dE45B8f001b7C4a2D9e6F1234abCd4b7C",
    "createdAt": 1755781260000,
    "updatedAt": 1755781295000
  }
}
Flows

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.
PathFires when
Money movement
/webhook/deposit-statusA deposit progresses: unpostedpostedsweptminted / failed
/webhook/withdrawal-statusA withdrawal progresses: pendingprocessing (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-verificationA micro-deposit verifying a linked external bank account lands
/accounts/updatedThe user's wallet address changes (the one field that fires this today)

Deposits

Deposit events carry your transfer_id back as transferId, plus the externalUserId, 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.

Deposit status
{
  "depositId": "dep_9f21ac04",
  "intentId": "di_1755781200000_k3f9x",
  "transferId": "your-idempotency-key-001",
  "externalUserId": "user_48291",
  "status": "minted",
  "amount": "25000",
  "currency": "USD",
  "txHash": "0xa9c3f21e7b6d4c8a0f1e2d3c4b5a69788f0e1d2c3b4a5968f7e6d5c4b3a29180"
}
Withdrawal status
{
  "success": true,
  "intent": {
    "intentId": "wi_1755781260000_p7q2m",
    "transferId": "your-idempotency-key-002",
    "status": "processing",
    "network": "base",
    "txHash": "0xa9c3f21e7b6d4c8a0f1e2d3c4b5a69788f0e1d2c3b4a5968f7e6d5c4b3a29180",
    "fiatTransferId": "ach_9182x"
  }
}
Account registration (to base URL)
{
  "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"
}
Account verification
{
  "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"
  }
}
Accounts updated
{
  "externalUserId": "user_48291",
  "fields": {
    "walletAddress": "0x9F2a1C3dE45B8f001b7C4a2D9e6F1234abCd4b7C"
  },
  "updatedAt": 1755781320000
}
Reference

Errors

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

StatusWhen
400A missing or malformed field, an unregistered user, unverified KYC, no wallet on file, or insufficient balance; the message says which
401Any authentication failure: missing, unknown, disabled, or out-of-scope key. Always the same body, {"error":"Unauthorized"}, with no further detail
404No such deposit or withdrawal. Someone else's, or none at all, look identical, so ids cannot be used to probe another partner's activity
405Wrong HTTP method (plain-text body)
409Create-user field conflict, with a conflicts array naming each clashing field
500{"success":false,"error":"Internal server error"}; safe to retry with the same transfer_id
Validation error
{
  "success": false,
  "error": "Amount must be a positive integer (cents) as number or string"
}
Auth failure 401
{ "error": "Unauthorized" }