In this tutorial you'll build a working Bitcoin checkout in Node.js using the Blockonomics API. Customers pay you in BTC, the payment lands directly in your wallet (Blockonomics never holds the funds), and your server gets notified via a webhook. No KYC, no custodian, no fiat conversion middleman. If you've tried adding crypto payments to a Node app before, you've probably hit the same wall I did: every "Bitcoin payment gateway" turns out to be a custodian. They hold the coins, they hand you fiat, and the moment something goes wrong with their bank rails, your customers get stuck mid-checkout.
This guide takes the other path. We're going to wire up a payment flow where:
- The customer's BTC goes straight to your wallet β Blockonomics generates fresh receive addresses derived from an extended public key (xpub) you control.
- Your server is notified by webhook the moment a payment hits the mempool, and again on confirmation.
- There's no KYC, no custody, no chargebacks.
Weβll cover three key components: POST /api/new_address, GET /api/price, and the callback notification system. Weβll wrap up by building a complete checkout example running directly on mainnet.
The full tutorial is roughly 25 minutes of reading and 45 minutes of coding. Let's go.
What you'll build
A minimal Express server with three responsibilities:
-
POST /checkoutβ accepts an order (e.g. one t-shirt for Β£30 GBP), asks Blockonomics for a fresh BTC address, converts Β£30 to BTC at the current rate, stores the order, and returns a payment page URL. -
GET /pay/:orderIdβ a simple HTML page showing the BTC address, the exact amount due in BTC, a QR code, and a live payment status that updates as the transaction confirms. -
GET /webhook/blockonomicsβ receives callbacks from Blockonomics as the payment progresses through status=0 (unconfirmed), status=1 (partial), status=2 (confirmed).
We'll persist everything in SQLite so a callback arriving 20 minutes later can be matched back to the right order.
Prerequisites
Before writing any code, you need three things:
1. A Bitcoin wallet that gives you an extended public key (xpub)
Blockonomics is non-custodial, which means it derives receive addresses from your wallet's xpub β your private keys never leave your wallet. Nearly every modern Bitcoin wallet provides this xpub:
- Electrum: Wallet β Information β Master Public Key
- BlueWallet: Wallet β Settings β Show wallet xpub
- Sparrow / Ledger / Trezor: each has a similarly named export option
Copy that string (it starts with xpub, ypub, or zpub depending on the address format) β you'll paste it into the Blockonomics dashboard, not into your code.
Never paste your seed phrase or private key anywhere. Only the xpub. The xpub allows us to safely generate new receiving addresses for your customers, but it has zero spending power.
2. A Blockonomics account and an API key
Sign up at blockonomics.co, then:
- Go to Dashboard β Stores
- Your API key is shown directly in the Stores section. Copy it.
- In the same Stores section, attach your wallet by pasting in your xpub.
- Set your HTTP Callback URL to where your server's webhook lives. For local development you'll want something like https://abc123.ngrok.io/webhook/blockonomics?secret=YOUR_SECRET β more on the secret in the webhook section.
π Where exactly is the API key? Dashboard β Stores. This is the current location as of November 2025. If a tutorial or LLM tells you to look under "Wallet Watcher" or "Merchants β API", that's outdated. The Stores section is the canonical place β and it's also where you rotate the key with the π button.
3. Node.js 18+ and the project skeleton
mkdir blockonomics-checkout && cd blockonomics-checkout
npm init -y
npm install express axios better-sqlite3 qrcode dotenv
npm install --save-dev nodemon
Create a .env file in the project root:
# .env
BLOCKONOMICS_API_KEY=your_api_key_from_the_blockonomics_dashboard
CALLBACK_SECRET=any_long_random_string_you_make_up
PORT=3000
PUBLIC_URL=http://localhost:3000
The CALLBACK_SECRET is a value you invent and include in your webhook URL β Blockonomics will echo it back to you on every callback, so you can verify the request actually came from them and not a random scanner hitting your endpoint.
Step 1: Generate a payment address
This is the single most important call in the entire integration. When a customer starts checkout, you ask Blockonomics for a fresh receive address derived from your xpub.
The endpoint is:
POST https://www.blockonomics.co/api/new_address
Authorization: Bearer YOUR_API_KEY
Query parameters:
| Param | Required | Notes |
|---|---|---|
| match_callback | yes | A string Blockonomics will use to match against your configured callback URL. Partial matches work β you don't need to paste the full URL. |
| crypto | no |
BTC (default) or USDT. |
| reset | no |
0 (default) generates a new address on each call; 1 reuses the last one. Must be the integer 0 or 1, not true/false. |
Here's the wrapper, in src/blockonomics.js:
// src/blockonomics.js
import axios from "axios";
const BASE = "https://www.blockonomics.co/api";
const apiKey = process.env.BLOCKONOMICS_API_KEY;
const client = axios.create({
baseURL: BASE,
headers: { Authorization: `Bearer ${apiKey}` },
});
/**
* Generate a fresh BTC receive address.
*
* @param {string} matchCallback - substring of your callback URL used to identify the store
* @returns {Promise<{address: string}>}
*/
export async function newAddress(matchCallback) {
const { data } = await client.post("/new_address", null, {
params: { match_callback: matchCallback, crypto: "BTC", reset: 0 },
});
// Successful response shape: { address: "bc1q...", reset: 0 }
return { address: data.address };
}
/**
* Get current price of 1 BTC in a given fiat currency.
*
* @param {string} currency - "GBP", "USD", "EUR", etc.
* @returns {Promise<number>} - price of 1 BTC in that currency
*/
export async function btcPrice(currency = "GBP") {
const { data } = await client.get("/price", {
params: { crypto: "BTC", currency },
});
// Response shape: { price: 127866.85 }
return data.price;
}
A few things worth noting that the API docs make explicit:
- The address is derived from your xpub β Blockonomics doesn't custody it. Funds sent to it land in your wallet.
- Calling new_address again without reset=1 will give you the next address in the derivation chain. Each customer gets their own address. This is what makes order reconciliation clean: address β order is one-to-one.
- If you accidentally lose track of an address client-side (e.g. browser crashes mid-checkout), reset=1 gives you the most recent address back without burning a new one.
Step 2: Convert fiat price to BTC
Bitcoin prices move every second, so you can't hard-code a BTC amount on your product. Instead, you fetch the live BTC/GBP (or USD, EUR, etc.) rate at the moment of checkout and lock in the BTC amount for that order.
The btcPrice() helper above wraps GET /api/price. To work out how much BTC a Β£30 order costs:
const pricePerBtc = await btcPrice("GBP"); // e.g. 52000
const btcAmount = +(30 / pricePerBtc).toFixed(8); // 0.00057692 BTC
Bitcoin has 8 decimal places (1 BTC = 100,000,000 satoshis), so we round to 8. Store both the fiat amount and the BTC amount on the order β you'll need the BTC amount to verify the customer paid enough, and the fiat amount for your accounting.
Step 3: The order endpoint
Now we tie steps 1 and 2 together. When the user clicks "Pay with Bitcoin" on your site, your frontend POSTs to /checkout:
// src/server.js
import "dotenv/config";
import crypto from "node:crypto";
import express from "express";
import { btcPrice, newAddress } from "./blockonomics.js";
import { createOrder, getOrder } from "./db.js";
const app = express();
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.post("/checkout", async (req, res) => {
try {
const { amountFiat = 30, currency = "GBP", productName = "T-shirt" } =
req.body;
// 1) Fetch live BTC price
const pricePerBtc = await btcPrice(currency);
const amountBtc = +(amountFiat / pricePerBtc).toFixed(8);
// 2) Generate a fresh receive address
// match_callback can be any substring of your configured callback URL.
// We use the path because we only have one store.
const matchCallback = "/webhook/blockonomics";
const { address } = await newAddress(matchCallback);
// 3) Persist the order so the webhook can find it later
const orderId = crypto.randomBytes(8).toString("hex");
createOrder({
orderId,
address,
amountFiat,
currency,
amountBtc,
pricePerBtc,
productName,
status: "pending",
});
res.json({
orderId,
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" });
}
});
The key idea: store the address with the order the moment you create it. This is the single most common production bug in homemade Bitcoin checkouts β people generate an address, show it to the customer, and never write it down anywhere. When the webhook arrives 30 minutes later carrying that address, they have no way to match it back to an order. Always persist address β orderId before you return a response to the customer.
Step 4: The payment page (showing address + QR)
The payment page just renders what's already in the database:
// src/server.js (continued)
import QRCode from "qrcode";
app.get("/pay/:orderId", async (req, res) => {
const order = getOrder(req.params.orderId);
if (!order) return res.status(404).send("Order not found");
// bitcoin: URI is the standard format wallets understand
const bip21 = `bitcoin:${order.address}?amount=${order.amountBtc}`;
const qrDataUrl = await QRCode.toDataURL(bip21);
res.send(`
<!doctype html>
<html>
<head>
<title>Pay with Bitcoin β ${order.productName}</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style>
body { font-family: system-ui, sans-serif; max-width: 480px; margin: 2rem auto; padding: 1rem; }
.qr { text-align: center; }
.addr { word-break: break-all; font-family: monospace; background: #f4f4f4; padding: 0.75rem; border-radius: 6px; }
.status { padding: 0.75rem; border-radius: 6px; margin-top: 1rem; }
.pending { background: #fff3cd; }
.confirmed { background: #d4edda; }
</style>
</head>
<body>
<h1>${order.productName}</h1>
<p>Send <strong>${order.amountBtc} BTC</strong> (Β£${order.amountFiat}) to:</p>
<div class="addr">${order.address}</div>
<div class="qr"><img src="${qrDataUrl}" alt="Bitcoin QR code" /></div>
<div class="status pending" id="status">Waiting for paymentβ¦</div>
<script>
// Poll for status updates every 5 seconds
async function check() {
const r = await fetch("/pay/${order.orderId}/status");
const { status } = await r.json();
const el = document.getElementById("status");
if (status === "partial") {
el.textContent = "Payment received, waiting for confirmationβ¦";
}
if (status === "paid") {
el.textContent = "β Payment confirmed. Thank you!";
el.className = "status confirmed";
}
if (status !== "paid") setTimeout(check, 5000);
}
check();
</script>
</body>
</html>
`);
});
app.get("/pay/:orderId/status", (req, res) => {
const order = getOrder(req.params.orderId);
if (!order) return res.status(404).json({ error: "not found" });
res.json({ status: order.status });
});
A bitcoin: URI (BIP-21) is the standard format every Bitcoin wallet understands. Scanning the QR code opens the customer's wallet with the address and amount pre-filled, so they just tap "Send."
π A note on polling: we're using a 5-second poll here for simplicity. In production you'd want WebSockets so the customer sees confirmation the instant it happens β Blockonomics has a real-time WebSocket endpoint for exactly this. We'll cover that in Tuesday's post.
Step 5: Handle the callback webhook
This is where most homemade integrations break. The Blockonomics callback is an HTTP GET request with query parameters β not a POST with a JSON body. This is the single biggest thing LLMs get wrong about this API, so write it on your wall.
When a payment lands on one of your addresses, Blockonomics hits your callback URL like this:
GET /webhook/blockonomics?secret=YOUR_SECRET&txid=abc123def456...&addr=bc1q...&value=50000&status=0&rbf=1
Host: yourserver.com
| Field | Type | Meaning |
|---|---|---|
| secret | string | The secret you put in your callback URL when you configured it in the dashboard. Use this to verify the request is genuine. |
| txid | string | The Bitcoin transaction ID. |
| addr | string | The receive address β your link back to the order. |
| value | integer | Payment amount in satoshis (1 BTC = 100,000,000 sats). |
| status | integer | 0 = seen in mempool (unconfirmed), 1 = partially confirmed, 2 = fully confirmed (2 confirmations). |
| rbf | integer | Present only on unconfirmed transactions that signal Replace-By-Fee. 1 = opted in, 2 = inherited. |
Your server must respond with HTTP 200 to acknowledge receipt. If you don't, Blockonomics retries up to 7 times with exponential backoff starting at 4 seconds.
Here's the handler:
// src/server.js (continued)
app.get("/webhook/blockonomics", (req, res) => {
const { secret, txid, addr, value, status, rbf } = req.query;
// 1) Verify the secret
if (secret !== process.env.CALLBACK_SECRET) {
console.warn("rejected callback with bad secret");
return res.status(403).send("forbidden");
}
// 2) Find the order by address
const order = db.prepare("SELECT * FROM orders WHERE address = ?").get(addr);
if (!order) {
console.warn("callback for unknown address:", addr);
return res.status(200).send("ok"); // still 200 so they stop retrying
}
// 3) Map Blockonomics status -> our order status
const statusInt = parseInt(status, 10);
const valueSats = parseInt(value, 10);
const valueBtc = valueSats / 1e8;
const expectedBtc = order.amountBtc;
// Sanity check: did they pay enough? Allow 5% under for price slippage.
if (valueBtc < expectedBtc * 0.95) {
console.warn(
`underpayment on ${order.orderId}: got ${valueBtc}, expected ${expectedBtc}`,
);
updateOrderStatus(order.orderId, "underpaid", { txid });
return res.status(200).send("ok");
}
// Reject zero-conf payments that signal RBF β they can be reversed.
if (statusInt === 0 && rbf) {
console.warn(`rejecting RBF payment for ${order.orderId}`);
return res.status(200).send("ok");
}
if (statusInt === 0) updateOrderStatus(order.orderId, "partial", { txid });
if (statusInt === 1) updateOrderStatus(order.orderId, "partial", { txid });
if (statusInt === 2) updateOrderStatus(order.orderId, "paid", { txid });
res.status(200).send("ok");
});
The status mapping deserves a careful read:
- status=0 β the transaction has been broadcast to the network but hasn't been included in a block yet. Treat this as "we've seen the payment, but it could still be reversed." Show a "payment detected, waiting for confirmation" message to the customer. Don't release goods yet.
- status=1 β partially confirmed. The transaction is in a block but Blockonomics' system hasn't yet flagged it as final. Still don't release goods if your goods are valuable.
- status=2 β confirmed (2 confirmations). This is the point at which the payment is effectively irreversible. Release goods, fire your order-fulfillment logic, send a receipt email.
The RBF check matters. If a transaction has rbf=1 or rbf=2 and is still at status=0, the sender can replace it with a different transaction (potentially one that doesn't pay you). For digital goods that you deliver instantly, reject any zero-conf payment that includes an rbf flag. For physical goods that ship in days, you have time to wait for confirmations, so it doesn't matter.
Step 6: Persist the order β address mapping
Here's the SQLite layer. Boring but essential:
// src/db.js
import Database from "better-sqlite3";
export const db = new Database("orders.db");
db.exec(`
CREATE TABLE IF NOT EXISTS orders (
orderId TEXT PRIMARY KEY,
address TEXT UNIQUE NOT NULL,
amountFiat REAL NOT NULL,
currency TEXT NOT NULL,
amountBtc REAL NOT NULL,
pricePerBtc REAL NOT NULL,
productName TEXT,
status TEXT NOT NULL DEFAULT 'pending',
txid TEXT,
createdAt INTEGER NOT NULL DEFAULT (strftime('%s','now')),
updatedAt INTEGER NOT NULL DEFAULT (strftime('%s','now'))
);
CREATE INDEX IF NOT EXISTS idx_orders_address ON orders(address);
CREATE INDEX IF NOT EXISTS idx_orders_status ON orders(status);
`);
export function createOrder(order) {
db.prepare(`
INSERT INTO orders (
orderId,
address,
amountFiat,
currency,
amountBtc,
pricePerBtc,
productName,
status
)
VALUES (
@orderId,
@address,
@amountFiat,
@currency,
@amountBtc,
@pricePerBtc,
@productName,
@status
)
`).run(order);
}
export function getOrder(orderId) {
return db.prepare("SELECT * FROM orders WHERE orderId = ?").get(orderId);
}
export function updateOrderStatus(orderId, status, extra = {}) {
const fields = ["status = ?", "updatedAt = strftime('%s','now')"];
const values = [status];
if (extra.txid) {
fields.push("txid = ?");
values.push(extra.txid);
}
values.push(orderId);
db.prepare(`UPDATE orders SET ${fields.join(", ")} WHERE orderId = ?`).run(
...values,
);
}
The two things that matter here:
-
address has a UNIQUE constraint. Each address is only ever used for one order. If somehow the same address came back twice (it shouldn't with
reset=0), the insert fails loudly rather than silently overwriting. - Index on address. The webhook arrives carrying an address, not an orderId β your lookup must be fast even with a million orders in the table.
Step 7: Run it locally with ngrok
Blockonomics needs a public URL to send webhooks to. For local dev, use ngrok:
# terminal 1
npm run dev
# terminal 2
ngrok http 3000
Copy the https://something.ngrok.io URL ngrok prints, then in the Blockonomics dashboard set your callback URL to:
https://something.ngrok.io/webhook/blockonomics?secret=YOUR_SECRET
Now create an order:
curl -X POST http://localhost:3000/checkout \\
-H 'Content-Type: application/json' \\
-d '{"amountFiat": 0.50, "currency": "GBP", "productName": "Test"}'
(50p is a good test amount β small enough not to break the bank, large enough to be above the dust limit.) Open the returned paymentUrl in your browser, scan the QR with a wallet, send the payment, and watch your server log fill up with callback events.
Common errors and fixes
A few things you'll hit, and how to handle them:
-
409 Conflict β Required wallet not attached to store: You called
new_addressbut haven't attached your xpub to a store yet. Go to Dashboard β Stores β attach your wallet. -
422 Unsupported Cryptocurrency: You passed
crypto=ETHor similar. The endpoint accepts only BTC or USDT. For other coins you need different endpoints. -
400 Bad request with no obvious cause: Almost always a missing
match_callbackparameter. It's required. -
The webhook never fires: Three usual suspects: (1) your callback URL in the dashboard doesn't actually contain the substring you passed to
match_callback; (2) ngrok tunnel died and you forgot to restart it; (3) you configured the callback URL for the wrong store. The dashboard has a "Test callback" button β use it. -
The webhook fires but you get a 403: You forgot the
secretquery param in the URL configured in the dashboard, or it doesn't matchCALLBACK_SECRETin your.env. -
My customer paid but the amount looks wrong: The
valuefield in the callback is in satoshis, not BTC. Divide by1e8. -
The same callback fires multiple times: It shouldn't β Blockonomics dedupes on (
txid,status,addr). But if your server returned non-200 on the first delivery, it'll retry. Always return 200, even for "I don't recognize this address" β that prevents wasted retries.











