Developers / Webhooks

Developers

Webhooks

Signed event deliveries with retries, logs and replay.

Webhooks push events to your systems as they happen: a meeting is booked, moved, cancelled or completed, or a router makes a decision about a lead. Use them to update a data warehouse, post to Slack, start a sequence in your outreach tool or keep a CRM other than Attio in sync.

Each event is an HTTPS POST with a JSON body, signed with a secret only you and Cauliflower know. Failed deliveries are retried for about 20 hours, and every attempt is logged so you can inspect and replay it.

Create an endpoint

Add the endpoint

Go to Developers → Webhooks and click Add endpoint. Enter the URL that should receive events, an optional description, and pick the events to send — or All events to receive everything, including event types added later. Only workspace owners and admins can manage endpoints.

Copy the signing secret

After saving, the endpoint page shows its Signing secret, which starts with whsec_. Store it with your receiving service; you'll use it to verify every request.

Send a test

Click Send test. Cauliflower sends a ping event right away and tells you whether your endpoint answered with a 2xx status.

You can also manage endpoints with the REST API (POST /api/v1/webhooks) or the create_webhook MCP tool. The API returns the secret only once, in the create response, and never lists it afterwards.

Events

EventSent when
meeting.bookedA meeting was booked from any source: booking page, router, Handoff, API or MCP.
meeting.rescheduledA meeting moved to a new time — by the invitee, a teammate, the API, or the host dragging the event in Google Calendar.
meeting.cancelledA meeting was cancelled by the host, the invitee, or by deleting the event in Google Calendar.
meeting.no_showA meeting was marked as a no-show.
meeting.completedA meeting was marked as completed. Confirmed meetings are also completed automatically two hours after they end.
lead.routedA lead went through a router, whatever the outcome.
lead.disqualifiedA router disqualified a lead. Sent in addition to lead.routed.
lead.assignedA router assigned an owner without booking a meeting. Sent in addition to lead.routed.
lead.dropped_offA lead sent to a calendar left without booking. The payload is the routing event, with booking_stage (how far they got) and dropped_off_at. See Leads who don't book.

Router dry runs, the router preview and Handoff's evaluation step don't send events. The ping test event is only sent to the endpoint you're testing.

Payloads

Every request body has the same envelope:

JSON
{
  "id": "evt_3k9x2m7q1c8w0t4d5z",
  "type": "meeting.booked",
  "created_at": "2026-10-07T14:03:22.418Z",
  "workspace_id": "ws_8f2k1m0q9c3x7w4t5z",
  "data": {}
}

id identifies the event. It stays the same across automatic retries and manual resends, so use it to make your handler idempotent.

Meeting events

For meeting.* events, data is the meeting — the same object GET /api/v1/meetings/:id returns:

JSON
{
  "id": "mtg_5q8w1t3k9x2m7c0d4z",
  "uid": "Vb8Kx2pQ7rT1yZ4mN6cL0sD3fG5hJ9wE",
  "title": "Product demo · Jane Cooper <> Alex Kim",
  "status": "confirmed",
  "source": "router",
  "start_time": "2026-10-12T14:00:00.000Z",
  "end_time": "2026-10-12T14:30:00.000Z",
  "timezone": "America/New_York",
  "duration_minutes": 30,
  "location_type": "google_meet",
  "location": null,
  "meeting_url": "https://meet.google.com/abc-defg-hij",
  "invitee": { "name": "Jane Cooper", "email": "[email protected]", "phone": null },
  "guests": [],
  "answers": { "company_size": "201-1000" },
  "notes": null,
  "host": { "id": "Hq3vN8rT2kLm", "name": "Alex Kim", "email": "[email protected]" },
  "co_hosts": [],
  "meeting_type": { "id": "mt_2c7d0x9k4m1q8w3t6z", "title": "Product demo", "slug": "demo" },
  "team": { "id": "team_1x7m3q9k2c8w0t4d5z", "name": "Sales" },
  "router": { "id": "rtr_9k2x7m3q1c8w0t4d5z", "name": "Inbound", "slug": "inbound" },
  "booked_by": null,
  "lead": { "id": "lead_7m3q9x2k8c1w0t4d5z", "email": "[email protected]", "company": "Acme" },
  "routing_event_id": "rte_4w9t2m8k1c0d7h3x5q",
  "cancel_reason": null,
  "cancelled_by": null,
  "cancelled_at": null,
  "reschedule_count": 0,
  "manage_url": "https://cal.example.com/booking/Vb8Kx2pQ7rT1yZ4mN6cL0sD3fG5hJ9wE",
  "app_url": "https://cal.example.com/meetings/mtg_5q8w1t3k9x2m7c0d4z",
  "calendar_event_id": "7b1c9e2d4f",
  "created_at": "2026-10-07T14:03:22.101Z",
  "updated_at": "2026-10-07T14:03:22.101Z"
}

A few fields worth knowing:

  • source is booking_link, router, handoff, api or mcp.
  • status is confirmed, cancelled, completed or no_show.
  • meeting.rescheduled adds previous_start_time with the old start time.
  • On meeting.cancelled, cancelled_by is host, invitee or system, and cancel_reason holds the reason when one was given.
  • booked_by is the teammate who booked it through Handoff; router and routing_event_id are set when a router sent the lead to the calendar.

Lead events

For lead.* events, data is the routing decision:

JSON
{
  "id": "rte_4w9t2m8k1c0d7h3x5q",
  "outcome": "disqualified",
  "source": "widget",
  "router": { "id": "rtr_9k2x7m3q1c8w0t4d5z", "name": "Inbound", "slug": "inbound" },
  "matched_path": { "id": "path_6t2k8m1q9c3x7w4d5z", "name": "Personal email" },
  "matched_rule": { "id": "path_6t2k8m1q9c3x7w4d5z", "name": "Personal email" },
  "steps": ["disqualify"],
  "lead": { "id": "lead_2k9x7m3q1c8w0t4d5z", "email": "[email protected]", "name": "Sam Lee", "company": null },
  "assigned_user": null,
  "team": null,
  "meeting_type": null,
  "disqualify_reason": "Personal email",
  "message": "Thanks! We'll be in touch by email.",
  "redirect_url": null,
  "meeting_id": null,
  "booked_at": null,
  "input": { "email": "[email protected]", "name": "Sam Lee", "company_size": "1-10" },
  "page_url": "https://acme.com/demo",
  "created_at": "2026-10-07T14:01:09.512Z"
}

outcome is book, assigned, disqualified, redirect, no_match or error. source is widget, link, handoff, api or mcp. input holds the fields as they were submitted. When the outcome is book, the meeting doesn't exist yet — listen for meeting.booked, whose routing_event_id points back to this decision.

Headers

HeaderValue
Content-Typeapplication/json
User-AgentCauliflower-Webhooks/1.0
Cauliflower-EventThe event type, e.g. meeting.booked
Cauliflower-DeliveryThe delivery id (whd_...). Stays the same across automatic retries; a manual resend gets a new one.
Cauliflower-Signaturet=<unix seconds>,v1=<hex HMAC>

Verify signatures

The signature header looks like this:

Text
Cauliflower-Signature: t=1791381802,v1=5f0c3e9a1b...

v1 is the hex-encoded HMAC-SHA256 of the string <t>.<raw body>, keyed with your endpoint's signing secret — the whole secret, including the whsec_ prefix. To verify a request:

  1. Read the raw request body before any JSON parsing. Re-serializing parsed JSON changes the bytes and breaks the signature.
  2. Split the header on commas and take t and v1.
  3. Compute the HMAC of t, a period and the raw body, and compare it to v1 in constant time.
  4. Reject timestamps more than five minutes from your clock to block replayed requests.

Node.js

JavaScript
import crypto from "node:crypto"
import express from "express"

const SECRET = process.env.CAULIFLOWER_WEBHOOK_SECRET // whsec_...

export function verifyCauliflowerSignature(rawBody, header, secret, toleranceSeconds = 300) {
  if (!header) return false
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")))
  const t = Number(parts.t)
  if (!t || !parts.v1) return false
  if (Math.abs(Date.now() / 1000 - t) > toleranceSeconds) 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)
}

const app = express()

app.post("/webhooks/cauliflower", express.raw({ type: "application/json" }), (req, res) => {
  const raw = req.body.toString("utf8")
  if (!verifyCauliflowerSignature(raw, req.get("Cauliflower-Signature"), SECRET)) {
    return res.status(400).send("Invalid signature")
  }
  const event = JSON.parse(raw)
  if (event.type === "meeting.booked") {
    // event.data is the meeting
  }
  res.sendStatus(204)
})

In a Next.js route handler, read the body with await request.text() and pass it to the same function.

Python

python
import hashlib
import hmac
import os
import time

from flask import Flask, abort, request

SECRET = os.environ["CAULIFLOWER_WEBHOOK_SECRET"]  # whsec_...
app = Flask(__name__)


def verify_cauliflower_signature(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
    try:
        parts = dict(item.split("=", 1) for item in header.split(","))
        timestamp = int(parts["t"])
        received = parts["v1"]
    except (KeyError, ValueError):
        return False
    if abs(time.time() - timestamp) > tolerance:
        return False
    signed = f"{timestamp}.".encode() + raw_body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, received)


@app.post("/webhooks/cauliflower")
def cauliflower_webhook():
    raw = request.get_data()
    if not verify_cauliflower_signature(raw, request.headers.get("Cauliflower-Signature", ""), SECRET):
        abort(400)
    event = request.get_json()
    if event["type"] == "meeting.cancelled":
        pass  # event["data"] is the meeting
    return "", 204

Rotating the secret

Click the rotate button next to the signing secret to generate a new one. It takes effect immediately for every request sent from then on — including retries of older events, because requests are signed when they're sent. Update your receiver right after rotating, or accept both secrets for a few minutes while you deploy.

Responding and retries

Answer with any 2xx status within 15 seconds to acknowledge a delivery. Anything else counts as a failure: a 4xx or 5xx, a timeout, a connection error, or a redirect — redirects are not followed.

Failed deliveries are retried on this schedule:

AttemptWhen
1Immediately after the event
21 minute after attempt 1
35 minutes later
430 minutes later
52 hours later
66 hours later
712 hours later

After the seventh failure the delivery is marked Failed and not retried again. Retries are sent by the background job runner, so they need INTERNAL_CRON=true or the /api/cron route (see Deploying).

Some practical advice for receivers:

  • Acknowledge fast. Store the event and process it in a queue; don't call slow services before responding.
  • Deduplicate on id. The same event can arrive more than once, for example after a timeout on your side.
  • Don't rely on order. Deliveries are sent in parallel and retries can overtake newer events. Compare updated_at or fetch the meeting from the API when order matters.

Delivery log and replay

Open an endpoint under Developers → Webhooks to see its last 100 deliveries: the event type, status (Delivered, Pending, Retrying or Failed), the HTTP status or error, response time, number of attempts and when the next attempt is due. Click a row to see the exact payload and the first 2,000 characters of your endpoint's response.

Resend queues a fresh delivery of the same event and sends it immediately — useful after you fix a bug in your receiver. It's available for delivered and failed deliveries.

The endpoint list shows a failing badge with the number of consecutive failed attempts. Endpoints are never disabled automatically; switch Enabled off to stop sending new events to an endpoint, and switching it back on resets the failure count. Deleting an endpoint drops its pending deliveries.

Delivered and failed deliveries are kept for 30 days. Platform admins can also see failed deliveries across every workspace under Admin → Jobs & webhooks and redeliver them from there.

URL rules and SSRF protection

Webhook URLs are checked when you save an endpoint and again before every delivery, so they can't be used to reach services inside your network:

  • In production, URLs must use https. Set ALLOW_INSECURE_WEBHOOKS=true to allow plain http.
  • localhost, *.localhost, *.local and *.internal hosts are rejected.
  • The hostname is resolved, and the URL is rejected if any address is private or reserved: loopback, RFC 1918 ranges, link-local (including cloud metadata at 169.254.169.254), carrier-grade NAT, multicast, IPv6 unique-local and link-local, NAT64 and IPv4-mapped forms of all of these.
  • Redirects are never followed, so a public URL can't bounce a request to an internal one.

For local development, set ALLOW_PRIVATE_WEBHOOKS=true to send events to http://localhost or a machine on your network. Leave both settings off in production. See Configuration.