Agent skill
Cal.com Webhooks Skill
Receive and verify Cal.com webhooks. Use when setting up Cal.com webhook handlers, debugging x-cal-signature-256 signature verification, or handling scheduling events like BOOKING_CREATED, BOOKING_RESCHEDULED, BOOKING_CANCELLED, BOOKING_PAID, and MEETING_ENDED.
Install this skill
npx skills add hookdeck/webhook-skills --skill cal-com-webhooks
When to Use This Skill
- How do I receive Cal.com webhooks?
- How do I verify Cal.com webhook signatures (the
x-cal-signature-256header)? - How do I handle
BOOKING_CREATED,BOOKING_RESCHEDULED, orBOOKING_CANCELLEDevents? - Why does my
MEETING_ENDEDhandler not findpayload? - Why is my Cal.com webhook signature verification failing?
Verification (core)
When a secret is set on the webhook, Cal.com sends x-cal-signature-256 containing the lowercase hex HMAC-SHA256 of the raw request body, keyed on that secret. There is no sha256= prefix, no timestamp, and it is not Standard Webhooks. With a custom payload template the body changes, but the signature is still over the exact bytes sent. Webhooks with no secret send the literal value no-secret-provided — treat that as unsigned.
Node:
const crypto = require('crypto');
function verifyCalComWebhook(rawBody, signatureHeader, secret) {
if (!signatureHeader || !secret) return false;
const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
try {
return crypto.timingSafeEqual(Buffer.from(signatureHeader), Buffer.from(expected));
} catch {
return false; // length mismatch (e.g. "no-secret-provided") = invalid
}
}
Python:
import hmac, hashlib
def verify_cal_com_webhook(raw_body: bytes, signature_header: str | None, secret: str | None) -> bool:
if not signature_header or not secret:
return False
expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(signature_header.encode(), expected.encode())
For complete handlers with route wiring, event dispatch, and tests, see:
Common Event Types
The event name is in the body's triggerEvent field (there is no event-type header).
| Event | Triggered When |
|---|---|
BOOKING_CREATED | A booking is confirmed |
BOOKING_REQUESTED | A booking needing confirmation is requested |
BOOKING_RESCHEDULED | A booking is moved (rescheduleUid = old booking uid) |
BOOKING_CANCELLED | A booking is cancelled (cancellationReason) |
BOOKING_REJECTED | The host rejects a requested booking |
BOOKING_PAID | Payment for a paid event succeeds |
MEETING_STARTED / MEETING_ENDED | Fire at the booking's start/end time (flat payload) |
RECORDING_READY | A Cal Video recording is available |
FORM_SUBMITTED | A Routing Form is submitted |
For the full list, see references/overview.md and Cal.com's webhook docs.
Payload Shape
Most events wrap data in payload; MEETING_STARTED and MEETING_ENDED put the booking fields at the top level. Read const data = event.payload ?? event.
{ "triggerEvent": "BOOKING_CREATED", "createdAt": "2024-01-01T00:00:00.000Z", "payload": { "uid": "...", "title": "...", "startTime": "...", "organizer": {}, "attendees": [] } }
Important Headers
| Header | Description |
|---|---|
X-Cal-Signature-256 | Hex HMAC-SHA256 of the raw body (or no-secret-provided) |
X-Cal-Webhook-Version | Payload version, e.g. 2021-10-20 |
Environment Variables
CALCOM_WEBHOOK_SECRET=your_webhook_secret # The "Secret" you set on the webhook in Cal.com
Local Development
# Start tunnel (no account needed)
npx hookdeck-cli listen 3000 cal-com --path /webhooks/cal-com
Cal.com SaaS only accepts public HTTPS subscriber URLs (no localhost), so use the tunnel URL.
Reference Materials
- references/overview.md - Cal.com webhook triggers and payload structure
- references/setup.md - Create a webhook and secret in Cal.com settings
- references/verification.md - Signature verification details and gotchas