Skip to the documentation
API

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.

422 Unprocessable Entity
{  "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

StatustypeCommon codes
400invalid_request_errormalformed_json, invalid_idempotency_key
401authentication_errormissing_api_key, invalid_api_key, revoked_api_key, invalid_credential_type
403permission_errorinsufficient_scope, from_address_forbidden
404not_found_errorresource_not_found
409conflict_erroremail_not_cancellable, translation_not_configured
422validation_errorinvalid_email_address, reserved_header, too_many_recipients, unknown_parameter, idempotency_key_reuse, label_not_directly_settable, unknown_language, translation_too_long
429rate_limit_errorsend_quota_exceeded, too_many_inboxes
500api_errorinternal_error
503api_errortranslation_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.

TypeScript
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);  }}