Skip to content

feat(#562): a booking finds the click that started it - #1165

Merged
TortoiseWolfe merged 1 commit into
mainfrom
feat/562-calcom-webhook
Sep 10, 2026
Merged

TortoiseWolfe merged 1 commit into
mainfrom
feat/562-calcom-webhook

Conversation

@TortoiseWolfe

Copy link
Copy Markdown
Owner

The webhook half of Phase 3b. An earlier pass in this same session concluded it could not be
built
on Cal.com. That was wrong, and the correction is measured rather than argued: utm_content
really does not survive, but a hidden custom booking field does — and that is a carrier we
control.

The loop, driven end to end on production before this was committed

click "Book a call"   →  create-lead returns an id
/schedule?lead=ID     →  embed prefills the hidden `lead_ref` booking field
visitor books         →  Cal.com POSTs BOOKING_CREATED
calcom-webhook        →  the lead advances to `scheduled`

The row afterwards:

status        scheduled
name          End To End
email         admin@scripthammer.com
scheduled_at  2026-09-14 13:00:00+00
booking_ref   cp8JzQ2am3bJPVgnUcN8S5

webhook_events:  provider=calcom  signature_verified=true  processed=true

The signature is measured, not documented

A live delivery carries x-cal-signature-256 with 64 hex characters and no prefix.

That matters more than usual: agents/skills/calcom-api/references/webhooks.md inside Cal.com's
own repository
compares against `sha256=${expected}` — which would reject 100% of real
deliveries — and it is the document an agent researching this problem is most likely to find. A test
pins the bare form specifically so nobody "fixes" this to match it.

Five rejection paths, all driven live

sent answer
no signature header 400
the literal no-secret-provided 400
a prefixed sha256= signature 400
valid signature over a tampered body 400
correctly signed replay 200 {handled:false, "already processed"}

The second is the sharpest trap in this integration. A webhook configured without a secret still
sends the header, valued no-secret-provided — so a receiver written if (!sig) return 401 sees a
present, non-empty string and accepts unsigned traffic. The literal is rejected by name.

A booking with no lead_ref is not an error. Somebody can always book from a Cal.com link that
never touched this site, and answering that with a 4xx would make the operator's webhook log red for
people booking calls correctly. It is recorded, acknowledged 200, and never retried.

The CTA now awaits, and that is the feature

The id only exists in create-lead's response, so fire-and-forget cannot carry it. The wait is
capped at 1200ms and every failure — slow, offline, rate-limited, unconfigured — falls through to
the plain navigation. Worst case is an unattributed booking, which is what happened for every booking
before this existed. It stays a real <a href> underneath, so no-JS and middle-click still reach the
calendar.

Schema

T036 widens webhook_events.provider to accept both schedulers, not just the one this deployment
runs — calendar.config.ts supports Calendly by design and a fork may be on it. DROP+ADD, and
applied to production: an inline edit to the CREATE TABLE is a silent no-op on a provisioned
database. The other three provider CHECKs are deliberately left alone; they already disagree with
each other and harmonising them is a separate decision.

Live outside the repo

The hidden lead_ref field on both bookable event types, and the BOOKING_CREATED webhook pointing at
calcom-webhook (v1, ACTIVE, verify_jwt: false). CALCOM_WEBHOOK_SECRET is documented in
.env.example including why leaving it unset fails closed. All test data was removed afterwards —
booking cancelled, lead and ledger rows deleted.

5,831 unit tests, 961 script tests, deno check clean across 18 functions.

Closes #562.

🤖 Generated with Claude Code

The webhook half of Phase 3b. An earlier pass concluded this could not be built
on Cal.com. That was wrong, and the correction is measured: `utm_content` really
does not survive, but a HIDDEN custom booking field does, and it is a carrier we
control.

THE LOOP, DRIVEN END TO END ON PRODUCTION BEFORE THIS WAS COMMITTED:

  click Book a call  -> create-lead returns an id
  /schedule?lead=ID  -> embed prefills the hidden `lead_ref` booking field
  visitor books      -> Cal.com POSTs BOOKING_CREATED
  calcom-webhook     -> lead advances to `scheduled`

  leads row after:  status=scheduled  name="End To End"
                    email=admin@scripthammer.com
                    scheduled_at=2026-09-14 13:00:00+00
                    booking_ref=cp8JzQ2am3bJPVgnUcN8S5
  ledger row after: provider=calcom  signature_verified=true  processed=true

THE SIGNATURE IS MEASURED, NOT DOCUMENTED. A live delivery carries
`x-cal-signature-256` with 64 hex characters and NO prefix. This matters because
`agents/skills/calcom-api/references/webhooks.md` inside Cal.com's OWN repository
compares against `sha256=${expected}` and would reject every real delivery — and
it is the document an agent researching this is most likely to find. A test
pins the bare form so nobody "fixes" it to match.

Five rejection paths, all driven live:

  no signature header                    -> 400
  the literal "no-secret-provided"       -> 400
  a prefixed sha256= signature           -> 400
  valid signature over a tampered body   -> 400
  correctly signed replay                -> 200 {handled:false,"already processed"}

The second is the sharpest trap in this integration and the reason it is called
out by name. A webhook configured WITHOUT a secret still sends the header, valued
`no-secret-provided`, so `if (!sig) return 401` accepts unsigned traffic. The
literal is rejected explicitly.

A booking with no `lead_ref` is NOT an error. Somebody can always book from a
Cal.com link that never touched this site, and answering that with a 4xx would
make the operator's webhook log red for people booking calls correctly. It is
recorded, acknowledged 200, and never retried.

THE CTA NOW AWAITS, AND THAT IS THE FEATURE. The id only exists in create-lead's
response, so fire-and-forget cannot carry it. The wait is capped at 1200ms and
every failure — slow, offline, rate-limited, unconfigured — falls through to the
plain navigation. Worst case is an unattributed booking, which is what happened
for every booking before this existed. It stays a real anchor underneath, so
no-JS and middle-click still reach the calendar.

T036 widens `webhook_events.provider` to accept BOTH schedulers, not just the one
this deployment runs — calendar.config.ts supports Calendly by design and a fork
may be on it. DROP+ADD, and applied to production: an inline edit to the CREATE
TABLE is a silent no-op on a provisioned database.

Also live now, outside the repo: the hidden `lead_ref` field on both bookable
event types, and the BOOKING_CREATED webhook. CALCOM_WEBHOOK_SECRET is documented
in .env.example, including why leaving it unset fails closed.

Closes #562.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@TortoiseWolfe
TortoiseWolfe merged commit 5d54ed7 into main Sep 10, 2026
26 checks passed
@TortoiseWolfe
TortoiseWolfe deleted the feat/562-calcom-webhook branch September 10, 2026 20:40
TortoiseWolfe added a commit that referenced this pull request Sep 10, 2026
#1165 shipped booking attribution and it worked — when the function was warm.
Driving the real browser path on production, minutes apart:

  click 1 (cold)  ->  lead recorded, landed on /schedule/            no ?lead=
  click 2 (warm)  ->  lead recorded, landed on /schedule/?lead=93a0305b-...
                      embed: app.cal.com/turtle-wolfe/15min/embed?...&lead_ref=93a0305b-...

Both clicks wrote a lead. Only the second could ever be matched to a booking. On
a low-traffic site the cold one is the common case, so the feature would have
looked built and mostly not worked — and failed silently, because the row looks
perfect and simply never leaves `link_opened`.

The API run that shipped #1165 could not have caught this: it warmed the function
itself on the first call.

THE WAIT COMES OUT RATHER THAN GETTING LONGER. Raising the cap punishes the
visitor; pinging the function on a schedule buys warmth with a recurring cost and
still loses the genuinely-first click. `leads.id` is a UUID primary key and an
INSERT may supply it, so BookingCta now mints one with crypto.randomUUID(), puts
it on the URL immediately, and records in the background with keepalive. No
await, no cap, no cold-start sensitivity, and no schema change.

Generated at CLICK time, not render time: this page is statically exported, so an
id minted during render would be baked into the HTML and every visitor would
share one.

What it concedes, stated plainly: the id is caller-supplied. A collision is a
unique violation, now answered 409 rather than 500. A CHOSEN id lets somebody
mark a lead they invented as `scheduled` — a row they created about themselves.
Neither is worth a mechanism. Shape is not conceded: anything that is not a v4
UUID is refused before Postgres sees it.

Driven live after redeploying:

  caller-supplied id       -> 201 with that id
  the same id again        -> 409 "That lead id already exists"
  "'; drop table leads; --" -> 400 "id is not a uuid"

The FR-024a case needed care rather than a number bump: it smuggled an `id` that
happens to be a valid v4 UUID, so that key is now legitimately accepted. It keeps
the exact key list, which is what catches a NEW field leaking in, and names
utm_*/name/email/status explicitly so the rule survives the list changing again.

Closes #1166.

Co-authored-by: TurtleWolfe <TurtleWolfe@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature 050 Phase 3b — booking webhook and lead capture (the join works via a hidden booking field, measured)

2 participants