# 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_PAYMENT_INITIATED`, `BOOKING_PAID` |
| Seats | `BOOKING_SEAT_ACCEPTED`, `BOOKING_SEAT_REJECTED` |
| Meetings | `MEETING_STARTED`, `MEETING_ENDED`, `AFTER_HOSTS_CAL_VIDEO_NO_SHOW`, `AFTER_GUESTS_CAL_VIDEO_NO_SHOW` |
| Recordings | `RECORDING_READY`, `RECORDING_TRANSCRIPTION_GENERATED` |
| Instant meetings | `INSTANT_MEETING`, `INSTANT_MEETING_ACCEPTED` |
| Routing forms | `FORM_SUBMITTED`, `FORM_SUBMITTED_NO_EVENT`, `WRONG_ASSIGNMENT_REPORT`, `ROUTING_FORM_FALLBACK_HIT` |
| Other | `OOO_CREATED`, `CALENDAR_ENTRY_REJECTED` |
| Delegation credentials | `DELEGATION_CREDENTIAL_ERROR`, `DELEGATION_CREDENTIAL_SECRET_ROTATED`, `DELEGATION_CREDENTIAL_SECRET_ROTATION_FAILED`, `DELEGATION_CREDENTIAL_ROTATION_REQUIRED` |

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_FORM_FALLBACK_HIT` 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_STARTED` and `MEETING_ENDED` are scheduled when the booking is confirmed and fire at its `startTime` and `endTime`. Cancelling or rescheduling the booking cancels or reschedules them.
* `FORM_SUBMITTED_NO_EVENT` fires 15 minutes after a routing form submission if no booking was made.
* The `AFTER_*_CAL_VIDEO_NO_SHOW` triggers need a delay (`time` and `timeUnit`) configured on the webhook.
* `BOOKING_SEAT_ACCEPTED` does not fire for the first accept on a seated booking. That one produces `BOOKING_CREATED`.

Most bodies share one envelope. Here is a shortened `BOOKING_CREATED` from Cal.com's docs:

```json
{
  "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_STARTED` and `MEETING_ENDED` are flat. The booking fields sit at the top level beside `triggerEvent`, with no `payload` wrapper.
* `FORM_SUBMITTED` and `FORM_SUBMITTED_NO_EVENT` repeat 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:

1. Enter the **Subscriber URL**. On Cal.com SaaS it must be HTTPS and must not resolve to a private address or `localhost`.
2. Choose the **Event triggers** you want.
3. Enter a **Secret**. Cal.com does not generate one, so use a long random string and keep a copy.
4. 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.

```javascript
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:

```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](/event-gateway) verifies the `x-cal-signature-256` header, 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 `3xx` is 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 booking `uid` and `createdAt`, 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 ?? body` and branch on `triggerEvent`.
* 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 `Authorization` as 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](/webhooks/guides/why-implement-asynchronous-processing-webhooks) and our [guide to webhook idempotency](/webhooks/guides/implement-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](https://hookdeck.com) 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](https://dashboard.hookdeck.com/signup) for free and handle Cal.com webhooks reliably in minutes.