Docs
blockwallets lets you accept USDT and USDC, plus BTC, LTC and native coins (credited in US dollars at a locked rate): the customer pays, we confirm it on chain, then we notify you. The API, signatures and webhooks are the same as Cryptomus — if you have integrated Cryptomus, change the domain and you are done. How it works comes first, then the API.
Two ways to get paid
Both accept any amount: fixed-price orders and top-ups where the customer types the amount. Once paid, you ship or credit the customer’s balance. The only difference is whether every payment gets a fresh address.
The customer picks a product or enters an amount on your page, you create a bill, and they send the amount shown on the payment page. Every bill has a new address and a countdown; when it arrives we notify you with the amount actually received.
POST /v1/payment → webhook type: payment
No amount up front: each customer gets a permanent address per network to save in their wallet and send to any time. Every transfer triggers its own webhook.
POST /v1/wallet → webhook type: wallet
When in doubt, use bills. Without code, the dashboard’s payment links are bills created for you. Not a merchant yet? Request access on the home page; once approved, sign in to the merchant dashboard and try “Test a payment” with a small amount first (the money goes to your own balance).
How a bill flows
A payment involves four parties: the customer, your site, blockwallets and the blockchain. The diagram runs top to bottom in time; the right column is the bill’s status at each step.
The diagram is wide — scroll sideways.
In short, five steps:
- You create a bill (a payment link does it for you, or your server calls
/v1/payment), get the payment pageurl, and redirect the customer there. - On the payment page the customer picks a coin and a network, gets a dedicated address and amount, and pays from a wallet or exchange.
- We see the transfer on chain, wait for enough confirmations, then cross-check it with a second node provider (so a node can’t fake it).
- The bill becomes
paidand we immediately POST a signed webhook to yoururl_callback; a few seconds later the payment page sends the customer back to yoururl_success. - The money is credited to your balance; withdraw to your own wallet from the merchant dashboard.
Bill status
| status | Meaning | What you do |
|---|---|---|
check | Awaiting payment | — |
confirm_check | Transfer seen, waiting for confirmations | — |
wrong_amount_waiting | Received but short; the customer can top up to the same address (when is_payment_multiple is true; notified once) | — (you get paid once it is topped up) |
paid | Paid | Ship / credit by payment_amount_usd |
paid_over | Paid, and overpaid | Same (payment_amount_usd is the amount received) |
wrong_amount | Expired with less than the full amount (with is_payment_multiple false: the first short transfer) | Top-up businesses can credit the received payment_amount_usd; shops ask the customer to pay the rest |
cancel | Expired with nothing received | — (money that arrives later is still credited and notified) |
When is_final is true the status will not change again (except for late payments).
Payment links (no code)
Merchant dashboard → “Payment links” → enter a description and amount (fix a network or let the customer choose) → create. Each link has a URL and a QR code.
- Every time someone opens the link a new bill is created, so one link can be reused (e.g. “Membership $99”).
- Every payment shows up under “Payments”; withdraw it to your wallet from “Balance & withdrawals”.
- If you have created credentials and a webhook URL under “Integration & developers”, payments through links are also notified in the format below.
- To stop accepting, click “Disable” and the link stops working immediately.
API integration
1. Credentials and signing
Merchant dashboard → “Integration & developers” → generate a merchant (UUID) and an API key. There is one set for Live and one for Test: Live takes real money, Test runs on testnets. Also add your website domains: url_callback, url_return and url_success must be on them. The API key is shown only once — keep it safe and only on your server.
Base URL https://blockwallets.io. Every endpoint is POST + JSON, with these headers:
merchant: your merchant UUID (this decides Live or Test)sign:md5(base64(request body) + API key), where the body is the exact JSON string you send
Success returns {"state": 0, "result": {…}}; failure returns {"state": 1, "message": "…"} (see Errors).
The key endpoints below have a “Try it” panel: enter your Test merchant and API key, and the page signs the request in your browser, calls the endpoint and shows the full request and response (credentials stay in the page’s memory). When a signature doesn’t match, compare it step by step with the signature checker.
import crypto from "node:crypto";
async function call(path, data) {
const body = JSON.stringify(data);
const sign = crypto.createHash("md5").update(Buffer.from(body).toString("base64") + process.env.BW_API_KEY).digest("hex");
const res = await fetch("https://blockwallets.io" + path, {
method: "POST",
headers: { merchant: process.env.BW_MERCHANT, sign, "Content-Type": "application/json" },
body,
});
return res.json();
}function bw_call(string $path, array $data): array {
$body = json_encode($data, JSON_UNESCAPED_UNICODE);
$sign = md5(base64_encode($body) . getenv('BW_API_KEY'));
$ch = curl_init('https://blockwallets.io' . $path);
curl_setopt_array($ch, [
CURLOPT_POST => true, CURLOPT_POSTFIELDS => $body, CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['merchant: ' . getenv('BW_MERCHANT'), 'sign: ' . $sign, 'Content-Type: application/json'],
]);
return json_decode(curl_exec($ch), true);
}import base64, hashlib, json, os, requests
def bw_call(path: str, data: dict) -> dict:
body = json.dumps(data, separators=(",", ":"), ensure_ascii=False)
sign = hashlib.md5((base64.b64encode(body.encode()).decode() + os.environ["BW_API_KEY"]).encode()).hexdigest()
headers = {"merchant": os.environ["BW_MERCHANT"], "sign": sign, "Content-Type": "application/json"}
return requests.post("https://blockwallets.io" + path, data=body.encode(), headers=headers).json()2. Create a bill POST /v1/payment
When the customer places an order, or enters an amount on your top-up page, call https://blockwallets.io/v1/payment and redirect them to the returned result.url.
{
"amount": "42.00",
"currency": "USD",
"order_id": "order_1042",
"url_callback": "https://your-site.com/webhooks/crypto",
"url_return": "https://your-site.com/cart",
"url_success": "https://your-site.com/thanks?order=1042"
}| Parameter | Required | Description |
|---|---|---|
amount | Yes | Amount as a string, up to two decimals: "42.00". Your price or a number the customer entered; not below the chosen network’s minimum (see networks) |
currency | Yes | USD, USDT or USDC, all 1:1 |
order_id | Yes | Your order number: letters, digits, -, _, ≤ 100 characters. Calling again with the same order_id returns the existing bill (no duplicate orders) |
url_callback | No | Webhook URL. Omit it and no webhook is sent |
url_return | No | Where “Back” goes before paying |
url_success | No | Where the customer goes after paying |
network | No | Preselect a network: tron / bsc / eth / base / polygon / arbitrum / optimism / avalanche / xlayer / btc / ltc / sol; omit it to let the customer choose on the payment page |
to_currency | No | Coin on that network: USDT / USDC; on btc / ltc it is BTC / LTC (optional) |
lifetime | No | Bill lifetime in seconds, 300 – 43200, default 3600; anything under 600 counts as 600 |
accuracy_payment_percent | No | How much underpayment still counts as paid, 0 – 5, default 0. 100 = accept whatever arrives: paid as soon as it confirms (paid_over if more), credit by payment_amount_usd in the webhook — good for top-ups. If omitted, the dashboard’s “Amount rule” applies: “credit what arrives” means 100 |
subtract | No | Share of the platform fee paid by the customer, 0 – 100, added to payer_amount (the platform fee is 1%, lower at higher volume; at 1%, with 100 a $100 bill costs the customer $101.00; see pricing). If omitted, the dashboard’s “Fees and underpayment” setting applies (default 0) |
is_payment_multiple | No | Whether a short payment can be topped up, default true: status wrong_amount_waiting, paying the rest to the same address makes it paid (if under 30 minutes remain at the first short payment, the deadline moves to 30 minutes later). false: the first short transfer makes it wrong_amount. If omitted, the dashboard setting applies |
additional_data | No | Any string (≤ 255), returned as-is in queries and webhooks |
{
"state": 0,
"result": {
"uuid": "6f1c2a7e-…",
"order_id": "order_1042",
"amount": "42.00",
"currency": "USD",
"network": null,
"address": null,
"payer_amount": null,
"payment_amount": null,
"url": "https://blockwallets.io/pay/inv_01J8…",
"expired_at": 1760965200,
"status": "check",
"is_final": false
}
}network, address and payer_amount are filled in once the customer picks a network.
3. Query bills
POST /v1/payment/info{"order_id": "…"}or{"uuid": "…"}: fetch one bill and check the chain on the way (at most once per 10 seconds).POST /v1/payment/list: list, 15 per page, optionaldate_from/date_to; next page via the returnedpaginate.nextCursor(?cursor=…).POST /v1/payment/services: available networks and each network’s minimum amount.POST /v1/payment/resend{"order_id": "…"}: resend the webhook of a paid / underpaid bill.
Webhooks are the primary channel; queries are the fallback (for example if your server was down and missed a webhook).
4. Static wallets POST /v1/wallet
Opens a permanent address for one customer on one network. The same order_id (usually your user id) + network always gives the same address; all EVM chains (BNB Chain, Ethereum, Base, Polygon, Arbitrum, Optimism, Avalanche, X Layer, Solana) share one address, while TRON, BTC and LTC each have their own. Whatever the customer sends is credited, with a webhook per transfer; BTC / LTC transfers are converted to US dollars at the rate when each was seen (payment_amount_usd).
{
"order_id": "user_42",
"currency": "USDT",
"network": "tron",
"url_callback": "https://your-site.com/webhooks/crypto"
}{
"state": 0,
"result": {
"wallet_uuid": "0b9d…",
"uuid": "5e2f…",
"order_id": "user_42",
"currency": "USDT",
"network": "tron",
"address": "TXy4…8kQ2",
"url": "https://blockwallets.io/deposit/dac_01J8…"
}
}result.url is a hosted deposit page (address + QR code + recent deposits) you can hand to the customer. Transfers under $1 are not credited automatically; handle them in the dashboard. Blocking addresses (/v1/wallet/block-address) is not supported yet.
Webhooks
When a bill’s status changes (paid, paid_over, wrong_amount_waiting, wrong_amount, cancel) and for every transfer to a static wallet, we POST JSON to url_callback. A 2xx response counts as delivered; otherwise we retry after 1 minute, 5 minutes, 30 minutes, 2 hours, 12 hours, 24 hours and 24 hours. You can also resend from the dashboard or with /v1/payment/resend.
{
"type": "payment",
"uuid": "6f1c2a7e-…",
"order_id": "order_1042",
"amount": "42.00",
"payment_amount": "42",
"payment_amount_usd": "42.00",
"merchant_amount": "41.832",
"commission": "0.168",
"is_final": true,
"status": "paid",
"from": "TQ8m…",
"wallet_address_uuid": null,
"network": "tron",
"currency": "USD",
"payer_currency": "USDT",
"payer_amount": "42",
"payer_amount_exchange_rate": "1",
"additional_data": null,
"txid": "9c1e…",
"sign": "5f0c9b…"
}| type | When | Crediting |
|---|---|---|
payment | A bill’s status changed — see status | Credit payment_amount_usd on paid / paid_over; deduplicate by uuid |
wallet | A transfer reached a static wallet (status is always paid; order_id is the one you opened the wallet with) | Credit payment_amount_usd; every transfer has its own uuid — deduplicate by it |
Verify the signature (required)
The signature is the sign field of the webhook body: remove sign, re-encode the rest the way PHP json_encode does (compact, / escaped as \/, non-ASCII not escaped), and md5(base64(encoded) + API key) must equal sign. Use the API key of the same mode. The same webhook may arrive more than once — deduplicate by uuid + status.
If verification fails, paste the raw webhook body into the signature checker: it shows the result of every step and marks where yours differs (it all runs in your browser; the API key is never uploaded). Every webhook in the dashboard’s “Webhook log” opens in the checker with one click.
import crypto from "node:crypto";
function verify(rawBody, apiKey) {
const { sign, ...data } = JSON.parse(rawBody);
const json = JSON.stringify(data).replaceAll("/", "\\/");
const expected = crypto.createHash("md5").update(Buffer.from(json).toString("base64") + apiKey).digest("hex");
return typeof sign === "string" && sign.length === expected.length && crypto.timingSafeEqual(Buffer.from(sign), Buffer.from(expected));
}$data = json_decode(file_get_contents('php://input'), true);
$sign = $data['sign'] ?? '';
unset($data['sign']);
$expected = md5(base64_encode(json_encode($data, JSON_UNESCAPED_UNICODE)) . $apiKey);
if (!hash_equals($expected, $sign)) {
http_response_code(401); exit;
}import base64, hashlib, hmac, json
def verify(raw_body: bytes, api_key: str) -> bool:
data = json.loads(raw_body)
sign = data.pop("sign", "")
encoded = json.dumps(data, separators=(",", ":"), ensure_ascii=False).replace("/", "\\/")
expected = hashlib.md5((base64.b64encode(encoded.encode()).decode() + api_key).encode()).hexdigest()
return hmac.compare_digest(expected, sign)Test mode
Bills created with the Test merchant and API key run on testnets, with no real money involved. The payment page shows a “Simulate payment” button (full / half / overpay), so you can see status changes and webhooks without getting testnet coins.
POST /v1/test-webhook/payment,/v1/test-webhook/wallet: send a signed sample webhook tourl_callback(passcurrency,network, optionallystatus,order_id) to debug your verification code.- Dashboard “Integration & developers” → “Send a test notification”: sends a sample to your webhook URL.
- Dashboard “Webhook log”: every delivery attempt with its time, target URL, HTTP status, duration, request body and your server’s response (first 4 KB); filter by order or status and resend with one click. Kept for 30 days.
Migrating from Cryptomus
- Dashboard “Integration & developers” → generate a merchant and API key.
- Replace
https://api.cryptomus.comin your code withhttps://blockwallets.io, and use the new merchant and API key. - Add your domains under “Website domains” (
url_callback/url_return/url_successonly accept these).
Supported: /v1/payment, /v1/payment/info, /v1/payment/list, /v1/payment/services, /v1/payment/resend, /v1/wallet, /v1/test-webhook/payment, /v1/test-webhook/wallet.
Differences: bills are priced only in USD / USDT / USDC (all 1:1, no exchange spread); commission is our platform fee (the amount received × the merchant’s rate when the bill was created — 1% standard, down to 0.5% by volume; nothing is charged twice on the part the customer covers) and merchant_amount = received − commission; when the customer pays in BTC / LTC we quote the median of several exchanges, locked for 30 minutes — payer_amount is the number of coins to send, payer_amount_exchange_rate is the USD value of one coin, and the payment is converted to payment_amount_usd at the locked rate (the coins stay with us, nothing is swapped); networks tron / bsc / eth / base / polygon / arbitrum / optimism / avalanche / xlayer / btc / ltc / sol; minimum bill lifetime 10 minutes; no is_refresh and no blocking of static wallets; webhooks come from changing IPs, so verify sign rather than allow-listing IPs.
Coming from NOWPayments or Coinbase Commerce? We have compatible APIs for both — change the base URL and keys; see the migration guide.
Networks and amounts
| Coin | Network | network parameter | Minimum | Arrival |
|---|---|---|---|---|
| USDT | TRON | tron | $1 | ~1 min |
| USDT | BNB Chain | bsc | $1 | ~1 min |
| USDC | BNB Chain | bsc | $1 | ~1 min |
| USDT | Ethereum | eth | $5 | ~10 min |
| USDC | Ethereum | eth | $5 | ~10 min |
| USDC | Base | base | $1 | ~3 min |
| USDT | Polygon | polygon | $1 | ~1 min |
| USDC | Polygon | polygon | $1 | ~1 min |
| USDT | Arbitrum | arbitrum | $1 | ~12 min |
| USDC | Arbitrum | arbitrum | $1 | ~12 min |
| USDT | Optimism | optimism | $1 | ~5 min |
| USDC | Optimism | optimism | $1 | ~5 min |
| USDT | Avalanche | avalanche | $1 | ~1 min |
| USDC | Avalanche | avalanche | $1 | ~1 min |
| USDT | X Layer | xlayer | $1 | ~2 min |
| USDC | X Layer | xlayer | $1 | ~2 min |
| BTC | Bitcoin | btc | $5 | ~10 min |
| LTC | Litecoin | ltc | $1 | ~8 min |
| TRX | TRON | tron | $1 | ~1 min |
| BNB | BNB Chain | bsc | $1 | ~1 min |
| ETH | Ethereum | eth | $5 | ~10 min |
| ETH | Base | base | $1 | ~3 min |
| ETH | Arbitrum | arbitrum | $1 | ~12 min |
| ETH | Optimism | optimism | $1 | ~5 min |
| POL | Polygon | polygon | $1 | ~1 min |
| AVAX | Avalanche | avalanche | $1 | ~1 min |
| OKB | X Layer | xlayer | $1 | ~2 min |
| USDT | Solana | sol | $1 | ~1 min |
| USDC | Solana | sol | $1 | ~1 min |
| SOL | Solana | sol | $1 | ~1 min |
ERC-20 fees are high, so the minimum is $5; Bitcoin fees are not low either, minimum $5; everything else is $1. Customers must send on the network shown on the payment page — money sent on another network is not credited automatically.
BTC / LTC show “detected” as soon as they are in the mempool and count once they have enough confirmations; replaced (RBF) or dropped transactions do not count.
Native coins (TRX, BNB, ETH, POL, AVAX, OKB, SOL) are quoted at a locked rate and credited in US dollars like BTC / LTC; specify both network and coin: bills network: "bsc", to_currency: "BNB", static wallets network: "bsc", currency: "BNB". A network on its own defaults to USDT on that chain (USDC on Base).
Transfers made by contracts (multisig, batch payouts) are received too; on some chains their transaction hash is unavailable and txid in the webhook is null.
Errors
{ "state": 1, "message": "amount must be a number like 10.28" }| HTTP | Common causes |
|---|---|
| 422 | Missing or malformed parameters; amount below the network minimum; callback / redirect URL not on your registered domains |
| 401 | Wrong merchant, wrong sign (Invalid Sign.) or a rotated API key |
| 404 | Bill does not exist or isn’t yours (Payment not found); unsupported coin / network combination (The service was not found) |
| 503 | The network is temporarily unavailable — pick another or don’t preselect one |
When the signature is wrong (Invalid Sign.) the response carries a debug object: body_base64 is the base64 of the request body we received (your own content, never the API key) and body_bytes its length. Compare it with the base64 you signed: if they differ, the body you sent is not the string you signed (your framework re-serialized it, a proxy changed the encoding, a trailing newline); if they match, the API key is wrong. Paste it into the signature checker to see the first difference.
{
"state": 1,
"message": "Invalid Sign.",
"debug": {
"body_base64": "eyJhbW91bnQiOiI0Mi4wMCIsImN1cnJlbmN5IjoiVVNEIiwi…",
"body_bytes": 158,
"sign_formula": "md5(body_base64 + API key)"
}
}FAQ
Customers top up any amount they like — which mode?
Bills. Let the customer enter an amount on your top-up page, create a bill for it, and credit payment_amount_usd when it arrives. Use static wallets only if customers should save one address and send to it repeatedly.
What if a customer underpays?
Before it expires the customer can send the difference on the payment page (it shows how much is still due; status wrong_amount_waiting, and if under 30 minutes remain at the first short payment the deadline moves to 30 minutes later). If it is still short at expiry, the bill becomes wrong_amount and payment_amount_usd in the webhook is the amount received; top-up businesses can simply credit that.
What if a customer pays late, or twice?
Money that arrives after expiry is still credited and notified; money that arrives after a bill is fully paid is recorded too and visible in the dashboard.
What if a customer sends on the wrong network?
For example the bill is on TRON and they send from BNB Chain: it is not credited automatically — contact us and we handle it manually.
How much does it cost?
1% of each payment received, lowered automatically by last month’s volume (0.9% from $10,000, 0.8% from $25,000, 0.7% from $50,000, 0.6% from $100,000 and 0.5% from $200,000), with no monthly or setup fees. The customer pays the network fee when sending; to pass the fee on to the customer, set subtract when creating a bill or as a default in the dashboard. Withdrawals carry no extra fee. See pricing.
When does the money reach me?
It is credited to your balance as soon as the payment confirms. Request a withdrawal to your own wallet under “Balance & withdrawals”; once we approve it we pay out, and the on-chain transaction appears in your withdrawal history.
You can also turn on auto-withdrawal: once your available balance reaches an amount you set, or once a day at 00:00 UTC, the whole available balance is withdrawn. Automatic withdrawals are reviewed the same way, and every settings change sends a notice to your email.