API reference

Base URL https://buxapi.com/v1. JSON in, JSON out, real HTTP status codes. Everything here is v1 and stable.

https://buxapi.com/v1 is the same API on the same servers, so either host works in every example below. The dashboard and the hosted checkout live on buxapi.com, which is why that second form exists.

Overview

BuxAPI is a crypto payment gateway and API. We do two things: we take payments for you (invoices with a hosted checkout page, or deposit addresses you hand out yourself), and we pay out (on-chain sends, and free internal transfers between BuxAPI wallets).

A wallet is created with one unauthenticated POST and is identified by a keypair. There is nothing to sign up for and nothing to verify.

Conventions

  • Every error body is { "error": { "code", "message" } }. Branch on code, show message to yourself, not to your customers.
  • Every list endpoint returns { "data": [...], "next_cursor": "..." }, newest first, 50 rows per page. Pass the cursor back as ?cursor=. A null cursor means the last page.
  • Every numeric amount is a decimal string in the smallest unit of its asset. See Amounts and precision. Record ids (deposits, sends, transfers, ledger entries, webhook deliveries) are decimal strings too, for the same reason: they are 64-bit and would not survive a JSON number.
  • Timestamps are ISO 8601 in UTC. Chains are identified by the keys base, eth, polygon, bsc, solana.

Authentication

Authenticate every call with your private key as a bearer token. There is no other credential, no API key pair, and no session for the API.

Request

curl https://buxapi.com/v1/wallet \
  -H "authorization: Bearer $BUX_SEC"

The three secrets

A wallet is created with exactly three, and they have different jobs.

SecretUsed forStored by BuxAPI
mnemonicMaster recovery. Twelve BIP39 words that re-derive the keypair.Never
bux_sec_...API and dashboard authentication, and send authorization.SHA-256 hash only
bux_pub_...Public identifier. The target of an internal transfer, and what support asks for.Yes, it is public
The mnemonic never leaves your machine when you use this site. The create and recovery pages derive the keypair in your browser and send only the public key and the hash of the private key. buxapi.com never receives the phrase, so we cannot lose it, leak it, or give it back to you. Losing it plus the private key means losing the wallet.

The keypair is derived deterministically from the phrase, so recovery reproduces the same bux_sec_ and bux_pub_. There is no key rotation: one mnemonic, one identity, for the life of the wallet.

401 response

{
  "error": {
    "code": "unauthorized",
    "message": "Provide your bux_sec_... private key as a Bearer token"
  }
}

The scheme is matched literally as Bearer, so a lowercased or padded header is a 401 rather than a signature failure. A disabled wallet answers 403 wallet_disabled on every authenticated route.

Bearer routes send no CORS headers and never accept the authorization header cross-origin, so a browser cannot call them at all. Only the public checkout endpoints and /v1/rates are reachable from a page. Your private key belongs in a server-side call, and this makes shipping it in front-end code fail loudly instead of quietly working.

Amounts and precision

All amounts, in requests and responses, are integers in the smallest unit of the asset, serialized as strings. There are no floats anywhere in this API.

  • usd is US cents. "2500" is $25.00.
  • A token amount is in that token's raw units on that chain. "25000000" is 25 USDC on Base (6 decimals) and "25000000000000000000" is 25 USDC on BNB Chain (18 decimals).
  • Requests accept a JSON number only while it is a safe integer. Anything larger must be a string, and an 18-decimal amount always is.

Parsing

// Correct: exact at any size.
const raw = BigInt(deposit.amount_raw)      // 25000000n

// Wrong: an 18-decimal amount is far past 2^53 and is silently rounded.
const broken = parseFloat(deposit.amount_raw)
Decimals are a property of the (chain, token) pair, not of the symbol. USDC and USDT on BNB Chain are 18 decimals, not the 6 they use on every other chain. Reading a BSC USDC balance at 6 decimals overstates it by a factor of a trillion. The full table is in Chains and tokens.

Wallets and settlement

POST /v1/wallets public

Creates a wallet and returns its credentials once. Every field is optional. Rate limited to 5 per hour per IP.

FieldTypeNotes
settlement_modestring'raw' (default) or 'convert'.
labelstringFree text, up to 100 characters.
webhook_urlstringURL for this wallet's events, up to 500 characters.
public_keystringClient-side keygen only. Send with sec_hash.
sec_hashstringLowercase hex SHA-256 of the full bux_sec_ string.

Request

curl -X POST https://buxapi.com/v1/wallets \
  -H 'content-type: application/json' \
  -d '{"settlement_mode":"raw","label":"acme-prod","webhook_url":"https://acme.example/hooks/bux"}'

201 response

{
  "public_key": "bux_pub_7Yd4Qk9v2mTb8sXaLpR3ZnHc6JwEuFgKmQ2Ts9VbXe1Z",
  "private_key": "bux_sec_3Rn8pQx5wKdA2vYcTb7LmZ9HsEjUfG4NpW6TqBx2Ye8L",
  "mnemonic": "canyon ridge olive panic sudden cliff moral tuna gospel wrist annual filter",
  "webhook_secret": "whsec_9f1c4a7d2e0b6538ac91f4d7e2b8054c6a3f19d7e04b28cf5619ad3e7b0c4f82",
  "settlement_mode": "raw",
  "warning": "Store the private key and mnemonic now. They are shown only once and BuxAPI cannot recover them for you."
}

Creating a wallet without sending us a key

Derive the keypair yourself and post only the public key and sec_hash, the lowercase hex SHA-256 of the full bux_sec_ string. Both fields must be present together. The response carries no secrets, because we never had them. This is exactly what the create page does in your browser.

Request

curl -X POST https://buxapi.com/v1/wallets \
  -H 'content-type: application/json' \
  -d '{
    "public_key": "bux_pub_7Yd4Qk9v2mTb8sXaLpR3ZnHc6JwEuFgKmQ2Ts9VbXe1Z",
    "sec_hash": "b9f4c7a1d3e05628fa47bc9e1d20537c8ae6420fbd913c7e58a0d6f24b13e9c7",
    "settlement_mode": "convert"
  }'

201 response

{
  "public_key": "bux_pub_7Yd4Qk9v2mTb8sXaLpR3ZnHc6JwEuFgKmQ2Ts9VbXe1Z",
  "webhook_secret": "whsec_9f1c4a7d2e0b6538ac91f4d7e2b8054c6a3f19d7e04b28cf5619ad3e7b0c4f82",
  "settlement_mode": "convert"
}

The webhook secret is returned once here and is not exposed by GET /v1/wallet. Store it with your other secrets.

Settlement modes

A wallet holds balances in one of two shapes, chosen at creation. This is the single most consequential decision about a wallet, because it decides what a balance is.

raw (default)convert
DepositSwept as received and credited to a per (chain, token) balance in raw units. Nothing is swapped, so the merchant carries no price risk or slippage.Swept, then swapped to USDC on-chain, and credited to a single usd balance in cents. Credited with the amount the swap actually produced, never the quote.
BalancesOne row per (chain, token). You hold USDC on Base and USDT on Solana as separate balances.One row, usd, chain null.
SendDebits the token you are sending, on the chain you are sending it, plus the network fee in that same token.Debits usd cents at the current price, plus the network fee in cents. The platform swaps out of USDC to pay.
Internal transferRaw token amounts, to another raw wallet only.USD cents, to another convert wallet only.
ExposureYou hold whatever your customers paid in, including volatile assets.You hold dollars.

The mode can be changed later only while every balance is zero and no deposit is still moving through the pipeline, otherwise the change answers 409 balances_not_empty or 409 deposits_in_flight. A half-processed deposit reads the mode at each step, so flipping it underneath would credit the wrong asset.

Fees

Every confirmed deposit is charged two fees, both taken in the credited asset. First a network fee: the estimated cost of sweeping the deposit (gas times a 1.5 buffer), locked at credit time and reported on the deposit record as network_fee_amount. A plain native-coin deposit (eth, bnb, sol) pays no network fee, because its sweep funds itself on-chain. Then a 0.5% platform fee, applied to the amount after the network fee: fee = (amount - network_fee) * fee_bps / 10000 with fee_bps defaulting to 50, so credited = amount - network_fee - fee. All three figures appear on the deposit record as credited_amount, fee_amount and network_fee_amount. Negotiated rates are set per wallet. Internal transfers are free. Sends carry their network cost, described under Sends.

GET /v1/wallet bearer

Wallet settings and every balance.

200 response

{
  "public_key": "bux_pub_7Yd4Qk9v2mTb8sXaLpR3ZnHc6JwEuFgKmQ2Ts9VbXe1Z",
  "label": "acme-prod",
  "settlement_mode": "raw",
  "webhook_url": "https://acme.example/hooks/bux",
  "activated_tokens": null,
  "created_at": "2026-08-01T09:14:02.881Z",
  "balances": [
    { "chain": "base",   "asset": "usdc", "amount": "124875000" },
    { "chain": "solana", "asset": "usdc", "amount": "0" }
  ]
}
PATCH /v1/wallet bearer

Updates label, webhook_url, activated_tokens or settlement_mode. At least one field is required. activated_tokens maps a chain key to lowercase token symbols; null means everything the platform supports. It gates which pairs an invoice may quote, which pairs the checkout offers, and which sends are allowed.

It does not gate crediting. A token you have not activated, sent directly to one of your addresses, is still detected, still credited and still charged the same fee. Activation controls what we offer on your behalf, not what the chain is allowed to deliver to an address you published.

Request

curl -X PATCH https://buxapi.com/v1/wallet \
  -H "authorization: Bearer $BUX_SEC" \
  -H 'content-type: application/json' \
  -d '{
    "webhook_url": "https://acme.example/hooks/bux",
    "activated_tokens": { "base": ["usdc", "usdt"], "solana": ["usdc"] }
  }'

200 response

{ "updated": true }

Deposit addresses

Unlimited addresses per wallet, in two families. One evm address receives on all four EVM chains; solana addresses are derived separately from the same wallet. Any token sent to one of your addresses is credited, whether or not an invoice is involved. There is no fixed minimum, but a token deposit must be worth more than its network fee: one below roughly 1.2 times the estimated sweep fee stays uncredited and is retried automatically, crediting once gas falls far enough for the deposit to cover it. Current per-pair fee estimates are published at GET /v1/network-fees.

Native coin works differently: a plain native deposit pays no network fee, because the coin funds its own sweep on-chain. Instead a native deposit is only picked up above a per-chain dust floor: 0.0002 ETH on Base, 0.0005 ETH on Ethereum, 0.001 BNB on BNB Chain and 0.002 SOL on Solana. Below the floor the funds sit at the address uncredited until a later deposit brings the balance over it. Polygon takes no native POL at all, only tokens.

Native deposits are found by polling balances rather than by reading a transfer, so the tx_hash recorded for one is a synthetic identifier and not an on-chain hash, and two native deposits that land in the same polling cycle are credited as one. Token deposits carry the real transaction hash and are always recorded separately.

POST /v1/addresses bearer

family is required and is evm or solana. label is your own reference. callback_url overrides the wallet webhook for deposit events on this address alone, which is the primitive for giving every one of your users their own address and their own callback.

Request

curl -X POST https://buxapi.com/v1/addresses \
  -H "authorization: Bearer $BUX_SEC" \
  -H 'content-type: application/json' \
  -d '{"family":"evm","label":"user_10412","callback_url":"https://acme.example/hooks/user-10412"}'

201 response

{
  "family": "evm",
  "index": 10412,
  "address": "0x8f2bC41d9E7a05F3b6D8241cA9e07B5d3F1a6E42",
  "label": "user_10412",
  "callback_url": "https://acme.example/hooks/user-10412"
}

index is the derivation index of the address. It is stable and makes a useful join key on your side.

GET /v1/addresses?family=&cursor= bearer

Newest first. Without a family filter the pages walk evm first, then solana; the cursor encodes the family (evm:10412) because the two families have separate index spaces.

Request

curl "https://buxapi.com/v1/addresses?family=evm" \
  -H "authorization: Bearer $BUX_SEC"

200 response

{
  "data": [
    {
      "family": "evm",
      "index": 10412,
      "address": "0x8f2bC41d9E7a05F3b6D8241cA9e07B5d3F1a6E42",
      "label": "user_10412",
      "callback_url": "https://acme.example/hooks/user-10412",
      "invoice_id": null,
      "created_at": "2026-08-05T14:21:44.117Z"
    }
  ],
  "next_cursor": null
}

Deposits and ledger

A deposit moves through detected to confirmed to credited to swept. It is yours from credited onward; swept only means the funds have since been moved to platform custody. A confirmed token deposit worth less than about 1.2 times its estimated network fee waits at confirmed without crediting; the credit is retried automatically and lands once gas prices drop far enough for the deposit to cover its fee. A deposit that fails re-verification after a reorg ends at orphaned and is never credited.

Confirmations are one block on every EVM chain, and the confirmed commitment on Solana. deposit.detected fires at zero confirmations and is a hint, not money. Wait for deposit.confirmed before releasing goods.

GET /v1/deposits bearer

fee_amount and network_fee_amount are the two deductions described under Fees. address is the receiving deposit address (null on an invoice-routed row that never allocated one), which is what lets you credit the right user when every user has their own address: match it against the addresses you handed out.

Request

curl https://buxapi.com/v1/deposits \
  -H "authorization: Bearer $BUX_SEC"

200 response

{
  "data": [
    {
      "id": "8842",
      "chain": "base",
      "token": "usdc",
      "tx_hash": "0x9c1f7e4b02a5d38c6f1b904e7ad2c5318be047f9d6a3c81205e7b49fd3a06c17",
      "amount_raw": "25010000",
      "credited_amount": "24875000",
      "credited_asset": "usdc",
      "fee_amount": "125000",
      "network_fee_amount": "10000",
      "status": "swept",
      "invoice_id": "inv_4kR2QpN7vXsB1mLdZaYc9F",
      "address": "0x8f2bC41d9E7a05F3b6D8241cA9e07B5d3F1a6E42",
      "created_at": "2026-08-05T14:36:02.004Z"
    }
  ],
  "next_cursor": null
}
GET /v1/transactions bearer

The append-only ledger: every balance movement, signed, newest first. This is the reconciliation surface. Kinds are deposit, send, network_fee, send_refund, transfer_in and transfer_out. Credits are positive, debits negative, and a balance is exactly the sum of its entries.

A deposit posts one entry, for the amount you were credited: the percentage fee and the network fee are both booked against the platform, not against you, so neither appears here. A send posts two, the amount as send and the gas charge as network_fee, which is what lets a refund give back one without the other.

200 response

{
  "data": [
    {
      "id": "19206",
      "kind": "network_fee",
      "chain": "base",
      "asset": "usdc",
      "amount": "-4120",
      "ref_type": "send",
      "ref_id": "512",
      "created_at": "2026-08-05T15:02:11.884Z"
    },
    {
      "id": "19205",
      "kind": "send",
      "chain": "base",
      "asset": "usdc",
      "amount": "-25000000",
      "ref_type": "send",
      "ref_id": "512",
      "created_at": "2026-08-05T15:02:11.882Z"
    },
    {
      "id": "19204",
      "kind": "deposit",
      "chain": "base",
      "asset": "usdc",
      "amount": "24875000",
      "ref_type": "deposit",
      "ref_id": "8842",
      "created_at": "2026-08-05T14:36:03.412Z"
    }
  ],
  "next_cursor": null
}

Invoices

An invoice is a payment request with a fixed amount and a deadline. Create one, send the payer to its checkout_url, and act on the webhook.

POST /v1/invoices bearer
FieldTypeNotes
amountinteger or stringUSD cents, or raw token units. Required.
currencystring'usd' or a token symbol. Required.
acceptedstring[]1 to 40 of the wallet's pairs, as 'chain:token'.
expires_inintegerSeconds, 60 to 86400. Default 1200.
success_urlstringWhere the checkout page sends a paying customer.
cancel_urlstringWhere the checkout page sends a customer who backs out.
order_idstringYour reference, up to 100 characters. Unique per wallet, doubles as the idempotency key.
metadataobjectFree JSON, echoed in webhooks. Up to 4000 characters serialized.
webhook_urlstringOverrides the wallet webhook for this invoice.

Request

curl -X POST https://buxapi.com/v1/invoices \
  -H "authorization: Bearer $BUX_SEC" \
  -H 'content-type: application/json' \
  -d '{
    "amount": 2500,
    "currency": "usd",
    "accepted": ["base:usdc", "solana:usdc"],
    "expires_in": 1200,
    "order_id": "order_10412",
    "success_url": "https://acme.example/thanks",
    "cancel_url": "https://acme.example/cart",
    "metadata": { "cart": "c_881" }
  }'

201 response

{
  "id": "inv_4kR2QpN7vXsB1mLdZaYc9F",
  "status": "pending",
  "currency": "usd",
  "amount": "2500",
  "accepted": ["base:usdc", "solana:usdc"],
  "quotes": { "base:usdc": "25010000", "solana:usdc": "25020000" },
  "received": {},
  "expires_at": "2026-08-05T14:52:11.000Z",
  "success_url": "https://acme.example/thanks",
  "cancel_url": "https://acme.example/cart",
  "order_id": "order_10412",
  "metadata": { "cart": "c_881" },
  "webhook_url": null,
  "created_at": "2026-08-05T14:32:11.000Z",
  "checkout_url": "https://buxapi.com/pay/inv_4kR2QpN7vXsB1mLdZaYc9F"
}

Quotes

A USD-priced invoice locks one raw amount per accepted pair at creation, in quotes, and that is the exact amount the payer must send for the whole lifetime of the invoice. Each quote is the invoice amount plus that pair's estimated network fee, locked together: the payer covers the sweep cost, so you are credited the invoice amount minus only the percentage fee. Nothing re-prices later. A pair whose price or network-fee estimate is unavailable is dropped from accepted rather than quoted wrong; if no pair can be quoted at all, creation fails with 422 quote_unavailable instead of locking a bad number. The current per-pair fee estimates are published at GET /v1/network-fees.

A token-priced invoice ("currency": "usdc") is payable in that token on any activated chain carrying it, and amount is read in the token's canonical decimals (usdc and usdt 6, dai 18, eth, bnb and pol 18, sol 9) then rescaled per chain, with each pair's estimated network fee added on top exactly as in the USD case. If a rescale would not be exact, creation fails with 422 amount_not_representable and names the pairs.

Because a locked quote is a free option for the payer, an invoice that accepts a volatile token (ETH, BNB, POL, SOL) caps expires_in at 3600 seconds. Asking for more is 422 expiry_too_long. Restrict accepted to stablecoin pairs to use the full 86400.

The minimum invoice is $1.00. A USD-priced invoice under it is 422 amount_too_small; a token-priced one worth less than that at the current price is 422 below_minimum. Every entry in accepted must be a pair this wallet has activated, otherwise 422 unsupported_pair, and a wallet whose activated_tokens leaves no pair at all answers 422 no_active_tokens.

Statuses

pending to detected to one of paid, overpaid, partially_paid or expired. The status only moves once a payment is confirmed and credited, never at zero confirmations.

  • A payment that does not yet cover the quote leaves the invoice on detected. Several partial payments accumulate in received, per pair, until the quote is met.
  • paid is anything from the quoted amount up to 102% of it. Past 102% the invoice is overpaid: the tolerance exists so a payer who rounds up a dust amount is not flagged as an exception.
  • partially_paid is not a live status but a terminal one: it is what an invoice becomes when it expires holding less than its quote.

Everything received is credited to your wallet whatever the status. An underpayment is credited, an overpayment is credited in full, and a payment that lands after expiry is still credited and still fires deposit.confirmed against the expired invoice. We never return funds on our own.

Idempotency

order_id is unique per wallet. Creating an invoice with an order_id that already exists returns the original invoice with 200 and "idempotent_replay": true, so a retried checkout never bills a customer twice.

GET /v1/invoices?status=&cursor= bearer

Newest first. status filters on the six statuses above. The cursor is opaque, pass it back unchanged.

GET /v1/invoices/:id bearer

Answers 404 for anything that is not your invoice, including ids that exist under another wallet.

POST /v1/invoices/:id/cancel bearer

Closes an unpaid invoice early. Only a pending invoice with no payment can be cancelled (otherwise 409 not_cancellable or 409 invoice_has_payments). A cancelled invoice lands on expired, the same terminal status, and fires invoice.expired.

Request

curl -X POST https://buxapi.com/v1/invoices/inv_4kR2QpN7vXsB1mLdZaYc9F/cancel \
  -H "authorization: Bearer $BUX_SEC"

200 response, abridged

{
  "id": "inv_4kR2QpN7vXsB1mLdZaYc9F",
  "status": "expired",
  "currency": "usd",
  "amount": "2500",
  "received": {},
  "expires_at": "2026-08-05T14:52:11.000Z",
  "created_at": "2026-08-05T14:32:11.000Z",
  "checkout_url": "https://buxapi.com/pay/inv_4kR2QpN7vXsB1mLdZaYc9F"
}

Hosted checkout

Every invoice comes with a hosted page at https://buxapi.com/pay/{invoiceId}, returned as checkout_url. The payer picks a chain and token from the accepted set, gets a dedicated address, the exact amount and a QR code, and the page follows the status until it settles or expires. If you set success_url, the page sends them there once it is paid.

The two endpoints behind that page are public and unauthenticated, so you can build your own checkout on top of them without exposing your key. They reveal nothing about the merchant: the public key, the webhook urls and every other invoice stay hidden.

GET /v1/public/invoices/:id public

Checkout data. accepted carries the payable amount and the decimals for each pair, and addresses holds the addresses already allocated for this invoice, per family. An invoice past its expiry reads as expired here even a moment before the expiry job closes it.

200 response

{
  "id": "inv_4kR2QpN7vXsB1mLdZaYc9F",
  "status": "pending",
  "currency": "usd",
  "amount": "2500",
  "accepted": [
    { "pair": "base:usdc",   "chain": "base",   "token": "usdc", "amount": "25010000", "decimals": 6 },
    { "pair": "solana:usdc", "chain": "solana", "token": "usdc", "amount": "25020000", "decimals": 6 }
  ],
  "received": {},
  "expires_at": "2026-08-05T14:52:11.000Z",
  "success_url": "https://acme.example/thanks",
  "cancel_url": "https://acme.example/cart",
  "addresses": { "evm": null, "solana": null }
}
POST /v1/public/invoices/:id/select public

The payer picks a pair and gets the address to pay. Calling it again for another token in the same family returns the same address. uri is EIP-681 on EVM, Solana Pay on Solana and BIP21 on Bitcoin and Litecoin, so a wallet scanning the QR prefills the exact amount.

Request

curl -X POST https://buxapi.com/v1/public/invoices/inv_4kR2QpN7vXsB1mLdZaYc9F/select \
  -H 'content-type: application/json' \
  -d '{"chain":"base","token":"usdc"}'

200 response

{
  "address": "0x8f2bC41d9E7a05F3b6D8241cA9e07B5d3F1a6E42",
  "chain": "base",
  "token": "usdc",
  "amount": "25010000",
  "decimals": 6,
  "expires_at": "2026-08-05T14:52:11.000Z",
  "uri": "ethereum:0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913@8453/transfer?address=0x8f2bC41d9E7a05F3b6D8241cA9e07B5d3F1a6E42&uint256=25010000"
}

409 invoice_closed if the invoice is expired or settled, 409 chain_unavailable if an operator paused that chain, 409 address_pending if an allocation is in flight (retry in a second), 422 pair_not_accepted for a pair this invoice does not take, 503 checkout_unavailable with a Retry-After if the platform configuration cannot be read. That last one fails closed on purpose: handing out an address is taking money, so an unknown chain state refuses rather than accepts.

GET /v1/rates public

Cached USD spot prices, for display. A feed that is down nulls its own symbol rather than failing the response, so check for null before rendering.

200 response

{
  "base": "usd",
  "rates": {
    "eth": 3184.22,
    "sol": 168.41,
    "pol": 0.4219,
    "bnb": 612.75,
    "usdc": 1,
    "usdt": 1,
    "dai": 1
  },
  "updated_at": "2026-08-05T14:32:00.113Z"
}
GET /v1/network-fees public

Estimated sweep fees per pair, in USD cents: what a deposit of that pair will be charged as its network fee at credit time, and what an invoice quote adds on top for the payer. buffer is the multiplier already applied to the raw gas estimate. A pair whose chain RPC or price feed is down reports usd_cents null rather than failing the response. These are estimates only: the fee actually charged is the one locked at credit time. Cached for about 15 seconds.

200 response, abridged

{
  "buffer": 1.5,
  "fees": {
    "base:usdc":   { "usd_cents": 1 },
    "base:eth":    { "usd_cents": 0 },
    "eth:usdc":    { "usd_cents": 142 },
    "eth:dai":     { "usd_cents": 187 },
    "solana:usdc": { "usd_cents": 2 },
    "bsc:usdt":    { "usd_cents": null }
  },
  "updated_at": "2026-08-05T14:32:00.113Z"
}

Sends

A send is an on-chain payout from your balance. It is queued, not synchronous: the API validates, prices, debits and answers 202, then the engine broadcasts and you learn the outcome from send.sent, send.confirmed or send.failed.

POST /v1/send bearer

chain, token, to_address and amount are required; amount is raw units of the token, as a string. It may also be the literal string "max", which resolves at debit time to the full debit balance minus the network fee: the send that empties the balance exactly. Pair it with GET /v1/send-fee below to preview what "max" will resolve to. idempotency_key is optional, up to 100 characters, and strongly recommended. The pair must be one this wallet has activated, otherwise 403 token_not_activated.

Request

curl -X POST https://buxapi.com/v1/send \
  -H "authorization: Bearer $BUX_SEC" \
  -H 'content-type: application/json' \
  -d '{
    "chain": "base",
    "token": "usdc",
    "to_address": "0x5aAe1B9F42c07d3E86bA104f7c25D9081eF3a6B2",
    "amount": "25000000",
    "idempotency_key": "payout_9931"
  }'

202 response

{
  "id": "512",
  "status": "queued",
  "chain": "base",
  "token": "usdc",
  "to_address": "0x5aAe1B9F42c07d3E86bA104f7c25D9081eF3a6B2",
  "amount_raw": "25000000",
  "debit_asset": "usdc",
  "debit_chain": "base",
  "debit_amount": "25004120",
  "network_fee": "4120",
  "swap_tx": null,
  "tx_hash": null,
  "expected_tx_hash": null,
  "explorer_url": null,
  "idempotency_key": "payout_9931",
  "created_at": "2026-08-05T15:02:11.882Z"
}

Network fees

The fee is estimated live at queue time, never hardcoded: gas price times the gas limit for the operation times a buffer on EVM, and the signature fee plus the current priority fee plus measured rent on Solana. It is converted into the asset you are debited in and added to the deduction, so debit_amount = amount + network_fee in raw mode, and the same in cents in convert mode. We take no margin on top of it.

One thing convert mode adds, stated plainly because it is money: you are debited in cents at the price when the send was queued, and the platform buys the output token at broadcast time. The recipient always gets exactly the amount you asked for, and whichever way the gap between those two moments falls, it belongs to the platform. Past 5% of movement the send is not broadcast at all: it fails and is refunded in full, fee included. Raw mode has no such gap, because nothing is swapped.

GET /v1/send-fee?chain=&token=&to_address= bearer

What a send of that pair would be charged as its network fee right now, using the same estimator POST /v1/send prices the debit with. It also reports your debit balance and what an amount of "max" would resolve to, so a "send everything" flow can show an honest number before committing. network_fee is in raw units of the debit asset; network_fee_usd_cents is a display figure and is null when the price feed is down. to_address is optional but worth passing on Solana: a recipient whose token account already exists is not charged the ATA rent, and the quote reflects that. This is an estimate; the fee actually charged is the one computed at send time. An unknown chain is 422 unsupported_chain, an unsendable token 422 unsupported_token, and a failed estimate 503 fee_estimate_unavailable.

Request

curl "https://buxapi.com/v1/send-fee?chain=base&token=usdc&to_address=0x5aAe1B9F42c07d3E86bA104f7c25D9081eF3a6B2" \
  -H "authorization: Bearer $BUX_SEC"

200 response

{
  "chain": "base",
  "token": "usdc",
  "debit_asset": "usdc",
  "network_fee": "4120",
  "network_fee_usd_cents": 0,
  "balance": "124875000",
  "max_amount_raw": "124870880"
}

Statuses and refunds

queued to sending to sent to confirmed, or failed. Refunds are additive ledger entries, never a rewritten balance:

  • A send that never reached the network refunds the amount and the network fee.
  • A send that was broadcast and reverted on-chain refunds the amount only. The gas was really burned.
  • A transaction that might still land is never auto-refunded. It is escalated to an operator, because refunding a send that then confirms is unrecoverable.

Validation

  • EVM addresses are checked against their EIP-55 checksum. A mixed-case address whose checksum does not match is rejected, which is the only typo protection that exists on a money-out path. Send it all-lowercase to skip the check.
  • Solana addresses must decode to 32 bytes and be on the ed25519 curve. An off-curve address (a PDA) cannot own a token account, so it is refused.
  • Minimum $1.00 equivalent, priced at request time (422 below_minimum). There is no maximum.
  • An unknown chain is 422 unsupported_chain; a token that chain cannot send is 422 unsupported_token. Polygon sends no native POL, for the reason given under Chains and tokens.
  • Pricing and fee estimation are live, so they can fail: 503 price_unavailable and 503 fee_estimate_unavailable both mean retry shortly, and nothing was debited. 503 chain_disabled and 503 sends_paused are operator switches.

Idempotency

idempotency_key is unique per wallet. Replaying one returns the original send with 200 instead of queueing a second payout. A 500 or a timeout is safe to retry with the same key; retrying with a new key is what pays twice.

GET /v1/sends bearer
GET /v1/sends/:id bearer

200 response

{
  "id": "512",
  "status": "confirmed",
  "chain": "base",
  "token": "usdc",
  "to_address": "0x5aAe1B9F42c07d3E86bA104f7c25D9081eF3a6B2",
  "amount_raw": "25000000",
  "debit_asset": "usdc",
  "debit_chain": "base",
  "debit_amount": "25004120",
  "network_fee": "4120",
  "swap_tx": null,
  "tx_hash": "0x41d0b7ce93a2f8501e6c47ba0d38915fe27c4a6b90de13857f024ca6b8e17d39",
  "expected_tx_hash": "0x41d0b7ce93a2f8501e6c47ba0d38915fe27c4a6b90de13857f024ca6b8e17d39",
  "explorer_url": "https://basescan.org/tx/0x41d0b7ce93a2f8501e6c47ba0d38915fe27c4a6b90de13857f024ca6b8e17d39",
  "idempotency_key": "payout_9931",
  "created_at": "2026-08-05T15:02:11.882Z"
}

expected_tx_hash is the hash signed locally before broadcast, and swap_tx is the first leg of a convert-mode send. Both are exposed so you can reconcile a send that is mid-flight.

Internal transfers

A transfer between two BuxAPI wallets is a ledger move: instant, free, no chain, no network fee, no confirmation to wait for. The target is a bux_pub_ key.

POST /v1/transfer bearer

Same asset on both sides, always. A usd transfer requires both wallets in convert mode and no chain; a token transfer requires both wallets in raw mode and a chain. A mismatch is 422 settlement_mismatch, because cents and raw token units are not the same accounting unit and nothing is converted here.

to, asset and amount are required, chain whenever the asset is not usd, and idempotency_key is optional, up to 200 characters. An asset that does not exist on the named chain is 422 unsupported_asset, and the transferable set is the same per-chain set the deposit engine credits.

Request

curl -X POST https://buxapi.com/v1/transfer \
  -H "authorization: Bearer $BUX_SEC" \
  -H 'content-type: application/json' \
  -d '{
    "to": "bux_pub_2Wq8HdN5xLm3ZrTc7VkPfA9YbGe4JsUnQ1Xt6RwDy8Bz",
    "asset": "usdc",
    "chain": "base",
    "amount": "25000000",
    "idempotency_key": "settle_2026_08_05"
  }'

201 response

{
  "id": "37",
  "replayed": false,
  "from": {
    "public_key": "bux_pub_7Yd4Qk9v2mTb8sXaLpR3ZnHc6JwEuFgKmQ2Ts9VbXe1Z",
    "asset": "usdc",
    "chain": "base",
    "amount": "25000000"
  },
  "to": {
    "public_key": "bux_pub_2Wq8HdN5xLm3ZrTc7VkPfA9YbGe4JsUnQ1Xt6RwDy8Bz",
    "asset": "usdc",
    "chain": "base",
    "amount": "25000000"
  }
}

A replayed idempotency_key answers 200 with "replayed": true and echoes what was actually recorded the first time, not what the repeat request asked for. Keys are scoped to your wallet.

GET /v1/transfers bearer

Both directions, newest first. direction is out or in and counterparty is the other wallet's public key.

200 response

{
  "data": [
    {
      "id": "37",
      "direction": "out",
      "counterparty": "bux_pub_2Wq8HdN5xLm3ZrTc7VkPfA9YbGe4JsUnQ1Xt6RwDy8Bz",
      "chain": "base",
      "asset": "usdc",
      "amount": "25000000",
      "created_at": "2026-08-05T15:40:09.221Z"
    }
  ],
  "next_cursor": null
}

Webhooks

Set webhook_url on the wallet and BuxAPI POSTs every event to it. A deposit event goes to the address's callback_url if that address has one, an invoice event to the invoice's webhook_url if it has one, and everything else to the wallet URL. An event that resolves to no URL is dropped rather than queued, so set a URL before you start taking money: the balances and the records are still exact either way, but that event will not be delivered later.

Events

EventFires when
deposit.detectedA payment to one of your addresses was seen on-chain, at zero confirmations.
deposit.confirmedThe deposit reached its confirmation, the fees were applied (the network fee, then the percentage fee) and the balance credited.
invoice.detectedA confirmed payment landed on the invoice but does not yet cover the quoted amount.
invoice.paidThe quoted amount is covered.
invoice.overpaidMore than 102% of the quoted amount arrived. All of it is credited.
invoice.partially_paidThe invoice expired holding less than the quoted amount. What arrived stays credited.
invoice.expiredThe invoice passed expires_at with nothing received, or was cancelled.
send.sentThe send transaction was broadcast. tx_hash is set.
send.confirmedThe send transaction is confirmed on-chain.
send.failedThe send failed. See the refund policy above.
transfer.receivedAnother BuxAPI wallet transferred funds to you.
testFired by POST /v1/webhook-test. No money moved.

Request

Headers

POST /hooks/bux HTTP/1.1
Content-Type: application/json
X-Buxapi-Event: deposit.confirmed
X-Buxapi-Delivery: 4471
X-Buxapi-Signature: t=1785940562,v1=6b2f8d1c04a7e39b5f0d82c147ae63095d8b21f7e4c06a93d15b8e72f04c3a19

deposit.confirmed payload

{
  "id": "8842",
  "chain": "base",
  "token": "usdc",
  "tx_hash": "0x9c1f7e4b02a5d38c6f1b904e7ad2c5318be047f9d6a3c81205e7b49fd3a06c17",
  "amount_raw": "25010000",
  "credited_amount": "24875000",
  "credited_asset": "usdc",
  "fee_amount": "125000",
  "network_fee_amount": "10000",
  "address": "0x8f2bC41d9E7a05F3b6D8241cA9e07B5d3F1a6E42",
  "invoice_id": "inv_4kR2QpN7vXsB1mLdZaYc9F"
}

invoice.paid payload

{
  "id": "inv_4kR2QpN7vXsB1mLdZaYc9F",
  "status": "paid",
  "currency": "usd",
  "amount": "2500",
  "received": { "base:usdc": "25010000" },
  "order_id": "order_10412",
  "metadata": { "cart": "c_881" },
  "deposit_id": "8842",
  "chain": "base",
  "token": "usdc",
  "tx_hash": "0x9c1f7e4b02a5d38c6f1b904e7ad2c5318be047f9d6a3c81205e7b49fd3a06c17"
}

deposit.detected carries the same shape as deposit.confirmed minus credited_amount, credited_asset, fee_amount and network_fee_amount, which do not exist until the credit. invoice.expired and invoice.partially_paid come from the expiry job, so they carry expires_at instead of the deposit fields. send.* payloads are the send object as returned by GET /v1/sends/:id.

Signature

Every delivery carries X-Buxapi-Signature: t=<unix>,v1=<hex>, where the hex is the HMAC-SHA256 of the string t + "." + rawBody keyed with your whsec_ secret. Verify it on the raw request bytes, before parsing: re-serializing the JSON changes the bytes and the signature will not match. Compare in constant time, and reject a timestamp outside a few minutes of now so an old capture cannot be replayed.

Verifying in Node

import { createHmac, timingSafeEqual } from 'node:crypto'

const TOLERANCE_SECONDS = 300

// rawBody must be the exact bytes BuxAPI sent. In Express, mount
// express.raw({ type: 'application/json' }) on this route: re-serializing a
// parsed object changes the bytes and the signature will not match.
export const verifyWebhook = (rawBody, signatureHeader, secret) => {
  const parts = {}
  for (const piece of String(signatureHeader || '').split(',')) {
    const eq = piece.indexOf('=')
    if (eq > 0) parts[piece.slice(0, eq).trim()] = piece.slice(eq + 1).trim()
  }

  const timestamp = Number(parts.t)
  if (!Number.isFinite(timestamp)) return false
  // Reject replays of an old, still-valid signature.
  if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) return false

  const expected = createHmac('sha256', secret)
    .update(parts.t + '.' + rawBody.toString('utf8'))
    .digest()
  const received = Buffer.from(parts.v1 || '', 'hex')

  return expected.length === received.length && timingSafeEqual(expected, received)
}

Delivery and retries

A 2xx within 10 seconds is a delivery. Anything else, including a timeout, is a failed attempt, retried at 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 24 hours after the immediate first try. After 7 failed attempts the event is dead-lettered and stops retrying. The money is unaffected either way, and the state is always recoverable from GET /v1/deposits, /v1/invoices and /v1/sends, so treat webhooks as an optimization over polling rather than the only path.

Deliveries are at-least-once. A crash between crediting and enqueueing is resolved by re-sending, so the same event can arrive twice: dedupe on X-Buxapi-Delivery, which is the event's id and stays the same across every retry of that event, and treat your handler as idempotent. Order is not guaranteed either.

POST /v1/webhook-test bearer

Queues a signed test event to your wallet webhook URL so you can check your endpoint and your signature verification before taking real money. Answers 409 no_webhook_url if none is set.

Request

curl -X POST https://buxapi.com/v1/webhook-test \
  -H "authorization: Bearer $BUX_SEC"

202 response

{
  "queued": true,
  "event_id": "4471",
  "type": "test",
  "target_url": "https://acme.example/hooks/bux"
}

Errors

Errors use real status codes and a single body shape. There is no 200-with-an-error pattern anywhere in this API.

Error body

{
  "error": {
    "code": "insufficient_funds",
    "message": "Insufficient balance for this send plus its network fee"
  }
}
StatusCodeMeaning
400bad_requestThe request body is not valid JSON.
401unauthorizedMissing, malformed or unknown private key.
403wallet_disabledThe wallet is disabled.
403token_not_activatedSend: the pair is not in this wallet's activated_tokens.
403recipient_disabledThe transfer recipient is disabled.
404not_foundNo such invoice or send for this wallet, or an unknown path.
404unknown_recipientNo BuxAPI wallet has that public key.
409key_in_useThat public key is already registered.
409balances_not_emptySettlement mode change requires zero balances.
409deposits_in_flightA deposit is mid-pipeline, retry once it settles.
409not_cancellableOnly a pending invoice can be cancelled.
409invoice_has_paymentsThe invoice already received a payment.
409invoice_closedCheckout: the invoice is expired or settled, or the merchant is disabled.
409chain_unavailableCheckout: that chain is unconfigured, disabled, or crediting is paused.
409address_pendingCheckout: an address allocation is in flight, retry in a second.
409no_webhook_urlSet a webhook_url before requesting a test event.
413payload_too_largeThe request body is over 100 kB.
415unsupported_media_typeUnsupported content encoding.
422invalid_requestSchema validation failed. The message names the field.
422invalid_cursorThe cursor is malformed, or contradicts the family filter.
422unsupported_chainUnknown chain key, on a send or a transfer.
422unsupported_tokenSend: that token cannot be sent on that chain.
422unsupported_assetTransfer: that asset does not exist on that chain.
422unsupported_currencyInvoice: currency is neither 'usd' nor a token this wallet can take.
422unsupported_pairInvoice: an entry in accepted is not activated for this wallet.
422no_active_tokensThe wallet has no activated pair at all. Set activated_tokens first.
422pair_not_acceptedCheckout: the payer picked a pair this invoice does not take.
422quote_unavailableNo usable price quote for the requested pairs.
422amount_not_representableA token-priced amount is not exact in some pair's decimals.
422amount_too_smallA USD-priced invoice below the $1.00 minimum.
422below_minimumA send, or a token-priced invoice, worth less than $1.00.
422expiry_too_longexpires_in exceeds the cap for a volatile token.
422invalid_addressBad EIP-55 checksum, or an off-curve Solana address.
422insufficient_fundsThe balance does not cover the amount plus fees.
422settlement_mismatchThe two wallets are not in the same settlement mode.
422self_transferThe transfer target is the sending wallet.
429rate_limitedToo many requests. See Rate limits.
500internalOur fault. Safe to retry with the same idempotency key.
503price_unavailableThe price feed is down. Retry shortly.
503fee_estimate_unavailableThe network fee could not be estimated.
503chain_disabledThat chain is switched off by an operator.
503sends_pausedSends on that chain are paused by an operator.
503unavailableSend: the operator kill switches could not be read. Retry shortly.
503checkout_unavailableCheckout: platform configuration could not be read. Retry shortly.

4xx means the request will not succeed as written, with 429 the one exception: wait and send it again. 5xx is retryable, and safe to retry with the same idempotency key. Several codes are scoped to one surface, noted in the meaning column: nothing marked "Checkout" can reach a bearer route, and nothing else can reach the public checkout endpoints.

Rate limits

SurfaceLimitScope
POST /v1/wallets5 per hourPer IP
/v1/public/*, /v1/rates, /v1/network-fees60 per minutePer IP
Every bearer endpoint120 per minutePer key

A throttled request answers 429 rate_limited. The per-key limit and the public checkout limit are fixed 60-second windows and both set Retry-After in seconds, so the count resets on the boundary rather than refilling gradually. Wallet creation counts the creations from your address over the trailing hour and sets no Retry-After.

The per-key limit is charged against the key, not the calling address, and it is consumed before the key is looked up: a wrong key spends the same budget as a right one, so a credential-stuffing loop is throttled without ever touching the database. If 120 per minute is not enough for your integration, tell us what you need and we will raise it.

Chains and tokens

Five chains. Deposits and sends support the same set on every chain. One EVM deposit address serves all four EVM chains.

KeyChainTokens and decimalsExplorer
baseBaseusdc 6, usdt 6, dai 18, eth 18basescan.org
ethEthereumusdc 6, usdt 6, dai 18, eth 18etherscan.io
polygonPolygonusdc 6, usdt 6, dai 18polygonscan.com
bscBNB Chainusdc 18, usdt 18, bnb 18bscscan.com
solanaSolanausdc 6, usdt 6, sol 9solscan.io
Binance-Peg USDC and USDT on BNB Chain are 18 decimals. Every other chain in this table uses 6 for both. A client that hardcodes 6 will read a BSC balance as a trillion times larger than it is, and will quote a payer a trillion times too little. Take decimals from the pair, never from the symbol.

Polygon has no native pol support: the platform holds no POL float beyond gas, so POL cannot be deposited or sent. It still appears in /v1/rates, which quotes every symbol the platform prices. Bitcoin and Litecoin are coming soon. Until they ship, these five chains are the whole matrix.

Two things worth knowing before you size an integration. BuxAPI runs as a single API process, because the deposit scanners and the EVM nonce manager cannot be run twice over the same rows; the rate limits above are measured against that one process. And there is no sandbox or testnet: every chain here is mainnet, so test with a $1.00 invoice.

Something missing?

If you need a chain, a token or an integration shape that is not here, or you would rather have this built directly into your product, contact us.