Guide to Cal.com Webhooks: Features and Best Practices
Cal.com webhooks tell your systems when something happens to a booking: it is created, requested, rescheduled, cancelled, paid for, or reassigned, a meeting starts or ends, an attendee is marked as a no-show, or a routing form is submitted. If you push bookings into a CRM, start onboarding when a call is booked, or react to no-shows, webhooks are how Cal.com tells you.
This guide covers how Cal.com webhooks are configured and signed, the triggers and their payloads, the ways a body can differ from the standard envelope, and the practices that keep a Cal.com receiver dependable in production. It applies to Cal.com's hosted service and, with the differences noted, to self-hosted instances.
What are Cal.com webhooks?
A Cal.com webhook is a subscription with a subscriber URL and a list of triggers. When one of those triggers fires, Cal.com sends an HTTP POST to the URL. A webhook can belong to a user, to a single event type, or to a team event type. The delegation credential triggers are subscribed on an organization-scoped webhook.
Signing is optional. If you give the webhook a secret, Cal.com sends an HMAC-SHA256 of the raw body in the x-cal-signature-256 header. If you leave the secret blank, the header is still sent, but its value is the literal string no-secret-provided.
Each webhook also chooses a payload version, which Cal.com sends on every delivery in x-cal-webhook-version.
Cal.com webhook features
| Feature | Details |
|---|---|
| Configuration | /settings/developer/webhooks, an event type's Webhooks tab, or API v2 |
| Scope | User, event type, team event type, or organization (delegation credential triggers) |
| Subscriber URL | HTTPS only on Cal.com SaaS, with private IPs and localhost blocked. Self-hosted allows HTTP and private IPs |
| Signature header | x-cal-signature-256 |
| Scheme | HMAC-SHA256 over the raw body, lowercase hex |
| Secret | Optional, chosen by you. Without one the header reads no-secret-provided |
| Replay window | None. No timestamp is signed |
| Version header | x-cal-webhook-version: 2021-10-20 or 2026-07-27 |
| Custom headers | Up to 10 static headers per webhook. Names starting X-Cal- are reserved |
| Custom payload | Optional template that replaces the body |
| Retries | Documented only for the delegation credential rotation triggers |
| Response window | Not documented |
| Source IPs | Not published |
The docs describe the signature as an "SHA256 hash" and never name the encoding. Cal.com's open-source sender produces it with digest("hex"), and that is what Hookdeck expects too.
Common events
Cal.com documents 30 triggers. The triggerEvent field in the body names the one that fired; no header carries it.
| Area | Triggers |
|---|---|
| Bookings | BOOKING_CREATED, BOOKING_REQUESTED, BOOKING_RESCHEDULED, BOOKING_CANCELLED, BOOKING_REJECTED, BOOKING_REASSIGNED, BOOKING_LOCATION_UPDATED, BOOKING_NO_SHOW_UPDATED |
| Payments | BOOKING_, BOOKING_PAID |
| Seats | BOOKING_SEAT_ACCEPTED, BOOKING_SEAT_REJECTED |
| Meetings | MEETING_STARTED, MEETING_ENDED, AFTER_, AFTER_ |
| Recordings | RECORDING_READY, RECORDING_ |
| Instant meetings | INSTANT_MEETING, INSTANT_MEETING_ACCEPTED |
| Routing forms | FORM_SUBMITTED, FORM_SUBMITTED_NO_EVENT, WRONG_ASSIGNMENT_REPORT, ROUTING_ |
| Other | OOO_CREATED, CALENDAR_ENTRY_REJECTED |
| Delegation credentials | DELEGATION_, DELEGATION_, DELEGATION_, DELEGATION_ |
The docs list these in three places that do not fully agree. The settings page shows 23 labels, while the API v2 enum has all 30. ROUTING_ and CALENDAR_ENTRY_REJECTED appear only in the enum, with no description or example.
A few triggers behave in ways that are easy to miss:
MEETING_STARTEDandMEETING_ENDEDare scheduled when the booking is confirmed and fire at itsstartTimeandendTime. Cancelling or rescheduling the booking cancels or reschedules them.FORM_SUBMITTED_NO_EVENTfires 15 minutes after a routing form submission if no booking was made.- The
AFTER_triggers need a delay (*_ CAL_ VIDEO_ NO_ SHOW timeandtimeUnit) configured on the webhook. BOOKING_SEAT_ACCEPTEDdoes not fire for the first accept on a seated booking. That one producesBOOKING_CREATED.
Most bodies share one envelope. Here is a shortened BOOKING_CREATED from Cal.com's docs:
{
"triggerEvent": "BOOKING_CREATED",
"createdAt": "2024-01-01T00:00:00.000Z",
"payload": {
"title": "Strategy Session between Organizer and Guest",
"startTime": "2024-01-01T10:00:00Z",
"endTime": "2024-01-01T10:15:00Z",
"type": "standard-event-type",
"eventTypeId": 123,
"organizer": {
"id": 1,
"name": "Organizer Name",
"email": "organizer@example.com",
"timeZone": "UTC"
},
"attendees": [
{ "email": "guest@example.com", "name": "Guest User", "timeZone": "UTC" }
],
"location": "integrations:video-provider",
"uid": "unique-booking-id",
"requiresConfirmation": false
}
}
There are three exceptions to that shape:
MEETING_STARTEDandMEETING_ENDEDare flat. The booking fields sit at the top level besidetriggerEvent, with nopayloadwrapper.FORM_SUBMITTEDandFORM_SUBMITTED_NO_EVENTrepeat the form answers at the root "for backward compatibility".- A webhook with a custom payload template sends the rendered template instead of the envelope (see below).
For seated event types, attendees holds only the seat that triggered the webhook. Version 2026-07-27 keeps every 2021-10-20 field and adds optional attendeeIcsContent and organizerIcsContent strings to BOOKING_CREATED, BOOKING_RESCHEDULED, BOOKING_REASSIGNED, BOOKING_CANCELLED, BOOKING_SEAT_ACCEPTED and, in some cases, BOOKING_PAID.
Setting up Cal.com webhooks
In Cal.com, go to /settings/developer/webhooks (or the Webhooks tab of an event type) and create a webhook:
- Enter the Subscriber URL. On Cal.com SaaS it must be HTTPS and must not resolve to a private address or
localhost. - Choose the Event triggers you want.
- Enter a Secret. Cal.com does not generate one, so use a long random string and keep a copy.
- Optionally add Custom headers and a Custom payload template.
You can do the same through API v2, which also lets you pick the payload version. If you want signed deliveries, always set a secret, because a webhook without one cannot be verified.
Securing Cal.com webhooks
Compute an HMAC-SHA256 of the raw request body with your secret, encode it as lowercase hex, and compare it with x-cal-signature-256 in constant time. Only the body is signed: not the version header, not any custom headers.
const crypto = require('crypto');
const express = require('express');
function verifyCalSignature(rawBody, signatureHeader, secret) {
if (!signatureHeader || !secret) return false;
const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
// "no-secret-provided" has a different length, so it fails here.
const received = Buffer.from(signatureHeader.trim().toLowerCase(), 'utf8');
const computed = Buffer.from(expected, 'utf8');
return received.length === computed.length && crypto.timingSafeEqual(received, computed);
}
const app = express();
app.post('/webhooks/cal', express.raw({ type: '*/*' }), (req, res) => {
if (!verifyCalSignature(req.body, req.get('x-cal-signature-256'), process.env.CAL_WEBHOOK_SECRET)) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body.toString('utf8'));
// MEETING_STARTED and MEETING_ENDED are flat; everything else is wrapped in payload.
const data = event.payload ?? event;
// Route on event.triggerEvent. Persist, then process asynchronously.
res.sendStatus(200);
});
express.raw({ type: '*/*' }) matters here. A webhook whose custom template is not valid JSON arrives as application/x-www-form-urlencoded, and a parser limited to application/json would hand you an empty body.
The same check in Python:
import hashlib
import hmac
import os
def verify_cal_signature(raw_body: bytes, signature_header, secret: str) -> bool:
if not signature_header or not secret:
return False
expected = hmac.new(secret.encode("utf-8"), raw_body, hashlib.sha256).hexdigest()
received = signature_header.strip().lower().encode("utf-8")
return hmac.compare_digest(received, expected.encode("utf-8"))
# In FastAPI: raw_body = await request.body()
# verify_cal_signature(raw_body, request.headers.get("x-cal-signature-256"),
# os.environ["CAL_WEBHOOK_SECRET"])
Cal.com publishes no test vector. To check your code, use the secret cal-com-test-secret and this exact body:
{"triggerEvent":"BOOKING_CREATED","createdAt":"2024-01-01T00:00:00.000Z","payload":{"uid":"abc123"}}
The header value should be:
5006787017b28c2b8c92f886061be07917d4864db8796dd936c3ae4274bf36d2
In Hookdeck, set the source's secret to the one you entered in Cal.com. If you didn't set a secret in Cal.com, leave verification off, because a no-secret-provided header never matches.
Make Cal.com webhooks production-ready. Hookdeck Event Gateway verifies the
x-cal-signature-256header, queues every booking, meeting and form event, and retries delivery to your service on a schedule you control.
Cal.com webhook limitations and pain points
Retries are only documented for a few triggers
The Problem: You cannot tell from the docs whether a booking event that hit an outage will ever be sent again.
Why It Happens: The only retry statement in Cal.com's docs covers the three delegation credential rotation triggers, which are "delivered asynchronously with retries on transient network errors and HTTP 408, 429, and 5xx responses", with no count or schedule. Nothing is said about retrying booking, meeting, recording or form deliveries, and no response timeout is documented. Cal.com's sender also does not follow redirects.
Workarounds:
- Assume a failed delivery may not come back. Acknowledge quickly and process asynchronously.
- Reconcile bookings from the API on a schedule, especially after downtime.
- Point the webhook at the final URL, since a
3xxis not chased.
How Hookdeck Can Help: Hookdeck accepts the delivery as soon as Cal.com sends it, stores it, and retries against your service on a schedule you control, with a log of every attempt and the option to replay any event.
Unsigned webhooks still send the header
The Problem: A receiver that checks only for the presence of x-cal-signature-256 accepts unsigned requests.
Why It Happens: The secret is optional, and a webhook without one sends the header with the value no-secret-provided. The docs do not mention this; it is in Cal.com's sender code.
Workarounds:
- Always set a secret on production webhooks.
- Verify the HMAC rather than testing for the header, and reject
no-secret-provided.
How Hookdeck Can Help: With verification enabled on the source, Hookdeck checks the HMAC on every request, so a delivery with no-secret-provided or a wrong digest is recorded as failed verification and never forwarded.
No delivery ID
The Problem: There is nothing in a Cal.com delivery that identifies the delivery itself.
Why It Happens: The envelope is triggerEvent, createdAt and payload, and no ID header is sent. Booking fields such as uid identify the booking, which many triggers share.
Workarounds:
- Build a key from
triggerEvent, the bookinguidandcreatedAt, and make handlers idempotent. - For updates such as reschedules, compare against the state you already hold rather than applying blindly.
How Hookdeck Can Help: Hookdeck's deduplication can drop repeated deliveries before they reach your service, based on the fields you choose, such as triggerEvent and payload.uid.
Payloads that change shape
The Problem: One handler for every trigger breaks on the meeting events, on routing forms, or after someone edits a template.
Why It Happens: MEETING_STARTED and MEETING_ENDED are flat, form submissions duplicate answers at the root, and a custom payload template replaces the envelope entirely. A template only includes triggerEvent if its author wrote {{triggerEvent}}, and a template that is not valid JSON is sent form-encoded.
Workarounds:
- Read
body.payload ?? bodyand branch ontriggerEvent. - Keep custom templates for no-code tools, and use separate webhooks with the default body for code you maintain.
- Verify before parsing, since the signature covers the rendered template output.
How Hookdeck Can Help: Hookdeck's transformations can reshape templated or flat bodies into one structure before they reach your service, and filters can route triggers to different destinations.
No published source IPs
The Problem: You cannot lock your endpoint to Cal.com's addresses.
Why It Happens: Cal.com publishes no list of sending IPs. The URL rules on SaaS restrict where you can point a webhook, not where it comes from.
Workarounds:
- Rely on the signature, and add a custom header such as
Authorizationas a second check if you want one.
How Hookdeck Can Help: Your service only needs to accept traffic from Hookdeck, which verifies Cal.com's signature first.
Best practices
Verify the raw bytes
Read the body before any JSON or form parser touches it: express.raw(), Buffer.from(await request.arrayBuffer()) in a Next.js route handler, or await request.body() in FastAPI. Re-serialising parsed JSON changes the bytes.
Pin the payload version
Set the version explicitly when you create webhooks through the API, and log x-cal-webhook-version so you notice if a webhook is sending a version you did not expect.
Handle both envelope shapes
Treat payload as optional and fall back to the top-level fields for MEETING_STARTED and MEETING_ENDED.
Use the meeting triggers for timing, not truth
MEETING_STARTED and MEETING_ENDED fire at scheduled times, not when people actually join or leave. Use the Cal Video no-show triggers or your video provider's events when attendance matters.
Acknowledge fast and process asynchronously
Verify, persist, return 200, then do the work. See why to process webhooks asynchronously and our guide to webhook idempotency.
Conclusion
The x-cal-signature-256 check is short: a lowercase hex HMAC-SHA256 of the raw body, keyed with a secret you choose. The harder parts are around it. Webhooks without a secret still send the header, bodies take three shapes, there is no delivery ID, and the docs say nothing about retrying most triggers.
Hookdeck Event Gateway sits in front of your receiver, checks every Cal.com signature, and holds each booking and meeting event until your service has processed it, so a deploy or an outage on your side does not lose bookings.
Get started with Hookdeck for free and handle Cal.com webhooks reliably in minutes.