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-256 header)?
  • How do I handle BOOKING_CREATED, BOOKING_RESCHEDULED, or BOOKING_CANCELLED events?
  • Why does my MEETING_ENDED handler not find payload?
  • 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).

EventTriggered When
BOOKING_CREATEDA booking is confirmed
BOOKING_REQUESTEDA booking needing confirmation is requested
BOOKING_RESCHEDULEDA booking is moved (rescheduleUid = old booking uid)
BOOKING_CANCELLEDA booking is cancelled (cancellationReason)
BOOKING_REJECTEDThe host rejects a requested booking
BOOKING_PAIDPayment for a paid event succeeds
MEETING_STARTED / MEETING_ENDEDFire at the booking's start/end time (flat payload)
RECORDING_READYA Cal Video recording is available
FORM_SUBMITTEDA 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

HeaderDescription
X-Cal-Signature-256Hex HMAC-SHA256 of the raw body (or no-secret-provided)
X-Cal-Webhook-VersionPayload 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


Repository

hookdeck/webhook-skills

v0.1.0 · MIT · Updated Oct 28, 2026

View on GitHub →