Errors
Every error response carries the same envelope: an error object with code, message and target, plus details when a request body failed validation. The table lists each status, when it occurs and what the envelope adds.
| Status | Code | When |
|---|---|---|
| 400 | BadRequest |
Required fields missing, wrong type, unknown property in body, or query param failing semantic mutex (e.g. expirationDate AND noExpiration both present on tenantAllowBlockListItems). |
| 401 | Unauthorized |
Authorization header missing, malformed, signed by an unexpected issuer, or expired. |
| 402 | PlanRequired |
Caller passed authentication and every failed authorization requirement is a plan gate (Licensing/PlanRequirement), not a scope/role check; the endpoint’s policy needs a plan higher than the host’s current license. A failed scope/role check, alone or mixed with a failed plan check, still returns 403 instead. |
| 403 | Forbidden |
Caller authenticated but the token doesn’t carry the endpoint’s requiredRole (app-only) or delegated scope (user-on-behalf). |
| 404 | NotFound |
ExoNotFoundException (Get-* against unknown identity), or post-execution ‘couldn’t be found’ / ‘wasn’t found’ / ‘does not exist’ from a re-fetch, or ‘isn’t a member’ / ‘ACE doesn’t exist’ from a Remove-* against a non-existent link. |
| 409 | Conflict |
Patterns: ‘already exists’, ‘is already in use’, ‘is already being used’, ‘is already a member’, ‘already has access rights’, ‘is already granted’, ‘is already configured’. Most-trafficked: duplicate New-* names + duplicate Add-MailboxPermission ACEs. |
| 422 | UnprocessableEntity |
PowerShell parameter-binding error (‘A parameter cannot be found that matches’, ‘Parameter set cannot be resolved’, ‘isn’t valid’, ‘Cannot bind parameter’, ‘missing mandatory parameters’). ALSO fires from the runtime drift gate when the tenant’s cmdlet version doesn’t expose a parameter the caller sent. |
| 429 | Throttled |
Two sources: (1) our per-caller token bucket (100 req / 5 min on (oid ?? appid)), (2) EXO-side patterns: ‘Micro Delay Applied’, ‘Cmdlet execution has been throttled’, ‘ServerBusyException’, ‘FailFastException’, ‘too many requests’. |
| 500 | ExchangeCommandFailed |
ExoCmdletException whose message doesn’t match any pattern above. The raw cmdlet error is surfaced in the envelope’s message field; correlationId in the response links to the server log. |
| 500 | InternalServerError |
Defense-in-depth catch-all. Any exception that escapes the typed catches above lands here. Always logged with the full stack trace. |
| 501 | NotImplemented |
Routes whose entity is still Deferred501 in EntityCatalog. The response body names the backing cmdlets + tier + required role so callers can plan. |
| 507 | InsufficientStorage |
Patterns: ‘quota exceeded’, ‘quota has been exceeded’, ‘exceeded the maximum’, ‘storage limit’. Typical sources: New-Mailbox with full SKU, Set-Mailbox -ProhibitSendQuota beyond plan limit. |
400 BadRequest
Section titled “400 BadRequest”Request body / query failed validation
When: Required fields missing, wrong type, unknown property in body, or query param failing semantic mutex (e.g. expirationDate AND noExpiration both present on tenantAllowBlockListItems).
Envelope: The error.details[] array enumerates each invalid field by name (matches the cmdlet’s PascalCase parameter name from Wiki SYNTAX).
Request body fails binder validation
Section titled “Request body fails binder validation”A write-endpoint body that’s missing required fields, has the wrong type for a field, or contains a property the cmdlet doesn’t accept returns HTTP 400 with the canonical envelope. The envelope’s details array enumerates each invalid property so the caller can fix all at once instead of resubmitting per-error.
GET /exchange/v1.0/... HTTP/1.1Authorization: Bearer {token}Content-Type: application/json
{}HTTP/1.1 400 Bad Request
{ "error": { "code": "BadRequest", "message": "Request body failed validation.", "target": "body", "details": [ { "code": "InvalidProperty", "target": "Name", "message": "'Name' is required." } ] }}Notes: The fields named in details[*].target match the cmdlet parameter names (PascalCase as Wiki SYNTAX documents them). On the wire, body keys are camelCase; the binder is case-insensitive when matching. Field names in details are the canonical Wiki names so callers can cross-reference.
401 Unauthorized
Section titled “401 Unauthorized”Missing or expired bearer token
When: Authorization header missing, malformed, signed by an unexpected issuer, or expired.
Envelope: WWW-Authenticate: Bearer header set on the response.
Missing or expired bearer token
Section titled “Missing or expired bearer token”Every endpoint under /exchange/v1.0/ requires an Entra-issued JWT bearer token. A missing, malformed, or expired token returns HTTP 401 with the canonical error envelope. The token’s required role/scope is documented per-endpoint (see the endpoint’s requiredRole metadata).
GET /exchange/v1.0/... HTTP/1.1HTTP/1.1 401 UnauthorizedWWW-Authenticate: Bearer
{ "error": { "code": "Unauthorized", "message": "Bearer token missing or invalid.", "target": null }}Notes: An expired-token failure surfaces with the same envelope shape. Inspect the access-token claims (aud, iss, exp, roles) when debugging; the API itself does not leak token contents.
402 PlanRequired
Section titled “402 PlanRequired”Host plan does not cover this operation
When: Caller passed authentication and every failed authorization requirement is a plan gate (Licensing/PlanRequirement), not a scope/role check; the endpoint’s policy needs a plan higher than the host’s current license. A failed scope/role check, alone or mixed with a failed plan check, still returns 403 instead.
Envelope: innerError.currentPlan / requiredPlan / policy / licenseStatus
Host plan does not cover this operation
Section titled “Host plan does not cover this operation”The caller authenticated and is authorized by scope or role, but the host’s license plan is lower than the endpoint’s policy requires. For example, calling PATCH /organizationConfig on a host licensed for Community: the endpoint requires the Enterprise plan. Returns HTTP 402 with the canonical envelope. Reads and ?dryRun=true previews stay available regardless of plan.
GET /exchange/v1.0/... HTTP/1.1Authorization: Bearer {token}Content-Type: application/jsonHTTP/1.1 402 Payment Required
{ "error": { "code": "PlanRequired", "message": "This operation requires the Enterprise plan; this host is licensed for Community. Set License:Key (env License__Key) to unlock it. Reads and ?dryRun=true previews stay available.", "target": "plan:Enterprise", "innerError": { "currentPlan": "Community", "requiredPlan": "Enterprise", "policy": "OrganizationConfig.ReadWrite", "licenseStatus": "unlicensed" } }}Notes: Which policies require which plan is defined in Licensing/PlanCatalog.All: per-object writes need Pro, tenant-wide and security writes need Enterprise. Reads and ?dryRun=true previews are never gated, regardless of plan.
403 Forbidden
Section titled “403 Forbidden”Token lacks the required role or scope
When: Caller authenticated but the token doesn’t carry the endpoint’s requiredRole (app-only) or delegated scope (user-on-behalf).
Envelope: The endpoint’s required role is published in /explorer/routes.json under requiredRole / writeRole.
Token lacks the required role or scope
Section titled “Token lacks the required role or scope”The caller authenticated successfully but the token doesn’t carry the endpoint’s required role (app-only) or delegated scope (user). For example, calling DELETE /distributionGroups/{key} with a token that only carries Group.Read.All - the endpoint requires Group.ReadWrite.All. Returns HTTP 403 with the canonical envelope.
GET /exchange/v1.0/... HTTP/1.1Authorization: Bearer {token-with-insufficient-scope}Content-Type: application/jsonHTTP/1.1 403 Forbidden
{ "error": { "code": "Forbidden", "message": "Caller is not authorized to access this resource.", "target": null }}Notes: The required role for each endpoint is published in /explorer/routes.json under requiredRole + writeRole. App-only tokens use the .All variants; delegated tokens use the bare scope. Both grant the same access to the endpoint.
404 NotFound
Section titled “404 NotFound”Resource not found
When: ExoNotFoundException (Get-* against unknown identity), or post-execution ‘couldn’t be found’ / ‘wasn’t found’ / ‘does not exist’ from a re-fetch, or ‘isn’t a member’ / ‘ACE doesn’t exist’ from a Remove-* against a non-existent link.
Envelope: Endpoint-specific NotFound examples should live with each entity (e.g. the internal notes).
409 Conflict
Section titled “409 Conflict”Resource already exists / link already in place
When: Patterns: ‘already exists’, ‘is already in use’, ‘is already being used’, ‘is already a member’, ‘already has access rights’, ‘is already granted’, ‘is already configured’. Most-trafficked: duplicate New-* names + duplicate Add-MailboxPermission ACEs.
Envelope: Endpoint-specific Conflict examples (e.g. the internal notes).
422 UnprocessableEntity
Section titled “422 UnprocessableEntity”Cmdlet rejected the request
When: PowerShell parameter-binding error (‘A parameter cannot be found that matches’, ‘Parameter set cannot be resolved’, ‘isn’t valid’, ‘Cannot bind parameter’, ‘missing mandatory parameters’). ALSO fires from the runtime drift gate when the tenant’s cmdlet version doesn’t expose a parameter the caller sent.
Envelope: innerError.cmdlet names the cmdlet that rejected. For drift-gate cases, innerError.unsupportedParameters lists every switch the tenant cmdlet doesn’t accept - drop them and retry.
429 Throttled
Section titled “429 Throttled”Rate-limited (per-caller bucket OR EXO-side)
When: Two sources: (1) our per-caller token bucket (100 req / 5 min on (oid ?? appid)), (2) EXO-side patterns: ‘Micro Delay Applied’, ‘Cmdlet execution has been throttled’, ‘ServerBusyException’, ‘FailFastException’, ‘too many requests’.
Envelope: Retry-After header set on the response (seconds until the bucket replenishes for source 1; raw EXO-suggested delay for source 2 if available).
Per-caller rate limit exceeded
Section titled “Per-caller rate limit exceeded”The API rate-limits per (oid ?? appid) at 100 requests / 5 minutes. When the bucket is empty, subsequent requests return HTTP 429 with the canonical envelope + a Retry-After header indicating when the bucket replenishes.
GET /exchange/v1.0/... HTTP/1.1Authorization: Bearer {token}Content-Type: application/jsonHTTP/1.1 429 Too Many RequestsRetry-After: 12
{ "error": { "code": "Throttled", "message": "Per-caller rate limit exceeded. Retry after the Retry-After interval.", "target": null }}Notes: EXO itself also throttles - independent of our limiter. EXO-side throttle surfaces as 429 with code ‘Throttled’ wrapping the cmdlet error. Honor Retry-After in both cases. Per-caller bucket: 100 req / 5 min, refilling continuously.
500 ExchangeCommandFailed
Section titled “500 ExchangeCommandFailed”Unmapped cmdlet failure
When: ExoCmdletException whose message doesn’t match any pattern above. The raw cmdlet error is surfaced in the envelope’s message field; correlationId in the response links to the server log.
Envelope: If you see this category often, the canonical fix is to add a pattern to ExoExceptionMiddleware.MapWriteError + a corresponding row here.
500 InternalServerError
Section titled “500 InternalServerError”Unhandled exception
When: Defense-in-depth catch-all. Any exception that escapes the typed catches above lands here. Always logged with the full stack trace.
Envelope: Response body never leaks the CLR type name (per audit 2026-05-22 E). Use the correlationId field to look up the server log.
501 NotImplemented
Section titled “501 NotImplemented”Endpoint declared in $metadata, not yet implemented
When: Routes whose entity is still Deferred501 in EntityCatalog. The response body names the backing cmdlets + tier + required role so callers can plan.
Envelope: details.backingCmdlets + details.tier + details.requiredRole populated. List every deferred set via GET /explorer/deferred-sets.json.
507 InsufficientStorage
Section titled “507 InsufficientStorage”Tenant quota exhausted
When: Patterns: ‘quota exceeded’, ‘quota has been exceeded’, ‘exceeded the maximum’, ‘storage limit’. Typical sources: New-Mailbox with full SKU, Set-Mailbox -ProhibitSendQuota beyond plan limit.
Envelope: Not retryable without tenant-admin intervention (upgrade SKU, free up storage).

