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.
| Secret | Used for | Stored by BuxAPI |
|---|---|---|
| mnemonic | Master 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 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) Wallets and settlement
Creates a wallet and returns its credentials once. Every field is optional. Rate limited to 5 per hour per IP.
| Field | Type | Notes |
|---|---|---|
| settlement_mode | string | 'raw' (default) or 'convert'. |
| label | string | Free text, up to 100 characters. |
| webhook_url | string | URL for this wallet's events, up to 500 characters. |
| public_key | string | Client-side keygen only. Send with sec_hash. |
| sec_hash | string | Lowercase 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 | |
|---|---|---|
| Deposit | Swept 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. |
| Balances | One row per (chain, token). You hold USDC on Base and USDT on Solana as separate balances. | One row, usd, chain null. |
| Send | Debits 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 transfer | Raw token amounts, to another raw wallet only. | USD cents, to another convert wallet only. |
| Exposure | You 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.
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" }
]
} 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.
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.
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.
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
} 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.
| Field | Type | Notes |
|---|---|---|
| amount | integer or string | USD cents, or raw token units. Required. |
| currency | string | 'usd' or a token symbol. Required. |
| accepted | string[] | 1 to 40 of the wallet's pairs, as 'chain:token'. |
| expires_in | integer | Seconds, 60 to 86400. Default 1200. |
| success_url | string | Where the checkout page sends a paying customer. |
| cancel_url | string | Where the checkout page sends a customer who backs out. |
| order_id | string | Your reference, up to 100 characters. Unique per wallet, doubles as the idempotency key. |
| metadata | object | Free JSON, echoed in webhooks. Up to 4000 characters serialized. |
| webhook_url | string | Overrides 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.
Newest first. status filters on the six statuses above. The cursor is opaque, pass it back unchanged.
Answers 404 for anything that is not your invoice, including ids that exist under another wallet.
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.
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 }
} 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.
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"
} 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.
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.
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.
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.
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.
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
| Event | Fires when |
|---|---|
| deposit.detected | A payment to one of your addresses was seen on-chain, at zero confirmations. |
| deposit.confirmed | The deposit reached its confirmation, the fees were applied (the network fee, then the percentage fee) and the balance credited. |
| invoice.detected | A confirmed payment landed on the invoice but does not yet cover the quoted amount. |
| invoice.paid | The quoted amount is covered. |
| invoice.overpaid | More than 102% of the quoted amount arrived. All of it is credited. |
| invoice.partially_paid | The invoice expired holding less than the quoted amount. What arrived stays credited. |
| invoice.expired | The invoice passed expires_at with nothing received, or was cancelled. |
| send.sent | The send transaction was broadcast. tx_hash is set. |
| send.confirmed | The send transaction is confirmed on-chain. |
| send.failed | The send failed. See the refund policy above. |
| transfer.received | Another BuxAPI wallet transferred funds to you. |
| test | Fired 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.
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"
}
} | Status | Code | Meaning |
|---|---|---|
| 400 | bad_request | The request body is not valid JSON. |
| 401 | unauthorized | Missing, malformed or unknown private key. |
| 403 | wallet_disabled | The wallet is disabled. |
| 403 | token_not_activated | Send: the pair is not in this wallet's activated_tokens. |
| 403 | recipient_disabled | The transfer recipient is disabled. |
| 404 | not_found | No such invoice or send for this wallet, or an unknown path. |
| 404 | unknown_recipient | No BuxAPI wallet has that public key. |
| 409 | key_in_use | That public key is already registered. |
| 409 | balances_not_empty | Settlement mode change requires zero balances. |
| 409 | deposits_in_flight | A deposit is mid-pipeline, retry once it settles. |
| 409 | not_cancellable | Only a pending invoice can be cancelled. |
| 409 | invoice_has_payments | The invoice already received a payment. |
| 409 | invoice_closed | Checkout: the invoice is expired or settled, or the merchant is disabled. |
| 409 | chain_unavailable | Checkout: that chain is unconfigured, disabled, or crediting is paused. |
| 409 | address_pending | Checkout: an address allocation is in flight, retry in a second. |
| 409 | no_webhook_url | Set a webhook_url before requesting a test event. |
| 413 | payload_too_large | The request body is over 100 kB. |
| 415 | unsupported_media_type | Unsupported content encoding. |
| 422 | invalid_request | Schema validation failed. The message names the field. |
| 422 | invalid_cursor | The cursor is malformed, or contradicts the family filter. |
| 422 | unsupported_chain | Unknown chain key, on a send or a transfer. |
| 422 | unsupported_token | Send: that token cannot be sent on that chain. |
| 422 | unsupported_asset | Transfer: that asset does not exist on that chain. |
| 422 | unsupported_currency | Invoice: currency is neither 'usd' nor a token this wallet can take. |
| 422 | unsupported_pair | Invoice: an entry in accepted is not activated for this wallet. |
| 422 | no_active_tokens | The wallet has no activated pair at all. Set activated_tokens first. |
| 422 | pair_not_accepted | Checkout: the payer picked a pair this invoice does not take. |
| 422 | quote_unavailable | No usable price quote for the requested pairs. |
| 422 | amount_not_representable | A token-priced amount is not exact in some pair's decimals. |
| 422 | amount_too_small | A USD-priced invoice below the $1.00 minimum. |
| 422 | below_minimum | A send, or a token-priced invoice, worth less than $1.00. |
| 422 | expiry_too_long | expires_in exceeds the cap for a volatile token. |
| 422 | invalid_address | Bad EIP-55 checksum, or an off-curve Solana address. |
| 422 | insufficient_funds | The balance does not cover the amount plus fees. |
| 422 | settlement_mismatch | The two wallets are not in the same settlement mode. |
| 422 | self_transfer | The transfer target is the sending wallet. |
| 429 | rate_limited | Too many requests. See Rate limits. |
| 500 | internal | Our fault. Safe to retry with the same idempotency key. |
| 503 | price_unavailable | The price feed is down. Retry shortly. |
| 503 | fee_estimate_unavailable | The network fee could not be estimated. |
| 503 | chain_disabled | That chain is switched off by an operator. |
| 503 | sends_paused | Sends on that chain are paused by an operator. |
| 503 | unavailable | Send: the operator kill switches could not be read. Retry shortly. |
| 503 | checkout_unavailable | Checkout: 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
| Surface | Limit | Scope |
|---|---|---|
| POST /v1/wallets | 5 per hour | Per IP |
| /v1/public/*, /v1/rates, /v1/network-fees | 60 per minute | Per IP |
| Every bearer endpoint | 120 per minute | Per 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.
| Key | Chain | Tokens and decimals | Explorer |
|---|---|---|---|
| base | Base | usdc 6, usdt 6, dai 18, eth 18 | basescan.org |
| eth | Ethereum | usdc 6, usdt 6, dai 18, eth 18 | etherscan.io |
| polygon | Polygon | usdc 6, usdt 6, dai 18 | polygonscan.com |
| bsc | BNB Chain | usdc 18, usdt 18, bnb 18 | bscscan.com |
| solana | Solana | usdc 6, usdt 6, sol 9 | solscan.io |
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.