Errors and limits
Error shape
Failed requests return JSON:
{
"code": "content_blocked",
"error": "message blocked by the content gate",
"hint": "Remove promotional phrasing or send on the broadcast route after DOI.",
"retryable": false
}
hint is safe to show in a UI. Do not parse error for control flow. Use code. retryable is true for every 5xx and for 429.
HTTP status
| Status | Typical meaning |
|---|---|
| 202 | Send accepted with a verifiable handle (message_id + id) |
| 400 | Validation, wrong route/kind, missing fields |
| 401 | Missing or invalid bearer |
| 402 | Feature needs a higher plan (plan_required) |
| 403 | Authenticated but not allowed (role / plan / paused / another workspace) |
| 404 | Unknown id (or hidden cross-tenant id) |
| 405 | Wrong method for the path — see the Allow header and the JSON body |
| 409 | Conflict (duplicate, already in that state, or a campaign transition its current status does not allow) |
| 410 | Retired endpoint (POST /api/public/unsubscribe) |
| 413 | Body larger than the message cap (~48 MiB) |
| 422 | Content gate hard-block or compliance gate |
| 429 | Rate limit, monthly cap, or warm-up cap |
| 502 | Send returned no message handle (send_unconfirmed) |
| 503 | A dependency is not available on this process (unavailable, inbound receiving, sign-in) |
Method mismatches
A method mismatch keeps the authoritative Allow header and adds a parseable body:
{
"code": "method_not_allowed",
"error": "Method GET not allowed on /api/projects",
"hint": "Allowed: POST"
}
Error-code vocabulary
retryable tells an agent whether to stop or to wait and resend. Prefer an
Idempotency-Key on any retry of a write.
| Code | Retryable | What to do |
|---|---|---|
validation |
no | Missing to/subject/body or invalid addresses |
bad_json |
no | Body is not valid JSON |
bad_id |
no | A path or body id is not a UUID |
route_kind_mismatch |
no | Newsletter → broadcast; termin → outgoing |
content_blocked |
no | Read content_reasons; use POST …/send/score |
suppressed |
no | Recipient is on a team/project/route list |
paused |
no | Reputation or abuse pause — fix the list, then resume |
impressum_incomplete |
no | Fill street, city, privacy URL |
dkim_unverified |
no | Publish DKIM and re-check DNS |
safe_mode |
no | Recipient not on the allowlist / simulator |
attachment_rejected |
no | Type, size, or double-extension |
not_supported |
no | This process cannot serve the call (message store or stats unavailable). scheduled_at on a single send is supported |
consent |
no | The recipient has not granted consent for this message kind (403) |
conflict |
no | Already in that state, or a unique field collides (409) |
invalid_domain |
no | Domain missing, an email address, or not a hostname |
immutable_field |
no | Field cannot change after create |
inbound_disabled |
no | Inbound receiving is turned off |
inbound_not_configured |
no | Inbound receiving is not set up |
not_found |
no | Check the id and that the token owns it |
campaign_not_found |
no | Unknown campaign id (404), also on start, schedule, PATCH, test and cancel |
list_not_found |
no | Unknown subscriber list id (404) |
plan_required |
no | The feature needs a higher plan (402) |
gone |
no | Retired endpoint (410); the hint names the replacement |
smtp_failed |
no | SMTP relay probe or test send failed (422); error carries the relay diagnostic |
forbidden |
no | Role, plan, or a foreign workspace UUID |
unauthorized |
no | Fix the bearer, then retry |
token_expired |
no | The API token passed its expiry (401); rotate or create a new token |
invalid_credentials |
no | Password sign-in failed; check email and password or use an email code |
invalid_code |
no | Sign-in code wrong, expired, or used up; request a new one |
method_not_allowed |
no | Read Allow, use the right method |
practice_required |
no | Drop-in path needs a team/project token or practice_id |
practice_limit |
no | The account cannot create another workspace |
rate_limited |
yes | Back off and resend; prefer batch/bulk |
plan_cap |
yes | 429. Daily or monthly volume cap reached. Resend after Retry-After (next UTC midnight or first day of the next UTC month), or upgrade |
warm_up |
yes | 429. Warm-up cap reached. Resend after Retry-After (next UTC midnight); stay on transactional meanwhile |
unavailable |
yes | 503. A dependency this route needs is not configured on this process |
send_failed |
yes | Transient send-path failure; retry with an idempotency key |
send_unconfirmed |
yes | 502 — the send returned no message handle, so nothing was confirmed |
inbound_unavailable |
yes | Inbound receiving is not configured on this process |
auth_not_configured |
yes | Sign-in is not configured on this process (503) |
Exact strings can grow; treat unknown codes as generic failures and show hint.
The same vocabulary is available machine-readably in the OpenAPI document as
x-error-codes (see https://werkmail.eu/openapi.yaml).
Idempotency
Idempotency-Key (or X-Idempotency-Key) on single send, and idempotency_key
on batch items / bulk, replay the original status and body for the same
workspace + key. Keys are scoped per tenant.
The key is claimed atomically before any work starts, so two parallel requests with the same key can never both send:
- A concurrent duplicate waits up to a few seconds for the first request. If that
one is still running, it gets 409
idempotency_in_progress(retryable: true,Retry-After: 2). Send the same request again with the same key and you get the stored result. - The same key with a different body returns 422
idempotency_key_reuse. - Failed attempts are not stored, so the same key can be retried. An attempt that crashed mid-way expires after 2 minutes.
- Keys are honoured for 24 hours and then purged.
A replay is marked explicitly and does not resend:
{
"message_id": "m-…",
"id": "…",
"status": "processed",
"idempotent": true
}
Success needs evidence
A send is only accepted when the response is 202 and carries a verifiable
handle — message_id, id, and the scanner echo. A bare 200 with an empty
body is never a successful send. If a send cannot be performed, the API returns a
4xx/5xx {code,error,hint} envelope instead. This holds for every token type
(project and team), every message kind, and every send endpoint.
Poll GET /api/emails/{id} with the returned id to follow the status after a
send; webhooks stay optional.
Rate limits
Werkmail applies a per-tenant client limiter in addition to the outbound platform. Bursting thousands of un-idempotent POSTs will 429. Prefer batch/bulk and backoff on 429.
Every 429 response carries a Retry-After header with the number of seconds to
wait before resending. The value comes from the limiter or cap that fired:
| Limit | Typical Retry-After |
|---|---|
Per-tenant send rate (rate_limited) |
1 second |
Daily warm-up cap (plan_cap / warm_up) |
Until the next UTC midnight |
Monthly volume cap (plan_cap) |
Until the first day of the next UTC month |
| Password sign-in (5 failures per email or 20 per IP in 15 minutes) | Until the oldest counted failure leaves the window |
| Sign-in codes (5 per email per hour) | Until the oldest code leaves the hour |
| Public waitlist and subscribe forms (per IP) | Until the per-IP window frees up |
When the exact wait is unknown the header is 60. Honour the larger of
Retry-After and your own backoff.
HTTP bodies are capped at MaxMessageRequestBytes (tens of MiB) so a single request cannot exhaust the process.