Most writing about webhook idempotency, including ours, argues from the database outward. You get a duplicate row, a second receipt, a doubled counter. Real problems, but abstract ones. The reader nods and moves on.
Scheduling integrations are where the argument stops being abstract. The duplicate is not a row. It is two people on a video call at 10:00 who both think the slot is theirs, and one of them took the morning off for it.
TL;DR
- Scheduling webhooks have loose timeouts and no dramatic disable policy, which makes them feel safe
- The consequence of a retry is a real-world conflict a human has to resolve, not a row you can clean up
- Calendly signs with
Calendly-Webhook-Signature, a timestamp and signature pair, HMAC-SHA256 overtimestamp.body - Dedupe on the booking's own URI or UUID, not on the payload contents, because a reschedule looks a lot like a new booking
- Cancellations and reschedules arriving out of order are the case that actually corrupts a calendar
Why this category feels safe and is not
Everything that makes other webhook categories scary is missing here. There is no five-second budget like Shopify's. No subscription gets deleted after a bad afternoon. No endpoint gets disabled and emails an address nobody reads. Booking volume for most products is low enough that you will never hit a rate limit.
So the handler gets written quickly, tested with one booking, and shipped.
The exposure is on the other side. When a payment webhook is processed twice, you notice in your own data and fix it before anyone external is affected. When a booking webhook is processed twice, the second copy is immediately visible to a customer, and by the time you find out the damage is a scheduling conflict rather than a data inconsistency.
The specific sequence
An invitee books 10:00. Calendly fires invitee.created.
Your handler writes the slot, then calls your calendar provider to create the event, then sends a confirmation email. The calendar call is slow that morning. The whole thing takes long enough that the delivery is recorded as failed.
Calendly retries. Your handler runs again. Nothing in it knows the first run finished, because the first run's completion happened after the response was already lost. Two calendar entries, two confirmation emails, and a slot your availability logic now believes is double-committed.
Every individual step worked. The handler is not buggy in any way a unit test would find.
Dedupe on the booking, not on the payload
The obvious instinct is to hash the payload and treat matching hashes as duplicates. It fails here in a specific way: a reschedule produces a payload that differs only in the time fields, and a cancellation followed by a rebooking at the same slot produces payloads that can be byte-identical.
Use the identifier the provider assigns to the booking itself. Calendly's payloads carry a URI that uniquely identifies the invitee and the scheduled event. Cal.com and Acuity have equivalents. That identifier is stable across retries of the same delivery, and different for a genuinely different booking.
CREATE TABLE processed_bookings (
booking_uri TEXT PRIMARY KEY,
event_type TEXT NOT NULL,
occurred_at TIMESTAMPTZ NOT NULL
);
const { rowCount } = await db.query(
`INSERT INTO processed_bookings (booking_uri, event_type, occurred_at)
VALUES ($1, $2, $3) ON CONFLICT (booking_uri, event_type) DO NOTHING`,
[payload.uri, payload.event, payload.created_at],
);
if (rowCount === 0) return new Response(null, { status: 200 });
Note the composite on (booking_uri, event_type) rather than the URI alone. The same booking legitimately produces invitee.created and later invitee.canceled, and you want both, exactly once each.
Cancellations out of order are the worse bug
Duplicates create a conflict somebody notices immediately. Ordering failures create a calendar that is quietly wrong.
Someone books 10:00 and cancels four minutes later. The invitee.created delivery fails and retries. The invitee.canceled delivery succeeds first. Your handler cancels a booking that does not exist yet, logs a warning, and moves on. Then the retry lands and creates the booking.
The slot is now blocked for a meeting nobody is coming to, and no error was raised anywhere. Deduplication is irrelevant: both events arrived exactly once, in the wrong order.
The fix is the same monotonic guard used for subscriptions and CRM records, applied to the booking's own timestamp rather than your arrival time:
UPDATE bookings
SET status = $1, updated_at = $2
WHERE booking_uri = $3
AND updated_at < $2;
For a state machine this small, an explicit transition check is better still. A booking that is already canceled should refuse to become active on a late-arriving create, and that refusal should log loudly rather than silently. The general treatment is in You Received the Same Webhook Twice.
Signature verification
Calendly sends a Calendly-Webhook-Signature header containing a timestamp and a signature, comma separated. The signed payload is the timestamp, a dot, and the raw request body, hashed with HMAC-SHA256 using the signing key you were given when the subscription was created.
If that shape looks familiar, it is the same construction Stripe uses, and it carries the same two requirements: hash the raw bytes rather than a re-serialized object, and compare in constant time. Details per provider in Verifying Stripe, GitHub, and Shopify Webhook Signatures.
Reject requests with a missing signature header rather than defaulting to allow. A booking endpoint that accepts unsigned POSTs lets anyone on the internet fill your calendar.
Make the calendar the constraint
The fix that holds up best in this category is not in the webhook handler at all. It is a uniqueness constraint at the resource level:
CREATE UNIQUE INDEX one_booking_per_slot
ON bookings (resource_id, starts_at)
WHERE status = 'active';
Now a duplicate delivery cannot produce a double booking even if every other layer fails, because the database refuses it. The handler catches the constraint violation and treats it as a duplicate rather than an error.
This is worth doing even with correct idempotency, because it moves the guarantee from "our handler is careful" to "the schema makes it impossible." Those are different strengths, and for something a customer experiences directly you want the second one.
Where AnyHook fits
The retry is a transport behaviour, and the reason your handler saw it twice is that the first delivery could not be acknowledged in time.
Point the webhook subscription at in.anyhook.net/you/scheduling. AnyHook verifies and persists at the edge and acknowledges in under 50ms, so a slow calendar API on your side never produces a failed delivery, which means the provider's retry never fires and the duplicate never reaches your handler at all.
- Delivery to your endpoint retries on exponential backoff, spaced so a recovering system gets room rather than pressure
- Every booking event is stored before delivery, so "did we receive the cancellation" is answerable from a log rather than inferred from a calendar that might be wrong
- Replay over a time range re-runs a broken window against a fixed handler, which for bookings means fixing the calendar rather than emailing customers to rebook
- Failure alerts at 1, 5, and 20 consecutive failures, which for this category is the difference between finding out from your monitoring and finding out from the person who showed up
Your handler still needs the dedupe key, the version guard, and ideally the unique index. A relay removes the retry, not the requirement to be correct when one happens.
Takeaway
Scheduling is the category where webhook correctness stops being an engineering concern and becomes a customer-facing one. The timeouts are relaxed enough that nothing forces you to think about it, right up until two people are in the same slot.
Dedupe on the booking identifier and the event type together. Guard writes on the provider's timestamp, not your own. Put a unique index on the slot so the schema catches what the handler misses. And treat a late cancellation the same way you would treat a late refund, because a calendar that is quietly wrong costs more to fix than a row that is.