Skip to content

Connect a fulfilment webhook

Beluga has no app ecosystem. What it has instead is a signed POST to a URL you own whenever something happens, retried until you answer. This tutorial wires one up: a receiver that gets order.paid, verifies it, and posts a pick list to a Slack channel. Swap the last step for your warehouse, your ledger, or your printer.

You need a Beluga store that can take a test payment and somewhere to run twenty lines of Node that Beluga can reach over HTTPS. A tunnel is fine for development.

The whole thing is in A webhook receiver. The parts that matter:

  • It uses express.raw, not express.json, on the webhook route. The signature is an HMAC over the raw bytes, and a body parser that has already turned the request into an object cannot reproduce them.
  • It rejects a timestamp more than five minutes old. The timestamp is inside the signed material so a replay is detectable.
  • It answers 200 before doing the work. Beluga waits ten seconds and then treats the delivery as failed.
  • It deduplicates on the event id. Delivery is at-least-once.

Run it, and expose it:

Terminal window
node receiver.js
ngrok http 3000

Admin → Webhooks → Add an endpoint. Paste the tunnel’s https:// URL with the /hooks/beluga path, tick order.paid, save.

You are shown the signing secret exactly once. Put it in the receiver’s environment as BELUGA_WEBHOOK_SECRET. Nothing reads it back afterwards; lose it and you roll a new one, which invalidates the old one immediately.

The URL must be https:// and its hostname must resolve to a public address. A tunnel satisfies both. For a receiver that genuinely lives on localhost, and only on a machine nobody else can reach, WEBHOOK_ALLOW_INSECURE_TARGETS=true lifts both rules.

Complete a test payment. checkout.session.completed arrives at Beluga, the order is marked paid, and an order.paid event is queued. Delivery is a background pass every ten seconds, never inline, so a slow subscriber can never delay Beluga’s answer to Stripe.

Your receiver logs the order. The Slack message says what to pick.

The endpoint’s row in the admin expands into its recent deliveries: event type, attempt count, response status, the error if there was one. Redeliver puts one back at the front of the queue with its attempts reset, which is the fastest loop while you are debugging the handler.

Stop the receiver and pay again. The delivery fails, and retries after 1 minute, 5, 25, 2 hours, 10 hours, then gives up. Five give-ups in a row disable the endpoint; re-enabling clears the count and sends what is still queued.

Resend a delivery with the receiver running. The same event id arrives twice. If your dedup store is in memory, that survives a restart badly; use the database you already have. Two subscribers to the same event receive the same id, so key the store per receiver.

  • order.updated fires on fulfilment status, carrier or tracking changes; a cancellation sends order.cancelled instead, so you do not diff statuses.
  • inventory.low fires per variant, once per qualifying sale, at five units or fewer. It is not latched; filter on remaining for a different threshold.
  • product.published carries kind, so a fulfilment receiver can branch on physical versus digital before raising a pick list.

Build a receiver is the reference: every payload, verification in Node and Python, the truncation rule for very large orders, and a troubleshooting table.