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_FOUNDCLIENT_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...
# ...