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, 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 with 400. 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.

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 supported stablecoin works — TBMC delivers the recipient's asset at par regardless of what you send.

Send to the deposit addressnot 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 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 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_DISTRIBUTEDISTRIBUTION_PENDINGDISTRIBUTION_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.