Error copy — Stripe-style message
Copy appendix for api-errors.md.
Locked
Section titled “Locked”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).
Writing message
Section titled “Writing message”- Show
error.message. Do not build a sentence fromdetailsor field paths. - Product UI shows the sentence, not
request_id, HTTP status, orcode. messagedoes 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.
Fallback message per code
Section titled “Fallback message per code”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).
Identity — specific message
Section titled “Identity — specific message”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. |
Members
Section titled “Members”| 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).
Service accounts
Section titled “Service accounts”| 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. |
Invites
Section titled “Invites”| 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.
API keys (user + member)
Section titled “API keys (user + member)”| When | message |
|---|---|
| Name > 128 | Name is too long. |
Internal validate (credential or idp required) is not product UI. Fallback 422 is enough.
Pagination (limit / starting_after)
Section titled “Pagination (limit / starting_after)”Fallback 422 is enough.
Route-registry
Section titled “Route-registry”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.