Webhooks
Get bookings and guest-list changes pushed to you, signed, retried and replayable.
With webhooks, Boozie calls your server when something happens at your venue. Instead of polling the API, you get the change within seconds:
- a booking is made, confirmed or cancelled;
- the door marks guests as arrived;
- someone joins the guest list;
- a review comes in.
You can set up an endpoint in two places:
- the back office: Developers → Webhooks;
- the API:
POST /venues/{venueId}/webhooks, which needs a key with thewebhooks:managescope.
When you create an endpoint, you get its signing secret (whsec_…). It is shown once, so keep it in your secrets store.
Events#
| Event | Sent when | data is |
|---|---|---|
booking.created | A booking was made from any channel: the app, the WeChat mini-program, the back office or the API. An instant booking also sends booking.confirmed. | Booking |
booking.updated | A booking's time, table, number of guests or remark changed. | Booking |
booking.confirmed | The booking was confirmed. | Booking |
booking.rejected | The venue turned the request down. | Booking |
booking.cancelled | The customer or the venue cancelled it. | Booking |
booking.expired | The request wasn't answered in time. | Booking |
booking.arrival_changed | The door marked the guests arrived, seated or a no-show, or took that back. | Booking |
guest_list.joined | Someone joined the guest list. | Guest-list entry |
guest_list.cancelled | A guest-list name was cancelled. | Guest-list entry |
guest_list.arrived | A guest-list name arrived, either marked at the door or self-checked-in. | Guest-list entry |
guest_list.no_show | The door marked a guest-list name a no-show. | Guest-list entry |
review.created | A customer reviewed the venue. | Review |
review.replied | The venue replied to a review, changed its reply or took it down. | Review |
webhook.ping | You pressed Send test or called POST …/webhooks/{id}/test. | Endpoint |
Notes on subscribing:
- Subscribe only to the events you need.
- Through the API, subscribing to an event needs the read scope of its family:
bookings:read,guestlist:readorreviews:read. - The OpenAPI spec (
https://api-dev-799b.up.railway.app/public/v1/openapi.json) describes each payload underwebhooks, and the API reference shows them.
The request#
Boozie sends a POST with a JSON body:
{
"id": "evt_5f0c2a9e41d3b7c86a1e0f42",
"type": "booking.confirmed",
"createdAt": "2026-11-07T15:04:05+08:00",
"venueId": 2,
"apiVersion": "1.0.0",
"data": { "id": 812, "status": "confirmed", "…": "…" }
}
About the body:
datais the object exactly as the API's GET returns it, for exampleGET /public/v1/venues/2/bookings/812, at the moment of the event.- Customers' contact details (name, phone, email) are included only if the endpoint has Include customers' contact details on. Turning that on through the API needs the
customers:piiscope.
The headers:
| Header | |
|---|---|
Boozie-Signature | t=1762499045,v1=5d41…, the timestamp and one or more signatures (see below). |
Boozie-Event-Id | The same as id in the body. |
Boozie-Event-Type | The same as type in the body. |
Content-Type | application/json |
User-Agent | Boozie-Webhooks/1.0 |
Answering#
What Boozie expects from your server:
- Answer fast. Return any 2xx within 10 seconds, then do the work afterwards, for example from a queue.
- Redirects aren't followed. Anything other than a 2xx counts as a failure, and so does a timeout.
- Retries. A failed delivery is retried up to 8 attempts in all, about an hour. The waits start at 30 seconds and double each time: 30 s, 1 min, 2 min, 4 min and so on.
- Auto-disable. After 20 deliveries in a row fail for good, Boozie turns the endpoint off and shows why. Turn it back on once it's fixed. Use Send test to check before you do.
- Duplicates. Deliveries can arrive more than once, for example when your answer was lost or a delivery is replayed. Deduplicate on
Boozie-Event-Id. - Order. Deliveries can arrive out of order. If order matters, compare
createdAt, or GET the object for its current state. - Delivery log. The back office keeps every delivery for 30 days: the status, the time taken and the first 4 KB of your answer. You can replay any delivery from there or through the API.
Endpoint URL rules#
An endpoint must be reachable from the public internet:
- It must use
https://and a hostname. IP addresses,localhost, internal names such as*.localand*.internal, and URLs with a username or password are refused. - At every delivery, Boozie resolves the hostname again and refuses private, loopback, link-local, carrier-grade NAT, multicast and cloud-metadata addresses, in both IPv4 and IPv6.
To test against your laptop, use a tunnel with a public https hostname.
Verifying the signature#
Check every request, so that nobody else can post fake events to your endpoint. The steps are:
- Read the raw body bytes before any JSON parsing; re-serialised JSON won't match.
- From
Boozie-Signature, taketand everyv1. - Compute
HMAC-SHA256(key = your whole secret string including "whsec_", message = t + "." + rawBody), written as lowercase hex. - Accept the request if any
v1equals it (use a constant-time compare), andtis within 5 minutes of your clock. The time check stops replays of old captured requests.
When you rotate the secret, the old secret keeps working for 24 hours. During that time each request carries two v1 values: one signed with the new secret and one with the old. That gives you a day to deploy the new secret without dropping events.
Go#
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
)
const tolerance = 5 * time.Minute
// verify reports whether header is a valid Boozie-Signature for body.
func verify(header string, body []byte, secret string, now time.Time) bool {
var t int64 = -1
var sigs []string
for _, part := range strings.Split(header, ",") {
k, v, ok := strings.Cut(strings.TrimSpace(part), "=")
if !ok {
continue
}
switch k {
case "t":
if n, err := strconv.ParseInt(v, 10, 64); err == nil {
t = n
}
case "v1":
sigs = append(sigs, v)
}
}
if t < 0 || len(sigs) == 0 {
return false
}
if d := now.Sub(time.Unix(t, 0)); d > tolerance || d < -tolerance {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(strconv.FormatInt(t, 10) + "."))
mac.Write(body)
want := []byte(hex.EncodeToString(mac.Sum(nil)))
for _, s := range sigs {
if hmac.Equal([]byte(s), want) {
return true
}
}
return false
}
func main() {
secret := os.Getenv("BOOZIE_WEBHOOK_SECRET") // whsec_…
http.HandleFunc("/boozie/webhooks", func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
if err != nil || !verify(r.Header.Get("Boozie-Signature"), body, secret, time.Now()) {
http.Error(w, "bad signature", http.StatusBadRequest)
return
}
// Deduplicate on r.Header.Get("Boozie-Event-Id"), queue the work, answer at once.
w.WriteHeader(http.StatusNoContent)
})
http.ListenAndServe(":8080", nil)
}
Node.js (Express)#
const crypto = require("crypto");
const express = require("express");
const TOLERANCE_SECONDS = 5 * 60;
function verify(header, rawBody, secret, nowSeconds = Math.floor(Date.now() / 1000)) {
let t = -1;
const sigs = [];
for (const part of (header || "").split(",")) {
const i = part.indexOf("=");
if (i < 0) continue;
const k = part.slice(0, i).trim();
const v = part.slice(i + 1).trim();
if (k === "t" && /^\d+$/.test(v)) t = Number(v);
if (k === "v1") sigs.push(v);
}
if (t < 0 || sigs.length === 0) return false;
if (Math.abs(nowSeconds - t) > TOLERANCE_SECONDS) return false;
const want = crypto
.createHmac("sha256", secret)
.update(`${t}.`)
.update(rawBody) // a Buffer: the exact bytes received
.digest("hex");
const wantBuf = Buffer.from(want);
return sigs.some((s) => {
const got = Buffer.from(s);
return got.length === wantBuf.length && crypto.timingSafeEqual(got, wantBuf);
});
}
const app = express();
const secret = process.env.BOOZIE_WEBHOOK_SECRET; // whsec_…
// express.raw keeps the body as a Buffer; express.json would break the signature.
app.post("/boozie/webhooks", express.raw({ type: "application/json" }), (req, res) => {
if (!verify(req.get("Boozie-Signature"), req.body, secret)) {
return res.status(400).send("bad signature");
}
const event = JSON.parse(req.body.toString("utf8"));
// Deduplicate on event.id, queue the work, answer at once.
res.sendStatus(204);
});
app.listen(8080);
Managing endpoints through the API#
All of these need the webhooks:manage scope. A POST needs an Idempotency-Key header, as every API write does. When an idempotent create or rotate is replayed, the answer has secret: null, so the secret is never shown twice.
GET /public/v1/venues/{venueId}/webhooks | List endpoints |
POST /public/v1/venues/{venueId}/webhooks | Create: {url, description, events[], includePii, enabled}; the answer has secret |
GET /public/v1/venues/{venueId}/webhooks/{endpointId} | Get one |
PATCH /public/v1/venues/{venueId}/webhooks/{endpointId} | Change any of those fields |
DELETE /public/v1/venues/{venueId}/webhooks/{endpointId} | Delete it and its log |
POST …/{endpointId}/rotate-secret | New secret; the old one also signs for 24 h |
POST …/{endpointId}/test | Send a webhook.ping now and return the delivery |
GET …/{endpointId}/deliveries?state=&after= | The delivery log, newest first, cursor paging |
POST …/{endpointId}/deliveries/{deliveryId}/replay | Send that event again |
Limits:
- A venue can have up to 10 endpoints.
- A test ping isn't retried and doesn't count towards turning the endpoint off.
The full request and response shapes are in the OpenAPI spec.
On the sandbox#
Your sandbox venue is your own, so an endpoint there gets your events only. Endpoints and their secrets survive the nightly reset, but the reset itself sends no events: old bookings disappear and seeded ones appear without booking.created. Use Send test (or POST …/test) to check your signature code at any time.
Next: Conventions