Errors
Every 4xx and 5xx response carries a stable code field. Build your error handling around the code, not the human-readable message — the message text can change; the code is contract.
Body shape:
{
"code": "INVALID_REQUEST",
"message": "Field 'name' must be at least 1 character",
"errors": [
{ "path": "name", "message": "String must contain at least 1 character(s)" }
]
}
The errors array is present only on validation errors (INVALID_REQUEST, INVALID_BODY); other codes carry just code + message.
The 17 error codes
INVALID_REQUEST — 400
The most common 4xx. Path, query, or body failed schema validation. The response includes the per-field errors array.
curl -X POST https://platforms.infiveminutes.com/api/v1/clients \
-H "Authorization: Bearer fmp_test_..." \
-H "Content-Type: application/json" \
-d '{}'
# → { "code": "INVALID_REQUEST", "message": "name is required", ... }
INVALID_CURSOR — 400
The pagination cursor (starting_after or ending_before) couldn't be parsed. Use only cursor values returned by the API.
curl "https://platforms.infiveminutes.com/api/v1/clients?starting_after=garbage" \
-H "Authorization: Bearer fmp_test_..."
INVALID_BODY — 400
A POST/PATCH body had extra unknown fields, missing required ones, or wrong types. Similar to INVALID_REQUEST but emitted specifically by handlers that use .strict() on their zod schemas — extra fields are an error.
INVALID_TOKEN — 401
The canonical 401 from the /v1/* middleware. Returned when the Authorization header is missing, malformed, expired, revoked, or doesn't match any known credential (API key or OAuth access token).
curl https://platforms.infiveminutes.com/api/v1/me \
-H "Authorization: Bearer fmp_test_bogus"
# → { "code": "INVALID_TOKEN", "message": "Invalid or expired credential" }
Distinct from INVALID_CLIENT_SECRET (which fires only at the OAuth /token exchange). All authenticated /v1/* rejects funnel to INVALID_TOKEN.
UNAUTHORIZED — 401
Older code emitted by a handful of pre-/v1/* routes (e.g. the OAuth authorize endpoint when a session is missing). New code targeting /v1/* should branch on INVALID_TOKEN instead, but UNAUTHORIZED is kept in the contract for legacy callers.
curl https://platforms.infiveminutes.com/api/v1/clients
# → { "code": "UNAUTHORIZED", "message": "Missing Authorization header" }
INSUFFICIENT_SCOPE — 403
The canonical 403 from the /v1/* middleware. The credential is valid but lacks the scope the route requires. The message names the missing scope so you can mint a key with it or re-prompt the tenant for OAuth consent.
# API key only has clients:read; try to write.
curl -X POST https://platforms.infiveminutes.com/api/v1/clients \
-H "Authorization: Bearer fmp_test_READONLY_KEY" \
-H "Content-Type: application/json" \
-d '{"name":"Acme"}'
# → { "code": "INSUFFICIENT_SCOPE", "message": "Missing required scope: clients:write" }
FORBIDDEN — 403
Legacy 403 from non-/v1/* surfaces. Treat as a synonym for INSUFFICIENT_SCOPE when branching, but always branch on code rather than HTTP status — the /v1/* middleware exclusively emits the more specific codes above.
VENDOR_KEY_NOT_ALLOWED_ON_V1 — 403
A legacy vendor-tier key (one with no tenant_id — minted before the platform's tenancy model was finalised) tried to call /v1/*. The /v1/* surface requires a tenant-scoped credential. Mint a new tenant-scoped key at /keys or move the integration to OAuth.
curl https://platforms.infiveminutes.com/api/v1/clients \
-H "Authorization: Bearer fmp_live_LEGACY_VENDOR_KEY"
# → { "code": "VENDOR_KEY_NOT_ALLOWED_ON_V1", "message": "Legacy vendor keys cannot call /v1/* — mint a tenant-scoped key" }
APP_NOT_VERIFIED_CANT_AUTHORIZE_TENANT — 403
Returned when an OAuth app whose verification status isn't verified tries to authorize a non-sandbox tenant. Unverified apps are sandbox-only — they can complete the flow against developer-owned sandbox tenants but the platform blocks consent screens for any other tenant. Submit the app for SA review at /apps/{appId}/verification to lift the restriction.
This replaces the older UNAUTHORIZED_CLIENT name. If your error handler still branches on UNAUTHORIZED_CLIENT, swap it to this code.
NOT_FOUND — 404
The resource doesn't exist in your tenant. Cross-tenant misses also surface as 404, not 403 — we don't leak the existence of resources you can't see.
curl https://platforms.infiveminutes.com/api/v1/clients/cli_does_not_exist \
-H "Authorization: Bearer fmp_test_..."
# → { "code": "CLIENT_NOT_FOUND", "message": "Client not found" }
(The specific code is <RESOURCE>_NOT_FOUND — CLIENT_NOT_FOUND, LOCATION_NOT_FOUND, etc. They all map to HTTP 404.)
CONFLICT — 409
A mutation collided with another concurrent state — typically a uniqueness constraint (slug already taken in this tenant; default-location flip; duplicate webhook endpoint URL).
# Two clients can't share the same slug in the same tenant.
curl -X POST https://platforms.infiveminutes.com/api/v1/clients \
-H "Authorization: Bearer fmp_test_..." \
-H "Content-Type: application/json" \
-d '{"name":"Acme2","slug":"acme"}'
# → { "code": "SLUG_IN_USE", "message": "A client with slug 'acme' already exists" }
UNPROCESSABLE — 422
The request is structurally valid but violates a business rule. Examples: trying to delete a tenant's only Location (LAST_LOCATION_PROTECTED); deleting a location that has users assigned (LOCATION_IN_USE); deleting the default location (DEFAULT_LOCATION_PROTECTED).
curl -X DELETE https://platforms.infiveminutes.com/api/v1/clients/cli_X/locations/loc_default \
-H "Authorization: Bearer fmp_test_..."
# → { "code": "DEFAULT_LOCATION_PROTECTED", "message": "Promote another location to default before deleting this one" }
RATE_LIMITED — 429
Per-credential rate limit exceeded. Retry after the Retry-After header (in seconds). The response also includes X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset for proactive backoff.
# After 1000+ requests in a minute…
# → 429 Retry-After: 13
# { "code": "RATE_LIMITED", "message": "Too many requests; retry in 13 seconds" }
INVALID_GRANT — 400
OAuth-specific. The authorization code expired (10 minutes), was already exchanged, or the refresh token was reused. Refresh-token reuse revokes the whole grant family (PAPI9) — re-prompt the tenant to re-consent.
INVALID_CLIENT_SECRET — 401
OAuth-specific. The client_secret you sent to /oauth/token doesn't match what the platform stored. Rotate at /apps/{appId}/edit.
INVALID_PKCE — 400
OAuth-specific. The code_verifier doesn't hash to the code_challenge you sent. Re-derive: base64url(SHA256(code_verifier)). See OAuth flow walkthrough.
INTERNAL_ERROR — 500
Something we didn't anticipate. Retry once with exponential backoff; if it persists, file a ticket with the X-Request-Id header value from the response — that maps to a server log line.
Inspecting an error
Every response carries X-Request-Id. Include it in any support ticket — we can locate the exact log line and stack trace from it.
curl -i https://platforms.infiveminutes.com/api/v1/clients
# HTTP/2 401
# x-request-id: req_01HY...
# ...