Skip to content

How to build calendar webhooks that keep working (Google push channels and Microsoft Graph subscriptions)

Updated

TL;DR

Google push channels expire, "there's no automatic way to renew a notification channel", and Google says "Notifications are not 100% reliable", so you watch again before expiry and sync with a sync token. Microsoft Graph subscriptions on Outlook events last at most 10,080 minutes, need a validation reply within 10 seconds and every notification acknowledged within 3 seconds, and "Dropped notifications can't be recovered." The design that survives both: a notification only triggers an incremental sync (sync token or delta link), and a scheduled sync catches whatever was lost.

Neither provider promises delivery

Google's push guide: "Notifications are not 100% reliable. Expect a small percentage of messages to get dropped under normal working conditions." Microsoft's webhooks guide: "Dropped notifications can't be recovered."

Everything below follows from that. A notification tells you something changed. It never tells you everything that changed.

Google push channels

A push channel watches one calendar's events.

Watch a calendarHTTP
POST https://www.googleapis.com/calendar/v3/calendars/primary/events/watch
Content-Type: application/json

{
  "id": "0b8f6a3e-5c1d-4e2a-9f7b-3d6c8e1a2b4f",
  "type": "web_hook",
  "address": "https://example.com/google/notifications",
  "token": "account=acct_123",
  "params": { "ttl": "604800" }
}

The address must use HTTPS with a valid certificate; self-signed certificates don't work. The id can be up to 64 characters and the token up to 256.

Expiry, with no renew call

The events.watch reference gives the ttl default as 604800 seconds, seven days. The maximum is not documented, and Google says expiration is set by your request "or by any Google Calendar API internal limits or defaults", whichever is more restrictive. Read expiration from the response instead of assuming.

"Currently, there's no automatic way to renew a notification channel." Before a channel expires, call watch again with a new id. Channels may overlap, so create the new one first, then stop the old one:

Stop a channelHTTP
POST https://www.googleapis.com/calendar/v3/channels/stop
Content-Type: application/json

{ "id": "0b8f6a3e-5c1d-4e2a-9f7b-3d6c8e1a2b4f", "resourceId": "ret08u3rv24htgh289g" }

Only the user who created a channel, through the same OAuth client, can stop it.

What arrives

A notification has headers and no body. X-Goog-Resource-State is sync when the channel is created, exists when something changed, or not_exists. "Sync messages always have an X-Goog-Message-Number HTTP header value of 1." Google retries with exponential backoff on 500, 502, 503 and 504; any status other than a success code counts as a failed message.

Since there is no body, the notification cannot tell you which event changed. You find out with a sync.

Sync tokens and 410

From Google's sync guide:

  • nextSyncToken "is present only on the very last page." Page through to the end before you store it.
  • "Each list request should use the same set of query parameters, including the initial request." Many filters can't be combined with a sync token at all, and disallowed parameters return 400.
  • Incremental results "will always contain deleted entries", as events with status cancelled. Delete them locally. See Google's deleted events are cancelled.
  • When the token is no longer valid, Google returns 410 with reason fullSyncRequired. That "should trigger a full wipe of the client's store and a new full sync."

Microsoft Graph subscriptions

A Graph subscription watches a mailbox's events.

Subscribe to a user's eventsHTTP
POST https://graph.microsoft.com/v1.0/subscriptions
Content-Type: application/json

{
  "changeType": "created,updated,deleted",
  "notificationUrl": "https://example.com/graph/notifications",
  "lifecycleNotificationUrl": "https://example.com/graph/lifecycle",
  "resource": "/me/events",
  "expirationDateTime": "2026-11-04T10:00:00Z",
  "clientState": "a-secret-you-check-on-every-notification"
}

The validation handshake

Before Graph creates the subscription, it POSTs to your URL with ?validationToken=.... Per the webhooks guide, you must answer within 10 seconds with 200 OK, a content type of text/plain, and "the URL decoded plain text validation token." "If the client returns an encoded validation token, the validation fails." Graph validates lifecycleNotificationUrl the same way.

graphNotifications.tsTypeScript
app.post('/graph/notifications', express.json(), async (req, res) => {
  const validationToken = req.query.validationToken; // Express has already URL-decoded it
  if (typeof validationToken === 'string') {
    res.status(200).type('text/plain').send(validationToken);
    return;
  }
  await queue.add('graph-notification', req.body); // check clientState in the worker
  res.sendStatus(202);
});

Lifetimes

From the subscription resource: Outlook events allow at most 10,080 minutes, "under seven days", or 1,440 minutes with resource data. Anything under 45 minutes is set to 45. Renew with PATCH /subscriptions/{id} and a new expirationDateTime. There is a limit of 1,000 active Outlook subscriptions per mailbox "for all applications", and a second subscription with the same changeType and resource fails with 409 Conflict.

Delivery timing

A notification counts as delivered when you return 2xx within 3 seconds. Otherwise Graph retries "for up to 4 hours." Microsoft recommends you "validate and persist the notification in a queue on your endpoint and return 202 Accepted." Slow endpoints get throttled: more than 10% of responses over 3 seconds in a 10-minute window marks you "slow" and delays new notifications by 10 minutes, and more than 15% over 10 seconds marks you "drop", which discards notifications for 10 minutes.

The subscription resource lists latency for the event resource as "Unknown". Don't build anything that assumes a notification arrives within a set time.

Lifecycle notifications

Set lifecycleNotificationUrl when you create the subscription. You can't add it later: "you must delete the existing subscription and create a new subscription." The lifecycle guide defines three events, each acknowledged with 202 Accepted:

  • reauthorizationRequired: call POST /subscriptions/{id}/reauthorize, or PATCH the subscription to reauthorize and renew at once. "Don't issue a reauthorize request ... and an update request ... for the same subscription within a 10-minute window."
  • subscriptionRemoved: create a new subscription, then sync to find what you missed.
  • missed: "Perform a full data resync of the resource", for example with delta query.

clientState

The webhooks guide says "The clientState property is required." The subscription resource lists it as "Optional", with a maximum of 128 characters. Set it every time and reject notifications whose value doesn't match.

Delta queries

The delta query for events is Graph's sync token:

Start a delta syncHTTP
GET https://graph.microsoft.com/v1.0/me/calendarView/delta?startDateTime=2026-10-01T00:00:00Z&endDateTime=2027-04-01T00:00:00Z
Prefer: odata.maxpagesize=50

Follow @odata.nextLink until you get @odata.deltaLink, and store that delta link. Deleted events come back with "@removed": {"reason": "deleted"}. $select is not supported. Microsoft does not document how long a delta link stays valid, so handle a failed delta link by starting a new full sync.

The design that survives lost notifications

  1. Store a cursor per calendar. A Google sync token or a Graph delta link, saved only after the last page.
  2. A notification only schedules a sync. Acknowledge fast, queue a job keyed by account, and let the job sync from the cursor. Ten notifications for one calendar collapse into one sync.
  3. Sync on a schedule anyway. Run an incremental sync for every calendar on a timer. This catches dropped Google messages, Graph's "drop" state and anything lost while a subscription was being recreated.
  4. Renew on a schedule, not on demand. Track each channel's and subscription's expiry and renew well ahead of it. Alert when renewal fails, because that usually means the user's access was revoked.
  5. Make processing idempotent. Graph retries for hours, Google retries on 5xx, and overlapping Google channels can both fire. Applying the same change twice must be harmless.
  6. Handle the reset paths. A 410 from Google or a failed delta link means wipe that calendar's local copy and run a full sync.

How Pyas handles it

Pyas renews Google push channels and Graph subscriptions itself. Your app receives Pyas webhooks for both providers, with types including event.created, event.updated, event.deleted and connection.broken. Each is signed with the Pyas-Signature header, which you verify with pyas.webhooks.constructEvent(rawBody, signature, secret). Deliveries are retried up to 8 attempts and can be replayed. Each one names the event or calendar that changed by its ID, and your app reads the details from the API.

Common questions

Why did my Google notifications stop after a week?

The channel expired. The default ttl is seven days and there is no renew call. Watch again with a new id before expiration.

Why does Graph say the validation request failed?

Usually one of these: the reply took longer than 10 seconds, it wasn't text/plain, or the token was sent back still URL-encoded.

Do notifications include the changed event?

Google's never do: headers only. Graph's include the resource ID, and the full event only on subscriptions with resource data, which are limited to 1,440 minutes and need an encryption certificate.

Can I renew a Google channel?

No. Create a new channel with a new id, then stop the old one with channels/stop.

Google keeps deleted events, and a read still returns themHow to get free/busy across Google and Outlook calendars in the right time zonesHow to add a Google Meet, Teams or Zoom link to a calendar event with an API