Errors
Body format
Error responses do not all share one envelope. There are three shapes:
1. Domain errors (thrown through onError) use the standard envelope:
{ "error": { "code": "invalid_credentials", "message": "Invalid credentials" } }code is a stable machine-readable string; message is human-readable and may change.
2. 400 validation errors (Hono's Zod validation) use the validator's own shape, with no code:
{ "success": false, "error": { "issues": [ … ], "name": "ZodError" } }3. Some 401/403 responses (webhook signature, api-key ownership) return a flat string, not an object:
{ "error": "Missing signature" }{ "error": "Forbidden" }Recommendation: match primarily on the HTTP status code; treat code as secondary — it is absent for validation errors and flat-string responses, and message may change.
Status code table
| Status | code | Raised when | How to handle |
|---|---|---|---|
400 | — (Zod format) | Validation failed (body/schema) | Fix the request payload; check required fields |
401 | invalid_credentials | Login with wrong email/password | Prompt for credentials again |
401 | unauthorized | Missing/invalid session, session revoked, customer suspended, invalid token | Re-authenticate (log in again) |
401 | error | Missing/invalid/revoked API key | Check the key header |
401 | — (plain string) | Missing/invalid webhook signature ({"error":"Missing signature"} / {"error":"Invalid signature"}) | Check the X-YooKassa-Signature header |
402 | insufficient_balance | Reservation/debit/adjustment would go negative | Top up the balance and retry |
403 | forbidden (reserved) | ForbiddenError is defined in the error map but not thrown in beta | — |
403 | error | Ownership/role mismatch on admin routes, CSRF (Invalid origin / Missing origin), insufficient scope, workspace/customer suspended | Confirm you own the resource; send a valid Origin; grant the required scope |
403 | — (plain string) | API-key ownership mismatch ({"error":"Forbidden"}) | Confirm the key belongs to the workspace |
404 | not_found | Unknown model, unknown workspace, unknown API key, no pricing for model | Check the identifier |
409 | email_already_registered | Register with an existing email | Log in instead |
429 | quota_exceeded (reserved) | Quota limit hit — reserved: not returned in beta (quota is enforced by atomically skipping the increment, not by rejecting the request) | Reduce usage or raise the quota |
429 | error | Rate limit exceeded (per-key or auth) | Back off and retry after X-RateLimit-Reset |
500 | internal_error | Unhandled server error | Retry with backoff; report |
Reserved codes.
quota_exceededandforbiddenexist in the error map but are not returned in beta. Quotas are enforced atomically (the usage increment is skipped past quota, so the request does not fail with429).403in practice comes as anerror-code envelope (ownership/CSRF/scope) or a flat{"error":"Forbidden"}string (api-key ownership).
Handling guidance
- Retry only idempotent requests. Webhooks are idempotent by design; chat completions are not safe to blindly replay (each replay is billed).
401on a session means the cookie is gone, revoked, or expired — re-login, don't loop.402is a balance problem, not a bug: it means the reservation or deficit could not be covered.403 insufficient scopeis returned as a plainerrorcode with a message likeInsufficient scope: chat:write required— check the key's scopes.429carries rate-limit headers (X-RateLimit-Limit,X-RateLimit-Remaining,X-RateLimit-Reset) on the per-key chat path — read them before retrying.
Auth vs. rate-limit specifics
- Auth rate limiting applies to
POST /v1/auth/login(10/min/IP) andPOST /v1/auth/register(5/min/IP), returning429with codeerror(Too many attempts). - Per-key rate limiting applies to
POST /v1/chat/completions, returning429with codeerror(Rate limit exceeded).
See Rate Limits & Quotas for details.