Skip to content

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.

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).

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.1
Authorization: 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.

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.

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.1
HTTP/1.1 401 Unauthorized
WWW-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.

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

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.1
Authorization: Bearer {token}
Content-Type: application/json
HTTP/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.

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.

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.1
Authorization: Bearer {token-with-insufficient-scope}
Content-Type: application/json
HTTP/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.

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).

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).

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.

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).

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.1
Authorization: Bearer {token}
Content-Type: application/json
HTTP/1.1 429 Too Many Requests
Retry-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.

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.

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.

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.

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).