Microsoft Teams Error Guide: 'Subscription validation request timed out' — Fix the Graph Webhook Handshake
Fix Microsoft Graph 'Subscription validation request timed out' when creating a Teams change-notification subscription: echo the validationToken within 10 seconds, return 200, and expose a reachable HTTPS endpoint.
- #microsoft-teams
- #adaptive-cards
- #troubleshooting
- #errors
Stuck on this Microsoft Teams error? Get the free incident triage checklist
A one-page PDF — the exact steps to isolate, fix, and verify a production error like this one. No spam, unsubscribe anytime.
Overview
Microsoft Graph rejects the creation of a Teams change-notification subscription when your notificationUrl fails the validation handshake. The POST /subscriptions call returns HTTP 400 with a body like this:
{
"error": {
"code": "ValidationError",
"message": "Subscription validation request timed out.",
"innerError": {
"code": "ValidationError",
"date": "2026-07-08T10:14:03",
"request-id": "b1f0e2a4-9c3d-4a77-8f21-6e0d4c2b1a55"
}
}
}
You may also see the closely related wording when the endpoint responds but returns the wrong body or status:
"message": "Subscription validation request failed. Notification endpoint must respond with 200 OK to validation request."
Symptoms
POST https://graph.microsoft.com/v1.0/subscriptionsreturns 400ValidationErrorand no subscription is created.- Subscriptions to Teams resources (
/teams/{id}/channels/{cid}/messages,/chats/{id}/messages,/teams/{id}/members) never appear inGET /subscriptions. - Your endpoint logs show a
GET/POSTwith avalidationTokenquery parameter arriving, but Graph still reports a timeout. - It works from a local tunnel during dev, then fails after deploying behind a load balancer, WAF, or API gateway.
- Intermittent failures under load — the endpoint is doing real work synchronously and occasionally exceeds the deadline.
Common Root Causes
- Missing echo — the endpoint does not return the raw
validationTokenvalue in the response body. - Wrong content type — Graph requires the token echoed back as
text/plain; returning JSON or an HTML wrapper fails validation. - Over the 10-second deadline — the endpoint must respond to the validation request within 10 seconds; cold starts (serverless), slow auth middleware, or synchronous downstream calls blow the budget.
- Non-200 status — the endpoint returns 202/301/302/401/403 instead of 200 on the validation request (auth middleware challenging Graph is a frequent culprit).
- Endpoint not publicly reachable — private-only ingress, an IP allowlist that excludes Microsoft’s ranges, or a WAF rule blocking the request.
- TLS problems — self-signed cert, incomplete chain, or TLS version the fetcher rejects.
- URL-encoding mistake — echoing the decoded token when it must be returned exactly as received, or double-decoding it.
Diagnostic Workflow
First, confirm what Graph sends. During subscription creation, Graph issues a request to your notificationUrl with a validationToken query parameter that you must echo verbatim:
POST https://your-endpoint.example.com/api/graph/notifications?validationToken=Validation%3a+Testing+client+application+abc123
Content-Type: text/plain; charset=utf-8
Your endpoint must reply within 10 seconds with:
HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8
Validation: Testing client application abc123
Reproduce the handshake yourself before blaming Graph. Simulate Graph’s validation call and inspect the raw response:
curl -i "https://your-endpoint.example.com/api/graph/notifications?validationToken=hello-world-123"
You want to see exactly this — status 200, text/plain, body equal to the decoded token, and a fast response:
HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8
Content-Length: 15
hello-world-123
If you get 401/403, an auth layer is intercepting the request — Graph’s validation call carries no bearer token, so the notification path must be anonymous. Time the round trip to rule out the deadline:
curl -o /dev/null -s -w "time_total=%{time_total}s http_code=%{http_code}\n" \
"https://your-endpoint.example.com/api/graph/notifications?validationToken=t"
Now attempt the real subscription and read the error precisely:
curl -i -X POST https://graph.microsoft.com/v1.0/subscriptions \
-H "Authorization: Bearer $GRAPH_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"changeType": "created",
"notificationUrl": "https://your-endpoint.example.com/api/graph/notifications",
"resource": "/teams/TEAM_ID/channels/CHANNEL_ID/messages",
"expirationDateTime": "2026-07-08T11:00:00Z",
"clientState": "a-secret-you-generate"
}'
A correct handshake returns 201 with the created subscription object. A failed one returns the 400 ValidationError shown above.
A minimal, correct handler (Express) that both validates and acks notifications:
app.post("/api/graph/notifications", express.text({ type: "*/*" }), (req, res) => {
const token = req.query.validationToken;
if (token) {
// Validation handshake: echo the raw token as text/plain, fast, 200.
res.set("Content-Type", "text/plain").status(200).send(token);
return;
}
// Real notification: ack immediately, then process async off the request path.
res.sendStatus(202);
enqueueForAsyncProcessing(req.body); // do NOT await downstream work here
});
Example Root Cause Analysis
A platform team wired a subscription to /teams/{id}/channels/{cid}/messages so a bot could react to posts. It worked from a laptop over an ngrok tunnel, then every POST /subscriptions from the AKS deployment returned ValidationError: Subscription validation request timed out.
The curl reproduction against the production URL returned HTTP/1.1 302 Found with a redirect to a login page. The service sat behind an ingress that enforced Entra ID authentication on all paths. Graph’s validation request — which carries no credentials — was being bounced to sign-in, so it never received the echoed token, and Graph recorded a timeout.
The fix was to exclude the /api/graph/notifications path from the auth middleware (anonymous ingress rule) while keeping the endpoint’s own security via the clientState secret it validates on every real notification. After adding the path exclusion, the same POST /subscriptions returned 201, and GET /subscriptions listed the new subscription. Message notifications began arriving within seconds.
Prevention Best Practices
- Echo, fast, plain, 200 — return the exact
validationTokenastext/plainwith status 200, and do zero downstream work on the validation path. - Make the notification path anonymous — Graph sends no bearer token; secure the endpoint with the
clientStatevalue you set at subscription creation and verify on each notification, not with request auth. - Beat the 10-second deadline — keep serverless functions warm or use always-on hosting; never call databases or downstream APIs before responding.
- Ack notifications with 202 then process async — decouple receipt from processing via a queue so a slow handler cannot cause future timeouts.
- Verify reachability from Microsoft — public HTTPS, valid full-chain certificate, and no WAF/IP rule blocking Microsoft’s fetchers.
- Renew before expiry — Teams message subscriptions expire in about 60 minutes; run a renewal job and handle lifecycle notifications so you do not silently lose events.
- Test the handshake in CI — a synthetic
validationTokenrequest that asserts status 200,text/plain, and body equality catches regressions before deploy.
Quick Command Reference
# Simulate Graph's validation handshake (expect 200, text/plain, echoed token)
curl -i "https://your-endpoint.example.com/api/graph/notifications?validationToken=probe"
# Measure round-trip time (must be well under 10s)
curl -o /dev/null -s -w "%{time_total}s %{http_code}\n" \
"https://your-endpoint.example.com/api/graph/notifications?validationToken=t"
# Create the subscription
curl -i -X POST https://graph.microsoft.com/v1.0/subscriptions \
-H "Authorization: Bearer $GRAPH_TOKEN" -H "Content-Type: application/json" \
-d @subscription.json
# List existing subscriptions to confirm creation / check expiry
curl -s https://graph.microsoft.com/v1.0/subscriptions \
-H "Authorization: Bearer $GRAPH_TOKEN"
# Renew a subscription before it expires
curl -i -X PATCH https://graph.microsoft.com/v1.0/subscriptions/SUB_ID \
-H "Authorization: Bearer $GRAPH_TOKEN" -H "Content-Type: application/json" \
-d '{"expirationDateTime":"2026-07-08T12:00:00Z"}'
Conclusion
Subscription validation request timed out is almost never a Graph outage — it means your notification endpoint failed the 10-second handshake by not echoing the validationToken as fast text/plain 200, or by sitting behind auth/ingress that intercepted an unauthenticated request. Reproduce the handshake with curl, make the notification path anonymous, secure it with clientState, and ack real notifications with 202 before doing any work. Once the handshake succeeds, keep a renewal job running so your Teams event stream never goes quiet.
Fixed it? Get 500 Microsoft Teams & DevOps AI prompts — free
500 battle-tested, copy-paste AI prompts engineered by a senior systems engineer — every one with fill-in placeholders and safety/back-out notes. Drop your email and it's yours.
- 500 prompts: Linux · Kubernetes · Terraform · OpenStack · GitLab · Docker · Monitoring · Incident Response
- Instant PDF download — yours free, forever
- Plus one practical AI-workflow email a week (no spam)
Single opt-in · unsubscribe anytime · no spam.
Did this fix your issue?
Get 500 Battle-Tested DevOps AI Prompts — Free
500 battle-tested, copy-paste AI prompts engineered by a senior systems engineer — every one with fill-in placeholders and safety/back-out notes. Drop your email and it's yours.
- 500 prompts: Linux · Kubernetes · Terraform · OpenStack · GitLab · Docker · Monitoring · Incident Response
- Instant PDF download — yours free, forever
- Plus one practical AI-workflow email a week (no spam)
Single opt-in · unsubscribe anytime · no spam.