Send API
Set BASE=https://werkmail.eu. Authenticate with a session, team, or project bearer.
New to the API? POST /api/emails with a project token — no workspace UUID in the path. Pointing an existing Resend, Postmark, or Lettermint client at Werkmail: Drop-in send APIs.
Blocked or invalid sends now include code and hint on the JSON body (same shape as Errors).
Single send
POST /api/practices/{id}/send → 202
curl -sS -X POST "$BASE/api/practices/$PRACTICE_ID/send" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-123" \
-d '{
"from": "Praxis <noreply@your-domain.example>",
"to": ["you@example.com"],
"reply_to": ["praxis@example.com"],
"subject": "Hello from Werkmail",
"text": "Plain text body",
"html": "<p>HTML body</p>",
"tag": "appointment",
"route": "outgoing",
"message_kind": "transactional",
"metadata": {"patient_id": "42"}
}'
Response:
{ "message_id": "uuid", "id": "uuid", "status": "queued" }
Blocked content returns 4xx with code content_blocked, content_reasons, and scanner.
Queued sends and provider outages
A send is processed once the outbound provider accepted it. If the provider has a
short outage (timeouts, connection errors, 5xx, throttling) Werkmail retries inline for
about two seconds. If it still fails, the message is stored as queued and you get
202 with status: "queued" and a warning, instead of an error. The same id
and RFC Message-ID are used when the retry runs, so the message is sent once. Retries
back off from 30 seconds up to 15 minutes, 10 attempts. If delivery finally fails, the
message becomes failed with the last error and a message.failed webhook is sent.
Permanent errors (validation, rejected sender, bad identity) still fail immediately.
Poll GET /api/emails/{id} for the final status. Replaying the Idempotency-Key
returns the same queued result.
Parallel requests with the same Idempotency-Key are safe: one sends, the others get the
same result, or 409 idempotency_in_progress with Retry-After. See Errors.
Fields
| Field | Type | Notes |
|---|---|---|
from |
string | Optional; defaults to the tenant From |
to |
string or string[] | Required |
cc, bcc, reply_to |
string or string[] | Optional |
subject |
string | Required |
text / text_body |
string | One of text or html required |
html / html_body |
string | |
mjml |
string | Optional raw MJML markup (compiled automatically to responsive HTML) |
scheduled_at |
string | Optional future RFC3339 timestamp for delayed transactional dispatch |
route |
string | Slug or UUID; default outgoing |
message_kind |
string | termin, recall, doi, transactional, newsletter, custom, … |
tag |
string | First tag; used in filters |
tags |
string[] | Normalized case-insensitive unique |
metadata |
object | String values; echoed on webhooks |
headers |
object | Extra MIME headers (restricted set) |
attachments |
array | See Sending |
settings.track_opens |
bool | Override route default |
settings.track_clicks |
bool | Override route default |
calendar |
object | ICS invite (summary, starts_at, …) |
idempotency_key |
string | Same as the header |
Also accepted: Postmark PascalCase (HtmlBody, TextBody, MessageStream, Headers: [{Name,Value}]), Resend tags: [{name,value}], comma-separated to, replyTo.
Recipient cap: 50 (to+cc+bcc). Dedup across buckets.
POST /api/emails is the same send when the bearer is a team or project token (or you pass practice_id).
Batch
POST /api/practices/{id}/send/batch — JSON array, max 500.
curl -sS -X POST "$BASE/api/practices/$PRACTICE_ID/send/batch" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '[
{"to":"a@example.com","subject":"One","text":"hi","idempotency_key":"order-1","tags":["orders"]},
{"to":"b@example.com","subject":"Two","text":"hi","idempotency_key":"order-2","tag":"receipt"}
]'
Bulk (templated)
POST /api/practices/{id}/send/bulk and POST /api/emails/send-bulk (flat; pass project_id).
Placeholders use {{variable}}. Each recipient is rendered locally and sent through the shared pipeline. Response is a per-recipient results[] array.
curl -sS -X POST "$BASE/api/emails/send-bulk" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: campaign-2026-03-20" \
-d '{
"project_id": "'"$PRACTICE_ID"'",
"template": {
"subject": "Hello {{name}}",
"html": "<p>Hi {{name}}, your code is {{code}}.</p>"
},
"recipients": [
{"email":"a@example.com","data":{"name":"Ada","code":"42"}},
{"email":"b@example.com","data":{"name":"Grace","code":"7"}}
]
}'
Defaults: message_kind=newsletter, route broadcast. List-Unsubscribe is always injected.
The visible "unsubscribe from all" line in the footer is a per-sender setting, off by default: PATCH /api/practices/{id}/compliance with {"unsubscribe_footer_enabled": true} switches it on for every message of that sender. It only controls the visible line. The List-Unsubscribe and List-Unsubscribe-Post headers (one-click) are always sent. For marketing mail you stay responsible for offering an unsubscribe option, for example your own link in the content.
Score (dry-run)
POST /api/practices/{id}/send/score — same body as send, no delivery.
Returns score, grade, inbox_risk, would_block, reasons, warnings, scanner, recommendations, and broadcast/Impressum eligibility when the route is marketing.
Test send
POST /api/practices/{id}/send-test
{ "kind": "quality", "to": "you@example.com" }
kind: quality | success | bounce | complaint | ooto
success / bounce / complaint / ooto target the mailbox simulator. quality sends a placement-oriented sample to the address you provide (allowlisted in safe mode).