DEV Community

Cover image for Bitcoin Payment Buttons and Widgets in Node.js with Blockonomics (no checkout UI to build)
Blockonomics
Blockonomics

Posted on • Edited on

Bitcoin Payment Buttons and Widgets in Node.js with Blockonomics (no checkout UI to build)

πŸ“Œ 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:

  1. Creates a temporary product on demand (price, description, customer metadata) using POST /api/create_temp_product
  2. Renders an HTML page with a Blockonomics Payment Button that opens the hosted checkout modal
  3. Receives the Order Callback when payment state changes (status 0, 1, 2, or -1)
  4. Fetches the full order via GET /api/merchant_order/{uuid} so we can reconcile against our own DB
  5. 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=...
Enter fullscreen mode Exit fullscreen mode

4) Prerequisites

You already have these from earlier posts in the series; reusing them:

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

  1. Go to Dashboard β†’ Buttons & Links β†’ Products
  2. Click Create Product
  3. Fill in: product name (e.g. "Default Product"), default price, description, currency
  4. Configure which customer fields to collect (email, name, address, phone, custom field 1, custom field 2)

  1. Set the Success URL (where the customer lands after paying) and Cancel URL
  2. Save, then copy the widget UID β€” you'll pass this as parent_uid in 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
Enter fullscreen mode Exit fullscreen mode
# .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
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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>
    `);
});
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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

Blockonomics API Reference

GitHub Repo – Full Example Code

Previous Post: Receive Payments API in Node.js

Top comments (0)