How to Receive Webhooks from Cal.com (Self-Hosted, Step by Step)
Receive and verify Cal.com booking webhooks on your own server: X-Cal-Signature-256 HMAC-SHA256 validation from source-confirmed facts, Docker setup, and RabbitMQ routing, every command verified locally.
By the end of this guide you will have a self-hosted endpoint that receives Cal.com webhooks, verifies the HMAC-SHA256 signature on every delivery, and routes the booking events to a log, RabbitMQ, or any other destination. Every command below was run and verified against the real Core Webhook Module Docker image, and the signature scheme comes straight from Cal.com's source code, so nothing here is guessed. It works for Cal.com's hosted service and self-hosted instances alike.
How Cal.com webhooks work
When a booking event happens (a booking is created, rescheduled, cancelled, a meeting ends), Cal.com POSTs a JSON payload to your subscriber URL:
{
"triggerEvent": "BOOKING_CREATED",
"createdAt": "2026-08-29T20:30:00.000Z",
"payload": {
"type": "30min",
"title": "30 Min Meeting between John Smith and Anna Berg",
"startTime": "2026-09-01T09:00:00Z",
"endTime": "2026-09-01T09:30:00Z",
"organizer": {"name": "John Smith", "email": "[email protected]", "timeZone": "Europe/Riga"},
"attendees": [{"name": "Anna Berg", "email": "[email protected]", "timeZone": "Europe/Berlin"}],
"uid": "nGHkGANhYbnLmtxcMlFsZc",
"status": "ACCEPTED"
}
}
Event triggers include BOOKING_CREATED, BOOKING_RESCHEDULED, BOOKING_CANCELLED, BOOKING_REJECTED, BOOKING_PAID, MEETING_STARTED, MEETING_ENDED, RECORDING_READY, and FORM_SUBMITTED. Two payload quirks from the official docs: MEETING_STARTED and MEETING_ENDED use a flat structure without the payload wrapper, and for seated event types the attendees array contains only the attendee whose seat triggered the event. The X-Cal-Webhook-Version header carries the payload version.
Authentication: you set a secret on the webhook, and Cal.com signs every delivery. In Cal.com's own source the signature is exactly createHmac("sha256", secret).update(body).digest("hex"), sent in the X-Cal-Signature-256 header as a plain lowercase hex digest, no prefix. Your receiver recomputes the digest over the exact bytes it received and compares in constant time. If no secret is configured, the header is sent with the literal value no-secret-provided, so always set one.
URL policy worth knowing: on Cal.com's hosted service, subscriber URLs must be HTTPS, and private IPs and localhost are blocked; self-hosted instances accept HTTP and private addresses (cloud metadata endpoints are always blocked).
Prerequisites
- Docker on any machine (your laptop is fine for the test run)
- A public HTTPS URL: mandatory with Cal.com's hosted service, sensible everywhere. Two ways to get one:
- HTTPS for webhooks with nginx and Let's Encrypt if you have a public server
- Receiving webhooks through Cloudflare Tunnel if you do not want to open inbound ports
Step 1: Configure the receiver
Create a working directory with two files.
webhooks.json:
{
"calcom_events": {
"data_type": "json",
"module": "log",
"hmac": {
"secret": "{$CALCOM_WEBHOOK_SECRET}",
"header": "X-Cal-Signature-256",
"algorithm": "sha256"
}
}
}
connections.json:
{}
What each parameter does:
calcom_eventsis the webhook ID; it becomes the URL path (/webhook/calcom_events).module: logprints each verified payload to the container log. Zero dependencies for the first run; we swap in RabbitMQ at the end.hmacrecomputes the HMAC-SHA256 of the raw body and compares it against theX-Cal-Signature-256header in constant time. Cal.com sends plain hex, which is accepted as-is; header lookup is case-insensitive.{$CALCOM_WEBHOOK_SECRET}pulls the secret from an environment variable, so it never lives in a config file.
Generate a strong secret (or use our webhook secret generator):
openssl rand -hex 32
Step 2: Run it
docker run -d --name webhook-gateway -p 8000:8000 \
-v "$PWD/webhooks.json:/app/webhooks.json:ro" \
-v "$PWD/connections.json:/app/connections.json:ro" \
-e CALCOM_WEBHOOK_SECRET="your-generated-secret" \
spiderhash/webhook:latest
Confirm it is up:
docker logs webhook-gateway
# ... INFO: Application startup complete.
The receiver now answers on http://localhost:8000/webhook/calcom_events, with auto-generated API docs at http://localhost:8000/docs.
Step 3: Test it locally
You do not need Cal.com to test; you need a request signed the way Cal.com signs. Save the BOOKING_CREATED payload from the top of this article as booking_created.json, then compute the signature over the exact bytes of the file and send it as plain hex:
SIG=$(openssl dgst -sha256 -hmac "your-generated-secret" -hex booking_created.json | awk '{print $NF}')
curl -s -w "\nHTTP %{http_code}\n" -X POST http://localhost:8000/webhook/calcom_events \
-H "Content-Type: application/json" \
-H "X-Cal-Webhook-Version: 2021-10-20" \
-H "X-Cal-Signature-256: $SIG" \
--data-binary @booking_created.json
Expected result:
{"message":"200 OK"}
HTTP 200
Now prove the verification works. A tampered or wrong signature:
{"detail":"Invalid HMAC signature"}
HTTP 401
And a request with no signature header at all:
{"detail":"Missing X-Cal-Signature-256 header"}
HTTP 401
Those are the exact responses the gateway returns. docker logs webhook-gateway shows the accepted payload.
Step 4: Create the webhook in Cal.com
In Cal.com, open Settings > Developer > Webhooks (/settings/developer/webhooks) and create a new webhook:
- Subscriber URL:
https://your-domain/webhook/calcom_events - Event Triggers: pick the events you want;
BOOKING_CREATED,BOOKING_RESCHEDULED, andBOOKING_CANCELLEDare the usual core set - Secret: the same value you passed as
CALCOM_WEBHOOK_SECRET - Leave the custom payload template empty to receive the standard JSON shown above (templates with
{{type}},{{title}},{{organizer.name}}variables can reshape it later)
Use the webhook's ping/test option to fire a delivery, then make a real test booking on one of your event types and cancel it; both deliveries should appear in docker logs webhook-gateway.
Troubleshooting
401 {"detail":"Invalid HMAC signature"}: the Secret field in Cal.com andCALCOM_WEBHOOK_SECRETdiffer, or a custom payload template changed the body after your expectations. The signature covers the exact bytes sent.- The signature header says
no-secret-provided: the webhook was created without a Secret. Cal.com sends that literal string instead of a signature; set the secret and save. - Hosted Cal.com refuses your URL: the SaaS only accepts public HTTPS subscriber URLs; private IPs and localhost are blocked. Use the nginx or Cloudflare setup from the prerequisites.
MEETING_ENDEDparses differently: it (andMEETING_STARTED) arrives flat, without thepayloadwrapper, and fires at the scheduled time rather than at user action; branch ontriggerEventbefore assuming a shape.
Going to production
Printing to logs is not a pipeline. Swap the module for a real destination; here is the RabbitMQ variant, verified end to end (the signed test delivery above landed as a message in the calcom_events queue):
webhooks.json:
{
"calcom_events": {
"data_type": "json",
"module": "rabbitmq",
"connection": "rabbitmq_local",
"module-config": {
"queue_name": "calcom_events"
},
"hmac": {
"secret": "{$CALCOM_WEBHOOK_SECRET}",
"header": "X-Cal-Signature-256",
"algorithm": "sha256"
}
}
}
connections.json:
{
"rabbitmq_local": {
"type": "rabbitmq",
"host": "{$RABBITMQ_HOST:localhost}",
"port": "{$RABBITMQ_PORT:5672}",
"user": "{$RABBITMQ_USER:guest}",
"pass": "{$RABBITMQ_PASS:guest}"
}
}
The same pattern works for 17 other destinations: archive booking history to S3, publish to Kafka, or fan out to several at once with webhook chaining. Built-in rate limiting and retry handling cover the operational edges. The full authentication reference, including HMAC validation and 11 other methods, is in the docs.