# Microsoft Graph Webhooks

Microsoft Graph delivers change notifications (webhooks) when a resource you
subscribe to — Outlook messages, Teams chatMessages, OneDrive/SharePoint
driveItems, users, groups, presence, and more — is `created`, `updated`, or
`deleted`. There is no HMAC signature and it does not follow the Standard
Webhooks spec. Instead, Graph uses a three-part validation model.

## When to Use This Skill

* How do I receive Microsoft Graph webhooks / change notifications?
* How do I respond to the `validationToken` endpoint validation handshake?
* How do I validate the `clientState` on a Microsoft Graph notification?
* How do I create/renew a Microsoft Graph subscription (they expire fast)?
* How do I decrypt rich notifications with `includeResourceData: true`?
* How do I handle lifecycle notifications (`reauthorizationRequired`, `subscriptionRemoved`, `missed`)?
* Why is my Microsoft Graph subscription creation failing validation?

## The Three-Part Validation Model

1. Endpoint validation handshake — On subscription create (and when the
  `notificationUrl` changes),
  Graph sends `POST <notificationUrl>?validationToken={token}` with an empty
  body. You must echo the URL-decoded token back as `text/plain` with HTTP
  200 within 10 seconds, or the subscription is not created.
2. `clientState` — An opaque shared secret (max 128 chars) you set when
  creating the subscription. Graph echoes it in the `clientState` field of every
  notification. Compare it (timing-safe) to your stored value and reject
  mismatches — this is what authenticates ordinary notifications.
3. `validationTokens` (rich notifications only) — When you subscribe with
  `includeResourceData: true`, each POST includes a `validationTokens` array of
  JWTs signed by the Microsoft identity platform, and the resource data is
  AES-encrypted. See [references/verification.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/microsoft-graph-webhooks/references/verification.md).

## Verification (core)

The two checks every handler needs — the handshake and the `clientState` compare:

```javascript
const crypto = require('crypto');

// 1) Endpoint validation handshake.
//    Graph sends ?validationToken=... on subscription create/renewal.
//    Echo the (already URL-decoded) token back as text/plain, HTTP 200, < 10s.
//    e.g. Express: const token = req.query.validationToken;
//         if (token) return res.status(200).type('text/plain').send(token);

// 2) clientState — compare the value Graph echoes to your stored secret.
//    Timing-safe, length-checked. Reject the notification on mismatch.
function verifyClientState(received, expected) {
  if (!received || !expected) return false;
  const a = Buffer.from(received);
  const b = Buffer.from(expected);
  if (a.length !== b.length) return false;   // timingSafeEqual throws on length mismatch
  return crypto.timingSafeEqual(a, b);
}

```

> For complete handlers with tests (handshake + clientState + change/lifecycle
> dispatch, plus a subscribe/renew helper), see [examples/express/](https://github.com/hookdeck/webhook-skills/tree/main/skills/microsoft-graph-webhooks/examples/express/),
> [examples/nextjs/](https://github.com/hookdeck/webhook-skills/tree/main/skills/microsoft-graph-webhooks/examples/nextjs/), [examples/fastapi/](https://github.com/hookdeck/webhook-skills/tree/main/skills/microsoft-graph-webhooks/examples/fastapi/).

## Notification Payload

A basic notification (`includeResourceData: false`) is a batch under `value`:

```json
{
  "value": [
    {
      "subscriptionId": "b3a...guid",
      "subscriptionExpirationDateTime": "2026-07-22T22:11:09.952Z",
      "changeType": "updated",
      "resource": "Users/{user-id}/messages/{message-id}",
      "clientState": "your-opaque-secret",
      "tenantId": "84bd8158-6d4d-4958-8b9f-9d6445542f95",
      "resourceData": {
        "@odata.type": "#Microsoft.Graph.Message",
        "@odata.id": "Users/{user-id}/Messages/{message-id}",
        "id": "{message-id}"
      }
    }
  ]
}

```

Return `202 Accepted` within 3 seconds (queue heavy work, process async).
Graph retries failed deliveries with backoff for up to 4 hours.

## Change Types (events)

Subscribe with one or more, comma-combined (e.g. `"created,updated"`):

| `changeType` | Fires when | Notes |
| --- | --- | --- |
| `created` | A matching resource is created | Not supported for `user`/`group` |
| `updated` | A matching resource is updated | Only value supported by driveItem root / SharePoint list |
| `deleted` | A matching resource is deleted (or soft-deleted) |  |

## Lifecycle Events

Sent to a separate `lifecycleNotificationUrl` in the `lifecycleEvent` field.
Acknowledge each with `202 Accepted`, then act:

| `lifecycleEvent` | Meaning | Action |
| --- | --- | --- |
| `reauthorizationRequired` | Subscription/token about to expire or permissions changed | `POST /subscriptions/{id}/reauthorize` and/or `PATCH` a new `expirationDateTime` |
| `subscriptionRemoved` | Graph removed the subscription | Recreate it, then resync via delta query |
| `missed` | One or more notifications could not be delivered | Resync missed data via delta query |

## Subscription Lifetimes (renew before expiry)

Graph enforces short maximum lifetimes, so you must renew via `PATCH /subscriptions/{id}` before `expirationDateTime`:

| Resource | Max lifetime |
| --- | --- |
| `presence` | ~1 hour |
| Teams `chatMessage`, `channel`, `chat` | ~3 days |
| Group `conversation` | ~3 days |
| Outlook `message`/`event`/`contact` | ~7 days (~1 day with resource data) |
| `driveItem` (OneDrive), SharePoint `list` | ~30 days |
| `user`, `group` (directory) | ~29 days |
| Security `alert` | ~30 days |

## Environment Variables

```bash
# Shared secret you set as clientState when creating the subscription.
MICROSOFT_GRAPH_CLIENT_STATE=your-opaque-secret

# Only needed by the subscribe/renew helper (creating subscriptions), not the receiver:
MICROSOFT_TENANT_ID=your-tenant-id
MICROSOFT_CLIENT_ID=your-app-client-id
MICROSOFT_CLIENT_SECRET=your-app-client-secret
NOTIFICATION_URL=https://your-app.example.com/webhooks/microsoft-graph
GRAPH_USER_ID=<target-user-guid>            # app-only auth can't use /me
GRAPH_RESOURCE=users/<target-user-guid>/messages
GRAPH_CHANGE_TYPES=created,updated

```

## Local Development

```bash
# Forward Microsoft Graph notifications to your local server (no account required)
npx hookdeck-cli listen 3000 microsoft-graph --path /webhooks/microsoft-graph

```

Use the printed HTTPS URL as the `notificationUrl` when you create the
subscription. Graph immediately calls it with `?validationToken=...`; your
handler must echo the token so the subscription is created.

## Reference Materials

* [references/overview.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/microsoft-graph-webhooks/references/overview.md) - What Graph change notifications are, common resources and change types
* [references/setup.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/microsoft-graph-webhooks/references/setup.md) - App registration, permissions, and creating/renewing subscriptions
* [references/verification.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/microsoft-graph-webhooks/references/verification.md) - Endpoint validation, clientState, rich-notification JWT validation and decryption