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

EventFires when
lead.createdA new enquiry arrived.
lead.updatedA lead's stage, owner, or details changed.
quote.createdA quote was drafted.
quote.sentA quote was sent to the customer.
quote.acceptedThe customer accepted a quote.
quote.expiredA quote passed its expiry date without an answer.
booking.createdA job was booked.
booking.rescheduledA job moved to a new time.
booking.cancelledA job was cancelled.
crew.offeredA job was offered to a crew member.
crew.acceptedA crew member accepted a job.
crew.declinedA crew member declined a job.
job.startedA crew member clocked in.
job.completedA job was finished.
payment.recordedMoney was received, online or offline.
payment.refundedMoney was returned — by the admin, as a tip refund, or in the company's Stripe dashboard — or a hand-recorded entry was reversed.
invoice.issuedAn 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.

On this page