Skip to content

Build a receiver

Beluga POSTs a signed JSON body to a URL you own whenever something happens in the store. This is the reference for the receiving end. Outbound webhooks is the overview.

POST /your/endpoint HTTP/1.1
content-type: application/json
user-agent: Beluga-Webhooks/1
beluga-event-id: evt_9a3f1c02-5d7e-4b18-9f2a-7c1e6b40d833
beluga-signature: t=1757336400,v1=6f2a…

Every body is the same envelope:

{
"id": "evt_9a3f1c02-5d7e-4b18-9f2a-7c1e6b40d833",
"type": "order.paid",
"created": 1757336400,
"data": { }
}

id matches the beluga-event-id header, so you can deduplicate without parsing the body. created is unix seconds.

order.paid, order.updated, order.refunded, order.cancelled

Section titled “order.paid, order.updated, order.refunded, order.cancelled”
{
"id": "evt_9a3f1c02-5d7e-4b18-9f2a-7c1e6b40d833",
"type": "order.paid",
"created": 1757336400,
"data": {
"id": "5f8c1e2a-9d43-4c7b-8a15-2e6f0b93d471",
"reference": "5F8C1E2A",
"email": "buyer@example.com",
"status": "paid",
"currency": "USD",
"subtotalCents": 3400,
"shippingCents": 600,
"taxCents": 280,
"discountCents": 0,
"totalCents": 4280,
"refundedCents": 0,
"carrier": null,
"trackingNumber": null,
"oversold": false,
"createdAt": 1757336388000,
"shipping": {
"name": "A Buyer",
"line1": "1 Test Street",
"line2": null,
"city": "Marfa",
"state": "TX",
"postalCode": "79843",
"country": "US"
},
"items": [
{
"productId": "demo-tote",
"variantId": "demo-tote-s",
"productName": "Canvas Tote",
"variantLabel": "Small",
"sku": "TOTE-S",
"unitPriceCents": 3400,
"quantity": 1,
"options": { "monogram": "AB" }
}
]
}
}

Every money field is an integer number of cents and is named *Cents to say so. 4280 is $42.80. createdAt is unix milliseconds (the order’s timestamp); the envelope’s created is unix seconds (the event’s). reference is what a customer sees; id is what you key on. productId and variantId are nullable, since the catalogue can change after a sale; the names and price are snapshots and always present.

{
"id": "evt_1c4b…",
"type": "product.published",
"created": 1757336500,
"data": {
"id": "demo-tote",
"slug": "canvas-tote",
"name": "Canvas Tote",
"kind": "physical",
"isLive": true,
"stripeProductId": "prod_QxYz123",
"variants": [
{ "id": "demo-tote-s", "label": "Small", "priceCents": 3400 },
{ "id": "demo-tote-l", "label": "Large", "priceCents": 3900 }
]
}
}
{
"id": "evt_7e91…",
"type": "inventory.low",
"created": 1757336400,
"data": {
"productId": "demo-tote",
"productName": "Canvas Tote",
"variantId": "demo-tote-s",
"variantLabel": "Small",
"remaining": 3,
"threshold": 5
}
}

No Stripe keys, no payment intent id, no session data, no password hashes, no customer record. Payloads are built field by field rather than spread from a database row, and a test asserts the serialised body contains none of those. If you need something that is not here, fetch it through the admin API with your own credentials.

Bodies are capped at 64 KB. When an order exceeds that, items is emptied and a top-level "truncated": true is added. Every identifier is still present, so treat it as a signal to fetch the order rather than as data loss.

Before you trust anything in the body. An unverified endpoint is a URL anyone on the internet can POST fake orders to.

The beluga-signature header is t=<unix seconds>,v1=<hex>, where the hex is an HMAC-SHA256 of ${t}.${rawBody} under that endpoint’s secret.

import crypto from "node:crypto";
import express from "express";
const app = express();
const SECRET = process.env.BELUGA_WEBHOOK_SECRET;
const TOLERANCE_SECONDS = 5 * 60;
// express.raw, NOT express.json — see the gotcha below.
app.post("/hooks/beluga", express.raw({ type: "application/json" }), (req, res) => {
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 res.status(400).end();
// The timestamp is inside the signed material, so a captured body cannot be re-stamped.
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > TOLERANCE_SECONDS) {
return res.status(400).end();
}
const expected = crypto
.createHmac("sha256", SECRET)
.update(`${timestamp}.${req.body}`)
.digest("hex");
const a = Buffer.from(provided);
const b = Buffer.from(expected);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.status(400).end();
}
const event = JSON.parse(req.body.toString("utf8"));
// Answer first, work afterwards — see §3.
res.json({ received: true });
void handle(event);
});
import hashlib, hmac, os, time
from flask import Flask, request
app = Flask(__name__)
SECRET = os.environ["BELUGA_WEBHOOK_SECRET"].encode()
TOLERANCE_SECONDS = 5 * 60
@app.post("/hooks/beluga")
def beluga():
header = request.headers.get("Beluga-Signature", "")
try:
t_part, v_part = header.split(",")
timestamp, provided = t_part[2:], v_part[3:]
except ValueError:
return "", 400
if abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS:
return "", 400
# request.get_data() is the raw body; request.json is not.
expected = hmac.new(
SECRET, f"{timestamp}.".encode() + request.get_data(), hashlib.sha256
).hexdigest()
if not hmac.compare_digest(provided, expected):
return "", 400
handle(request.get_json())
return {"received": True}

Answer fast, then do the work. Beluga waits 10 seconds, then treats the delivery as failed. Any 2xx means accepted; anything else, a timeout, or a connection error gets the retry schedule. Hand the event to a queue; do not ship a parcel while Beluga is holding the socket open. Redirects are followed up to 3 hops, each re-checked against the address rules.

At-least-once, so make your handler idempotent. You will see the same event twice; a lost response looks identical to one that never arrived. Deduplicate on the event id, which is stable across retries. Two subscribers to the same event receive the same id, so key your dedup store per receiver. Ordering is not guaranteed either; reconcile on the order’s own status, not arrival order.

Attempt Sent after
1 immediately (within ~10 s of the event)
2 1 minute
3 5 minutes
4 25 minutes
5 2 hours
6 10 hours

After the sixth, the delivery is marked Gave up in the admin and can be replayed with Redeliver. Five give-ups in a row disable the endpoint; fix the receiver and flip it back on. Any success resets the count.

Your machine is not publicly reachable, which the address rules correctly refuse.

A tunnel (recommended): ngrok http 3000, and register its https:// URL. Real DNS, real TLS, real signatures, no configuration change in Beluga.

The opt-out: WEBHOOK_ALLOW_INSECURE_TARGETS=true allows plain http:// and private addresses. Off by default; leave it off anywhere public.

Triggering a real event: complete a test payment with 4242 4242 4242 4242. That produces a genuine checkout.session.completed, which emits order.paid. Marking the order shipped gives you order.updated. Redeliver in the admin is the fastest loop while debugging.

Symptom Almost always
Every signature fails You hashed a re-serialised body. HMAC the raw bytes.
Signatures failed suddenly, after working Someone rolled the secret.
Deliveries show no response Your endpoint took longer than 10 s, or the connection failed. Answer first, work after.
Endpoint rejected on save The hostname resolves to a private address, or the URL is not https://.
Nothing arrives at all The endpoint is disabled or not subscribed to that event type. Both are on its row.
The same order processed twice Your handler is not idempotent. Deduplicate on the event id.
Totals are 100× too big You read *Cents as a currency amount. 4280 is $42.80.
items is empty on a large order The 64 KB cap; check for "truncated": true and fetch the order.

There is no write API; webhooks tell you what happened, and the admin API with your own session is how you push changes back. There is no app install flow, no OAuth, no per-app scopes. Delivery is neither ordered nor exactly-once.