API mode
API mode is the endpoint shape for server-to-server callers — Cloudflare Workers, background jobs, internal services. It’s the alternative to form-mode endpoints, which are designed for browser POSTs.
The two modes are mutually exclusive per endpoint. A single Posthorn instance can host both — a contact form on /api/contact (form mode) alongside a transactional endpoint on /api/transactional (API mode) — but each individual endpoint is one or the other.
When to use API mode
Section titled “When to use API mode”Pick API mode when the caller is your own code rather than a browser:
- A worker sending password-reset emails after a click event
- A cron job sending daily digest notifications
- A monitoring tool firing alert webhooks
- An internal API that proxies email through Posthorn rather than calling the provider directly
Pick form mode for browser submissions (contact forms, feedback widgets) — see Spam protection.
Configuration
Section titled “Configuration”[[endpoints]]path = "/api/transactional"to = ["alerts@yourcompany.com"]from = "Notifications <noreply@yourcompany.com>"
auth = "api-key"api_keys = [ "${env.WORKER_KEY_PRIMARY}", "${env.WORKER_KEY_BACKUP}",]
required = ["subject_line", "message"]subject = "{{.subject_line}}"body = "{{.message}}"
[endpoints.transport]type = "postmark"
[endpoints.transport.settings]api_key = "${env.POSTMARK_API_KEY}"
[endpoints.rate_limit]count = 100interval = "1m"| Field | Required | Description |
|---|---|---|
auth = "api-key" | yes | Enables API mode. Default "form" keeps existing form-mode endpoints unchanged. |
api_keys = [...] | yes (when auth = "api-key") | List of Bearer tokens accepted on this endpoint. Multiple keys support rotation without downtime. ${env.VAR} substitution honored. |
idempotency_cache_size | no (default 10000) | Per-endpoint cache capacity. See Idempotency below. |
Making a request
Section titled “Making a request”API-mode endpoints accept JSON request bodies with an Authorization: Bearer <key> header:
curl -X POST https://yourdomain.com/api/transactional \ -H "Authorization: Bearer $WORKER_KEY_PRIMARY" \ -H "Content-Type: application/json" \ --data '{ "subject_line": "Daily digest", "message": "Here is your daily digest..." }'Response on success:
{"status":"ok","submission_id":"f9c7..."}What’s different from form mode
Section titled “What’s different from form mode”| Concern | Form mode | API mode |
|---|---|---|
| Body shape | application/x-www-form-urlencoded or multipart/form-data | application/json |
| Authentication | None (origin/honeypot defend against bots) | Authorization: Bearer <key> (constant-time compare) |
| Rate-limit bucket | Per client IP | Per matched API key |
| Honeypot field | Available | Not applicable (no bots) |
| Origin / Referer check | Optional via allowed_origins | Not applicable (servers don’t send Origin) |
| Redirect on success/error | Optional via redirect_* | Not applicable (servers don’t follow redirects) |
| Idempotency keys | Not applicable | Honored via Idempotency-Key header |
Authentication
Section titled “Authentication”API mode uses Authorization: Bearer <key>. Key comparison is constant-time (crypto/subtle.ConstantTimeCompare) so a timing attack can’t leak which key matched.
Multiple keys in api_keys are supported so you can rotate without downtime:
- Add the new key to
api_keysalongside the existing one - Restart Posthorn (or reload via your process manager)
- Switch callers to the new key
- Remove the old key, restart again
Failed auth returns HTTP 401. The failed key is never written to logs — operators investigating auth failures see an auth_failed log line with the endpoint and timestamp, but no key material.
Brute-force defense
Section titled “Brute-force defense”Posthorn tracks failed-auth attempts per client IP. After 10 failed auth attempts from one IP within ~1 minute, the bucket empties and subsequent failed attempts from that IP return 429 Too Many Requests instead of 401, with body too many failed authentication attempts. The bucket refills at the same rate, so a slow legitimate caller who occasionally typos a key recovers; a brute-force scanner trying thousands of keys per minute hits the lockout within seconds.
Successful auth never consumes the budget, so legitimate callers from the same IP are unaffected even if a misconfigured worker shares the egress with a brute-forcer. Once locked out, the IP can still attempt — Posthorn just returns 429 until the bucket refills enough to allow another attempt.
The log event is auth_rate_limited (INFO level) with client_ip set for forensic follow-up. Omitted when strip_client_ip = true is configured on the endpoint.
Brute-force lockout is the in-process protection. The network-layer protection — keeping unauthorized callers from reaching Posthorn at all — is a deployment decision: bind to loopback for in-VPS callers, or front the endpoint with Cloudflare Tunnel + Access service tokens for off-VPS callers like Cloudflare Workers. See Deploying API mode safely for the three deployment shapes and how to pick.
JSON body shape
Section titled “JSON body shape”The JSON body is a flat object whose keys become template variables. Primitive types coerce automatically:
{ "name": "Alice", "count": 42, "confirmed": true, "tags": ["urgent", "support"]}In the template:
Name: {{.name}} → AliceCount: {{.count}} → 42Confirmed: {{.confirmed}} → trueTags: {{.tags}} → [urgent support]Coercion rules:
- Strings pass through unchanged
- Numbers render without a decimal when integer-valued (
42not42.0); arbitrary-precision viastrconv.FormatFloat - Booleans render as
true/false - Arrays of primitives become multi-value fields (same shape as
?name=a&name=bin form mode) nullvalues are omitted (treated as if the field were absent)
Rejected:
- Nested objects (
{"user": {"name": "Alice"}}) → HTTP 400 - Arrays containing objects or arrays → HTTP 400
- Top-level arrays or primitives (must be an object) → HTTP 400
Nested objects are a v1.0 limitation — operators wanting structured data should flatten at the caller (e.g. user_name and user_email rather than user.name / user.email).
Per-request recipients (to_override)
Section titled “Per-request recipients (to_override)”API-mode JSON bodies may include an optional to_override field that replaces the endpoint’s configured recipients for that one request:
{ "to_override": "alice@example.com", "subject_line": "Reset your password", "message": "Click here: ..."}Or an array for multiple recipients:
{ "to_override": ["alice@example.com", "audit-log@yourcompany.com"], "subject_line": "Receipt for order #12345", "message": "Thanks for your purchase..."}This is the load-bearing primitive for transactional sends: a Worker sending password resets, receipts, or notifications inherently sends to a different user each time. The recipient can’t be pre-declared in TOML.
Validation: every address must pass the same email-syntax check used for form-mode submitters. Any invalid address returns HTTP 422 — the whole request fails (no partial sends).
Empty array ("to_override": []) is rejected with 422. Either name at least one recipient or omit the field.
Absent to_override falls back to the endpoint’s configured to list. This is useful when the same endpoint serves both per-recipient transactional sends and operator-routed sends.
Idempotency
Section titled “Idempotency”API mode honors the standard Idempotency-Key request header. When present, Posthorn caches the response and returns a byte-identical replay for any retry with the same key within 24 hours:
curl -X POST https://yourdomain.com/api/transactional \ -H "Authorization: Bearer $WORKER_KEY_PRIMARY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: ord-12345-welcome-email" \ --data '{"subject_line":"Welcome","message":"..."}'A subsequent request with the same Idempotency-Key (within 24 hours) returns the exact original response without re-sending the email. This is the primitive that lets a worker safely retry on a transient failure without duplicate sends.
Key shape: 1–255 printable ASCII characters. UUIDs, ULIDs, hex digests, or operator-meaningful strings all work. Malformed keys return HTTP 400.
Concurrent collisions: if a second request with the same key arrives while the first is still processing, the second gets HTTP 409 Conflict — not a wait-for-the-first. Callers retrying without backoff will see 409s, which is intentional: 409 surfaces a retry-without-backoff caller bug rather than hiding it.
What gets cached:
- HTTP 200 success responses — including the
submission_idandtransport_message_id - HTTP 422 validation failures — same input gives the same answer, no point re-validating
What doesn’t:
- HTTP 429 (rate limit) — transient; the bucket will refill
- HTTP 502 (transport failure) — retry is the whole point of idempotency for this case
- HTTP 5xx generally — retries should get a fresh shot
Scope: keys are per-endpoint. The same Idempotency-Key used against /api/transactional and /api/notifications does not collide — each endpoint has its own cache.
Cache size: default 10,000 entries per endpoint, evicted Least Recently Used (LRU) first. Configurable via idempotency_cache_size. Persistence across restarts is deferred to v2; a Posthorn restart wipes the cache.
Rate limiting in API mode
Section titled “Rate limiting in API mode”Unlike form mode (which keys on client IP), API mode rate-limits per matched API key. Two workers using different keys on the same endpoint have independent buckets.
This matters because server-to-server callers commonly route through shared egress IPs via Network Address Translation (NAT) — Cloudflare Workers, for example. IP-keyed rate limiting would conflate independent callers.
trusted_proxies does not apply to API mode (the rate-limit key isn’t an IP, so there’s no proxy to unwrap).
Logging
Section titled “Logging”API-mode requests log the same shape as form mode (per-request submission_id, endpoint, transport, latency_ms). Auth failures log auth_failed (without the rejected key value). Idempotent replays log idempotent_replay with the replayed status. In-flight collisions log idempotent_conflict.
The matched API key is never logged. Operators wanting to identify which key was used on a successful request need to design that into the endpoint shape (e.g. one endpoint per caller).