π TL;DR β Build a Bitcoin Payment Gateway Locally
A fully functional, locally testable crypto payment integration for your Node.js & TypeScript apps using Blockonomics Cryptocurrency Payment API:
β Secure Crypto Payments β Generate dynamic BTC addresses and real-time exchange rates to build a frictionless checkout experience.
β Real-Time UX Redirection β Integrate WebSockets to instantly detect when a transaction hits the mempool, providing immediate feedback and automated routing for global users.
β
Bulletproof Webhook Handler β Set up a secure server-to-server HTTP callback loop to automatically verify payment status (status=0 to status=2) and safely update your database.
β
Zero-Cost Local Sandbox via ngrok β Bridge the gap between the public internet and localhost by routing Blockonomics callbacks directly to your development machine with an secure ngrok tunnelβperfect for testing decentralized payment flows anywhere in the world.
π Want to skip the reading and see the live project? Download the complete, ready-to-run Blockonomics Node.js and ngrok Demo Code Sample on GitHub to spin up your local test environment instantly.
1. Overview
Almost every crypto payment integration has one part you can't test on your laptop: the callback. The customer pays. The payment provider sends an HTTP request to your server confirming the payment, and that one request decides whether the order ships. But the provider lives on the public internet and localhost does not, so the callback never arrives during local development. You're left deploying to production just to find out if your handler works.
The fix is ngrok. ngrok opens a public tunnel that forwards requests to localhost, letting a payment provider like Blockonomics reach your dev server as if it were a live domain. To test crypto payment callbacks locally, you run your server behind an ngrok tunnel and point the Blockonomics callback URL at it.
This guide builds a Bitcoin payment integration on the Blockonomics Receiving Payments API and tests the entire callback loop locally, with no deploy and no real BTC spent. Examples are in Node.js and TypeScript.
2. How the payment flow works
Here's the full walkthrough of what the diagram shows, written to sit alongside it.
Four actors are involved: the customer's browser, the merchant backend, the Blockonomics API, and the Bitcoin network.
[Customer's Browser] <---> [Merchant Backend] <---> [Blockonomics API] <---> [Bitcoin Network]
The merchant backend generates an address, the customer pays on-chain, and Blockonomics notifies you two ways at once: a WebSocket update to the browser for instant UX, and an HTTP callback to your server as the source of truth.
The flow breaks into three phases.
First, setup: the customer hits checkout, your backend asks Blockonomics for the BTC price and a fresh payment address, saves the order as pending, and sends the address and amount to the browser. The browser shows the payment details and opens a WebSocket (steps 1 to 8).
Second, payment: the customer sends Bitcoin on-chain to that address (step 9). This never touches your backend. The money moves between the wallet and the Bitcoin network, and your server has no idea it happened yet. That is the problem this design solves.
Third, notification: Blockonomics detects the confirmed transaction and tells you twice, on purpose. A WebSocket update to the browser (step 11) gives the customer an instant "payment received" and redirects them. An HTTP callback to your backend (step 12) updates the order and returns a 200.
3. Prerequisites
- Node.js 18+ (native
fetch) - A Blockonomics account, API key, and a wallet attached to a store
- ngrok installed
4. Why Do Crypto Payment Callbacks Require a Public URL?
When a payment lands, Blockonomics sends an HTTP GET to your callback URL:
https://yourserver.com/api/callback?secret=xyz&txid=abc&addr=1A1zP1...&value=50000&status=0
Every callback carries:
-
statusβ0unconfirmed,1partial,2confirmed -
addrβ receiving address -
valueβ amount in satoshis -
txidβ transaction ID -
rbfβ present only on unconfirmed Replace-By-Fee transactions
Your server must return 200. If it doesn't, Blockonomics retries up to 7 times with exponential backoff. A callback can't reach localhost from the outside. That's the gap ngrok closes.
5. Build and test the integration
Step 1: The backend
Two routes: one to start a payment, one to receive the callback.
// server.ts
import express, { Request, Response } from "express";
const app = express();
app.use(express.json());
const API_KEY = process.env.BLOCKONOMICS_API_KEY!;
const CALLBACK_SECRET = process.env.CALLBACK_SECRET!;
const BASE = "https://www.blockonomics.co/api";
const orders = new Map<string, any>(); // use a real DB in production
// Start a payment: lock price, generate address, save pending order
app.post("/api/new_address", async (req: Request, res: Response) => {
const { price } = await (await fetch(${BASE}/price?crypto=BTC¤cy=USD)).json();
const btcAmount = (req.body.fiatPrice / price).toFixed(8);
const addrRes = await fetch(
${BASE}/new_address?match_callback=yoursite.com/api/callback&crypto=BTC,
{ method: "POST", headers: { Authorization: Bearer ${API_KEY} } }
);
if (!addrRes.ok) return res.status(addrRes.status).json(await addrRes.json());
const { address } = await addrRes.json();
orders.set(address, { address, btcAmount, status: "pending" });
res.json({ address, btcAmount });
});
app.listen(3000, () => console.log("Listening on :3000"));
match_callback only needs to overlap with the callback URL you set in the dashboard. A fresh address per order is what lets you match a payment back to a customer.
Now the route that matters. This is the server-to-server source of truth that finalizes the order, even if the customer closes the tab.
app.get("/api/callback", (req: Request, res: Response) => {
const { addr, status, txid, value, secret, rbf } = req.query;
// 1. Verify the secret. Reject anything not from you.
if (secret !== CALLBACK_SECRET) return res.status(403).send("Forbidden");
const order = orders.get(addr as string);
if (!order) return res.status(404).send("Unknown address");
// 2. Map status. Skip zero-conf RBF if you deliver instantly.
if (Number(status) === 0 && !rbf) order.status = "paid";
if (Number(status) >= 2) order.status = "completed";
order.txid = txid;
// 3. Return 200, or Blockonomics retries.
res.send("OK");
});
value is in satoshis (100,000,000 sats = 1 BTC). Compare it against what you expected to catch under- and overpayments.
Step 2: The checkout page
On the website, the flow is: call your backend for an address, show the payment information, and open a WebSocket so the page reacts the instant the payment is detected.
async function startCheckout(fiatPrice: number) {
// 1. Ask your backend for an address and the BTC amount.
const res = await fetch("/api/new_address", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ fiatPrice }),
});
const { address, btcAmount } = await res.json();
// 2. Render a QR with the bitcoin URI so wallets auto-fill the amount.
// Pass this string to any QR library (e.g. qrcode.react).
const uri = `bitcoin:${address}?amount=${btcAmount}`;
showQrCode(uri, address, btcAmount);
// 3. Watch this address in real time.
listenForPayment(address);
}
function listenForPayment(address: string) {
const ws = new WebSocket(`wss://www.blockonomics.co/payment/${address}`);
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
// status: 0 unconfirmed, 1 partial, 2 confirmed, -1 failed
if (data.status >= 0) {
window.location.href = `/success?txid=${data.txid}`;
}
};
}
The callback is the part that matters here. Blockonomics sends it straight to your server, so it updates your database even if the customer closes the tab. That makes it your source of truth, and it's what the rest of this guide is built around. Never mark an order paid from the frontend alone.
Step 3: Open the tunnel
npx ts-node server.ts # terminal 1
ngrok http 3000 # terminal 2
ngrok prints a public URL:
Forwarding https://a1b2-c3d4.ngrok-free.app -> http://localhost:3000
Open http://localhost:4040. This is ngrok's inspector, and it's the most useful tool in the whole flow. You see every callback with full params, and you can replay any request without waiting for a new payment.
Step 4: Configure the callback URL
In Dashboard > Stores, set your callback URL to the ngrok address plus your route and secret:
https://a1b2-c3d4.ngrok-free.app/api/callback?secret=your_secret_key
The secret is your authenticity check, so make it long and random. On the free ngrok tier the subdomain changes on every restart, so update the dashboard each time. A paid plan gives you a static domain.
Step 5: Test the whole loop
Don't spend real BTC to test. Use the Blockonomics testmode walkthrough to simulate payments.
- Call
POST /api/new_address. Confirm you get an address and a pending order. - Trigger a testmode payment to that address.
- Watch
localhost:4040for astatus=0callback. Order flips topaid. - Trigger confirmation. A
status=2callback arrives. Order goes tocompleted.
If a callback shows in the inspector but your order doesn't update, the bug is in your handler. If nothing shows at all, it's upstream: wrong URL, wrong store, or a match_callback that doesn't overlap. That split alone saves hours.
The three gotchas that bite
Make the handler idempotent. It gets hit once per status change, plus retries on any non-200. Check current state before acting and never fulfill the same order twice.
Wait for confirmations on anything you deliver instantly. status=0 means the transaction is sitting in the mempool, not settled. While it's there, the sender can still replace it using RBF (replace-by-fee) and cancel the payment.
Digital goods are the real exposure. If you release a download the moment you see status=0, a malicious buyer can take the file and then RBF the transaction away, leaving you with nothing. Wait for status=2 before handing over anything digital.
Physical goods are lower risk. They ship hours or days later, by which point the transaction has almost always confirmed on its own. The delay does the waiting for you. Still, check the status before you mark the order fulfilled.
Going to production
ngrok was scaffolding. The same callback handler you debugged locally is the one that runs in production. Before you swap the tunnel for your real domain: move orders to a database, verify both the secret and the payment amount, and set https://api.yoursite.com/callback?secret=... in the dashboard.
The hard part of crypto payments was never generating an address. It's trusting the callback, the one piece you couldn't see until production.ngrok routes it directly to your local machine, allowing you to inspect the payload, replay requests, and step through your code with a debugger. Pair it with testmode and you run the full payment lifecycle without spending a satoshi or deploying a line of code.
Frequently Asked Questions
Why can't you test crypto webhooks on localhost?
Crypto payment providers require a public internet URL to send HTTP callback/webhook notifications. Because localhost is isolated to your local machine, the payment provider cannot reach your server without a secure public tunnel like ngrok.
What is a non-custodial crypto payment gateway?
A non-custodial payment gateway (like Blockonomics) routes funds directly from the customer to the merchant's personal wallet. Unlike traditional custodial processors or platforms like Stripe, it does not hold or freeze your funds, making it an ideal low-fee alternative for high-risk e-commerce or subscription billing.

Top comments (0)