Keep Graph Subscriptions Alive With Lifecycle Notifications
Graph change-notification subscriptions expire and silently die. Lifecycle notifications and a renewal loop keep your Teams event pipeline from going dark.
- #microsoft-teams
- #graph-api
- #change-notifications
- #subscriptions
- #automation
The first time a Teams event pipeline I built went dark, nobody noticed for almost a full business day. No errors, no 500s, no alerts. The webhook endpoint was healthy, the function app was warm, and the dashboards were green. The Microsoft Graph subscription feeding it had simply expired, and Graph had stopped sending notifications without so much as a goodbye. That silent death is the defining failure mode of Graph change notifications, and it is entirely preventable. The mechanism that prevents it is lifecycle notifications plus a disciplined renewal loop, and that is what this post is about.
This is not a “how to subscribe to channel messages” tutorial. It assumes you already have a subscription. The hard part, the part that keeps you paged at 2 a.m., is keeping that subscription alive.
Why subscriptions die in the first place
Every Graph subscription carries an expirationDateTime. When that timestamp passes, the subscription is gone. Graph does not renew it for you, and it does not retry. The catch is that the maximum lifetime you are allowed to request is short and varies wildly by resource type.
For the Teams resources most of us care about, the limits are aggressive:
/chats/{id}/messagesand/teams/{id}/channels/{id}/messageswithout rich (encrypted) notifications: roughly 60 minutes maximum.- The same resources with
includeResourceData(encrypted payloads): up to about 3 days. /chatsand/teams/{id}/channels(collection-level changes): also in the multi-day range.- Many other Graph resources (mail, calendar) allow far longer windows.
So if you create a plain channel-message subscription and walk away, you have under an hour before it expires. That is by design. Microsoft wants you actively re-asserting that you still want the data, and it wants webhook endpoints that disappear to stop receiving messages quickly.
Pro Tip: Always read the per-resource maximum from the docs and store it in config, not in your head. The limits have changed over the years, and hardcoding “4230 minutes” in three different files is how you get a renewal loop that silently overshoots and gets rejected by Graph every cycle.
The renewal PATCH: extend before you expire
You keep a subscription alive by issuing a PATCH to /subscriptions/{id} with a new expirationDateTime. You do not delete and recreate; that would drop notifications in the gap and reset any state. You extend in place.
The body is deliberately minimal:
PATCH https://graph.microsoft.com/v1.0/subscriptions/7f105c7d-2dc5-4530-97cd-4e7ae6534c07
Authorization: Bearer eyJ0eXAiOiJKV1Qi...
Content-Type: application/json
{
"expirationDateTime": "2026-06-20T11:00:00.0000000Z"
}
The new expirationDateTime must still be within the maximum window allowed for that resource, measured from now. You cannot PATCH a channel-message subscription three days out; Graph will reject it. You request the max the resource allows, then renew again before that runs out.
The scheduling rule I follow: renew when you cross two-thirds of the lifetime, never at the last minute. For a 60-minute subscription that means renewing around the 40-minute mark, which leaves a 20-minute safety margin for clock skew, throttling (HTTP 429), and a couple of retries. For a 3-day subscription, renew daily. Cutting it close is how you turn a transient throttle into a dead pipeline.
Lifecycle notifications: the safety net
The renewal loop assumes your scheduler is always running and always succeeds. Real infrastructure is not that kind. Functions get cold, deployments roll, tokens expire, and tenants revoke consent. Lifecycle notifications are Graph’s out-of-band channel telling you something is wrong with the subscription itself, independent of the data flow.
You opt in by setting lifecycleNotificationUrl when you create the subscription (it can be the same host as notificationUrl, but I keep it on a distinct path so the handlers stay clean):
{
"changeType": "created,updated,deleted",
"notificationUrl": "https://hooks.example.com/graph/notifications",
"lifecycleNotificationUrl": "https://hooks.example.com/graph/lifecycle",
"resource": "/teams/{team-id}/channels/{channel-id}/messages",
"expirationDateTime": "2026-06-20T11:00:00.0000000Z",
"clientState": "a-secret-you-generated"
}
There are exactly three lifecycle event types, and each demands a different response.
reauthorizationRequired
Graph is telling you the subscription needs to be re-authorized before it will keep flowing data, even though it has not expired yet. This commonly fires before the encryption certificate or the access token’s authorization needs refreshing. The correct response is to PATCH the subscription (the same renewal call above), which re-asserts authorization. Treat it as “renew now, ahead of schedule.”
subscriptionRemoved
The subscription is gone or is being removed, often because authorization fully lapsed or the resource became inaccessible. You cannot PATCH this back; the only recovery is to create a brand-new subscription. Your handler should enqueue a recreate, not a renew.
missed
Graph could not deliver one or more change notifications (your endpoint was down, throttled, or too slow). The notifications themselves are lost. The event carries a resource and a time window, and your job is to reconcile by querying Graph directly (a delta query or a fetch over the affected resource) to backfill what you missed. Ignoring missed events is how you end up with quietly incomplete data.
A lifecycle notification payload looks like this:
{
"value": [
{
"lifecycleEvent": "reauthorizationRequired",
"subscriptionId": "7f105c7d-2dc5-4530-97cd-4e7ae6534c07",
"subscriptionExpirationDateTime": "2026-06-20T11:00:00.0000000+00:00",
"resource": "/teams/{team-id}/channels/{channel-id}/messages",
"clientState": "a-secret-you-generated",
"tenantId": "9f4ce...d2a1"
}
]
}
Always check clientState against the secret you set at subscribe time before acting on any notification, lifecycle or data. It is your cheapest defense against a spoofed callback.
The validation handshake and webhook security
When you create or PATCH a subscription, Graph immediately calls your notificationUrl (and lifecycleNotificationUrl) with a ?validationToken=... query parameter. Your endpoint must respond within 10 seconds, HTTP 200, Content-Type: text/plain, echoing the decoded token verbatim. Miss it and the subscription is never created. Both URLs must pass this handshake, so do not forget to wire the lifecycle path before you reference it.
Beyond the handshake, treat the webhook as hostile-internet-facing, because it is. Validate clientState on every call. If you use encrypted resource data, verify the payload signature. Do not trust the tenantId or resource fields blindly to drive privileged actions.
Encrypted content and includeResourceData
If you want the actual message content delivered in the notification (rather than just an ID you then go fetch), you set includeResourceData: true and provide encryptionCertificate (a base64 public-key cert) and encryptionCertificateId. Graph encrypts each payload’s resource data with a symmetric key, then encrypts that key with your public certificate. Your endpoint decrypts the key with the matching private key, then decrypts the data.
This is also what unlocks the longer (multi-day) expiration windows for Teams message resources, so it is worth setting up even if you do not strictly need inline content. But it raises the security stakes: the private key that decrypts message bodies lives in your infrastructure, ideally in a key vault, never in source and never in a notebook.
Pro Tip: The private decryption key and any real tenant credentials should never enter an AI prompt, a paste buffer, or a screenshot. When you ask an assistant to scaffold the decryption handler, give it the certificate-thumbprint flow and a placeholder key reference, and wire the real secret from your vault yourself, after review.
A renewal scheduler pattern (and where AI fits)
The pattern that has held up for me:
- Persist subscription state (id, resource, expiry, lifecycle counters) in a store, not just memory.
- Run a timer every few minutes that finds subscriptions past two-thirds of their lifetime and PATCHes them, with exponential backoff on 429/5xx.
- Handle lifecycle events out-of-band:
reauthorizationRequiredtriggers an immediate PATCH,subscriptionRemovedtriggers a recreate,missedtriggers a delta reconcile. - Alert on failure, specifically on a PATCH that fails repeatedly or a subscription whose expiry is approaching with no successful renewal. The whole point is to make the silent failure loud.
This is exactly the kind of well-specified, boilerplate-heavy code where a model like Claude or GitHub Copilot earns its keep. I treat it like a fast junior engineer: it drafts the timer, the backoff, and the three lifecycle branches in minutes, and it rarely forgets the clientState check if you remind it. What it does not get to do is decide the renewal threshold, touch the decryption key, or push to a tenant unreviewed. I read every line of the renewal logic before it goes near production, because an off-by-one on the expiry math is the difference between a live pipeline and a dead one nobody notices.
If you want a head start, the prompt workspace and our prompt library have scaffolds for webhook validators and renewal loops, and the prompt packs bundle the Teams-specific ones together.
Conclusion
Graph change notifications do not fail loudly; they fail by going quiet. The cure is a renewal PATCH that fires well before expirationDateTime, lifecycle notifications wired to three distinct handlers, and a scheduler that treats an unrenewed subscription as a page-worthy incident. Let an AI draft the plumbing, keep the secrets and the judgment in human hands, and your Teams event pipeline stays alive while you sleep. For more in this vein, browse the rest of the Microsoft Teams category.
keep-graph-change-notification-subscriptions-alive-with-lifecycle-events
Download the Free 500-Prompt DevOps AI Toolkit
500 battle-tested, copy-paste AI prompts engineered by a senior systems engineer — every one with fill-in placeholders and safety/back-out notes. Drop your email and it's yours.
- 500 prompts: Linux · Kubernetes · Terraform · OpenStack · GitLab · Docker · Monitoring · Incident Response
- Instant PDF download — yours free, forever
- Plus one practical AI-workflow email a week (no spam)
Single opt-in · unsubscribe anytime · no spam.