Microsoft Teams Error: 'Subscription validation request failed' — Cause, Fix, and Troubleshooting Guide
Fix the Microsoft Graph 'Subscription validation request failed' (HTTP 400): echo the raw decoded validationToken as text/plain with a 200 and no extra bytes.
- #microsoft-teams
- #troubleshooting
- #errors
- #graph-api
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.
What this error means
When you create a Microsoft Graph change-notification subscription with POST /subscriptions, Graph first sends a validation request to your notificationUrl to prove you own the endpoint. That request includes a validationToken query parameter, and your endpoint must respond 200 OK with Content-Type: text/plain and a body equal to the exact decoded validationToken — nothing more. If the response content or format is wrong, subscription creation fails with HTTP 400 and the message Subscription validation request failed.
The literal error body from the create call:
HTTP/1.1 400 Bad Request
Content-Type: application/json
{
"error": {
"code": "InvalidRequest",
"message": "Subscription validation request failed. ..."
}
}
The validation request Graph sends to your endpoint looks like this:
POST {notificationUrl}?validationToken=Validation%3A+TestToken...
Content-Type: text/plain
Content-Length: 0
And the only response Graph accepts:
HTTP/1.1 200 OK
Content-Type: text/plain
Validation: TestToken...
This guide covers the content/format mismatch: wrong body, JSON wrapping, leftover URL-encoding, extra whitespace, or the wrong Content-Type. It is distinct from the validation timeout case (endpoint too slow to answer), which is covered separately.
Where it surfaces
POST /subscriptionsreturns400with a message containingSubscription validation request failed.- Your endpoint logs show a
validationTokenquery param arriving but creation still fails. - The endpoint returns
200, yet Graph rejects it — pointing at the response body or headers. - Validation succeeds with a raw handler but fails once routed through a JSON API framework.
- The echoed value differs from the token because it is still URL-encoded or JSON-wrapped.
- Manual
curlreplays of the token echo look right visually but include a trailing newline orcharset-only content type.
Identity and permission causes
- JSON-wrapped response. A framework serializes the string to
{"value":"..."}or adds quotes; Graph needs the raw token text, not JSON. - Wrong Content-Type. Responding
application/json(or omittingtext/plain) fails validation even when the body text is correct. - URL-encoded token echoed back. Returning the token still percent-encoded (for example
Validation%3A) instead of the decoded value. - Extra bytes. A trailing newline, leading whitespace, a BOM, or surrounding quotes make the body unequal to the expected token.
- Non-200 status. Auth middleware, a redirect, or a
401/302on the validationPOSTprevents the200 OKGraph requires. - Missing the validation branch. Treating the validation
POSTas a normal notification (parsing a JSON body that is empty) and returning the wrong thing.
Tracing the failed authorization
Log the raw query string and the exact bytes you return. Verify the response has status 200, Content-Type: text/plain, and a body byte-for-byte equal to the decoded token.
Replay the validation handshake with curl, sending a known token and inspecting the response headers and body precisely:
TOKEN='Validation: TestToken 12345'
curl -si -X POST \
"https://your.endpoint.example.com/notifications?validationToken=$(python3 -c 'import urllib.parse,sys;print(urllib.parse.quote(sys.argv[1]))' "$TOKEN")"
Check three things in the output: the status line is HTTP/1.1 200, the content-type is text/plain (a bare charset addition is fine, application/json is not), and the body exactly equals $TOKEN. Confirm there are no stray bytes:
curl -s -X POST "https://your.endpoint.example.com/notifications?validationToken=$(...)" | xxd | tail
A trailing 0a (newline), leading spaces, 22 quote bytes, or a leading ef bb bf BOM all indicate extra bytes that will fail the equality check.
Resolution
Echo the raw, decoded validationToken as text/plain with a 200 and no extra bytes. Detect the validation request by the presence of the validationToken query parameter and short-circuit before any JSON/notification handling.
Node.js (Express):
app.post("/notifications", (req, res) => {
const token = req.query.validationToken;
if (token) {
res.set("Content-Type", "text/plain");
return res.status(200).send(token); // raw decoded token, nothing else
}
// ...handle real notifications, respond 202...
});
C# (ASP.NET Core):
[HttpPost("/notifications")]
public IActionResult Notifications([FromQuery] string validationToken)
{
if (!string.IsNullOrEmpty(validationToken))
return Content(validationToken, "text/plain"); // no JSON wrapping, 200
// ...process notifications, return Accepted()...
}
Do not JsonConvert/res.json() the token, do not add quotes, and do not append a newline. Read the framework-decoded query value (do not re-decode or re-encode it). Make sure auth middleware allows the unauthenticated validation POST through so it returns 200, and that the endpoint answers well within the validation window. Once the echo is correct, re-issue POST /subscriptions.
Hardening access
- Raw text only. The body must equal the decoded token exactly — no JSON envelope, no quotes.
- text/plain is required.
application/jsonfails even with the right characters. - Zero extra bytes. Trailing newlines, BOMs, and leading/trailing whitespace break equality.
- Use the decoded value. Frameworks decode the query param for you; echoing the still-encoded string fails.
- Let the validation POST through. Auth gates or redirects that block a
200cause the failure just as a wrong body would. - Different from the timeout case. If the token is correct but the endpoint is slow, you get the validation timeout failure instead — fix latency there, content here.
Related identity errors
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.