TL;DR: Accepting USDT with Blockonomics
This guide walks you through the technical differences between accepting Bitcoin and USDT (ERC-20) in your Node.js application. While Bitcoin allows for automatic address generation per order, USDT requires a "shared address" model. We cover how to handle this by using "jittered" payment amounts to keep orders unique, how to use the monitor_tx API to track payments on the Ethereum blockchain, and how to properly manage 6-decimal precision to ensure your payments are accurately identified and confirmed. By the end of this post, you'll have a production-ready setup for stablecoin payments that bypasses the high-risk restrictions of traditional merchant processors.
Table of Contents
- Overview — why stablecoins, and how USDT differs from BTC at a glance
-
Accepting USDT with Blockonomics: What Changes When You Move Beyond BTC
- The three things that are different from BTC
- 2.1 One fixed address per store, not per order
- 2.2 You explicitly start monitoring each transaction
- 2.3 Confirmation thresholds count actual blockchain confirmations
- The three things that are different from BTC
-
Wiring USDT into the existing Express server
- Step 1 — Add USDT to the order schema
- Step 2 — Branch the
/checkouthandler on crypto type - Step 3 — Update the Blockonomics helper to take a
cryptoparam - Step 4 — Add an endpoint where the customer submits their txhash
- Step 5 — Update the webhook to handle USDT statuses
-
USDT reconciliation — the painful bit
- Approach 1 — Trust the customer-submitted txhash
- Approach 2 — Match on a uniquely jittered amount
- Approach 3 — Use the Web3 USDT Component
- Frontend: showing the USDT payment page (EIP-681 QR codes)
- Common errors and pitfalls
Overview
USDT (Tether on Ethereum, ERC-20) is the most-used stablecoin in crypto payments, and Blockonomics added it as a first-class option in late 2025. It behaves very differently from BTC: there's no fresh address per order (you reuse one fixed address per store), and you have to explicitly start monitoring each transaction via POST /api/monitor_tx.Bitcoin is volatile.
A merchant who quoted £30 in BTC at 9 a.m. and got paid at 9:15 a.m. might end up with £29.40 or £30.60 depending on which way the price moved. For most merchants this is fine. For high-margin digital goods sellers it's fine. For low-margin physical sellers, it's a problem.Stablecoins solve this. USDT is pegged 1:1 to the US dollar — when a customer sends you $30 USDT, you receive ≈$30 USDT, full stop. That's why stablecoins now make up the majority of crypto payment volume globally.
Accepting USDT with Blockonomics: What Changes When You Move Beyond BTC
Blockonomics supports USDT on the Ethereum mainnet (ERC-20) and on Sepolia testnet. But the integration model is different enough from BTC that you can't just "set crypto=USDT and ship." Here's what changes.
The three things that are different from BTC
1. One fixed address per store, not per order
For BTC, every call to POST /api/new_address returns the next address in your derivation chain perfect for 1:1 order ↔ address mapping. For USDT, the API always returns the same address:
"When set to USDT, the same payment address will always be returned for the store (address is not regenerated)." — from the Blockonomics API reference
This is because Ethereum addresses aren't cheap to derive in the same way Bitcoin's BIP-32 chain is, and the conventional model on Ethereum is "one wallet, many transactions to it." So you can't use the address itself to identify which order a payment belongs to. You need another way to link them typically by asking the customer to send a specific amount (e.g. exactly $30.13 instead of $30.00) and matching on that, or by using a dedicated wallet/store per order which is overkill for most use cases.
2. You explicitly start monitoring each transaction
For BTC, the moment a payment hits one of your derived addresses, Blockonomics' indexer notices and fires your callback. For USDT, you have to tell Blockonomics which transaction to track by hitting POST /api/monitor_tx with the transaction hash — the hash the customer's wallet produces when they send the payment. (How you actually get hold of that hash is the next section.)
This means the flow is:
- Show the customer the USDT address and amount.
- Customer sends funds from their wallet, gets a transaction hash back.
- Either the customer manually enters the transaction hash on your page, or your Web3 component detects it automatically.
- Your server calls
POST /api/monitor_txwith that hash. - Blockonomics starts sending you HTTP callbacks as the tx gains confirmations.
A bit more friction than BTC, but unavoidable on Ethereum because there's no way to "watch for any incoming tx to address X" without an indexer pulling every block — which is what monitor_tx outsources to Blockonomics.
3. Confirmation thresholds count actual blockchain confirmations
For BTC, the state is a simple enum: 0 for detect on mempool, 1 for partial confirmation, and 2 for fully confirmed (2+ confirmations)
For USDT, the status field is the literal confirmation count:
- -1 = Reverted (failed or dropped from mempool)
- 0 = Exactly 0 confirmations (unconfirmed)
- 1 = Exactly 1 confirmation
- 2 = Exactly 2 confirmations — final status reported
⚠️ Important: Blockonomics stops sending callbacks at status=2. If your business requires waiting beyond 2 confirmations (e.g. for very large payments), you have to poll the blockchain yourself from there. For typical merchant volumes, 2 confirmations on Ethereum (~24 seconds) is plenty.
The -1 status is new compared to BTC and matters for UX — a customer's transaction can fail (gas runs out, replacement transaction wins) and you'll see it transition to -1. Handle this case in your handler.
Here's how to wire USDT into the existing Express server.
Step 1: Add USDT to the order schema
Adds three columns to the orders table so the same table can hold both kinds of payment: crypto (BTC or USDT), txHashHint (the hash you're tracking), and confirmations (the live count). Everything else about the order stays the same.
// src/db.js — extend the orders table
db.exec(`
ALTER TABLE orders ADD COLUMN crypto TEXT NOT NULL DEFAULT 'BTC';
ALTER TABLE orders ADD COLUMN txHashHint TEXT;
ALTER TABLE orders ADD COLUMN confirmations INTEGER DEFAULT 0;
`);
(In real life you'd typically use a migration tool— node-pg-migrate, Knex, etc. For this tutorial we'll keep it simple.)
Step 2: Branch the /checkout handler on crypto type.
The /checkout handler now reads a crypto field and branches on it. For both coins it fetches the price and a payment address. The USDT-only extra is the jitter: it nudges the amount by a tiny random fraction (e.g. $30.000037) so that two orders rarely ask for the exact same amount — that's what later lets you tell them apart, since they all share one address. It saves the order as pending and returns a pay URL.
// src/server.js
import { randomBytes } from "node:crypto"; // ← add this import
import { newAddress, btcPrice } from "./blockonomics.js";
const TESTNET = Number(process.env.TESTNET || 0);
app.post("/checkout", async (req, res) => {
try {
const {
amountFiat = 30,
currency = "GBP",
productName = "T-shirt",
crypto = "BTC", // BTC or USDT
} = req.body;
if (!["BTC", "USDT"].includes(crypto)) {
return res.status(400).json({ error: "unsupported crypto" });
}
const pricePerUnit = await btcPrice(currency, crypto);
const decimals = crypto === "BTC" ? 8 : 6;
const amountCrypto = +(amountFiat / pricePerUnit).toFixed(decimals);
const matchCallback = "/webhook/blockonomics";
const { address } = await newAddress(matchCallback, crypto);
// `crypto` is a STRING here ("BTC"/"USDT") — not the node crypto module.
// Use the imported randomBytes directly.
const orderId =
crypto === "USDT"
? `usdt_${randomBytes(8).toString("hex")}`
: randomBytes(8).toString("hex");
// USDT: nudge the amount so concurrent orders are distinguishable (they all
// share one address). See the reconciliation section.
let amountFinal = amountCrypto;
if (crypto === "USDT") {
const jitter = Math.floor(Math.random() * 99) / 1_000_000; // 0..0.000099
amountFinal = +(amountCrypto + jitter).toFixed(6);
}
createOrder({
orderId,
address,
amountFiat,
currency,
amountBtc: amountFinal, // reused column; rename to amountCrypto in real code
pricePerBtc: pricePerUnit,
productName,
status: "pending",
crypto,
});
res.json({
orderId,
crypto,
paymentUrl: `${process.env.PUBLIC_URL}/pay/${orderId}`,
});
} catch (err) {
console.error("checkout error:", err.response?.data || err.message);
res.status(500).json({ error: "could not create order" });
}
});
Step 3: Update the Blockonomics helper to take a crypto param
Three thin wrappers around the API: newAddress() and btcPrice() get a crypto parameter so they work for either coin, and a new monitorUsdtTx() posts the hash to monitor_tx. The one gotcha called out here: monitor_tx takes a JSON body, not query params like the others.
// src/blockonomics.js
export async function newAddress(matchCallback, crypto = "BTC") {
const { data } = await client.post("/new_address", null, {
params: { match_callback: matchCallback, crypto, reset: 0 },
});
return { address: data.address };
}
// Pass crypto through to the price endpoint too
export async function btcPrice(currency = "GBP", crypto = "BTC") {
const { data } = await client.get("/price", {
params: { crypto, currency },
});
return data.price;
}
// New: tell Blockonomics to start watching a USDT transaction
export async function monitorUsdtTx(txhash, matchCallback, testnet = 0) {
const { data } = await client.post(
"/monitor_tx",
{ txhash, crypto: "USDT", match_callback: matchCallback, testnet },
);
return data;
}
Note monitor_tx is a JSON POST body, not query params. Easy to get wrong.
Step 4: Add an endpoint where the customer submits their txhash
Before you can track a USDT payment, you need its transaction hash — a unique ID the blockchain gives every transaction.The easiest way to get it is the Blockonomics Web3 component. You drop it on your checkout page, and it does the work for you: the customer connects their wallet (like MetaMask), pays, and the component automatically hands your page the transaction hash. Your server then passes that hash to monitor_tx, and you're done. No copy-pasting, and the payment is matched to the right order for you.If you want something simpler to start with, you can skip the component and just add a box that says "I've sent it — paste your transaction hash here." It works fine — you're only relying on the customer to paste the right hash.
// src/server.js
const TESTNET = Number(process.env.TESTNET || 0);
app.post("/pay/:orderId/submit-tx", async (req, res) => {
const order = getOrder(req.params.orderId);
if (!order) return res.status(404).json({ error: "not found" });
if (order.crypto !== "USDT") {
return res.status(400).json({ error: "BTC order — no tx submission needed" });
}
const { txhash } = req.body;
if (!/^0x[a-fA-F0-9]{64}$/.test(txhash || "")) {
return res.status(400).json({ error: "invalid Ethereum transaction hash" });
}
try {
// Pass TESTNET. On Sepolia this MUST be 1 — otherwise Blockonomics watches
// mainnet for a tx that only exists on testnet and no callback ever arrives.
await monitorUsdtTx(txhash, "/webhook/blockonomics", TESTNET); // ← was missing testnet
db.prepare("UPDATE orders SET txid = ?, status = ? WHERE orderId = ?").run(
txhash,
"submitted",
order.orderId,
);
res.json({ ok: true });
} catch (err) {
console.error("monitor_tx failed:", err.response?.data || err.message);
res.status(500).json({ error: "could not start monitoring" });
}
});
Step 5: Update the webhook to handle USDT statuses
The /webhook/blockonomics callback is shared by both coins, but the USDT branch reads the status as a confirmation count: −1 marks the order failed, 0 and 1 mean partial, and 2 means paid. This is where the order finally gets marked done.
// src/server.js
app.get("/webhook/blockonomics", (req, res) => {
const { secret, txid, addr, value, status } = req.query;
if (secret !== process.env.CALLBACK_SECRET) return res.status(403).send("forbidden");
// BTC: match by address. USDT: match by txid, then fall back to the uniquely
// jittered amount (value is in USDT base units — 6 decimals, not 8).
let order = db
.prepare("SELECT * FROM orders WHERE address = ? AND crypto = 'BTC'")
.get(addr);
if (!order && txid) {
order = db.prepare("SELECT * FROM orders WHERE txid = ? AND crypto = 'USDT'").get(txid);
}
if (!order && value != null) {
const valueUsdt = Number(value) / 1e6;
order = db.prepare(`
SELECT * FROM orders
WHERE crypto = 'USDT'
AND status IN ('pending', 'submitted')
AND ABS(amountBtc - ?) < 0.000001
AND createdAt > strftime('%s','now') - 3600
ORDER BY createdAt DESC
LIMIT 1
`).get(valueUsdt);
}
if (!order) return res.status(200).send("ok"); // unknown — ack so retries stop
const statusInt = parseInt(status, 10);
const meta = { txid, confirmations: Math.max(statusInt, 0) };
switch (order.crypto) {
case "BTC":
if (statusInt === 0 || statusInt === 1) updateOrderStatus(order.orderId, "partial", meta);
else if (statusInt >= 2) updateOrderStatus(order.orderId, "paid", meta);
break;
case "USDT":
if (statusInt === -1) updateOrderStatus(order.orderId, "failed", meta);
else if (statusInt === 0 || statusInt === 1) updateOrderStatus(order.orderId, "partial", meta);
else if (statusInt >= 2) updateOrderStatus(order.orderId, "paid", meta);
break;
}
res.status(200).send("ok");
});
USDT reconciliation — the painful bit
Every USDT order in your store points to the same address, so you can't tell orders apart by address alone. The moment two customers have orders open at the same time, they're both paying into that one 0xabc… address. So when a monitor_tx callback arrives with a transaction hash, you need another way to figure out which order it belongs to.
Three approaches, in increasing order of robustness:
Approach 1: Trust the customer-submitted txhash
The flow above (/pay/:orderId/submit-tx) puts the txhash directly on the order at the moment the customer submits it. The webhook then looks up by txhash This is fine for low-friction MVPs but has a failure mode: a malicious customer could submit someone else's txhash and claim credit for it.
Approach 2: Match on a uniquely jittered amount
If your "amount due" includes a per-order jitter (the jitter field in the checkout handler above adds 0–9.9¢ of randomness), most concurrent orders will have unique amounts. The callback's value field tells you exactly how many USDT base units arrived, and you can find the order whose amountBtc (we're reusing the column) matches within rounding.
// Look up by amount + status + recent-ish createdAt
const order = db.prepare(`
SELECT * FROM orders
WHERE crypto = 'USDT'
AND status IN ('pending', 'submitted')
AND ABS(amountBtc - ?) < 0.000001
AND createdAt > strftime('%s','now') - 3600
ORDER BY createdAt DESC
LIMIT 1
`).get(valueUsdt);
This works for moderate concurrency. Won't work cleanly if you have 10,000 orders/hour in the same currency.
Approach 3: Use the Web3 USDT Component
Blockonomics ships a Web3 USDT Component for checkout pages — it integrates directly with the user's browser wallet (MetaMask, Coinbase Wallet, etc.), constructs the transaction, gets the txhash directly from the wallet before the user even confirms in the wallet UI, and lets you wire it back to a specific order. This is the production-grade option for serious USDT volume.
For most readers building their first integration, Approach 1 or 2 is fine. Reach for the Web3 component when reconciliation pain becomes real.
Frontend: showing the USDT payment page
The QR code format for USDT-on-Ethereum is different from BTC. Wallets understand the EIP-681 format:
// On the /pay page, when order.crypto === 'USDT':
const usdtContractMainnet = "0xdAC17F958D2ee523a2206206994597C13D831ec7";
const amountBaseUnits = Math.round(order.amountBtc * 1e6); // USDT has 6 decimals
const eip681 = `ethereum:${usdtContractMainnet}/transfer?address=${order.address}&uint256=${amountBaseUnits}`;
const qrDataUrl = await QRCode.toDataURL(eip681);
Most modern Ethereum wallets understand this URI and will pre-fill the recipient, contract, and amount. Older wallets fall back to a plain address — show the address and amount as text alongside the QR for that case.
Common errors and pitfalls
422 Unsupported Cryptocurrency from /api/new_address You passed crypto=USDC or another stablecoin. Only BTC and USDT are supported by this endpoint.
monitor_tx returns 200 but you never get a callback Either: (a) the transaction was already past 2 confirmations when you submitted it (Blockonomics only sends callbacks for changes, and 2 is the terminal state), (b) your callback URL is wrong, or (c) the txhash was malformed. Check the transaction on Etherscan first.
The customer paid the right address but value doesn't match USDT has 6 decimals, not 8 like BTC. The value field is in token base units (1 USDT = 1,000,000 base units). Dividing by 1e8 instead of 1e6 will silently make every payment look like a 100x underpayment. Source of many production incidents.
Gas fees eat the payment For USDT specifically, the sender pays gas in ETH. If your customer's wallet has USDT but no ETH, the transaction fails before it reaches your address. There's nothing you can do about this on the merchant side — the wallet will warn them.
Testnet confusion If you're testing on Sepolia, pass testnet=1 to monitor_tx. Forgetting this means you're asking Blockonomics to watch the mainnet for a tx that only exists on testnet — you'll never get a callback.
Further Reading & Implementation Guides
If you're building out a full-stack integration, these guides provide additional context for scaling and testing your setup:





Top comments (0)