# Guide to Microsoft Graph Webhooks: Features and Best Practices

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

| Feature | Details |
| --- | --- |
| Configuration | `POST /subscriptions` (Graph API / SDKs) |
| Endpoint validation | POST with `?validationToken=`, echo it back as text/plain within 10 seconds |
| Authenticity | `clientState` (required) echoed in every notification |
| Rich notifications | `validationTokens` JWTs + AES-encrypted resource data (opt-in) |
| Delivery acknowledgement | 2xx within 3 seconds (202 recommended); retried up to 4 hours |
| Retry timeout | Extended to 10 seconds on retried deliveries |
| Subscription lifetime | Short and resource-specific; renew via `PATCH` |
| Lifecycle events | Separate `lifecycleNotificationUrl` for reauth/removal/missed |
| Signature model | No 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 + resource | Notifies you when |
| --- | --- |
| `created,updated` on `/me/mailFolders('inbox')/messages` | A message arrives in or is updated in the inbox |
| `updated,deleted` on `/users` | A user is updated or removed in the directory |
| `updated` on `/teams/{id}/channels/{id}/messages` | A Teams channel message changes |
| `updated` on `/drives/{id}/root` | A 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:

```http
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.

### 1. Answer the validation handshake

Echo the `validationToken` back as plain text within 10 seconds.

### 2. 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.

```javascript
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):

```python
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

```

### 3. 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](/webhooks/guides/implement-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](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

### 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.

## 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](https://hookdeck.com) 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](https://dashboard.hookdeck.com/signup) for free and handle Microsoft Graph webhooks reliably in minutes.