# Error codes

Errors are RFC 9457 problem documents with a stable machine code, a request id and a documentation URL. Read the code, not the HTTP status or the human-readable title — codes are a permanent contract and are never changed, only added.

```json
{
  "type": "https://docs.mailhaap.com/errors/domain_not_verified",
  "title": "Domain not verified",
  "status": 422,
  "code": "domain_not_verified",
  "detail": "The domain abcemlak.com has 2 of 4 required DNS records in place.",
  "request_id": "req_01J8XQ7M2K9P",
  "docs_url": "https://docs.mailhaap.com/domains/verification",
  "missing_records": [
    { "kind": "dkim", "record_type": "CNAME", "host": "mh1._domainkey.abcemlak.com" }
  ]
}
```

| Status | Code | What to do |
|---|---|---|
| 400 | invalid_request | A field failed validation. The detail names it. |
| 400 | invalid_json | The body was not parseable JSON. |
| 400 | unsupported_content_type | Send application/json. |
| 401 | missing_api_key | No Authorization header. |
| 401 | invalid_api_key | The key does not exist or the hash did not match. |
| 401 | revoked_api_key | The key was revoked. Create a new one. |
| 403 | insufficient_scope | The key lacks the scope this endpoint needs. |
| 403 | ip_not_allowed | The request came from outside the key's IP allowlist. |
| 403 | tenant_suspended | The workspace is suspended. Contact support. |
| 404 | not_found | Also returned for cross-workspace access — we never confirm existence. |
| 409 | idempotency_conflict | The same key was reused with a different body. |
| 422 | domain_not_verified | Publish the missing DNS records listed in the response. |
| 422 | identity_not_owned | The From address is not on a verified domain of this workspace. |
| 422 | recipient_suppressed | The address is on your suppression list. Remove it deliberately or not at all. |
| 422 | attachment_too_large | 20 MB per attachment, 25 MB per message after encoding. |
| 422 | invalid_recipient | The address failed syntax or MX validation. |
| 422 | template_render_failed | A merge tag could not resolve. The detail names it. |
| 422 | missing_unsubscribe | A campaign-plane message with no unsubscribe. |
| 429 | rate_limited | Honour Retry-After. Retry with jitter, never in a tight loop. |
| 451 | content_blocked | The abuse filter rejected the content. |
| 500 | internal_error | Retry with backoff. Quote the request id to support. |
| 503 | provider_unavailable | Retry with backoff. Queued work is unaffected. |

> **Why cross-workspace access returns 404, not 403** — A 403 confirms the object exists. Returning 404 for anything outside your workspace means an attacker with a valid key learns nothing by probing ids. There is an automated test asserting this for every resource type, and it blocks merges.
