Architecture

Microsoft Graph webhooks: validate, renew and recover missed notifications

Build a Microsoft Graph change notification endpoint that passes validation, renews subscriptions on time, handles lifecycle events and resyncs missed changes with delta query.

14 min read
On this page

A Microsoft Graph webhook works in four parts: your app creates a subscription with POST /subscriptions, Graph validates your HTTPS endpoint by sending a validationToken that you must echo back as plain text within 10 seconds, Graph then POSTs change notifications that you acknowledge with a 2xx response within 3 seconds, and your app renews the subscription with PATCH /subscriptions/{id} before it expires. To avoid silent gaps, also set a lifecycleNotificationUrl when you create the subscription, act on reauthorizationRequired, subscriptionRemoved and missed events, and use delta query to pick up any changes you didn't get notified about. Notifications tell you that something changed; delta tells you exactly what.

Who this is for and what you will have at the end

This guide is for developers and integration architects who need to react to changes in Microsoft 365 data: new mail in a shared mailbox, calendar updates for room booking, user and group changes for provisioning, or Teams messages for compliance tooling. At the end you will have:

  • An HTTPS endpoint, built as a PowerShell Azure Function, that handles validation, notifications and lifecycle events.
  • A subscription created with the right permissions, lifetime and clientState.
  • A renewal job that also recreates subscriptions that have disappeared.
  • A recovery path based on delta query for changes that were missed.
  • An understanding of when to deliver through Azure Event Grid instead of a webhook.

How the subscription lifecycle works

 Your app                    Microsoft Graph                 Your endpoint
    |  POST /subscriptions  ->      |                              |
    |                               |  POST ?validationToken=...  ->|
    |                               |<- 200 text/plain (token)     |
    |<- 201 Created (subscription)  |                              |
    |                               |  POST change notifications  ->|  validate clientState,
    |                               |<- 202 Accepted (< 3 s)       |  enqueue, return
    |                               |  POST lifecycle events      ->|  reauthorizationRequired,
    |                               |<- 202 Accepted               |  subscriptionRemoved, missed
    |  PATCH /subscriptions/{id} -> |  (renew before expiry)       |
    |  GET .../delta             -> |  (resync after gaps)         |

Microsoft Graph supports three types of notification. Basic notifications contain only the ID of the changed resource, so your app fetches the details. Rich notifications include the resource data, encrypted with a certificate you supply. Lifecycle notifications warn you about the subscription itself. This guide uses basic notifications, which are the right starting point for most integrations.

Choose a delivery channel

Graph can deliver change notifications through three channels:

ChannelBest whenNotes
WebhooksYou can host a reliable public HTTPS endpointYou own validation, response times and throttling behaviour
Azure Event HubsHigh volume, or the endpoint can't meet webhook response timesNotifications are read from the hub by your consumers
Azure Event GridYou want one Graph subscription routed to several handlers, with filtering and dead-letteringEvents arrive in a partner topic in CloudEvents format

Microsoft's guidance is to consider Event Hubs or Event Grid as the target if your endpoint can't meet the webhook performance requirements described in Step 1.

Prerequisites

  • An app registration in Microsoft Entra ID with the application permission for the resource. Creating a subscription needs read permission on the resource: for example Mail.Read for messages, Calendars.Read for events, User.Read.All for users and Group.Read.All for groups.
  • A public HTTPS endpoint. If Graph can't reach it, it sends nothing. Optionally restrict inbound traffic to the Microsoft Graph change notification IP addresses published in Microsoft's additional Microsoft 365 endpoints list.
  • A secret clientState value of up to 128 characters, stored in configuration, never in code.
  • A queue between the endpoint and the processing code, for example an Azure Storage queue. Any durable queue works; Kafka vs RabbitMQ vs AWS SQS compares common options.
  • Microsoft Graph PowerShell (Microsoft.Graph.Authentication and Microsoft.Graph.ChangeNotifications) for creating and renewing subscriptions.

Step 1: Build the endpoint

The endpoint must do three things correctly.

Validation. When you create a subscription, Graph sends POST https://{notificationUrl}?validationToken={token} with content type text/plain. Within 10 seconds, your endpoint must return HTTP 200, content type text/plain, and the URL-decoded token as the body. If you also set a lifecycleNotificationUrl, Graph validates that URL the same way.

Fast acknowledgement. A notification counts as delivered when Graph receives a 2xx response within 3 seconds. A non-2xx response or a timeout makes Graph retry, with exponential backoff, for up to 4 hours; retried requests get a 10-second timeout. Microsoft's guidance is to return 200 OK if you finished processing within 3 seconds, otherwise validate and queue the notification and return 202 Accepted, and return a 5xx code if you couldn't even queue it.

Throttling awareness. Graph marks an endpoint "slow" when more than 10% of responses take longer than 3 seconds in a 10-minute window, and then delays new notifications by 10 minutes. It marks an endpoint "drop" when more than 15% of responses exceed the 10-second retry timeout in a 10-minute window, and then drops notifications for 10 minutes. Dropped notifications can't be recovered, which is why the endpoint must never do slow work inline.

Here is the HTTP binding for a PowerShell Azure Function. Add an Azure Queue storage output binding named outQueue to the same function.json for the queue writes.

{
  "bindings": [
    {
      "type": "httpTrigger",
      "direction": "in",
      "authLevel": "anonymous",
      "name": "Request"
    },
    {
      "type": "http",
      "direction": "out",
      "name": "Response"
    }
  ]
}

And the run.ps1 that handles validation, change notifications and lifecycle notifications in one endpoint (Microsoft allows the lifecycle URL to be the same as the notification URL):

param($Request, $TriggerMetadata)
 
# 1. Endpoint validation: echo the token as plain text
$validationToken = $Request.Query.validationToken
if ($validationToken) {
    Push-OutputBinding -Name Response -Value ([HttpResponseContext]@{
        StatusCode  = [System.Net.HttpStatusCode]::OK
        ContentType = 'text/plain'
        Body        = $validationToken
    })
    return
}
 
# 2. Change and lifecycle notifications: check clientState, queue, acknowledge
$expected = $env:GRAPH_CLIENT_STATE
$accepted = @()
foreach ($n in $Request.Body.value) {
    if ($n.clientState -ne $expected) {
        Write-Warning "Rejected notification with unexpected clientState for subscription $($n.subscriptionId)"
        continue
    }
    $accepted += ($n | ConvertTo-Json -Depth 10 -Compress)
}
 
if ($accepted.Count -gt 0) {
    Push-OutputBinding -Name outQueue -Value $accepted
}
 
Push-OutputBinding -Name Response -Value ([HttpResponseContext]@{
    StatusCode = [System.Net.HttpStatusCode]::Accepted
})

A few points about this code. The Functions runtime passes a JSON request body to PowerShell as a hashtable, so $Request.Body.value is the notification collection. A single POST can contain several notifications, for different subscriptions and even mixed with lifecycle events, so always loop. Treat the validation token as opaque, return it exactly as received after URL decoding, and test the validation path once before you rely on it. Because authLevel is anonymous, the clientState check is your main authenticity control; any query string you add to notificationUrl is included in every POST, which you can also use as an additional check.

A separate worker function reads outQueue, branches on whether the item has a lifecycleEvent property, and for change notifications fetches the resource by the ID in resourceData.

Step 2: Create the subscription

Use the same app identity that will renew the subscription later. The list API, called with application permissions, returns only the subscriptions created by the calling app.

Connect-MgGraph -ClientId '<app ID>' -TenantId '<tenant ID>' -CertificateThumbprint '<thumbprint>'
 
$params = @{
    changeType               = 'created,updated'
    notificationUrl          = 'https://fn-graph-hooks.azurewebsites.net/api/graph'
    lifecycleNotificationUrl = 'https://fn-graph-hooks.azurewebsites.net/api/graph'
    resource                 = "users/servicedesk@contoso.com/mailFolders('inbox')/messages"
    expirationDateTime       = [datetimeoffset]::UtcNow.AddDays(3).ToString('o')
    clientState              = '<secret from configuration>'
}
 
New-MgSubscription -BodyParameter $params

Set lifecycleNotificationUrl now. You can't add it to an existing subscription later; you have to delete the subscription and create a new one. For Teams resources it is required when the expiration is more than one hour away.

The expiration must stay within the maximum for the resource, and any value under 45 minutes is raised to 45 minutes:

ResourceMaximum subscription lifetime
Outlook message, event, contact10,080 minutes (under 7 days); 1,440 minutes with resource data
User, group, other directory resources41,760 minutes (under 29 days)
OneDrive driveItem, SharePoint list42,300 minutes (under 30 days)
Teams chatMessage, chat, channel, team4,320 minutes (3 days)
Security alert43,200 minutes (under 30 days)
Presence60 minutes

Watch the quotas too. Outlook allows 1,000 active subscriptions per mailbox across all applications. For users and groups the limits are 100 subscriptions per app per tenant, 1,000 per tenant across all apps and 50,000 per app across all tenants. Teams resources share an organization-wide quota of 10,000 subscriptions. Exceeding a quota returns 403 Forbidden with the limit named in the error message.

For Outlook resources, note two permission rules: delegated permissions can only subscribe to the signed-in user's own mailbox, and the .Shared permissions (such as Mail.Read.Shared) don't support change notifications on shared or delegated folders. Use the application permission for those.

Step 3: Renew before expiry and recreate what is gone

Renewal is a PATCH with a new expirationDateTime. Renewing also reauthorizes the subscription. If the PATCH returns 404 Not Found, the subscription has already expired or been removed; Microsoft's guidance is to create a new one rather than retry. The following script, run on a schedule, renews what is about to expire and flags subscriptions that are missing from Graph compared with your own records. As a timer-triggered Azure Function, the six-field NCRONTAB schedule 0 0 */6 * * * runs it every six hours.

Connect-MgGraph -ClientId '<app ID>' -TenantId '<tenant ID>' -CertificateThumbprint '<thumbprint>'
 
$renewWithin = [datetimeoffset]::UtcNow.AddHours(24)
# 3 days suits Outlook and directory resources; keep it under the maximum for each resource type
$newExpiry   = [datetimeoffset]::UtcNow.AddDays(3).ToString('o')
 
$uri = 'https://graph.microsoft.com/v1.0/subscriptions'
$subs = @()
do {
    $page = Invoke-MgGraphRequest -Method GET -Uri $uri
    $subs += $page.value
    $uri = $page.'@odata.nextLink'
} while ($uri)
 
# Your own records of the subscriptions this app should have (resource, changeType, URLs)
$expected = Get-Content -Path .\subscriptions.json | ConvertFrom-Json
foreach ($r in $expected) {
    if ($subs.id -notcontains $r.id) {
        Write-Warning "Subscription $($r.id) for $($r.resource) no longer exists; recreate it and resync with delta."
    }
}
 
foreach ($s in $subs) {
    if ([datetimeoffset]$s.expirationDateTime -gt $renewWithin) { continue }
 
    $body = @{ expirationDateTime = $newExpiry } | ConvertTo-Json
    $null = Invoke-MgGraphRequest -Method PATCH -Uri "https://graph.microsoft.com/v1.0/subscriptions/$($s.id)" `
        -Body $body -ContentType 'application/json' -SkipHttpErrorCheck -StatusCodeVariable 'status'
 
    if ($status -eq 404) {
        Write-Warning "Subscription $($s.id) for $($s.resource) is gone; recreate it and resync with delta."
        # Call your create routine here, then trigger a delta resync for the resource
    }
}

The list response never includes clientState, and a subscription that Graph removed simply stops appearing in it. That is why the script compares the list with your own stored record of each subscription's ID, resource, change types and URLs; the same record gives you everything you need to recreate a missing subscription.

Step 4: Handle lifecycle notifications

Lifecycle notifications arrive at the lifecycleNotificationUrl with a lifecycleEvent property and no resource data. Acknowledge each one with 202 Accepted, validate it (check clientState), make sure the app has a valid token, then act:

lifecycleEventSupported forWhat to do
reauthorizationRequiredAll resourcesCall POST /subscriptions/{id}/reauthorize, or PATCH a new expirationDateTime to reauthorize and renew in one call
subscriptionRemovedOutlook message, event, contact; Teams chatMessageCreate a new subscription, then resync with delta
missedOutlook message, event, contactRun a full resync of the resource with delta

reauthorizationRequired is sent when the access token behind the subscription is about to expire, when the subscription itself is about to expire, or when an administrator revoked the app's permission. Don't send a reauthorize request and a PATCH for the same subscription within 10 minutes of each other; Microsoft warns that doing so can leave the subscription in an inconsistent state. Don't assume a frequency for these challenges either: they can arrive every few minutes for some subscriptions and rarely for others.

If the token expires before you reauthorize, notifications stop being delivered but Graph keeps retrying each one for up to 4 hours. Reauthorize within that window and the backlog is delivered.

Step 5: Recover missed changes with delta query

Any gap (a removed subscription, a missed event, an endpoint outage longer than 4 hours, a dropped-notification window) means some changes never reached you. Microsoft's guidance in each case is to resync with delta query. Keep an @odata.deltaLink per tracked resource, refresh it whenever you process a batch, and run delta after every lifecycle event that signals a gap. For mail, delta works per folder, so track each subscribed folder separately; the delta pattern for messages is shown in Migrating EWS applications to Microsoft Graph.

A scheduled delta pass (for example nightly) is a useful safety net even when nothing looks wrong. It costs a few requests and turns any undetected gap into a bounded delay instead of permanent data loss.

Optional: deliver through Azure Event Grid

To send notifications to an Event Grid partner topic instead of your own endpoint:

  1. Register the Event Grid resource provider in your Azure subscription and authorize the Microsoft Graph API partner to create a partner topic in your resource group.
  2. Create the Graph subscription with a notificationUrl (and lifecycleNotificationUrl) in this form: EventGrid:?azuresubscriptionid=<id>&resourcegroup=<name>&partnertopic=<name>&location=<region name>. Use the region name, such as westcentralus, not the display name.
  3. Activate the partner topic that Graph creates, then add event subscriptions that route events to your handlers, for example an Azure Function.

Each tenant and application ID combination can create up to 10 partner topics. Renew as usual; when you set a new expiration, make it at least three hours away, or you may receive subscriptionReauthorizationRequired events soon after renewing. If a subscription expired less than 30 days ago you can recreate it against the same partner topic; after 30 days you need a new topic name or must delete the old topic first.

Verification

  1. Create a subscription and confirm it returns 201 Created with an id and the expected expirationDateTime.
  2. Trigger a change (send a test message to the mailbox) and confirm a notification is queued within the expected latency. For Outlook messages Microsoft lists an average under one minute and a maximum of three minutes.
  3. Check the function's response times; they should be well under 3 seconds.
  4. Run the renewal script and confirm expirationDateTime moves forward.
  5. Delete a test subscription and confirm the next renewal run reports it as missing and your create routine replaces it.

Troubleshooting

Subscription creation fails with 400 Bad Request. Endpoint validation failed. Check that the endpoint is publicly reachable over HTTPS, returns 200 within 10 seconds, uses content type text/plain and returns the decoded token without quotes or encoding. Remember that the lifecycle URL is validated too.

409 Conflict with "Subscription Id <> already exists for the requested combination". A subscription with the same changeType and resource exists. Renew or delete it instead of creating another.

403 Forbidden on creation. You have hit a subscription quota (for example 1,000 per mailbox, or 100 per app per tenant for users and groups) or the app lacks the read permission. The error message names the limit.

404 Not Found when renewing. The subscription expired or was removed. Create a new one and resync with delta.

Notifications arrive late or in bursts. The endpoint may be in the "slow" state, where new notifications are delayed by 10 minutes. Move processing behind the queue and return 202 Accepted immediately.

Notifications stop and never arrive. Check for the "drop" state, an expired subscription or an unhandled reauthorizationRequired event. Fix the cause, then resync with delta because dropped notifications can't be recovered.

Notifications fail the clientState check. Either the value in configuration changed after the subscription was created, or the request didn't come from Graph. Investigate the source before processing anything.

No lifecycle events arrive. The subscription was created without lifecycleNotificationUrl. Delete and recreate it with the property set.

Checklist

  • Endpoint returns the validation token as text/plain within 10 seconds.
  • Endpoint acknowledges notifications within 3 seconds and queues the work.
  • clientState stored as a secret and checked on every notification.
  • lifecycleNotificationUrl set on every subscription.
  • Expiration within the resource maximum; quotas checked.
  • Scheduled renewal that recreates subscriptions on 404.
  • reauthorizationRequired, subscriptionRemoved and missed handled.
  • Delta links stored per resource; resync after every gap.
  • Event Hubs or Event Grid considered for high volume or multiple consumers.

References

Questions people ask

How long does a Microsoft Graph subscription last?

It depends on the resource. Outlook messages, events and contacts allow up to 10,080 minutes (under seven days), or 1,440 minutes when the notifications include resource data. Users and groups allow 41,760 minutes, and Teams chat messages 4,320 minutes. Renew with a PATCH on the subscription's expirationDateTime before it expires.

How do I respond to the Graph webhook validation request?

Graph sends a POST to your notification URL with a validationToken query parameter. Within 10 seconds, respond with HTTP 200, content type text/plain and the URL-decoded token as the body. If validation fails, Graph doesn't create the subscription.

What should my endpoint return when it receives a notification?

Return a 2xx status within 3 seconds. If you can't process the notification that quickly, validate it, put it on a queue and return 202 Accepted. Return a 5xx code if you couldn't queue it, so Graph retries; retries continue for up to 4 hours.

What does a missed lifecycle notification mean?

It means some change notifications weren't delivered, for example because your endpoint was throttled. Acknowledge it with 202 Accepted, then run a full resync of the resource, typically with delta query, to pick up the changes you didn't receive.

Microsoft GraphWebhooksAzure Event GridAzure Functions
  1. Business Central API Webhooks: Create, Validate and Renew Subscriptions

    Receive Business Central change notifications instead of polling: build a validating receiver in Azure Functions, create a subscription, process notifications and renew before the three-day expiry.

    Architecture10 min read
  2. Webhooks that don't double-charge: retries, idempotency and dead letters

    Build webhook receivers that survive retries and duplicate deliveries: verify, deduplicate on the event ID, queue to Azure Service Bus, process idempotently and handle dead letters.

    Architecture12 min read
  3. Azure Service Bus SBMP Retirement: Migrate to Azure.Messaging.ServiceBus

    SBMP and the WindowsAzure.ServiceBus and Microsoft.Azure.ServiceBus libraries reached end of support on 30 September 2026. Find affected code, switch to AMQP and move to Azure.Messaging.ServiceBus.

    Architecture10 min read