Gareth Wilson Gareth Wilson

Guide to Microsoft Graph Webhooks: Features and Best Practices

Published


Microsoft Graph change notifications are how your app learns that something changed in Microsoft 365, such as a new message in a mailbox, an updated user, a Teams chat message, or a modified SharePoint list item, without polling the Graph API. You create a subscription to a resource, and Graph POSTs a notification to your endpoint whenever that resource changes.

Graph's model is unlike most webhook systems. There are no HMAC signatures, subscriptions expire quickly and must be renewed, and validating a delivery involves up to three separate mechanisms. This guide covers all of it: the validation handshake, clientState, rich-notification JWTs, subscription lifecycle, delivery timing, and the best practices that keep a Graph integration reliable in production.

What are Microsoft Graph webhooks?

Microsoft Graph delivers change notifications to a publicly accessible HTTPS endpoint you register when creating a subscription. Each notification tells you a resource changed; for most resources the payload carries a resource pointer rather than the full changed object, so you follow up with a Graph API read (or opt into resource data, covered below).

Graph uses a validation handshake to prove you control the endpoint, a shared clientState secret echoed in every notification, and, for rich notifications, signed JWTs.

Microsoft Graph webhook features

FeatureDetails
ConfigurationPOST /subscriptions (Graph API / SDKs)
Endpoint validationPOST with ?validationToken=, echo it back as text/plain within 10 seconds
AuthenticityclientState (required) echoed in every notification
Rich notificationsvalidationTokens JWTs + AES-encrypted resource data (opt-in)
Delivery acknowledgement2xx within 3 seconds (202 recommended); retried up to 4 hours
Retry timeoutExtended to 10 seconds on retried deliveries
Subscription lifetimeShort and resource-specific; renew via PATCH
Lifecycle eventsSeparate lifecycleNotificationUrl for reauth/removal/missed
Signature modelNo HMAC; not Standard Webhooks

The change-notification model

Graph doesn't have an event-name catalog. You subscribe to a resource with a changeType (created, updated, deleted, comma-combinable), and each notification tells you which changed:

changeType + resourceNotifies you when
created,updated on /me/mailFolders('inbox')/messagesA message arrives in or is updated in the inbox
updated,deleted on /usersA user is updated or removed in the directory
updated on /teams/{id}/channels/{id}/messagesA Teams channel message changes
updated on /drives/{id}/rootA driveItem in OneDrive/SharePoint changes

Notifications arrive as a {"value": [ ... ]} collection and may batch several changes across your subscriptions into one POST.

Setting up Microsoft Graph webhooks

Create a subscription

Send a POST to /subscriptions with the resource, changeType, your notification URL, an expiry, and a clientState secret:

POST https://graph.microsoft.com/v1.0/subscriptions
Content-Type: application/json

{
  "changeType": "created,updated",
  "notificationUrl": "https://your-app.example.com/notifications",
  "lifecycleNotificationUrl": "https://your-app.example.com/lifecycle",
  "resource": "/me/mailFolders('inbox')/messages",
  "expirationDateTime": "2026-07-23T11:00:00.0000000Z",
  "clientState": "a-secret-you-choose"
}

The validation handshake

Before the subscription is created, Graph sends a POST to your notification URL with a validationToken query parameter and Content-Type: text/plain. You must respond within 10 seconds with HTTP 200, Content-Type: text/plain, and the URL-decoded token as the body. Return anything encoded and validation fails.

Subscription lifetime and renewal

Graph subscriptions expire quickly, and the maximum lifetime depends on the resource (from around an hour for presence, a few days for Teams messages and Outlook, up to roughly a month for driveItem, SharePoint list, and user/group resources). Renew before expiry with PATCH /subscriptions/{id} and a new expirationDateTime, and subscribe to lifecycle notifications so you're warned about reauthorization and expiry.

Securing Microsoft Graph webhooks

Because there's no HMAC, authenticity rests on three checks.

Answer the validation handshake

Echo the validationToken back as plain text within 10 seconds.

Verify clientState on every notification

You set clientState at subscription time and Graph echoes it in every notification. Reject any notification whose clientState doesn't match the secret you stored. This is the primary authenticity check for standard notifications.

app.post("/notifications", express.json(), (req, res) => {
  // Validation handshake: echo the URL-decoded token as text/plain
  if (req.query.validationToken) {
    return res.status(200).type("text/plain").send(req.query.validationToken);
  }

  // Notification delivery: verify clientState on every item
  const items = req.body.value || [];
  for (const item of items) {
    if (item.clientState !== process.env.GRAPH_CLIENT_STATE) {
      return res.sendStatus(202); // not from your subscription; drop it
    }
  }

  res.sendStatus(202); // acknowledge within 3 seconds
  for (const item of items) processQueue.add(item); // process asynchronously
});

The same in Python (Flask):

import os
from flask import Flask, request, Response

app = Flask(__name__)


@app.post("/notifications")
def notifications():
    token = request.args.get("validationToken")
    if token:
        return Response(token, mimetype="text/plain", status=200)

    items = (request.get_json() or {}).get("value", [])
    for item in items:
        if item.get("clientState") != os.environ["GRAPH_CLIENT_STATE"]:
            return "", 202  # drop notifications that fail the clientState check

    # enqueue items and acknowledge within 3 seconds
    return "", 202

Validate rich-notification JWTs (if you opt in)

If you subscribe with includeResourceData: true, each notification carries a validationTokens array of JWTs. Validate them against the Microsoft identity platform's OIDC signing keys, with the audience set to your app ID and the caller's appid equal to 0bf30f3b-4a52-48df-9a82-234910c4a086 (Microsoft Graph's change-notification publisher). The resource data itself is AES-encrypted with an RSA-wrapped data key, so you supply an encryptionCertificate at subscription time and decrypt on receipt. Use this only when you need the changed object inline; otherwise a standard subscription plus a follow-up Graph read is simpler.

Microsoft Graph webhook limitations and pain points

The three-second acknowledgement window

The Problem: Graph considers a notification delivered only if it gets a 2xx within 3 seconds. Miss it and the notification is retried (with the timeout extended to 10 seconds) for up to 4 hours, and endpoints that are consistently slow get throttled or dropped.

Why It Happens: Graph caps how long it waits so one slow endpoint can't back up delivery for everyone.

Workarounds:

  • Return 202 Accepted the moment clientState checks out, and persist the notification to a queue for async processing.
  • Do zero blocking work (no Graph reads, no DB writes) before you respond.

How Hookdeck Can Help: Hookdeck answers Graph within milliseconds and durably queues the notification, decoupling the 3-second window from however long your processing actually takes.

Subscriptions expire and must be renewed

The Problem: Graph subscriptions have short, resource-specific lifetimes. If you don't renew before expirationDateTime, the subscription is deleted and notifications simply stop, often silently.

Why It Happens: Short lifetimes bound the blast radius of stale or abandoned subscriptions.

Workarounds:

  • Track expirationDateTime and renew via PATCH well before expiry.
  • Subscribe to lifecycle notifications to get reauthorizationRequired and expiry warnings.
  • Re-create subscriptions from a scheduled job as a backstop.

How Hookdeck Can Help: Hookdeck's observability surfaces a drop in delivery volume immediately, so a lapsed subscription shows up as an alert instead of as quietly missing data while you investigate downstream.

No HMAC, so authenticity leans on clientState

The Problem: There's no cryptographic signature on standard notifications. If you don't check clientState, your endpoint acts on any POST that reaches it.

Why It Happens: Graph's design uses the shared clientState secret (and JWTs for rich notifications) instead of an HMAC.

Workarounds:

  • Treat clientState as required and verify it on every notification.
  • Restrict the endpoint to Microsoft Graph's published IP ranges at your firewall.
  • Use rich-notification JWT validation where you need stronger guarantees.

How Hookdeck Can Help: Hookdeck gives you a single verified ingestion point with full request visibility, so you can confirm exactly what reached your endpoint and enforce checks consistently.

Retries and batching mean duplicates and multiplexed payloads

The Problem: The 4-hour retry window means the same notification can arrive more than once, and a single POST can batch changes from several subscriptions. Handlers that assume one change per request, processed exactly once, break.

Why It Happens: Retries prioritize eventual delivery; batching improves delivery efficiency.

Workarounds:

  • Iterate the full value array and process each item independently.
  • Dedupe on the notification id (or the resource plus change) so retries are no-ops.

How Hookdeck Can Help: Hookdeck deduplicates deliveries and can split batched payloads into individual events, so your app receives one clean, unique notification at a time. See our guide to webhook idempotency.

Best practices

Answer the handshake and check clientState

Echo the validationToken as plain text within 10 seconds, and verify clientState on every notification before doing anything with it.

Acknowledge within three seconds, process asynchronously

Return 202 as soon as clientState checks out and defer all real work to a queue. See why to process webhooks asynchronously.

Renew subscriptions ahead of expiry

Track expirationDateTime, renew via PATCH, and use lifecycle notifications so reauthorization and expiry never surprise you.

Process every item and dedupe

Loop the value array, treat each change independently, and dedupe on the notification id to absorb the 4-hour retry window.

Reconcile with delta queries

A lifecycle missed notification (or any gap) means you should resync. Use Graph delta queries to catch up rather than trusting webhooks as a complete record.

Make Microsoft Graph webhooks production-ready

Hookdeck acknowledges within the 3-second window, deduplicates, splits batches, and surfaces lapsed subscriptions

Conclusion

Microsoft Graph change notifications give you real-time visibility into Microsoft 365 changes, but the delivery model asks more of you than most. There's a 10-second validation handshake, a required clientState check in place of an HMAC, optional rich-notification JWTs, short-lived subscriptions you have to renew, and a hard 3-second acknowledgement window backed by 4-hour retries and throttling.

That's a lot of operational surface: fast acknowledgement, subscription renewal, deduplication, batch handling, and resync after missed notifications are all yours to run. Hookdeck acknowledges Graph inside the 3-second window, deduplicates, splits batched payloads, and makes delivery health visible, so your app only ever processes clean, verified notifications and a lapsed subscription surfaces immediately.

Get started with Hookdeck for free and handle Microsoft Graph webhooks reliably in minutes.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.