π TL;DR β What Youβll Build
A fully functional Bitcoin payment flow for your Node.js app:
β One-click βPay with Bitcoinβ button that opens a hosted checkout modal
β Inline payment widget (optional)
β Automatic order tracking via webhooks
β SQLite order storage for reconciliation
β Admin dashboard to view all Blockonomics orders
Here's a collection of runnable Node.js demos showing different ways to accept cryptocurrency payments with Blockonomics. Each demo lives in its own self-contained folder with its own package.json, source, and README. https://github.com/blockonomics/nodejsdemo
1) Overview: Why Skip Building Your Own Checkout UI?
If youβve ever built a Bitcoin checkout from scratch, you know the pain: generate addresses, monitor transactions, handle confirmations, send emails, manage timeouts.
Building a Bitcoin checkout from scratch gives you ultimate control. With the Receive Payments API, you handle the address generation, QR codes, and order states yourself. But sometimes, thatβs overkill. If you want to skip the heavy lifting, the Blockonomics Payment Buttons and Widgets API offers a faster route. You simply create a product via the API, drop in a button, and let Blockonomics manage the checkout UI, payment monitoring, and confirmation emails. In this post, weβll walk through the Node.js and HTML implementation for both flows, and show you how to use the Merchant Orders API GET /api/merchant_orders to seamlessly sync transactions back to your database.
There are two integration models in the Blockonomics API, and they sit at opposite ends of the build-vs-buy axis:
| Concern | Receive Payments API | Checkouts / Payment Button API |
|---|---|---|
| Address generation | You call /api/new_address | Blockonomics |
| Checkout UI | You build it | Blockonomics-hosted modal/widget |
| Customer fields (email, name, etc.) | You build the form | Built-in to the widget |
| Order state machine | You manage it | Blockonomics |
| Order confirmation emails | You send them | Blockonomics |
| Best for | Fully custom flows | Drop-in checkout on existing sites |
2) What we're building?
Let's build a small Express app that:
-
Creates a temporary product on demand (price, description, customer metadata) using
POST /api/create_temp_product - Renders an HTML page with a Blockonomics Payment Button that opens the hosted checkout modal
- Receives the Order Callback when payment state changes (status 0, 1, 2, or -1)
- Fetches the full order via GET /api/merchant_order/{uuid} so we can reconcile against our own DB
- Lists all orders via GET /api/merchant_orders for an admin dashboard
3) The architecture:
sequenceDiagram
autonumber
participant B as Customer browser
participant S as Your Express server
participant K as Blockonomics
B->>S: GET /product/123
S->>K: POST /api/create_temp_product
K-->>S: temp product uid
S-->>B: HTML page with <a class="blockoPayBtn" data-uid="...">
B->>K: Click Pay button (hosted checkout modal opens)
K-->>S: GET /webhook/order?status=0&uuid=...
S->>K: GET /api/merchant_order/{uuid}
K-->>S: full order details
K-->>S: GET /webhook/order?status=2&uuid=...
4) Prerequisites
You already have these from earlier posts in the series; reusing them:
- A Blockonomics account with an API key (Dashboard β Stores)
- A Bitcoin wallet xpub attached to the store
- Node.js 18+
You also need one new thing specific to the Buttons/Widget flow: a parent product configured in the dashboard.
Why a parent product?
The Checkouts API uses a parent/temp-product model:
- The parent product is the template β it defines the default price, description, customer fields to collect, and success/cancel URLs.
- Temp products are short-lived copies of the parent, created per order with any overrides you need.
Setting up the parent product
- Go to Dashboard β Buttons & Links β Products
- Click Create Product
- Fill in: product name (e.g. "Default Product"), default price, description, currency
- Configure which customer fields to collect (email, name, address, phone, custom field 1, custom field 2)
- Set the Success URL (where the customer lands after paying) and Cancel URL
- Save, then copy the widget UID β you'll pass this as
parent_uidin API calls
Setting up the Order Hook
You also need to set the Order Hook URL under Dashboard β Buttons & Links β Options:
https://yourserver.com/webhook/order?secret=YOUR_SECRET
β οΈ Heads up: there are two different webhook URLs
- The Payments callback lives under Stores (you set this up in an earlier post).
- The Order Hook for Buttons & Links lives under Buttons & Links β Options.
They are separate. If you only configured the Payments callback, the Payment Button flow will not fire callbacks to your server.
Environment
mkdir blockonomics-buttons && cd blockonomics-buttons
npm init -y
npm install express axios better-sqlite3 dotenv
npm install --save-dev nodemon
# .env
BLOCKONOMICS_API_KEY=your_api_key
PARENT_PRODUCT_UID=b5c04c7c395011ea # from step 6 above
ORDER_CALLBACK_SECRET=any_random_long_string
PORT=3000
PUBLIC_URL=http://localhost:3000
5) The Build
Step 1: Create a temporary product on demand
When a customer clicks "Buy" on your product page, you create a fresh temp product. The temp product inherits everything from the parent and lets you override price, description, and attach an extra_data field for whatever order metadata you need to reconcile later (your internal order ID, customer ID, SKU, anything).
// src/blockonomics.js
import axios from "axios";
const BASE = "https://www.blockonomics.co/api";
const client = axios.create({
baseURL: BASE,
headers: { Authorization: `Bearer ${process.env.BLOCKONOMICS_API_KEY}` },
});
/**
* Create a temporary product (one-off) under a parent product.
* Temp products are auto-deleted after 7 days; orders against them persist.
*
* @param {object} opts
* @param {string} opts.parentUid - Widget UID of the parent product
* @param {string} [opts.productName] - Override the product name
* @param {string} [opts.productDescription] - e.g. "Red T-shirt size L"
* @param {number} [opts.valueMinor] - Price in the smallest unit (e.g. cents/pence)
* @param {string} [opts.extraData] - Anything you want echoed back on the order
* @returns {Promise<{ uid: string }>}
*/
export async function createTempProduct({
parentUid,
productName,
productDescription,
valueMinor,
extraData,
}) {
const body = { parent_uid: parentUid };
if (productName) body.product_name = productName;
if (productDescription) body.product_description = productDescription;
if (valueMinor != null) body.value = valueMinor;
if (extraData) body.extra_data = extraData;
const { data } = await client.post("/create_temp_product", body);
// Response: { "uid": "f7570454529a11e7-1ee5f340" }
return { uid: data.uid };
}
/**
* Fetch a single order's details by UUID. Used after the order callback.
*/
export async function getMerchantOrder(uuid) {
const { data } = await client.get(`/merchant_order/${uuid}`);
return data;
}
/**
* List all payment-button orders.
* @param {number} [limit=500]
*/
export async function listMerchantOrders(limit = 500) {
const { data } = await client.get("/merchant_orders", { params: { limit } });
return data; // array of orders, newest first
}
A few things worth knowing about create_temp_product:
- value is in the smallest unit of the currency (cents, pence, satoshis, etc.) β not the major unit. Β£5.00 in GBP = value: 500. This is the same convention Stripe uses; easy to overlook the first time.
-
All overridable fields are optional. If you omit
value, the parent's price is used. Pass it when you want a dynamic price (e.g. cart total). - extra_data is the magic field. It's echoed back on the order and accessible via merchant_order/{uuid}. Put your internal order ID, user ID, cart hash β whatever you need to look up the order in your DB when the callback arrives. This is how you avoid the "callback arrives but I don't know which order it belongs to" problem.
Step 2: The product page with a Payment Button
The product page calls your /checkout endpoint, gets back a temp product UID, and renders a Blockonomics Payment Button bound to that UID.
// src/server.js
import "dotenv/config";
import express from "express";
import crypto from "node:crypto";
import { createTempProduct, getMerchantOrder, listMerchantOrders } from "./blockonomics.js";
import { db, upsertOrder, getOrderByUuid } from "./db.js";
const app = express();
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// Show a product page
app.get("/product/:productId", async (req, res) => {
const productId = req.params.productId;
// Pretend we look up the product from our DB. For the demo, hardcode:
const product = {
productId,
name: "Vintage t-shirt",
description: "Red, size L β pre-loved",
priceMinor: 3000, // Β£30.00
currency: "GBP",
};
// Generate an internal order ID we want echoed back on the callback
const internalOrderId = `ord_${crypto.randomBytes(6).toString("hex")}`;
// Create the temp product on Blockonomics
const { uid } = await createTempProduct({
parentUid: process.env.PARENT_PRODUCT_UID,
productName: product.name,
productDescription: product.description,
valueMinor: product.priceMinor,
extraData: internalOrderId,
});
// Persist a placeholder row so the callback handler can find this order
upsertOrder({
internalOrderId,
blockonomicsUuid: null, // we get this on the first callback
tempProductUid: uid,
productName: product.name,
valueMinor: product.priceMinor,
currency: product.currency,
status: "created",
});
res.send(`
<!doctype html>
<html>
<head>
<title>Buy ${product.name}</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; }
h1 { margin-bottom: 0.5rem; }
.desc { color: #555; }
.price { font-size: 1.5rem; font-weight: 600; margin: 1rem 0; }
.pay { margin-top: 1.5rem; }
</style>
</head>
<body>
<h1>${product.name}</h1>
<p class="desc">${product.description}</p>
<p class="price">Β£${(product.priceMinor / 100).toFixed(2)}</p>
<!-- The Blockonomics Payment Button -->
<div class="pay">
<a href="" class="blockoPayBtn" data-toggle="modal" data-uid="${uid}">
<img width="160" src="https://www.blockonomics.co/img/pay_with_bitcoin_medium.png" alt="Pay with Bitcoin" />
</a>
</div>
<script src="https://blockonomics.co/js/pay_button.js"></script>
</body>
</html>
`);
});
The Payment Button is the snippet from the Blockonomics docs, reformatted for clarity:
<a href="" class="blockoPayBtn" data-toggle="modal" data-uid="UID_FROM_API">
<img width="160" src="https://www.blockonomics.co/img/pay_with_bitcoin_medium.png" />
</a>
<script src="https://blockonomics.co/js/pay_button.js"></script>
When the customer clicks it, Blockonomics' hosted checkout modal opens over your page. They enter the customer fields you configured on the parent product, see the BTC amount, get a QR code, scan from their wallet, and watch the payment confirm β all without leaving your page. When they're done, they're redirected to the parent product's success URL.
Step 3: Embed a Widget instead (optional)
If you want the checkout inline on the page instead of in a modal, use the Widget snippet:
<div id="payment_area"></div>
<script src="https://blockonomics.co/js/pay_widget.js"></script>
<script>
function pay() {
Blockonomics.widget({
msg_area: 'payment_area',
uid: `${uid}`,
email: 'customer@email.com' // optional pre-fill
});
}
pay(); // auto-render on page load
</script>
The widget renders into the
you provide. Same checkout flow, just inline rather than modal. Useful for dedicated checkout pages or donation widgets where you want the BTC payment visible without an extra click.When to use which:
- Button β product pages, listings, "Pay with Bitcoin" on a checkout option list. Less visual real estate, opens modal on click.
- Widget β dedicated checkout pages, donation pages, paywall unlocks. Inline, always visible.
Both hit the same UID, both fire the same Order Callback, both populate the same merchant_orders list.
Step 4: Handle the Order Callback
When the order state changes (customer started checkout, paid, fully confirmed, or errored), Blockonomics hits your Order Hook URL.Same shape as the payments callback from Monday's post: HTTP GET with query parameters. Easy gotcha LLMs and a lot of stale tutorials describe this as a JSON POST. It isn't.
The format is:
GET https://yourserver.com/webhook/order
?secret=YOUR_SECRET
&status=2
&uuid=2b0c7e2cd523458098b2
The status values for the Order Callback are slightly different from the Payments callback:
| status | Meaning |
|---|---|
| -1 | PAYMENT_ERROR β paid BTC amount doesn't match expected value |
| 0 | UNPAID (customer entered checkout, hasn't paid yet) |
| 1 | IN_PROCESS (transaction seen, awaiting confirmation) |
| 2 | PAID (fully confirmed) |
Don't confuse this with the Payments callback status enum (0/1/2, where 1 means "partially confirmed"). The Order Callback uses different semantics: 1 here means "in process" (see table above), not "partially confirmed". They're separate APIs with separate state machines.
Notice that the callback only gives you status and uuid. To get the full order β customer email, amount paid, txid, extra_data β you call GET /api/merchant_order/{uuid}. Two calls is fine; it keeps the webhook payload tiny and means you always get the freshest order state from Blockonomics rather than a possibly-stale snapshot.
// src/server.js (continued)
app.get("/webhook/order", async (req, res) => {
const { secret, status, uuid } = req.query;
// 1. Verify the secret
if (secret !== process.env.ORDER_CALLBACK_SECRET) {
return res.status(403).send("forbidden");
}
// 2. Fetch the full order
let order;
try {
order = await getMerchantOrder(uuid);
} catch (err) {
console.error(
"merchant_order fetch failed:",
err.response?.data || err.message
);
// Return 200 anyway so Blockonomics doesn't retry while we debug
return res.status(200).send("ok");
}
// 3. Reconcile against our internal order using extra_data
// (we set extra_data = internalOrderId when creating the temp product)
const internalOrderId =
order.data?.["Custom Field1"] || order.extra_data || null;
// 4. Map status
const statusInt = parseInt(status, 10);
let derived = "unknown";
if (statusInt === -1) derived = "error";
if (statusInt === 0) derived = "unpaid";
if (statusInt === 1) derived = "in_process";
if (statusInt === 2) derived = "paid";
// 5. Upsert (callback might fire before we've stored the uuid)
upsertOrder({
internalOrderId,
blockonomicsUuid: uuid,
tempProductUid: order.code,
productName: order.name,
valueMinor: order.value,
currency: order.currency,
status: derived,
paidSatoshi: order.paid_satoshi,
txid: order.txid,
customerEmail: order.data?.emailid,
customerName: order.data?.name,
});
// 6. Trigger business logic on paid
if (derived === "paid") {
await fulfillOrder(internalOrderId, order);
}
res.status(200).send("ok");
});
async function fulfillOrder(internalOrderId, order) {
// Ship the t-shirt, send the receipt, etc.
console.log(
`β ORDER PAID: ${internalOrderId} β ${order.paid_satoshi} sats from ${order.data?.emailid}`
);
}
The OrderResponse object from /api/merchant_order/{uuid} includes everything you need:
| Field | Meaning |
|---|---|
| code | The temp product UID |
| order_id | Blockonomics' unique order ID |
| address | The BTC address used for this order |
| value | Order value in fiat (in the smallest unit, e.g. cents) |
| satoshi | Order value in satoshis |
| paid_satoshi | Actual amount paid in satoshis |
| txid | Transaction ID (nullable until paid) |
| status | Integer order status |
| data.emailid, data.name, etc. | Customer fields collected by the widget |
| data.Custom Field1/2 | Whatever extra fields you configured |
| currency | Fiat currency code |
| timestamp | Unix timestamp of order creation |
Pull what you need, persist it.
Step 5: The SQLite layer
// src/db.js
import Database from "better-sqlite3";
export const db = new Database("orders.db");
db.exec(`
CREATE TABLE IF NOT EXISTS orders (
internalOrderId TEXT PRIMARY KEY,
blockonomicsUuid TEXT UNIQUE,
tempProductUid TEXT,
productName TEXT,
valueMinor INTEGER,
currency TEXT,
status TEXT NOT NULL,
paidSatoshi INTEGER,
txid TEXT,
customerEmail TEXT,
customerName TEXT,
createdAt INTEGER NOT NULL DEFAULT (strftime('%s','now')),
updatedAt INTEGER NOT NULL DEFAULT (strftime('%s','now'))
);
CREATE INDEX IF NOT EXISTS idx_orders_uuid ON orders(blockonomicsUuid);
CREATE INDEX IF NOT EXISTS idx_orders_status ON orders(status);
`);
const cols = [
"internalOrderId",
"blockonomicsUuid",
"tempProductUid",
"productName",
"valueMinor",
"currency",
"status",
"paidSatoshi",
"txid",
"customerEmail",
"customerName",
];
export function upsertOrder(order) {
const existing = db
.prepare("SELECT * FROM orders WHERE internalOrderId = ?")
.get(order.internalOrderId);
if (!existing) {
const placeholders = cols.map((c) => `@${c}`).join(", ");
db.prepare(`
INSERT INTO orders (${cols.join(", ")})
VALUES (${placeholders})
`).run(Object.fromEntries(cols.map((c) => [c, order[c] ?? null])));
return;
}
// Only update non-null fields, so later callbacks don't blank out earlier data
const updates = cols
.filter((c) => order[c] != null && order[c] !== existing[c])
.map((c) => `${c} = @${c}`);
if (updates.length === 0) return;
updates.push("updatedAt = strftime('%s','now')");
db.prepare(`
UPDATE orders SET ${updates.join(", ")}
WHERE internalOrderId = @internalOrderId
`).run({ ...order, internalOrderId: order.internalOrderId });
}
export function getOrderByUuid(uuid) {
return db.prepare("SELECT * FROM orders WHERE blockonomicsUuid = ?").get(uuid);
}
// Why upsert: the callback at status 0 arrives before you've even stored the UUID locally
// (you only got the temp product UID back from create_temp_product, not the order UUID β
// that's only generated when the customer enters checkout).
// So you create a placeholder when the temp product is made, then the callback fills in the UUID,
// customer email, satoshi amount, txid, etc. as they become known.
// Step 6: An admin dashboard (using merchant_orders)
// The GET /api/merchant_orders endpoint returns every order across your account, newest first.
// Useful for an admin dashboard, daily reconciliation cron, or exporting to accounting.
// src/server.js
app.get("/admin/orders", async (req, res) => {
const limit = parseInt(req.query.limit || "100", 10);
// Fetch from Blockonomics (source of truth)
const remote = await listMerchantOrders(limit);
// Also pull our local snapshot for comparison
const localByUuid = Object.fromEntries(
db.prepare("SELECT * FROM orders").all().map((o) => [o.blockonomicsUuid, o])
);
// Reconcile: flag any orders Blockonomics knows about but we don't
const enriched = remote.map((r) => ({
...r,
localOrderId: localByUuid[r.order_id]?.internalOrderId || null,
inSync: !!localByUuid[r.order_id],
}));
const drift = enriched.filter((o) => !o.inSync);
res.json({
total: enriched.length,
drift: drift.length,
orders: enriched.slice(0, 50),
});
});
A few real-world uses for /api/merchant_orders:
- Daily reconciliation cron. Once a day, fetch the last 24h of orders from Blockonomics and compare to your DB. Any drift gets emailed to ops. This is your safety net if a callback ever gets dropped.
- CSV export for accounting. Paid orders β spreadsheet β accountant.
- Refund decisioning. Customer complaints "I paid but didn't get the goods" β look up the order, confirm paid_satoshi >= satoshi, check txid on a block explorer, decide.
- The limit parameter defaults to 500. For larger merchants, do it in chunks across multiple calls.
6) Which flow should you pick?
Now that you've seen both approaches in code, here's when to pick which:
| Use case | Pick |
|---|---|
| Custom-designed checkout page, on-brand UI | Receive Payments API (MonβThu) |
| Drop-in "Pay with Bitcoin" button on a Shopify-style product page | Payment Button |
| Dedicated donation page or paywall | Widget |
| Selling 1 product with a fixed price | Either, Payment Button is faster |
| Selling 10,000 products with dynamic pricing | Payment Button with create_temp_product per checkout |
| You want the customer's email / phone collected automatically | Payment Button / Widget (built in) |
| You want full control of confirmation emails, partial-payment handling, RBF logic | Receive Payments API |
| You're integrating into an existing app with its own order management | Receive Payments API |
| Internal tooling for one-off invoices | Payment Button (or even just the dashboard UI, no code) |
The two flows aren't mutually exclusive. A common pattern is: use the Payment Button for the public storefront (fast to ship, low maintenance) and the Receive Payments API for backend invoicing or subscription renewals where you want programmatic control.
7) Common errors and pitfalls
create_temp_product returns 400. Almost always a missing or wrong parent_uid. Re-copy it from the Buttons & Links β Products page in the dashboard. The UID has a hyphen in it β make sure you copied the whole thing.
The Payment Button renders but clicking it does nothing. The pay_button.js script either failed to load, or you have a CSP that blocks third-party scripts. Open dev tools β Network β check for the script. CSP needs script-src 'self' https://blockonomics.co.
value price looks 100x off in the checkout. You passed value: 30 thinking "Β£30" instead of value: 3000 for "Β£30.00". Same mistake everyone makes once with Stripe.
Order Callback never fires. Three usual suspects: (1) you set the Order Hook URL in the wrong place β it's under Buttons & Links β Options, not Stores; (2) your URL doesn't include the secret param so your handler rejects it as forbidden; (3) ngrok tunnel restarted with a new URL but the dashboard still points at the old one. The dashboard has a "Test callback" button β use it.
I get one callback but never a second one. Blockonomics fires callbacks on status changes. If a transaction goes straight from 0 (created) to 2 (confirmed) because the customer paid and waited for confirmation before closing their wallet, you might only see the 2 callback. Always fetch via merchant_order/{uuid} on the callbacks you do receive, rather than trying to reconstruct state from a sequence.
Order's extra_data is empty when I fetch it. Two things: (1) you didn't pass extra_data to create_temp_product, or (2) you're reading the wrong field β depending on how the dashboard is configured, your data can come back under extra_data, data["Custom Field1"], or description. Log the full order object once and you'll see exactly where Blockonomics put it for your setup.
8) Production checklist
Before turning the Payment Button flow live:
[ ] Parent product configured in dashboard with success/cancel URLs pointing at your domain
[ ] Order Hook URL configured under Buttons & Links β Options (not under Stores)
[ ] Order Hook secret matches ORDER_CALLBACK_SECRET in .env
[ ] extra_data set to your internal order ID on every create_temp_product call
[ ] Callback handler is idempotent (use the same webhook_events dedup table from
[ ] Callback handler returns 200 before doing heavy work
[ ] Daily reconciliation cron running against /api/merchant_orders to catch any missed callbacks
[ ] CSP allows https://blockonomics.co for script-src and frame-src
[ ] value is always in minor units (cents/pence) β write a helper, don't trust yourself to remember
[ ] Tested the full happy path on Bitcoin testnet before mainnet
[ ] Tested the -1 PAYMENT_ERROR path (send slightly less than the amount due) to confirm your error handling works
π Resources
GitHub Repo β Full Example Code




Top comments (0)