Werkmail Docs
Contents

API

Errors and limits

Error shape

Failed requests return JSON:

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:

json
{
  "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:

json
{
  "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.

View this page as Markdown · llms.txt · full corpus