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.
1. The request
Section titled â1. The requestâPOST /your/endpoint HTTP/1.1content-type: application/jsonuser-agent: Beluga-Webhooks/1beluga-event-id: evt_9a3f1c02-5d7e-4b18-9f2a-7c1e6b40d833beluga-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.
product.published
Section titled âproduct.publishedâ{ "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 } ] }}inventory.low
Section titled âinventory.lowâ{ "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 }}What is never in a payload
Section titled âWhat is never in a payloadâ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.
Truncation
Section titled âTruncationâ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.
2. Verify the signature
Section titled â2. Verify the signatureâ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.
Node / Express
Section titled âNode / Expressâ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);});Python / Flask
Section titled âPython / Flaskâimport hashlib, hmac, os, timefrom 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}3. Delivery semantics
Section titled â3. Delivery semanticsâ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.
4. Testing locally
Section titled â4. Testing locallyâ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.
5. Troubleshooting
Section titled â5. Troubleshootingâ| 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. |
6. What this is not
Section titled â6. What this is notâ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.