Skip to content

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 the webhooks:manage scope.

When you create an endpoint, you get its signing secret (whsec_…). It is shown once, so keep it in your secrets store.

Events#

EventSent whendata is
booking.createdA 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.updatedA booking's time, table, number of guests or remark changed.Booking
booking.confirmedThe booking was confirmed.Booking
booking.rejectedThe venue turned the request down.Booking
booking.cancelledThe customer or the venue cancelled it.Booking
booking.expiredThe request wasn't answered in time.Booking
booking.arrival_changedThe door marked the guests arrived, seated or a no-show, or took that back.Booking
guest_list.joinedSomeone joined the guest list.Guest-list entry
guest_list.cancelledA guest-list name was cancelled.Guest-list entry
guest_list.arrivedA guest-list name arrived, either marked at the door or self-checked-in.Guest-list entry
guest_list.no_showThe door marked a guest-list name a no-show.Guest-list entry
review.createdA customer reviewed the venue.Review
review.repliedThe venue replied to a review, changed its reply or took it down.Review
webhook.pingYou 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:read or reviews:read.
  • The OpenAPI spec (https://api-dev-799b.up.railway.app/public/v1/openapi.json) describes each payload under webhooks, 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:

  • data is the object exactly as the API's GET returns it, for example GET /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:pii scope.

The headers:

Header
Boozie-Signaturet=1762499045,v1=5d41…, the timestamp and one or more signatures (see below).
Boozie-Event-IdThe same as id in the body.
Boozie-Event-TypeThe same as type in the body.
Content-Typeapplication/json
User-AgentBoozie-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 *.local and *.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:

  1. Read the raw body bytes before any JSON parsing; re-serialised JSON won't match.
  2. From Boozie-Signature, take t and every v1.
  3. Compute HMAC-SHA256(key = your whole secret string including "whsec_", message = t + "." + rawBody), written as lowercase hex.
  4. Accept the request if any v1 equals it (use a constant-time compare), and t is 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}/webhooksList endpoints
POST /public/v1/venues/{venueId}/webhooksCreate: {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-secretNew secret; the old one also signs for 24 h
POST …/{endpointId}/testSend a webhook.ping now and return the delivery
GET …/{endpointId}/deliveries?state=&after=The delivery log, newest first, cursor paging
POST …/{endpointId}/deliveries/{deliveryId}/replaySend 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