Errors
One shape, two levels, and a request id on everything.
The envelope
type is a frozen set you can branch on and will never grow. code is specific and additive, so treat one you do not recognise as its type. requestId is on every response, successes included, and is what ties a report to a log line.
{ "error": { "type": "validation_error", "code": "invalid_email_address", "message": "Not a valid email address: ada@", "param": "to.0", "docUrl": "https://openemail.uk/docs/api/errors#invalid_email_address", "requestId": "req_e103790543af4…" }}param is dotted and indexed, so it points at the exact element (to.0, attachments.2.filename) rather than at the field that contains it.
Status codes
| Status | type | Common codes |
|---|---|---|
| 400 | invalid_request_error | malformed_json, invalid_idempotency_key |
| 401 | authentication_error | missing_api_key, invalid_api_key, revoked_api_key, invalid_credential_type |
| 403 | permission_error | insufficient_scope, from_address_forbidden |
| 404 | not_found_error | resource_not_found |
| 409 | conflict_error | email_not_cancellable, translation_not_configured |
| 422 | validation_error | invalid_email_address, reserved_header, too_many_recipients, unknown_parameter, idempotency_key_reuse, label_not_directly_settable, unknown_language, translation_too_long |
| 429 | rate_limit_error | send_quota_exceeded, too_many_inboxes |
| 500 | api_error | internal_error |
| 503 | api_error | translation_failed |
A 404 never distinguishes "does not exist" from "belongs to another workspace". That is deliberate: the difference is itself information.
A 429 never carries Retry-After, so choose your own wait. send_quota_exceeded is the monthly send allowance and it resets on the first of the month, so show it to a person rather than backing off. too_many_inboxes is the disposable inbox mint ceiling, and extending an inbox you already hold costs nothing against it.
500 and 503 share a type and mean different things to a caller. A 503 is a dependency that did not answer (today that is the translator), and the request is worth retrying unchanged; a 500 is ours and is worth reporting with its requestId.
Handling them
Branch on type for behaviour and read code for the message you show a human. An unrecognised code is not an error in your client. It means we named a failure more precisely than we used to.
const res = await fetch(`${BASE}/emails`, { method: 'POST', headers, body }); if (!res.ok) { const { error } = await res.json(); switch (error.type) { case 'rate_limit_error': throw new RateLimited(error.code); case 'validation_error': // error.param points at the offending field throw new BadRequest(`${error.param}: ${error.message}`); case 'authentication_error': // revoked_api_key and expired_api_key are worth telling an operator apart throw new AuthFailed(error.code); default: // quote requestId when you report it throw new Unexpected(error.message, error.requestId); }}