Graph subscriptions, webhooks, and callbacks with GraphWarden

Microsoft Graph subscriptions deliver change notifications directly from Graph to your endpoint — they bypass the GraphWarden proxy entirely. This page explains the edge cases: which Graph traffic flows through GraphWarden, which does not, how to configure subscriptions safely, and the audit-gap implications for compliance.

Edge case reference for Graph subscriptions

Prerequisites

- Familiarity with Graph subscriptions — the `/subscriptions` endpoint and change-notification delivery model - A deployed GraphWarden proxy and a separate webhook endpoint owned by your application

Decide between Graph subscriptions and polling for GraphWarden

Help me decide between Graph subscriptions vs polling for my GraphWarden deployment.

My application is governed by a GraphWarden ruleset. I need to track changes to a Graph resource (for example, new mail messages, user directory changes, or Team membership changes) and I am weighing the tradeoffs between /subscriptions change notifications and periodic polling.

I need:
1. A clear statement of which option flows through the GraphWarden proxy and produces audit events
2. The compliance implications of each option under my framework (Loi 25 / PIPEDA / GDPR / SOC 2)
3. A recommendation based on my update frequency, latency tolerance, and audit retention requirements

Reference documentation: https://graphwarden.com/llms.txt

Ask me for my Graph resource type, expected change volume, and compliance framework before recommending.

Reference: llms.txt

What flows through GraphWarden

GraphWarden is a reverse proxy your application calls in place of https://graph.microsoft.com. Every Graph request your application initiates — read, write, delete, or subscription management — goes through the proxy and is evaluated against your ruleset.

The traffic that flows through GraphWarden:

  • Direct Graph API calls from your application. Every GET /users, POST /teams, DELETE /messages/{id}, and similar call. The proxy matches each request against your ruleset, applies the verdict (allow, filter, block, log-only), and returns a filtered response.
  • Subscription creation, update, and deletion calls. POST /subscriptions (to create), PATCH /subscriptions/{id} (to renew), and DELETE /subscriptions/{id} (to remove) are ordinary Graph API calls. They pass through the proxy and are audited like any other request. Your ruleset can allow or block these calls.
  • Token acquisition against /proxy/token. Your application acquires a proxy-issued JWT from the proxy; the proxy exchanges that JWT for a real Graph token against Azure AD. Both hops are audited.

Every audit event produced by this traffic carries the method, path, caller, status code, matched rule, and verdict — the full evidence chain your compliance framework requires.

What does NOT flow through GraphWarden

The critical edge case for anyone governing Graph traffic: change notification deliveries bypass the proxy entirely.

Once you create a subscription (via POST /subscriptions, which the proxy audits), Microsoft Graph is authoritative for delivery. When the subscribed resource changes, Graph sends an HTTP POST to the notificationUrl you specified in the subscription. That POST originates from Microsoft's infrastructure and arrives at your endpoint directly. It does not touch the GraphWarden proxy.

The asymmetry in plain terms:

Step Path Audited by GraphWarden?
Create subscription Your app -> GraphWarden proxy -> Graph API (POST /subscriptions) Yes
Renew subscription Your app -> GraphWarden proxy -> Graph API (PATCH /subscriptions/{id}) Yes
Delete subscription Your app -> GraphWarden proxy -> Graph API (DELETE /subscriptions/{id}) Yes
Change notification delivery Microsoft Graph -> your webhook endpoint (direct POST) No
Validation token exchange Microsoft Graph -> your webhook endpoint (direct POST with validationToken) No

The same rule applies to every Graph capability that follows the same delivery pattern — including Teams change notifications, presence subscriptions, and any feature that Microsoft delivers to your endpoint from their infrastructure. Outbound calls your app makes to Graph are audited; inbound deliveries from Graph to your app are not.

Audit-gap implications

Change notifications are not captured in GraphWarden audit logs. Your compliance scope must account for this gap when you rely on subscriptions for workflows that touch regulated data.

The practical consequence: if your application processes personal data, health information, or other regulated content that arrives via change notifications, the processing record is not in GraphWarden. Your compliance evidence for that portion of the data flow must come from elsewhere.

This matters differently for different frameworks:

  • Loi 25 and PIPEDA (Canada) — both frameworks require records of access to personal information. See Loi 25 (FR) and PIPEDA. Change notifications that carry personal information need an audit trail your privacy officer can consult during a breach investigation or subject-access request.
  • GDPR (EU) — Article 30 records of processing activities cover every touch of personal data. See GDPR. If a change notification delivers the name or email of a data subject, the processing needs to be recorded even though the proxy did not see the request.
  • SOC 2 / HIPAA / ISO 27001 — all three frameworks require audit trails of data access events. A delivery gap widens the surface area your auditor examines because the evidence needs to come from the application logs rather than a single GraphWarden stream.

The mitigation section below describes three patterns for closing the gap without giving up on subscriptions entirely.

Mitigation patterns

Three patterns close the audit gap in order of increasing engineering effort. Pick the one that fits your workload.

Pattern 1: Prefer polling over subscriptions

For compliance-sensitive resources, periodic polling replaces subscriptions. Your application queries Graph on a schedule (for example, GET /me/messages?$filter=receivedDateTime ge {last_poll}) and every request flows through the proxy. The audit log carries a complete record of every access.

Tradeoffs:

  • Latency. Polling updates arrive on the poll interval, not on the Graph change. If you poll every 60 seconds, the update lag is up to 60 seconds.
  • Quota. Polling consumes Graph request quota even when nothing changed. Use $delta queries against endpoints that support them to minimize wasted calls.
  • Simplicity. No webhook endpoint, no validation token handling, no subscription renewal. The entire data flow is audited.

Polling is the default recommendation for resources governed by regulated-data frameworks. The latency cost is usually acceptable against the audit-completeness benefit.

Pattern 2: Log subscription deliveries from your webhook endpoint

When subscriptions are the right fit for a workload (for example, high-volume Teams presence updates where polling quota would be prohibitive), emit audit events from your webhook endpoint that match the proxy's audit schema. Your application becomes the audit source for the delivery leg of the flow.

The schema to match is documented in the Agent API reference — same event fields, same UTC timestamp format, same GWOrgId tenant tagging. Write the events to the same Elasticsearch index your proxy writes to (or to a parallel index your compliance team can query). The result: one query against the audit system reveals both proxy-originated and webhook-originated events.

Tradeoffs:

  • Engineering cost. You author and maintain the audit-emit code in your webhook handler. Schema drift between proxy and webhook emitters is an ongoing concern; add a CI check that compares field names.
  • Reliability. Your webhook endpoint must emit the audit event on every delivery. If the endpoint crashes mid-request, the audit record is missing. Use the same reliability pattern you use for any critical side-effect (retry queue, dead-letter on failure).
  • Completeness. This closes the audit gap from the compliance auditor's perspective. The gap is conceptual — two sources instead of one — but the evidence chain is complete.

Pattern 3: Validate deliveries and process through proxy calls

A hybrid pattern: your webhook endpoint receives the notification but does not read the data from the notification payload. Instead, the endpoint extracts the resourceData.id or the resource URL from the notification and issues a fresh Graph call through the proxy to fetch the data. The notification acts only as a trigger; the data read flows through the audit path.

Tradeoffs:

  • Correctness. The delivered payload can be richer than the re-fetched payload when Graph includes encrypted content via encryptedContent. Using the re-fetch pattern sacrifices the delivered payload richness.
  • Quota. Every notification produces a follow-up Graph call. For high-volume subscriptions, this doubles the proxy traffic.
  • Audit completeness. The data access flows through the proxy; the webhook delivery itself carries no data the compliance team cares about (only an identifier). The audit chain stays inside GraphWarden.

This pattern fits when notification volume is moderate and the compliance team wants a single audit source without the engineering cost of dual-emit.

Configuring a subscription via GraphWarden

Creating a subscription is an ordinary Graph call through the proxy. The call passes through your ruleset; if the ruleset allows POST /subscriptions, the call reaches Graph and the subscription is created.

A typical subscription create:

POST /subscriptions HTTP/1.1
Host: proxy.customer.internal
Authorization: Bearer {proxy-issued-jwt}
Content-Type: application/json

{
  "changeType": "created,updated",
  "notificationUrl": "https://yourapp.example.com/webhooks/graph",
  "resource": "me/mailfolders('Inbox')/messages",
  "expirationDateTime": "2026-04-20T18:23:45.9356913Z",
  "clientState": "secretClientValue",
  "latestSupportedTlsVersion": "v1_2"
}

This POST flows through the proxy and is audited. Your ruleset can restrict subscription creation by path (POST /subscriptions -> allow) or by resource (/subscriptions + condition on request body -> allow for specific resource values only). See the rule schema for the condition-type reference.

The subscription itself, once created, is owned by Graph. Your endpoint at notificationUrl starts receiving POSTs directly from Microsoft. Those POSTs do not flow through the proxy even though the subscription was created through it.

Certificate pinning and validation tokens

When you create a subscription, Graph sends an initial POST to notificationUrl with a validationToken query parameter. Your endpoint must echo the token in the response body within 10 seconds. This handshake confirms to Graph that your endpoint is reachable before Graph starts delivering notifications.

The validation POST comes from Microsoft, not from the proxy. Your endpoint must be reachable from the public internet (or from Microsoft's service tags) regardless of whether the proxy is involved in any other part of the flow.

A minimal validation handler:

POST /webhooks/graph?validationToken=abc123 HTTP/1.1
Host: yourapp.example.com

(empty body)

Response:

HTTP/1.1 200 OK
Content-Type: text/plain

abc123

Subsequent notification POSTs include the clientState value you specified when creating the subscription. Your endpoint validates clientState matches the expected value on every delivery — this is your defense against spoofed notifications from attackers who discovered your webhook URL.

For higher assurance, Graph supports encrypted notifications where the payload is encrypted with a certificate your application supplies at subscription creation time. The certificate's public key goes in the subscription request; Graph encrypts the notification body with it; your endpoint decrypts with the matching private key. This does not change the audit path — encrypted or not, the delivery still bypasses the proxy — but it does guarantee only your endpoint can read the payload.

Troubleshooting

The entries below cover the common surprises when wiring subscriptions behind GraphWarden.

Subscription creation returns 403 from the proxy

Your ruleset blocks POST /subscriptions. The 403 response body names the matched rule id. Direct your GraphWarden administrator to the rule with that id — either the path pattern is broader than intended, or a condition on the rule excludes your App Identity. This is a ruleset configuration issue, not a subscription or webhook issue.

Webhook endpoint not reachable by Graph

Graph cannot POST to your notificationUrl. Common causes: the endpoint is behind a firewall that only allows internal traffic; the DNS name does not resolve publicly; the TLS certificate is self-signed or expired; or the endpoint responds to the validation handshake with a non-200 status code. Test the endpoint with a direct curl -X POST from a machine outside your network before creating the subscription.

clientState mismatch in delivered notifications

A notification arrives with a clientState that does not match the value you specified when creating the subscription. Either the notification is spoofed (reject it) or your subscription record has drifted from the stored expected value. Re-fetch the subscription via GET /subscriptions/{id} through the proxy; the returned clientState is the authoritative value. If the delivered notification's clientState matches the stored value after re-fetch, your application's stored expected value was stale.

Certificate validation failures on encrypted notifications

Graph cannot find a usable public key to encrypt the notification with, or your endpoint cannot decrypt with the matching private key. The encryption certificate passed at subscription creation must be valid (not expired, correct key usage) and your endpoint must have the private key available at the time of each delivery. Rotating the certificate requires creating a new subscription; you cannot swap the key on an existing subscription.

Subscription renewal returns 403 with rule id

Same root cause as subscription creation — the ruleset blocks PATCH /subscriptions/{id}. Add the PATCH method to the allowed path pattern in the rule, or add a dedicated rule with methods: [PATCH] on /subscriptions/*.

Last reviewed:

Last reviewed: .