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 dead. Every attempt is visible in Admin → Webhooks, where you can also retry by hand.
Reply with any 2xx status within 10 seconds. Acknowledge the request before doing longer processing.
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. |
provider.offered | A job was offered to a pro. |
provider.accepted | A pro accepted a job. |
provider.declined | A pro declined a job. |
job.started | A pro clocked in. |
job.completed | A job was finished. |
payment.recorded | Money was received, online or offline. |
payment.refunded | Money was returned or an entry was reversed. |
An empty event selection subscribes the endpoint to every event, including event types added later. Select specific event types when the receiving system should opt in deliberately.
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.