Werkmail Docs
Contents

API

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

bash
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:

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

bash
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.

bash
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

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

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