- Aug 31, 2026
- 6 min read
Cal.com Bookings Into My Own Pipeline: a Signed Webhook and a PAID BOOKING Alert
My consulting site sells one thing up front: a paid 30-minute decision call, booked and paid through Cal.com. Bookings lived only in Cal.com’s dashboard, while every other lead — enquiries, qualifiers, newsletter sign-ups — lands in my own pipeline: a Cloudflare Worker, Workers KV, an ntfy alert on my phone, and a homelab copy.
So I added a Cal.com webhook to the same Worker. Three requirements:
- Only Cal.com can write bookings — verify the signature.
- Store scheduling facts, not what people type into the notes field.
- The first paid booking must be impossible to miss.
The qualifier that comes before the booking is covered in replacing a Formbricks qualifier with my own form.
Verify the raw body, before parsing
Cal.com signs each webhook with a secret you choose. The header
X-Cal-Signature-256 is the hex HMAC-SHA256 of the raw request body.
That detail matters: if you parse the JSON first and re-serialise it, key
order and whitespace change and the signature no longer matches. So the
handler reads the body as text, verifies, and only then parses:
export async function validCalSignature(secret: string, rawBody: string, header: string | null) {
if (!secret || !header) return false;
const key = await crypto.subtle.importKey("raw", encoder.encode(secret),
{ name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
const mac = new Uint8Array(await crypto.subtle.sign("HMAC", key, encoder.encode(rawBody)));
const expected = [...mac].map((b) => b.toString(16).padStart(2, "0")).join("");
const given = header.trim().toLowerCase();
if (given.length !== expected.length) return false;
let diff = 0;
for (let i = 0; i < expected.length; i++) diff |= expected.charCodeAt(i) ^ given.charCodeAt(i);
return diff === 0;
}
const raw = await request.text();
if (!(await validCalSignature(env.CALCOM_WEBHOOK_SECRET ?? "", raw,
request.headers.get("X-Cal-Signature-256")))) {
return json({ error: "bad_signature" }, 401);
}
const hook = JSON.parse(raw) as CalWebhook;
A missing or wrong signature gets 401. The comparison is constant-time, the
same habit as the key check in my
Mailjet event webhook.
The secret never passed through a chat or a terminal scrollback: generated locally into a file only I can read, piped into the Worker, and pasted only into Cal.com’s webhook form.
(umask 077; openssl rand -hex 32 | tr -d '\n' > ~/.config/leads-hub/calcom_webhook_secret)
npx wrangler secret put CALCOM_WEBHOOK_SECRET < ~/.config/leads-hub/calcom_webhook_secret
Which events, and what gets stored
Cal.com can send many triggers. Five of them describe a booking’s life and
are recorded; everything else — the PING test, BOOKING_REASSIGNED,
meeting events — is acknowledged with 200 and ignored, so Cal.com doesn’t
retry them.
| Trigger | Stored as a booking lead | Alert |
|---|---|---|
BOOKING_CREATED | yes | ”Booking created” (or PAID if already paid) |
BOOKING_REQUESTED | yes | ”Booking requested” |
BOOKING_PAID | yes | PAID BOOKING (urgent) |
BOOKING_RESCHEDULED | yes | ”Booking rescheduled” |
BOOKING_CANCELLED | yes | ”Booking cancelled” |
PING, reassigned, others | no | none |
The lead keeps only scheduling facts: attendee name and email, the event title, the start time, the booking id, and whether it’s paid (plus the price and currency Cal.com reports). The booking form’s free-text notes are not copied — a test feeds a payload with notes and asserts they never reach storage. Cal.com still has them; my pipeline doesn’t need them, so it doesn’t hold them.
An alert I can’t miss
Every booking pings my phone through my self-hosted ntfy. A paid one is different on purpose:
const headers = paid
? { Title: `PAID BOOKING - ${lead.site}`, Tags: "moneybag", Priority: "urgent" }
: { Title: `Booking ${verb} - ${lead.site}`, Tags: "calendar", Priority: "high" };
The body carries the call title, the email domain and the start time — never the name or the full address. Header values stay ASCII; an em dash in a header once got me a Workers warning, and Cloudflare already taught me to send a proper User-Agent when my own alerts were blocked with error 1010.
Testing it live
Unit tests cover the 401 paths, a normal booking, the PING, and the paid
case (title, urgent priority, no name or address in the body).
Live, Cal.com’s webhook form has a Ping test button. It answered 200 —
and in this Worker a 200 is only possible with a correct signature, because
anything unsigned or mis-signed stops at 401. That one click verified the
URL and the shared secret together. I had a log tail running for it, but it
connected a moment too late and caught nothing; the status code was enough.
The business side: price as a signal
The call costs money on purpose. A visible price filters out people who want free advice and anchors the conversation as professional work — so I kept it, even though selling online to consumers comes with duties.
What I put where:
- In the Cal.com paid event description, which the buyer reads before paying: business name, tax identification numbers, a contact address, and links to the terms and privacy pages.
- A required checkbox in the booking form: the buyer asks for the call to happen within the 14-day withdrawal period and acknowledges losing the right to withdraw once it has been held. This is the piece consumer law cares about most — without it, the withdrawal window can stretch much longer.
- No home address on the site. The public business register already lists it for anyone who needs it; I don’t hand it to every scraper.
One setting to watch: the event requires confirmation, but the button says “Pay to book”. Paying happens first, approval second — so anyone I decline has to be refunded. Charging only after confirmation would be cleaner where the payment app allows it.
This is how I set up my own booking flow, not legal advice. If you sell to consumers in the EU, check the rules that apply to you. More on the privacy side in GDPR-friendly lead capture for a small EU business.
Takeaways
- Verify webhook signatures over the raw body, before parsing; reject
with
401. - Acknowledge triggers you don’t use with
200, or the provider retries them. - Store what you need to act — times and contacts — not free-text notes.
- Make the first sale loud: a separate, urgent alert is a cue to act, not just a notification.
- Put pre-contract information where the buyer pays, not only on the homepage.