Getting started
The TBMC clearinghouse moves dollars between stablecoins and chains at par — a transfer, not a trade. This guide takes you from zero to a settled crypto-to-crypto payment: register a user, create a payment order, send funds to the deposit address TBMC returns, and track the order until it reaches its recipient.
If you haven't yet, skim the Overview for the concept map (accounts → users → wallets → payment orders) and the API Reference for the full endpoint catalog.
What you'll build
- Authenticate and make a safe first call.
- Register a user and their on-chain wallets.
- Create a payment — TBMC returns the deposit addresses to fund it.
- Send funds to the matching deposit address from the sender's wallet.
- Confirm the transfer so TBMC attributes the deposit and marks the order
FUNDED. - Track the order until it reaches
DISTRIBUTED.
Before you begin
Work through this checklist before your first payment:
-
A TBMC account that has been onboarded and approved. Onboarding includes KYB in the
developer console; every API request is rejected with
403until your account isACTIVEand KYBAPPROVED. -
Create an API key in the developer console. Send it as the
x-api-keyheader on every request; each key is scoped to one account. -
Register your users and their wallets. Call
POST /add-user-record(see Step 1) for the wallets you send from and the wallets you pay to. Each registered address must belong to one end user — omnibus addresses are unsafe here, because returns go back to the address that funded the order (see Step 1). -
Get your wallets activated — both ends. Newly registered wallets start out inactive (
PENDING); activation (whitelisting) is TBMC-driven and is not instant or self-serve. The recipient wallet must be active to receive funds — a payment to an inactive recipient is rejected with400. The sending wallet must also be active: only deposits from an activated sender are attributed to the order (Step 4), and any refund or returned payment is delivered back to it — which, like every delivery, only reaches an activated wallet. Request activation during onboarding or on request. - Be able to send a stablecoin transfer from the sending wallet — your own signing infrastructure or an embedded-wallet provider (e.g. Privy, Turnkey). You build and broadcast the transfer to the deposit address TBMC returns.
Authentication
All requests go to the production base URL, under the /api/v1 prefix, with your key in the x-api-key header:
Base URL: https://api.bettermoney.com
Header: x-api-key: <your-api-key>
Every error response uses the same shape — a single error string:
{ "error": "Recipient wallet is not registered or not active" }
Under construction — there is no sandbox or testnet environment yet. All calls hit production, so test with small amounts on a provisioned account. A dedicated test environment is on the roadmap.
Make your first call
GET /supported-assets is a safe, read-only way to confirm your key works and to see the live list of stablecoin/chain
pairs you can transact in.
curl https://api.bettermoney.com/api/v1/supported-assets \
-H "x-api-key: $TBMC_API_KEY"
const res = await fetch('https://api.bettermoney.com/api/v1/supported-assets', {
headers: { 'x-api-key': process.env.TBMC_API_KEY! },
});
const assets = await res.json();
[
{ "chain": "ethereum", "symbol": "USDC", "tokenAddress": "0xA0b8...", "tokenDecimals": 6 },
{ "chain": "solana", "symbol": "PYUSD", "tokenAddress": "2b1kV6...", "tokenDecimals": 6 }
]
Treat this endpoint as canonical — use it to populate asset pickers and to validate asset / chain values before
creating a payment.
Step 1 — Register a user and their wallets
A user is a party you move money on behalf of, identified by a label you choose (your own stable identifier,
unique within your account). Register the user together with the on-chain wallets involved — the one they send from
and the recipient they pay to. (Here both belong to your test user; in production the recipient is often a separate
party, registered the same way.)
Register only addresses a single end user controls — never an omnibus address. A pooled, custodial, or exchange deposit address that holds funds for more than one party cannot be returned to safely. Automated returns of an inbound transfer (an overfunded, expired, or canceled order, or a stray late deposit) go back to the exact address that funded the order. On a pooled address those funds reach the pool operator, not the end user who paid, and TBMC cannot redirect them once sent — reattributing them is entirely yours to solve, off-platform.
The response returns the user's system id alongside your label, plus the user's wallets — each echoed back with its
activation status (freshly registered wallets start PENDING until TBMC activates them; see
Before you begin). Keep the id: you use it anywhere another endpoint needs to reference the user,
including POST /payment, payment-order responses, and GET /payment-orders?fromUserId={id}. Your label is only a
human-readable/customer-side identifier for creating, listing, and looking up users.
curl -X POST https://api.bettermoney.com/api/v1/add-user-record \
-H "x-api-key: $TBMC_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"label": "usr_3f8a92",
"wallets": [
{ "address": "0x4e3a1b2c5d6e7f8091a2b3c4d5e6f7a8b9c0d1e2", "chain": "ethereum" },
{ "address": "0x8f2a3b1c4d5e6f708192a3b4c5d6e7f8091a2b3c", "chain": "ethereum" }
]
}'
const res = await fetch('https://api.bettermoney.com/api/v1/add-user-record', {
method: 'POST',
headers: {
'x-api-key': process.env.TBMC_API_KEY!,
'Content-Type': 'application/json',
},
body: JSON.stringify({
label: 'usr_3f8a92',
wallets: [
{ address: '0x4e3a1b2c5d6e7f8091a2b3c4d5e6f7a8b9c0d1e2', chain: 'ethereum' }, // sender
{ address: '0x8f2a3b1c4d5e6f708192a3b4c5d6e7f8091a2b3c', chain: 'ethereum' }, // recipient (Step 2 toAddress)
],
}),
});
{
"message": "success",
"data": {
"id": "a3bb189e-8bf9-4888-9912-ace4e6543002",
"label": "usr_3f8a92",
"wallets": [
{ "address": "0x4e3a1b2c5d6e7f8091a2b3c4d5e6f7a8b9c0d1e2", "chain": "ethereum", "status": "PENDING" },
{ "address": "0x8f2a3b1c4d5e6f708192a3b4c5d6e7f8091a2b3c", "chain": "ethereum", "status": "PENDING" }
]
}
}
Add more { address, chain } entries for additional chains or wallets. Addresses are normalized (e.g. EVM addresses are
lowercased) and each is returned with its status. Recipient wallets must be activated by TBMC before they can
receive funds (see Before you begin).
Step 2 — Create a payment
POST /payment creates the order and returns the deposit addresses you fund it through. You declare what the recipient
receives; you choose which stablecoin to send, and TBMC delivers the recipient's asset at par. The toAddress must be a
registered, activated recipient wallet.
curl -X POST https://api.bettermoney.com/api/v1/payment \
-H "x-api-key: $TBMC_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"fromUserId": "a3bb189e-8bf9-4888-9912-ace4e6543002",
"toAddress": "0x8f2a3b1c4d5e6f708192a3b4c5d6e7f8091a2b3c",
"toChain": "ethereum",
"amountUsd": 25.00,
"asset": "USDC",
"memo": "Invoice #1042",
"expiresAt": "2027-01-01T00:00:00.000Z",
"idempotencyKey": "b7e2c1a0-5f3d-4c8a-9e21-7a6b5c4d3e2f"
}'
const res = await fetch('https://api.bettermoney.com/api/v1/payment', {
method: 'POST',
headers: {
'x-api-key': process.env.TBMC_API_KEY!,
'Content-Type': 'application/json',
},
body: JSON.stringify({
fromUserId: 'a3bb189e-8bf9-4888-9912-ace4e6543002',
toAddress: '0x8f2a3b1c4d5e6f708192a3b4c5d6e7f8091a2b3c',
toChain: 'ethereum',
amountUsd: 25.0,
asset: 'USDC',
memo: 'Invoice #1042',
expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(), // 1 hour from now
idempotencyKey: crypto.randomUUID(),
}),
});
const { paymentOrderId, paymentOrder, depositAddresses } = await res.json();
Request fields:
| Field | Required | Notes |
|---|---|---|
fromUserId |
yes | The registered user the funds come from — pass the user's system id returned by user read/list endpoints. |
toAddress |
yes | Recipient wallet address — must be a registered, activated wallet. |
toChain |
yes | Chain to deliver on, e.g. ethereum, solana. |
amountUsd |
yes | USD value to deliver to the recipient. |
asset |
yes | The outbound stablecoin the recipient receives, e.g. USDC. |
memo |
yes | Free-text reference you attach to the order. |
expiresAt |
yes | ISO 8601 timestamp in the future. Generate it per request (see the TypeScript example); fund the order before it. |
idempotencyKey |
no | See Idempotency. Strongly recommended. |
Because asset and chain are chosen independently for the sender and recipient, the same call can pull USDC on Ethereum and deliver PYUSD on Solana — at par, with no FX leg.
The 201 response contains the paymentOrderId, the full paymentOrder (initially AWAITING_FUNDS), and
depositAddresses — TBMC's custody deposit address for every supported (chain, asset) on your account. Each entry is
{ chain, symbol, address }.
{
"paymentOrderId": "9f2c7b1a-4d3e-4a8b-bc21-1e2f3a4b5c6d",
"paymentOrder": {
"id": "9f2c7b1a-4d3e-4a8b-bc21-1e2f3a4b5c6d",
"mode": "netted",
"status": "AWAITING_FUNDS",
"fromUserId": "a3bb189e-8bf9-4888-9912-ace4e6543002",
"toAddress": "0x8f2a3b1c4d5e6f708192a3b4c5d6e7f8091a2b3c",
"toChain": "ethereum",
"amountUsd": 25,
"asset": "USDC",
"memo": "Invoice #1042",
"createdAt": "2026-06-22T15:00:00.000Z",
"expiresAt": "2027-01-01T00:00:00.000Z"
},
"depositAddresses": [
{ "chain": "ethereum", "symbol": "USDC", "address": "0x9d4e2f7a1b3c5d6e8f09a1b2c3d4e5f607182a3b" },
{ "chain": "solana", "symbol": "USDC", "address": "7xKpQ2mEgn4dWf1uYb3VtH9rZ6sJcLkN8aD5o0pQwRt" }
]
}
depositAddresses lists one entry per supported (chain, asset); pick the one you'll fund with in the next step.
fromUserIdis always the user's systemidon payment-order endpoints. Uselabelonly to make users easy to find with the user endpoints.
Step 3 — Send funds to the deposit address
Pick the stablecoin you'll fund with, find its entry in depositAddresses (match on chain and symbol), and send
amountUsd worth of that stablecoin to its address from the sender's wallet. Any supported stablecoin works — TBMC
delivers the recipient's asset at par regardless of what you send.
Send to the deposit
address— not the order'stoAddress(the end recipient). TBMC sweeps the deposit and delivers totoAddressfor you; sending straight totoAddressskips custody and won't be attributed to the order.
You build and broadcast the transfer with your own signer (your key, or an embedded-wallet provider such as Privy or
Turnkey). Token contract addresses and decimals come from GET /supported-assets. On EVM chains it's a standard ERC-20
transfer:
import { createWalletClient, http, erc20Abi, parseUnits } from 'viem';
import { mainnet } from 'viem/chains';
const deposit = depositAddresses.find((d) => d.chain === 'ethereum' && d.symbol === 'USDC');
if (!deposit) throw new Error('no deposit address for ethereum/USDC');
// `account` is whatever signer controls the sender wallet.
const walletClient = createWalletClient({ account, chain: mainnet, transport: http() });
// USDC token address + 6 decimals come from GET /supported-assets.
const hash = await walletClient.writeContract({
address: USDC_TOKEN_ADDRESS,
abi: erc20Abi,
functionName: 'transfer',
args: [deposit.address as `0x${string}`, parseUnits('25', 6)],
});
On Solana, send an SPL token transfer of the same amount to the matching depositAddresses entry's address.
One funding transfer per transaction. Don't batch multiple token transfers to the deposit address into a single transaction — Step 4 rejects a tx hash that carries more than one funding transfer from your wallet. Send each in its own transaction.
The same addresses are available any time from
GET /deposit-addresses(account-scoped, no payment order required), so you can also pre-fetch and cache them.
Funds stay recoverable until settlement. Sending to the deposit address moves a real on-chain transfer into TBMC custody — but the payment isn't final. You can cancel it and have the funds refunded to the sender any time before the netting window closes (see Canceling a payment).
Keep the transaction hash from the transfer — you'll submit it in the next step so TBMC can attribute the deposit to this order.
Step 4 — Confirm the inbound transfer
Tell TBMC which transactions funded the order by submitting their hashes to
POST /payment-orders/{paymentOrderId}/transactions. TBMC matches each hash on-chain against the order's funding wallet
and records the deposit; the response reports how many transfers were attributed (transfersProcessed). Only transfers
originating from an activated sending wallet are matched. The order must still be AWAITING_FUNDS when you submit.
Attribution starts funding — it doesn't finish it. On-chain verification and the custody sweep complete asynchronously,
so the order advances to FUNDED only once they do. Don't expect FUNDED in this response; poll the order (Step 5) to
observe it.
curl -X POST \
https://api.bettermoney.com/api/v1/payment-orders/9f2c7b1a-4d3e-4a8b-bc21-1e2f3a4b5c6d/transactions \
-H "x-api-key: $TBMC_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "transactions": [{ "hash": "0x9a1b...", "chain": "ethereum" }] }'
const res = await fetch(`https://api.bettermoney.com/api/v1/payment-orders/${paymentOrderId}/transactions`, {
method: 'POST',
headers: {
'x-api-key': process.env.TBMC_API_KEY!,
'Content-Type': 'application/json',
},
body: JSON.stringify({ transactions: [{ hash, chain: 'ethereum' }] }),
});
{ "message": "success", "data": { "paymentOrderId": "9f2c7b1a-4d3e-4a8b-bc21-1e2f3a4b5c6d", "transfersProcessed": 1 } }
Step 5 — Track the payment to completion
Once funded, your order joins the current netting window. When that window closes, TBMC nets all funded orders,
locks yours, and delivers the output to the recipient. So an order reaches DISTRIBUTED at the close of the netting
window — not the instant it's funded.
Poll GET /payment-orders/{paymentOrderId} until the order reaches its terminal state, DISTRIBUTED:
curl https://api.bettermoney.com/api/v1/payment-orders/9f2c7b1a-4d3e-4a8b-bc21-1e2f3a4b5c6d \
-H "x-api-key: $TBMC_API_KEY"
async function waitForDistribution(paymentOrderId: string) {
const TERMINAL = new Set(['DISTRIBUTED', 'CANCELED', 'EXPIRED', 'REFUNDED']);
while (true) {
const res = await fetch(`https://api.bettermoney.com/api/v1/payment-orders/${paymentOrderId}`, {
headers: { 'x-api-key': process.env.TBMC_API_KEY! },
});
const order = await res.json();
if (TERMINAL.has(order.status)) return order;
await new Promise((r) => setTimeout(r, 3_000)); // poll every 3s
}
}
{
"id": "9f2c7b1a-4d3e-4a8b-bc21-1e2f3a4b5c6d",
"mode": "netted",
"status": "DISTRIBUTED",
"fromUserId": "a3bb189e-8bf9-4888-9912-ace4e6543002",
"toAddress": "0x8f2a3b1c4d5e6f708192a3b4c5d6e7f8091a2b3c",
"toChain": "ethereum",
"amountUsd": 25,
"asset": "USDC",
"memo": "Invoice #1042",
"distributionTransactionHash": "0x3f7b9c2e1a4d6f80b3c5e7092a1b4d6f8c0e2a4d",
"createdAt": "2026-06-22T15:00:00.000Z",
"expiresAt": "2027-01-01T00:00:00.000Z"
}
Beyond the API, you and your team can watch every payment's full lifecycle — status timeline, inbound transfers, and distribution — in the TBMC developer console.
Prefer push over polling? Register a webhook endpoint to receive a signed
POSTon every payment-order state change instead of polling. See the Webhooks guide.
Payment status lifecycle
A crypto-to-crypto order moves through these states. A funded order is not a finished order — funds are delivered
only at DISTRIBUTED, after the netting window closes.
AWAITING_FUNDS → FUNDED → LOCKED → READY_TO_DISTRIBUTE
→ DISTRIBUTION_PENDING → DISTRIBUTION_SUBMITTED → DISTRIBUTED ✓
| Status | Meaning |
|---|---|
AWAITING_FUNDS |
Order created; waiting for the sender's transfer to be observed. |
FUNDED |
Inbound transfer observed and sufficient. Still cancelable. |
LOCKED |
Netting window closed; the order will now run to completion. |
READY_TO_DISTRIBUTE → DISTRIBUTION_PENDING → DISTRIBUTION_SUBMITTED |
Outbound delivery in progress. |
DISTRIBUTED |
Terminal — funds delivered to the recipient. |
CANCELED |
Terminal — order canceled before funding completed. |
EXPIRED |
Terminal — expiresAt passed with no funds. |
Canceling a payment
Cancel an order that is still AWAITING_FUNDS or FUNDED with POST /payment-orders/{paymentOrderId}/cancel:
curl -X POST \
https://api.bettermoney.com/api/v1/payment-orders/9f2c7b1a-4d3e-4a8b-bc21-1e2f3a4b5c6d/cancel \
-H "x-api-key: $TBMC_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "reason": "customer changed their mind" }'
const res = await fetch(`https://api.bettermoney.com/api/v1/payment-orders/${paymentOrderId}/cancel`, {
method: 'POST',
headers: {
'x-api-key': process.env.TBMC_API_KEY!,
'Content-Type': 'application/json',
},
body: JSON.stringify({ reason: 'customer changed their mind' }),
});
Cancellation is deferred: the call returns 202 immediately and records your intent; the order settles into its
terminal state once any in-flight inbound transfers resolve, so keep polling.
- If no funds had arrived, the order ends at
CANCELED. -
If funds had already arrived, they're returned to the sender automatically — the order moves through the refund states
and ends at
REFUNDED.
Idempotency
Pass an idempotencyKey (any unique string, 1–255 chars — a UUID works well) when creating a payment. Retrying with the
same key and body is safe and won't create a duplicate order; reusing a key with a different body returns 409.
This matters most on POST /payment, where a network blip during a retry could otherwise double-create an order.
Errors
All errors share the { "error": string } shape. Common status codes:
| Code | When |
|---|---|
400 |
Validation failed, or the recipient wallet isn't active. |
401 |
Missing or malformed x-api-key. |
403 |
Account not ACTIVE / KYB not APPROVED, or the resource belongs to another account. |
404 |
Payment order not found (or not visible to your account). |
409 |
Idempotency-key reuse with different parameters, or canceling a non-cancelable order. |
500 |
Unexpected server error. |
What's next
- Browse the full API Reference for every endpoint, field, and status.