Grout

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

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.
provider.offeredA job was offered to a pro.
provider.acceptedA pro accepted a job.
provider.declinedA pro declined a job.
job.startedA pro clocked in.
job.completedA job was finished.
payment.recordedMoney was received, online or offline.
payment.refundedMoney 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.

On this page