Webhooks
Get a signed HTTP request every time a booking is created, rescheduled, or canceled, so your own systems can react without polling.
Events
Each event fires once per subscribed endpoint.
- booking.created — a new booking is made. One event per booking; a multi-date series fires one event per child booking.
- booking.rescheduled — a booking moves to a new time.
- booking.canceled — a booking is canceled.
Payload
Every delivery is a POST with content-type: application/json and an envelope around the event-specific data:
{
"version": "1",
"id": "cm4d0h5x10001abcdefghijkl",
"event": "booking.created",
"createdAt": "2026-09-08T14:03:12.000Z",
"data": { }
}id is the delivery id. It is stable across retries of the same delivery — use it as your idempotency key. A resend from the dashboard gets a new id.
createdAt is the time of this attempt, not of the booking. It equals the t value in the signature and changes on every retry. For the time the booking was made, read data.booking.createdAt.
data always carries event. A booking event also carries scheduledEventId and booking. A ping carries { event, spaceId, sentAt } and has no booking key.
A full booking.created delivery:
{
"version": "1",
"id": "cm4d0h5x10001abcdefghijkl",
"event": "booking.created",
"createdAt": "2026-09-08T14:03:12.000Z",
"data": {
"event": "booking.created",
"scheduledEventId": "cm4cz9k7t0001xyz789abcdef",
"manageUrl": null,
"booking": {
"id": "cm4cz9k7t0001xyz789abcdef",
"uid": "bk_9f2c1d",
"status": "confirmed",
"title": "Intro call with Jessie Smith",
"description": null,
"start": "2026-09-10T15:00:00.000Z",
"end": "2026-09-10T15:30:00.000Z",
"allDay": false,
"timeZone": "Europe/London",
"durationMinutes": 30,
"createdAt": "2026-09-08T14:03:11.000Z",
"manageUrl": null,
"source": null,
"eventType": {
"id": "cm4cy1a2b0001evt000000001",
"name": "Intro call",
"duration": 30,
"locationType": "zoom_auto"
},
"sheet": { "urlId": "intro", "title": "Book a call" },
"series": null,
"location": {
"provider": "zoom",
"label": "Zoom meeting",
"address": null,
"joinUrl": "https://zoom.us/j/98765432101",
"pending": false
},
"host": {
"id": "cm4cx0000user00000000001",
"name": "Alex Doe",
"email": "[email protected]",
"timeZone": "Europe/London"
},
"guests": [
{
"uid": "inv_71ab3c",
"name": "Jessie Smith",
"email": "[email protected]",
"timeZone": "America/New_York",
"locale": "en",
"status": "pending",
"phone": "+1 555 0100",
"manageUrl": null,
"additionalGuests": [],
"answers": [
{
"questionId": "phone",
"label": "Phone number",
"type": "phone",
"value": "+1 555 0100",
"values": null,
"files": []
},
{
"questionId": "agenda",
"label": "What would you like to cover?",
"type": "long_text",
"value": "Pricing",
"values": null,
"files": []
}
],
"answersByKey": {
"phone": "+1 555 0100",
"agenda": "Pricing"
}
}
],
"answersByKey": {
"phone": "+1 555 0100",
"agenda": "Pricing"
},
"additionalGuests": [],
"cancellation": null,
"reschedule": null,
"space": { "id": "cm4cw0000space0000000001", "name": "Acme" }
}
}
}The other events share this shape. Rather than repeat the whole body, here's what changes:
- booking.rescheduled — same shape, plus data.booking.reschedule = { previousStart, previousEnd } and start / end hold the new time.
- booking.canceled — same shape, status is canceled, and data.booking.cancellation = { reason, canceledAt, canceledBy }. canceledBy is guest, host, system, or api. reason may be null.
- data.booking can be null. A booking that was hard-deleted before the delivery went out can no longer be read, and the event still goes out.
- A booking made through the multi-date flow carries data.booking.series = { uid, position, count } — position is 1-based and chronological, count is the number of dates chosen at booking time. It's null for a single booking.
- location.provider is zoom, meet (Google Meet), teams (Microsoft Teams), phone, custom, or null. joinUrl is the link the guest joins on, a tel: URI for phone when the guest calls the host. When the host calls the guest, is , is and the number is ; is the venue of an in-person booking; is the one-line text shown in the confirmation email. The meeting id and password are never sent — only . is while the Meet or Zoom link is still being made. No extra event fires when it lands, so read it from the next event for that booking.
What we guarantee about strings
Text a guest or a host typed is cleaned once before signing. Numbers, booleans, lists and objects are sent as they are. Strings never carry HTML. The signature covers the exact bytes we sent.
- Every typed string is Unicode NFC normalized, so two visually identical names compare equal. Control characters are removed, with two exceptions. A tab survives. A newline survives in a long_text answer, in description and in a cancellation reason, and becomes a single space everywhere else, so a single-line field can't forge a record boundary in your logs or CSV.
- Length caps, measured in code points so a truncation never splits an emoji: answer values 2000, answer labels 200, titles 500, description 2000, names 255, cancellation reasons 500, URLs 2000, and 512 for anything else (emails, time zones, locales, questionId). At most 12 answers per guest.
- The whole body is capped at 256 KB. If a body would exceed it, we drop description first, then answers from the end of the last guest backwards, and set truncated: true at the top level next to test. Ids, times, status and identity are never dropped.
Guest manage links
manageUrl is a bearer credential: anyone holding it can cancel or reschedule the booking without signing in.
- It is off by default. Turn on Include guest manage links on the endpoint in Settings → Webhooks to receive it.
- Endpoints created before this switch shipped do not receive manage links any more. manageUrl is null at both data.manageUrl and data.booking.manageUrl until the switch is turned on.
- When it is on, each guest also carries their own guests[].manageUrl, so a shared-capacity booking gives one link per attendee rather than the first attendee's link for everyone.
- Store it like an access token and keep it out of logs and analytics.
Verifying the signature
Every request carries these headers:
- x-callslot-signature — t=<unix seconds>,v1=<hex hmac>
- x-callslot-event — the event name, matching event in the body
- x-callslot-delivery — the delivery id, matching id in the body
- user-agent — always CallSlot-Webhooks/1, so a firewall can recognise us. Never authenticate on it: a header is trivial to forge. Use the signature.
The signature is HMAC-SHA256 over the string ${t}.${rawBody} keyed with the endpoint's signing secret (the whsec_... value shown once when the endpoint is created). Verify against the raw request body, before any JSON parsing — re-serializing changes the bytes and the signature won't match.
import crypto from "node:crypto";
const TOLERANCE_SECONDS = 300;
export function verifyCallslotSignature(rawBody, header, secret) {
const parts = Object.fromEntries(
header.split(",").map((part) => part.split("=")),
);
const timestamp = Number(parts.t);
if (!Number.isFinite(timestamp)) return false;
// Reject a replayed request that is older than the tolerance window.
if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) return false;
// v1 must be a 64-char hex sha256 digest, or timingSafeEqual throws on a length mismatch.
if (!/^[0-9a-f]{64}$/i.test(parts.v1 ?? "")) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
return crypto.timingSafeEqual(
Buffer.from(expected, "hex"),
Buffer.from(parts.v1, "hex"),
);
}The secret is shown only once. Rotating it in the dashboard invalidates the old one immediately.
Responses and retries
- Answer 2xx as fast as you can — do the work after you reply. Any other status is a failure.
- Timeout is 10 seconds per attempt.
- Redirects are not followed. The request fails instead, and the attempt is spent. Give us the final URL.
- 408, 429, 401, 403 and any 5xx are retried. 401 and 403 on purpose: they are usually a secret that rotated mid-flight. Any other 4xx is treated as permanent and isn't retried.
- Up to 5 attempts, with four waits in between: about 1 minute, then 2, 4 and 8. After the fifth attempt the delivery is marked failed. The worker runs every few minutes, so the first attempt can arrive minutes after the booking.
- Retries reuse the same delivery id, so a receiver that keys on x-callslot-delivery can safely ignore a duplicate.
- An endpoint can be switched off and back on. A delivery queued for an endpoint that is off fails at once and is not retried.
- A failed or voided delivery can be resent by hand from Settings → Webhooks → the endpoint's delivery log. The resend gets a new delivery id, and the body is rebuilt from a fresh read, so it can differ from the original.
Requirements and limits
- The URL must be https://, must carry no credentials (https://user:pass@… is refused), and must resolve to a public address. Loopback, private, link-local and reserved ranges are refused, as are single-label hosts, .local and .localhost names, and cloud metadata hosts — both as literal IPs and by resolving the hostname when the endpoint is saved.
- Up to 10 endpoints per space.
- Deleting an endpoint drops its pending deliveries and its delivery log with it.
- Deliveries come from the CallSlot application servers. There's no fixed egress IP list, so authenticate with the signature rather than by IP allowlist.
- Webhooks are a space-wide integration and only the space owner can configure them. They're available on every plan.
Versioning
version stays "1" while changes are additive, so write your receiver to ignore keys it does not know. It bumps only when an existing key changes meaning, changes type, or is removed.
Testing it
- Create the endpoint at Settings → Webhooks with the events you want.
- Copy the whsec_... secret shown once.
- Press Send test delivery. This queues a signed ping, which goes out on the next worker run, so give it a few minutes.
A ping body carries "event": "ping" and "test": true at the top level, so a receiver can ignore it.
The endpoint's delivery log shows the last 20 deliveries, each as Delivered, Pending, Failed or Voided, with the response code and the attempt count.