Skip to content

Errors

Error responses use a single JSON shape:

{
  "error": {
    "type": "invalid_request_error",
    "code": "NOT_FOUND",
    "message": "Resource not found.",
    "request_id": "01H…",
    "details": {
      "resource": "subscriber",
      "id": "cust_123"
    }
  }
}

Clients show error.message. Branch on code and HTTP status. Include request_id when you contact support.

Types

type Use
invalid_request_error 4xx — bad input or business rule
api_error 5xx — something failed on our side

Common codes

The fallback message is used only when nothing more specific applies.

HTTP code Fallback message When
400 INVALID_JSON Request body is not valid JSON. Content-Type: application/json with a body that does not parse. An empty body with that header is treated as no body, so DELETE, cancel, archive, and unarchive work either way
413 BODY_TOO_LARGE Request body is too large. Body over the size limit
415 UNSUPPORTED_MEDIA_TYPE Send the body as application/json. A body in a content type the API does not read. Send JSON
401 UNAUTHORIZED Authentication required. Missing or invalid credentials
403 FORBIDDEN You don't have permission to do that. The key's role doesn't allow the route. message doesn't name the label; details.required_labels does.
404 NOT_FOUND Resource not found. Unknown key or subscriber id, or not in your organization
409 CONFLICT That already exists. Duplicate key or subscriber id, subscriber already has an active subscription, subscription already canceled, plan still in subscription history (message points at POST /org/plans/{key}/archive), feature still on a plan/override. On check and GET entitlements: Free monthly eval allotment exhausted (Eval usage)
409 OPTION_IN_USE "{option}" is still used by … Change those first. PATCH /org/features/{key} removes enum options that a plan grant or live override still uses. Nothing changes.
409 PLAN_ARCHIVED Plan {key} is archived; unarchive it or pick another plan. Create a subscription on an archived plan, or change a subscription to one. Existing subscriptions are unchanged.
422 VALIDATION_ERROR Names the field. Bad body or query (including unknown feature key on check). That doesn't look right. is only a last resort, not a schema-validation message.
429 RATE_LIMITED Too many requests. Try again in a moment. GET entitlements and POST check share 100 requests per second per organization.
500 INTERNAL_ERROR An unexpected error occurred. Unexpected server error
503 SERVICE_UNAVAILABLE Service temporarily unavailable. Temporary outage; safe to retry with backoff. A write that can't be recorded in the audit log is 503 and did not run

Details

details may be null. When set: fields for validation (path is the field, message repeats the fix), resource + id for not found and for PLAN_ARCHIVED (resource is plan, id is the plan key), field + value for CONFLICT, resource + id + field + usage for OPTION_IN_USE (each removed option in use, with the plan keys and subscriber ids that use it), required_labels for 403 (the labels the route accepts; any one is enough). message is always present. For 429, use Retry-After or details.retry_after_seconds, not message.

Validation

Schema failures are 422 VALIDATION_ERROR. message names the field the API expects, says what is allowed, and is repeated on one details.fields entry. A union (flag, limit, or config grant; flag, limit, or config override) returns the branch that matches kind when kind is present, not one entry per branch.

Missing plan_id on subscription create:

{
  "error": {
    "type": "invalid_request_error",
    "code": "VALIDATION_ERROR",
    "message": "plan_id is required.",
    "request_id": "01H…",
    "details": {
      "fields": [{ "path": "plan_id", "message": "plan_id is required." }]
    }
  }
}

{"plan":"free"} on that same body (unknown field that is clearly plan_id): Unknown field plan; did you mean plan_id? with path plan.

?limit=0 (or any non-integer) on a paginated list: limit must be an integer from 1 to 100. with path limit. A limit above 100 is clamped, not a 422. A bad starting_after names starting_after and the id format for that collection.

A bad expires_at (wrong form, or not a real date) is one detail, not one per union branch: expires_at must be a real RFC 3339 timestamp (fractional seconds optional, Z or a numeric offset), like 2026-12-31T00:00:00Z.

Catalog writes reject unknown fields. POST /org/features with "period": "month": Unknown field period. Subscriber, subscription, check, and webhook bodies still ignore unknown fields.

PATCH /org/plans/{key} with archived_at in the body: Unknown field archived_at; use POST /org/plans/{key}/archive or /unarchive.

GET /org/plans and GET /org/features reject unknown query parameters, one detail each. GET /org/plans?archived=true: Unknown query parameter archived; did you mean include_archived? with path archived. A name that is not close to a known parameter (?limit=10 on the feature list): Unknown query parameter limit. Other list routes ignore unknown query parameters.

include_archived on GET /org/plans accepts true, false, 1, and 0. Any other value is one detail at include_archived: include_archived must be true, false, 1, or 0.