Calendar

Calendar sync: webhooks, polling, and knowing when you're behind

Aug 25, 2026 · 3 min read

Calendar sync fails quietly. When a change subscription expires unnoticed, your copy of the calendar simply stops updating and keeps looking plausible for days. The hard problem is not receiving changes; it is knowing when you have stopped receiving them.

Change notifications are leases, not subscriptions

Google and Microsoft will both push a notification when a connected calendar changes, and both attach an expiry measured in days. You do not subscribe once and move on. You hold a lease, and you renew it before it lapses, forever, for every calendar you watch. Renewal jobs fail. Tokens get revoked mid-lease. Providers occasionally drop a notification with no error surfaced on either side.

Treat expiry as the normal case rather than the exception. Record the expiration timestamp for every subscription, renew on a schedule with margin, and page someone when renewal fails twice in a row. A subscription you cannot prove is alive should be treated as dead.

The notification tells you almost nothing

Provider notifications are intentionally thin. They say that a calendar changed, not which event or how. The real payload comes from a follow-up fetch using a sync cursor, a token from the provider that means "everything after this point." You fetch the delta, apply it, store the new cursor, and wait for the next ping.

This design is good news once you accept it. Because the notification carries no data, a lost notification costs you latency, not information. The changes are still sitting behind your cursor. The notification is a doorbell, and the door still opens without it.

Cursors die, and the provider won't apologize

Sync cursors are not durable. After enough time or enough change, the provider invalidates them, and the response is blunt: this token is gone, start over. Your sync engine needs a full-resync path that lists everything, diffs the listing against local state, and emits the same normalized changes your incremental path emits, so downstream consumers cannot tell which path produced an update.

Full resync is also your recovery tool for every other failure, which is exactly why it cannot be a dusty code path. Run it on a schedule against a known calendar. If resync only executes during incidents, it will fail during incidents.

Polling is the safety net

Push handles latency and nothing else. It cannot give you correctness, because a silent subscription and a quiet calendar look identical from your side. So you sweep: a periodic reconciliation pass per calendar that advances the cursor whether or not a notification arrived. Hours-scale intervals are fine. The sweep exists to bound staleness, not to be fast.

The inverted design fails too. Polling alone at short intervals burns provider quota and still leaves minutes of latency on every change. Push alone eventually goes quiet without telling you. Each mechanism covers the other's blind spot, and you need both.

Make staleness a number

Store two timestamps per connected calendar: the last change you applied and the last sweep that succeeded. Staleness then becomes a number you can graph, alert on, and show in a dashboard. A calendar with no notifications for a week and a passing sweep is idle. The same silence with a failing sweep is an outage. Without the timestamps, those two cases are indistinguishable until a user misses a meeting.

This is the plumbing Horato runs beneath its calendar API: subscription renewal, cursor-based sync, reconciliation sweeps, and signed webhooks to your endpoint when something actually changed. If you build it yourself instead, build the staleness measurement first. It is the only part that tells you whether the rest works.