A webhook receiver
Sixty lines, no framework beyond Express, and a SQLite file for deduplication so
it survives a restart. Run it, tunnel it, register the URL under
Admin → Webhooks, and put the secret you are shown in BELUGA_WEBHOOK_SECRET.
mkdir receiver && cd receivernpm init -y && npm install express better-sqlite3BELUGA_WEBHOOK_SECRET=whsec_from_the_admin node receiver.jsreceiver.js
Section titled “receiver.js”import crypto from "node:crypto";import express from "express";import Database from "better-sqlite3";
const SECRET = process.env.BELUGA_WEBHOOK_SECRET;const PORT = process.env.PORT ?? 3000;const TOLERANCE_SECONDS = 5 * 60;
if (!SECRET) throw new Error("Set BELUGA_WEBHOOK_SECRET.");
const db = new Database("receiver.sqlite");db.exec("CREATE TABLE IF NOT EXISTS seen (id TEXT PRIMARY KEY, at INTEGER NOT NULL)");const claim = db.prepare("INSERT OR IGNORE INTO seen (id, at) VALUES (?, ?)");
function verify(req) { const header = req.get("beluga-signature") ?? ""; const [tPart, vPart] = header.split(","); const timestamp = tPart?.slice(2); const provided = vPart?.slice(3); if (!timestamp || !provided) return false; if (Math.abs(Date.now() / 1000 - Number(timestamp)) > TOLERANCE_SECONDS) return false;
const expected = crypto .createHmac("sha256", SECRET) .update(`${timestamp}.${req.body}`) // req.body is a Buffer: the raw bytes .digest("hex");
const a = Buffer.from(provided); const b = Buffer.from(expected); return a.length === b.length && crypto.timingSafeEqual(a, b);}
async function handle(event) { switch (event.type) { case "order.paid": { const o = event.data; const lines = o.truncated ? ["(large order — fetch it from the admin)"] : o.items.map((i) => `${i.quantity} × ${i.productName}${i.variantLabel ? ` (${i.variantLabel})` : ""}${i.sku ? ` [${i.sku}]` : ""}`); console.log(`PICK ${o.reference} for ${o.shipping.name ?? o.email}, ${o.shipping.country}`); for (const line of lines) console.log(` ${line}`); console.log(` total ${(o.totalCents / 100).toFixed(2)} ${o.currency}`); break; } case "order.refunded": console.log(`REFUND ${event.data.reference}: ${event.data.refundedCents} of ${event.data.totalCents}`); break; case "inventory.low": console.log(`LOW ${event.data.productName} / ${event.data.variantLabel}: ${event.data.remaining} left`); break; default: console.log(`${event.type} ${event.id}`); }}
const app = express();
// express.raw, and only on this route. A global express.json() mounted first// would consume the body and the signature could never be checked.app.post("/hooks/beluga", express.raw({ type: "application/json" }), (req, res) => { if (!verify(req)) return res.status(400).end();
const event = JSON.parse(req.body.toString("utf8"));
// At-least-once delivery: the first claim on this id wins, later ones are // acknowledged and dropped. Deliveries can also arrive out of order. if (claim.run(event.id, Date.now()).changes === 0) { return res.json({ received: true, duplicate: true }); }
// Answer within Beluga's 10 second window, then do the work. res.json({ received: true }); handle(event).catch((error) => console.error(`failed ${event.id}:`, error));});
app.listen(PORT, () => console.log(`receiver on :${PORT}`));- Add
"type": "module"topackage.jsonfor theimportsyntax. - The division by 100 is for the console only. Do not store money as a float
anywhere downstream; pass
totalCentson as the integer it is. - If
handlefails after the id was claimed, the event is not retried, because Beluga saw a200. That is the right trade for a pick list. For something that must not be lost, insert the raw event into a table inside the same claim and process it from there. - The reference page has the Python version and the troubleshooting table.