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.