Webhooks
Receive signed, retried notifications when activity occurs in Grout.
How delivery works
Events are written to an outbox in the same database transaction as the change that caused them. If the booking commits, the event exists. If it rolls back, so does the event. Delivery happens after the request, so a slow or unavailable endpoint cannot fail a customer's booking.
A failed delivery is retried after 1, 5, 15, 60, 180 and 360 minutes, then marked Failed.
Every attempt is visible in Admin → Webhooks (/admin/webhooks, reached
from Settings in the sidebar), where you can also retry by hand.
Add endpoint at the top of that page opens the Add endpoint page for a
new one. An endpoint's name, or Edit in its … menu, opens the Edit
endpoint page, where you change its URL, purpose and events. Its signing secret stays the same, even
with a new URL, and a paused endpoint stays paused.
Endpoint requirements
An endpoint URL must be a public https address. Requests to loopback, private,
link-local, multicast, or otherwise reserved addresses are refused — at creation,
at update, and again at delivery — in any form (https://10.0.0.5,
https://127.0.0.1, https://localhost, integer or hex IPs, and IPv4-mapped
IPv6 all included). At delivery the hostname is resolved and every address is
checked, and the connection is pinned to a checked address, so a hostname cannot
resolve public and then connect to something private. Redirects are not followed:
respond with a 2xx directly. An endpoint whose destination becomes disallowed
is disabled automatically; re-enable it from Admin → Webhooks once it points
at a public address again.
Reply with any 2xx status within 10 seconds. Acknowledge the request before doing longer processing.
The signing secret
When you add an endpoint in Admin → Webhooks, its signing secret is shown
once, on the page, right after you create it — copy it then and store it
somewhere safe. It never appears in the address bar, and reloading will not show
it again. If you lose it, remove the endpoint and add it again to get a fresh
one. Use the secret to verify the bb-signature header on every delivery.
Verifying a delivery
Each request carries bb-signature: t=<unix>,v1=<hex>. The signature covers the
timestamp and the raw body together, so a captured request cannot be replayed later with
a fresh timestamp. Compare in constant time, and reject anything older than about five
minutes.
import crypto from "node:crypto";
export function verify(rawBody: string, header: string, secret: string): boolean {
const parts = Object.fromEntries(
header.split(",").map((p) => p.trim().split("="))
);
const t = Number(parts.t);
if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > 300) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${t}.${rawBody}`)
.digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(parts.v1 ?? "");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}Hash the raw body before parsing JSON. Re-serializing the payload changes its bytes and invalidates the signature.
The payload
{
"id": "evt_…",
"type": "booking.created",
"createdAt": "2026-03-02T09:00:00.000Z",
"data": { "bookingId": "…", "code": "BB-10042", "totalCents": 18734 }
}Other headers are bb-event-id, bb-event-type, and bb-delivery-id. A retry sends the same bb-event-id, so store that value and ignore duplicates.
Events
| Event | Fires when |
|---|---|
lead.created | A new enquiry arrived. |
lead.updated | A lead's stage, owner, or details changed. |
quote.created | A quote was drafted. |
quote.sent | A quote was sent to the customer. |
quote.accepted | The customer accepted a quote. |
quote.expired | A quote passed its expiry date without an answer. |
booking.created | A job was booked. |
booking.rescheduled | A job moved to a new time. |
booking.cancelled | A job was cancelled. |
crew.offered | A job was offered to a crew member. |
crew.accepted | A crew member accepted a job. |
crew.declined | A crew member declined a job. |
job.started | A crew member clocked in. |
job.completed | A job was finished. |
payment.recorded | Money was received, online or offline. |
payment.refunded | Money was returned — by the admin, as a tip refund, or in the company's Stripe dashboard — or a hand-recorded entry was reversed. |
invoice.issued | An invoice was issued, with its amount and due date. |
Under Events, choose All events, including ones added later to receive everything, or Only selected events and tick the event types the receiving system should opt in to deliberately. Through the API, an empty event list means all events.
Good practice
- Acknowledge fast, process asynchronously.
- Deduplicate on
bb-event-id— retries are expected, not exceptional. - Treat events as hints, not as the source of truth; re-read the resource when it matters.
- Keep the secret out of source control, and rotate it by creating a new endpoint.