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

  1. Authenticate and make a safe first call.
  2. Register a user and their on-chain wallets.
  3. Create a payment — TBMC returns the deposit addresses to fund it.
  4. Send funds to the matching deposit address from the sender's wallet.
  5. Confirm the transfer so TBMC attributes the deposit and marks the order FUNDED.
  6. 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 403 until your account is ACTIVE and KYB APPROVED.
  • Create an API key in the developer console. Send it as the x-api-key header 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. Set return wallets before funding an order (see Direct payment returns).
  • 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 with 400. The sending wallet must also be active: only deposits from an activated sender are attributed to the order (Step 4). Return destinations also require active wallets. 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.

The human-readable list is on Supported assets.

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. A return sent to a pooled, custodial, or exchange address reaches its operator. Use a return wallet that can credit the correct end user. Direct payment returns explains user defaults and order-level destinations.

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" }
    ],
    "returnWallets": [{ "address": "0x4e3a1b2c5d6e7f8091a2b3c4d5e6f7a8b9c0d1e2", "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)
    ],
    returnWallets: [{ address: '0x4e3a1b2c5d6e7f8091a2b3c4d5e6f7a8b9c0d1e2', chain: 'ethereum' }], // refunds go here
  }),
});
{
  "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" }
    ],
    "returnWallets": [{ "address": "0x4e3a1b2c5d6e7f8091a2b3c4d5e6f7a8b9c0d1e2", "chain": "ethereum" }]
  }
}

Some accounts collect each user's location. If yours does, send a country code and a subdivision code when the country lists regions: {"country":"US","subdivision":"US-CA"}. Send only a country when it lists none, such as {"country":"HK"}. Omit location to keep the saved value; send it to replace that value. Sending location when collection is off returns 400.

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

Set returnWallets to the wallets TBMC returns this user's deposits to, one per chain. Each must be a wallet registered to the user, and TBMC uses it only once it is active. TBMC never returns funds to the sending address on its own: with no default for the deposit's chain, a return waits with returnDetailsReason: RETURN_DETAILS_MISSING until you set one. Omit returnWallets to keep the current defaults.

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, arbitrum.
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 each (chain, asset) you can fund with. 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" }
  ]
}

Pick the entry you'll fund with and use it in the next step. It is not always every pair /supported-assets returns — a pair TBMC custodies with a provider that issues no deposit address has none to give — so match on chain and symbol rather than assuming an entry exists.

fromUserId is always the user's system id on payment-order endpoints. Use label only 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 stablecoin with an entry in depositAddresses works — TBMC delivers the recipient's asset at par regardless of which one you send.

Send to the deposit address — not the order's toAddress (the end recipient). TBMC sweeps the deposit and delivers to toAddress for you; sending straight to toAddress skips 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 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 POST on 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, TBMC returns each deposit to the order's return wallet for that chain. An order with no return details uses the funding user's default return wallet instead. The order moves through the refund states and ends at REFUNDED.
  • A deposit with no destination waits in REFUND_PENDING with returnDetailsReason: RETURN_DETAILS_MISSING until you set order return details or a default. If the order's return details have no wallet for the deposit's chain, the order shows REFUND_REJECTED with RETURN_DETAILS_INCOMPATIBLE until you replace them with details that cover it. See Direct payment returns.

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.