Creating a subscription
There is no screen for this, so the first delivery costs an API call. Create a URL above, then:
curl -X POST https://api.calendly.com/webhook_subscriptions \
-H "Authorization: Bearer $CALENDLY_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"url": "https://trywebhook.com/w/your-bin-id",
"events": ["invitee.created", "invitee.canceled"],
"organization": "https://api.calendly.com/organizations/YOUR_ORG",
"user": "https://api.calendly.com/users/YOUR_USER",
"scope": "user",
"signing_key": "a-long-random-string-you-generate"
}'
Get the organization and user URIs from GET /users/me. Scope user covers your own bookings; organization covers everyone’s and needs an admin token.
Keep the signing key. Calendly will not show it to you again, and the only way to change it is to delete the subscription and make another one.
Then book yourself a meeting. A single-use link on a throwaway event type is the cheapest way, and the delivery lands here within a second.
Reading the payload
{
"created_at": "2026-06-15T10:04:00.000000Z",
"event": "invitee.created",
"payload": {
"uri": "https://api.calendly.com/scheduled_events/FAKE/invitees/FAKE",
"email": "buyer@example.com",
"name": "Ada Lovelace",
"status": "active",
"timezone": "America/New_York",
"rescheduled": false,
"questions_and_answers": [{ "question": "What do you want to discuss?", "answer": "Webhooks" }],
"scheduled_event": {
"start_time": "2026-06-20T15:00:00.000000Z",
"location": { "type": "google_conference", "join_url": "https://meet.google.com/fake-fake-fake" }
}
}
}
Switch on the top-level event. Everything else is under payload, and the two halves of the booking are separated: the invitee’s own details sit at the top, and the meeting they booked is nested under scheduled_event.
Times are UTC with microsecond precision. payload.timezone is the invitee’s timezone, and it is the one to render in — sending a confirmation that says 15:00 UTC to someone in New York is technically correct and practically useless.
location needs a branch on type. google_conference and zoom carry a join_url; physical carries an address; outbound_call carries a phone number. There is no single field that always holds “where the meeting is”.
tracking is where UTM parameters and your own utm_content end up — the join back to whatever campaign produced the booking.
Verifying Calendly-Webhook-Signature
Calendly-Webhook-Signature: t=1750000000,v1=3f9d2a7c...
Split on ,, then on =. Build the signed string as the timestamp, a literal dot, and the raw body:
const { t, v1 } = parseSignatureHeader(header);
const signed = `${t}.${rawBody}`;
const digest = hmacSha256Hex(signingKey, signed);
const ok = timingSafeEqual(digest, v1);
Then reject anything older than a few minutes, because the signature itself never expires:
if (Math.abs(Date.now() / 1000 - Number(t)) > 60 * 3) return reject();
If that looks familiar, it is: this is Stripe’s scheme with a different header name. Which is convenient — any Stripe verification code works after one change — and also a trap, because a copied verifier that still reads stripe-signature will find nothing there. Make sure the missing-header case rejects rather than skipping the check.
Reschedules, and the cancellation that is not one
The most common Calendly integration bug is treating invitee.canceled as a lost booking. Rescheduling emits both events:
invitee.canceled payload.uri = .../invitees/OLD (the original slot)
invitee.created payload.rescheduled = true, payload.old_invitee = .../invitees/OLD
So the sequence for a moved meeting is indistinguishable from a cancellation followed by an unrelated new booking — unless you read rescheduled and old_invitee.
if (event === 'invitee.created' && payload.rescheduled) {
await moveBooking(payload.old_invitee, payload.uri);
} else if (event === 'invitee.created') {
await createBooking(payload.uri);
}
Handle the cancellation idempotently on the other side, since the two events can arrive in either order.