API reference
Emails
Send, batch-send, retrieve and cancel messages.
Quick start
Start with a Test key (API keys page, mode Test). Sends with a Test key never leave the platform. The worker simulates the outcome (delivered, or a bounce for bounce@simulator.amazonses.com), writes the same events, and counts nothing against your domain. When the integration works, mint a Live key and change one environment variable.
Use POST /emails/batch for more than one message. One call sends up to 100 messages and returns one id per item. A loop over POST /emails hits the per-key rate limit (429 with Retry-After), and each call counts one request.
SMTP
The SMTP relay is the one ingress that is not HTTP. Each message that it accepts becomes one POST /emails with the same key, limits, checks and log row. The reference page lists the host, the reply codes, the headers that the relay keeps and the ones it replaces.
Send an email
POST /api/v1/emails
Body
| Field | Type | Notes |
|---|---|---|
from |
string | Required. email or Name <email>. The domain must be one of the verified domains of the organization (and allowed for the key). |
to |
string | string[] | Required. One address or an array. |
cc |
string | string[] | Optional. |
bcc |
string | string[] | Optional. to + cc + bcc ≤ 50 addresses. |
reply_to |
string | Optional single address. |
subject |
string | Required unless template is set. 1–998 chars, no line breaks. |
html |
string | Body; ≤ 2 MB. html or text (or both) required unless template is set. |
text |
string | Plain-text body; ≤ 2 MB. |
template |
object | Send from a published template instead of subject/html/text (mutually exclusive). See below. |
headers |
object | Extra headers, { "X-Entity-Ref-ID": "…" }. Names [A-Za-z0-9-], values ≤ 1000 chars, no line breaks. Names the platform sets (Message-ID, Feedback-ID, Return-Path, DKIM-Signature, From, To, Cc, Bcc, Reply-To, Sender, Subject, Date, MIME-Version, Content-Type, Content-Transfer-Encoding, Content-ID, Content-Disposition) answer 422 reserved_header. The platform drops List-Unsubscribe and List-Unsubscribe-Post. |
tags |
array | Up to 20 { "name", "value" } pairs (name ≤ 64, value ≤ 256). Returned in webhook payloads. |
kind |
string | Message class, ≤ 64 chars, e.g. "invite", "digest.weekly". Bulk prefixes (bulk., broadcast., digest., sequence.) require exactly one recipient and add unsubscribe headers. Also read from a kind tag if the field is absent. |
scheduled_at |
string | ISO 8601 with offset (2026-09-01T09:00:00-04:00). Message stays queued until then and can be canceled. |
idempotency_key |
string | 1–256 chars. Same effect as the Idempotency-Key header. |
track |
object | { "opens": true, "clicks": true }. Default both off. Needs the tracking host of the domain and a single recipient. |
template
| Field | Type | Notes |
|---|---|---|
id / slug |
string | One of them required. |
version |
number | "latest" |
A published version number; omitted or "latest" = highest published. Drafts never resolve. |
data |
object | Values for the declared variables of the template (dotted paths become nested objects: { "user": { "name": "Jane" } } for {{user.name}}). |
The platform renders the template when it accepts the request, so the stored message is exactly what goes out. A render error returns 422 template_variables. A missing or unpublished template returns 422 template_not_found.
attachments
Up to 10 files per message, 10 MiB in total after decoding. Each item is { "filename", "content", "content_type" }. content is base64. content_type defaults to application/octet-stream. The platform stores attachments with the message body (same retention) and never returns the content through the API — GET /emails/:id lists filename, content_type and size. Batch requests do not accept attachments (422 attachments_not_in_batch).
{ "from": "Acme <billing@example.com>", "to": "jane@example.org", "subject": "Invoice 1234", "text": "Attached.", "attachments": [{ "filename": "invoice-1234.pdf", "content": "JVBERi0xLjQK…", "content_type": "application/pdf" }] }
Response
{ "id": "cmf9x1v0000018lfr6zot86p" }
- The platform drops recipients on the suppression list before it queues the message. It lists them in
"suppressed": ["a@b.c"]. If no recipient remains, it stores the message with statussuppressedand does not send it. - A replayed idempotency key returns the original id with the header
Idempotent-Replayed: true.
Example
curl -X POST https://api.f5send.com/api/v1/emails \
-H "Authorization: Bearer $F5SEND_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: digest:2026-08-17:jane" \
-d '{
"from": "Acme Digest <digest@example.com>",
"to": "jane@example.org",
"kind": "digest.weekly",
"template": { "slug": "weekly-digest", "data": { "user": { "first_name": "Jane" }, "count": 3 } },
"tags": [{ "name": "week", "value": "2026-33" }],
"track": { "opens": true, "clicks": true }
}'
Send a batch
POST /api/v1/emails/batch
The body is a JSON array of 1–100 send objects (same fields as above). The API processes the items in order. A failing item does not stop the rest.
{
"data": [
{ "id": "cmf9x1v0000018lfr6zot86p" },
{ "error": { "code": "domain_not_verified", "message": "Domain other.com is pending" } }
]
}
Per-item idempotency_key works. The API does not apply the Idempotency-Key header to batches.
Retrieve an email
GET /api/v1/emails/:id
{
"object": "email",
"id": "cmf9x1v0000018lfr6zot86p",
"from": "Acme <hello@example.com>",
"to": ["jane@example.org"],
"cc": [],
"bcc": [],
"reply_to": null,
"subject": "Welcome to Acme",
"created_at": "2026-08-17T14:03:11.120Z",
"scheduled_at": null,
"last_event": "delivered",
"mode": "live",
"events": [
{ "type": "sent", "recipient": "jane@example.org", "at": "2026-08-17T14:03:12.410Z" },
{ "type": "delivered", "recipient": "jane@example.org", "at": "2026-08-17T14:03:13.902Z" }
]
}
last_event is the message status in lower case: queued, sending, sent, delivered, bounced, complained, failed, suppressed, canceled, unconfirmed. An unconfirmed message left the provider, but no delivery event arrived; an operator closes such messages after an ingest outage. Each event has type (queued, sent, delivered, delivery_delayed, bounced, complained, rejected, opened, clicked, unsubscribed, suppressed, failed), recipient and at. When present, an event also has bounce_type (hard / soft / undetermined), detail (provider sub-type or failure text) and url (clicks).
mode is live or test (see below).
Test mode
A f5_test_ key makes each POST /emails a simulation. The message goes through the full contract (domain check, key restriction, suppression, idempotency, bulk rules). Then the worker, not Amazon SES, decides the outcome from the local part of each recipient:
| Recipient | Events |
|---|---|
bounce@… |
sent, bounced (hard) |
complaint@… |
sent, delivered, complained |
suppressionlist@… |
sent, rejected |
ooto@… or any other address |
sent, delivered |
The domain part does not matter. The worker ignores a +label. Test messages show a Test pill in the console and report "mode": "test". They never touch metrics, warm-up, daily caps or the suppression list. Webhooks fire with the same payloads as live mail, so you can test your handlers end to end. A test key cannot create or send broadcasts, post events, or write contacts and subscriptions (403 test_key_not_allowed), because those paths start live mail from the worker.
List emails
GET /api/v1/emails?limit=25&cursor=…&status=delivered&to=jane@example.org&kind=invoice&mode=live&since=2026-08-01T00:00:00Z&until=2026-08-31T23:59:59Z
Returns summaries, newest first. Every filter is optional. limit is 1–100 (default 25). The response carries next_cursor when more pages exist; pass it back as cursor. The cursor is opaque; an invalid one answers 422 invalid_cursor. A domain-restricted key sees only the messages of its domains.
{
"object": "list",
"data": [
{ "id": "cmf9…", "from": "Acme <hello@example.com>", "to": ["jane@example.org"], "subject": "Welcome", "last_event": "delivered", "mode": "live", "kind": "welcome", "created_at": "2026-08-17T14:03:11.120Z", "sent_at": "2026-08-17T14:03:12.410Z" }
],
"next_cursor": "MjAyNi0wOC0xN1QxNDowMzoxMS4xMjBafGNtZjk…"
}
Fetch /emails/:id for events and bodies.
Cancel an email
POST /api/v1/emails/:id/cancel
The call works while the message is queued (this includes scheduled sends). Response:
{ "id": "cmf9x1v0000018lfr6zot86p", "object": "email", "status": "canceled" }
Once the worker has picked the message up, or the platform has already sent it, the call returns 409 not_cancelable.