Skip to content

Error copy — Stripe-style message

Copy appendix for api-errors.md.

Stripe’s split, in our envelope:

Field Owner Use
type machine invalid_request_error | api_error
code machine Closed set in api-errors.md. Clients branch on this + HTTP status.
message human One sentence. Safe to show in a UI. No regex, no body/key must match, no VALIDATION_ERROR.
request_id support Correlation. Not shown in product UI.
details machine Extra context. Optional. Never the only place the human sentence lives.

Clients show error.message.

details.fields is for multi-field 422s. message is still a complete sentence (first / most specific field).

  • Show error.message. Do not build a sentence from details or field paths.
  • Product UI shows the sentence, not request_id, HTTP status, or code.
  • message does not include SQL, stack traces, or bind-error text. Bind failures use a fallback sentence.
  • Unknown org, non-member, and inactive member are 404 Resource not found.

Use these only when a more specific sentence does not apply.

Code HTTP Fallback message
INVALID_REQUEST 400 Malformed request.
VALIDATION_ERROR 422 That doesn’t look right.
UNAUTHORIZED 401 Authentication required.
FORBIDDEN 403 You don’t have permission to do that.
NOT_FOUND 404 Resource not found.
CONFLICT 409 That already exists.
ORGANIZATION_HANDOFF_REQUIRED 409 Hand off or delete your organizations first.
PAYLOAD_TOO_LARGE 413 Request body is too large.
RATE_LIMITED 429 Too many requests. Try again in a moment.
INTERNAL_ERROR 500 An unexpected error occurred.
SERVICE_UNAVAILABLE 503 Service temporarily unavailable.

UNAUTHORIZED / NOT_FOUND / INTERNAL_ERROR / SERVICE_UNAVAILABLE stay generic on purpose.

RATE_LIMITED stays this sentence. Do not interpolate remaining seconds into message (details.retry_after_seconds / Retry-After / X-RateLimit-* carry that).

FieldError(path, message) sets message as the product sentence. details.fields[0] repeats that sentence (path stays machine).

When message
Name empty Name is required.
Name > 128 Name is too long.
When message
user_id empty Choose someone to add.
user_id > 128 That user ID is too long.
PATCH status missing Status is required.
PATCH status not active or suspended Status must be active or suspended.
Duplicate user in org This person is already a member.
Remove last member Delete the organization instead of its last member.
PATCH at the org address with no fields Nothing to update.

Members, invites, and service accounts.

When message
role not a slug in the roles file That role doesn’t exist.
role sent while roles are off Roles aren’t set up for this deployment.
Demote, remove, or delete the last creator_role holder Keep at least one member with the {creator_role} role.

All are 422 VALIDATION_ERROR with path role, except the last-holder rule on a remove, whose path matches the last-member rule (member_id, or service_account_id on a service-account delete).

When message
Name empty Name is required.
Name > 128 Name is too long.
PATCH with no name Nothing to update.
Delete last member via the service account Delete the organization instead of its last member.
When message
expires_in_seconds out of range Expiry must be between 60 seconds and 30 days.
email > 320 That email is too long.
max_uses 0 or negative Max uses must be at least 1.
Redeem of a redeemed invite This invite has already been used.
Redeem of a revoked invite This invite is no longer valid.
Redeem of an expired invite This invite has expired.

Unknown tokens use the generic 404 Resource not found. (existence policy; do not name the org). Redeemed / revoked / expired tokens are 409 CONFLICT with { "field": "status", "value": "redeemed" | "revoked" | "expired" }. Already a member on a still-active token is 200, not 409.

When message
Name > 128 Name is too long.

Internal validate (credential or idp required) is not product UI. Fallback 422 is enough.

Fallback 422 is enough.

AppError::validation puts the validation text in message. Operator-facing.

Gateway-owned 401/403/404/413/429/500/503 keep the fallback table.

Siblings (cli, template-*, web-demo): update only if they snapshot exact plat5 message strings.