Email API
Send transactional email messages.
Every request needs a bearer token and goes to a path under https://api.anypost.com/v1. See API conventions for the shared request, error, and pagination rules.
Send an email
POST /v1/email
Submits a transactional message for delivery. Anypost validates the request, generates a public message id, and queues the message for delivery. A 202 response means the message was accepted for delivery — not that it has been delivered. Delivery events (delivered, bounced, complained) are surfaced via webhooks.
Multi-recipient sends in this endpoint use a single envelope
with multiple RCPT TOs — one logical message goes to all
recipients. To send many independent messages in one HTTP
request — different froms, subjects, bodies — use
POST /v1/email/batch instead. The two are not interchangeable.
See EmailSendRequest for the full set of supported fields.
At least one of text, html, or template_id must be
present.
Idempotency
Pass an optional Idempotency-Key header to make retries
safe. See the parameter description for full semantics.
Parameters
Idempotency-Keystringin header[optional]Optional client-supplied key (1–255 printable-ASCII bytes) that de-duplicates retries of the same logical request. Scoped per team — the same key reused across
POST /v1/emailandPOST /v1/email/batchshares one cache entry, so different request shapes under the same key will collide.- Replay — if the same key is reused with the same request body within 24 hours, the original response (status, body) is returned verbatim. The message is not re-sent.
- Mismatch (different body) — if the same key is reused
with a different request body, the gateway returns
422 idempotency_mismatch. - Concurrent — if a request with the same key is still
in-flight, the second request returns
409 idempotency_concurrent. - Server errors —
5xxresponses are not cached, so a retry with the same key actually retries the operation.2xxand4xx(including207 Multi-Statuson batch) are cached and replayed.
Request body
Send as JSON with Content-Type: application/json.
fromstring[required]Sender address on a verified domain. Accepts either a bare address (
addr@host) or RFC 5322 name-addr form with a friendly display name (Display Name <addr@host>, or with the name quoted:"Last, First" <addr@host>). The display name appears in theFrom:header only — the SMTP envelope (MAIL FROM) always uses the bare address. CR/LF in either part is stripped before the message is built. The recipient fields (to,cc,bcc,reply_to) accept the same two forms.toarray of string[required]One or more primary recipient addresses. All recipients receive the same envelope (a single submission with multiple
RCPT TOs) and appear in theTo:header. Each entry accepts either a bare address (addr@host) or RFC 5322 name-addr form (Display Name <addr@host>, or quoted:"Last, First" <addr@host>); the display name flows into the renderedTo:header. Combined withccandbcc, the total recipient count must not exceed 50.ccarray of string[optional]Courtesy-copy recipients. Visible to all other recipients via the
Cc:header. Each entry accepts a bare address or RFC 5322 name-addr form (seeto). Counts against the combined 50-recipient limit.bccarray of string[optional]Blind-carbon-copy recipients. Receive the message but are not listed in any header — hidden from all other recipients. Each entry accepts a bare address or RFC 5322 name-addr form (see
to); display names on bcc are accepted for symmetry but never surface anywhere in the rendered message. Counts against the combined 50-recipient limit.reply_tostring or array of string[optional]One or more addresses to put in the
Reply-Toheader. Accepts a single string or an array (maximum 10). Each entry accepts a bare address or RFC 5322 name-addr form (seeto).subjectstring[optional]Subject header value. CR and LF characters are stripped before the message is built (header injection guard); a subject that sanitizes to empty is rejected. The 998-character cap matches RFC 5322's line-length limit.
Required unless
template_idis supplied and the referenced template carries a subject. When both are present, the request value wins.template_idstring[optional]Reference to a stored template (see
POST /v1/templates). When supplied, the server resolves the template and uses its pre-renderedhtml/textbodies and, if the request omits its ownsubject, the template's subject.Cannot be combined with inline
htmlortext— pick one source of body content per send. The lookup is scoped to the authenticated team; atemplate_idfrom another team is indistinguishable from a missing template (both return400witherrors.template_id).The referenced template must have been published. Sends are always rendered from a template's published content; a template that has only a draft (never published) is rejected with
400and anerrors.template_idmessage stating it has no published content. Publish a draft viaPOST /v1/templates/{id}/publishfirst.textstring[optional]Plain-text body. UTF-8. Maximum 1,000,000 characters. At least one of
text,html, ortemplate_idis required.textandhtmlmay both be set (sent as a multipart/alternative message); neither may be combined withtemplate_id.htmlstring[optional]HTML body. UTF-8. Maximum 1,000,000 characters. At least one of
text,html, ortemplate_idis required. Cannot be combined withtemplate_id.headersEmailHeaderMap[optional]Custom message headers as a name → value map. Names must be alphanumeric/hyphen, start with a letter or digit, and be at most 78 characters; malformed names are silently dropped. Headers that the platform sets itself or that carry trust signals from receiving infrastructure are also dropped — use this map for application-specific custom headers, not to override transport, authentication, routing, or trace headers. The 25-entry cap applies after dropping; exceeding it is the only condition that errors.
attachmentsarray of EmailAttachment[optional]Inline attachments. The total request body is bounded by the 5 MB gateway limit; base64 encoding inflates binary content by roughly 33%, so plan for an effective payload ceiling of about 3.5 MB of binary data.
tagsarray of string[optional]Free-form labels for grouping this message's events in the events UI and webhook payloads. Up to 10 tags, each matching
[A-Za-z0-9_-]{1,64}. Duplicates are removed, preserving first-occurrence order. Stripped before delivery — recipients never see them.Tags are for grouping (cohort, mailing list, marketing vs. transactional), not for per-message identifiers such as
user_idororder_id. Anypost enforces a per-team cap on the number of distinct tag values used per rolling hour; exceeding it returns429withscope: "tag_cardinality". For per-message correlation use theidreturned in the response.trackingobject[optional]Per-message override of the sending domain's default open / click tracking. Each field, when set, overrides the domain default for this message only; omit a field to inherit it. Tracking only takes effect when the sending domain has a verified tracking subdomain (see
Domain.tracking).variablesobject[optional]Per-send substitution map. When set and non-empty, the server renders Handlebars
{{ markers }}insubject,text,html, and customer-supplied header values, per recipient, before delivery. Attachments are never templated.Omit
variables(or send an empty object) and the bodies pass through untouched — any literal{{ … }}is left as written. The one exception is anunsubscribe.mode = "generate"send: it always renders so the reservedunsubscribe_urlmarker can resolve, so any literal{{ … }}in such a send is evaluated too.Limits: the encoded JSON must be at most 64 KB, with at most 100 keys per object level and at most 4 levels of nesting.
See Variables & personalization for the marker syntax, conditionals and loops, the per-recipient
name/emailbindings, and the reservedunsubscribe_urlvariable.campaignstring[optional]Optional stream-segmentation label (e.g.
newsletter,receipts,password-reset). When set, Anypost gives this send its own delivery queue, so a high-volume marketing stream cannot delay transactional mail to the same destination domain. Stripped before delivery — recipients never see it — and surfaced on every event derived from this send.Use this as a coarse stream classifier, not a per-message identifier. Anypost enforces per-team caps on the number of distinct
campaignvalues: per rolling hour, and a wider cap per rolling 72 hours (the lifetime of a campaign's delivery queues). Exceeding either returns429withscope: "campaign_cardinality". For per-message correlation, use theidreturned in the response. For free-form labels, usetags.ip_poolstring[optional]Which of your dedicated IP pools this message sends from. Only available on accounts with dedicated IPs and more than one named pool; the pool names are the ones agreed when your pools were set up.
Use this to keep one stream's sending reputation off another's — the usual split being marketing away from transactional, so a campaign's complaint rate cannot affect delivery of your password resets and receipts. Mail in different pools is also queued and paced separately, so a large marketing run cannot delay transactional mail to the same destination domain.
Not a reporting label: unlike
tags,topicandcampaign, this changes how the message is delivered. It is echoed on every event derived from this send asip_pool, so a delivery question can be answered per pool.Omit it to send from your account's default pool, which is what unlabeled mail always uses. An unrecognized name returns
422listing the pools your account does have; mail is never quietly sent from addresses you did not choose. Pool names are scoped to your account, so a name belonging to another customer is indistinguishable from one that does not exist.Pools are set up with you rather than self-serve — ask [email protected].
topicstring[optional]Customer-namespaced bucket the message belongs to. Used as the suppression scope: recipients who unsubscribe from a specific topic stop receiving sends in that topic but continue receiving sends in other topics (and vice versa). Sends with no topic skip every topic-scoped suppression check — the explicit transactional carve-out for OTP, password-reset, and other mail that must reach the recipient regardless of marketing-opt-out state.
Required when
unsubscribe.mode = "generate", since the topic is embedded in the signed unsubscribe token and becomes the suppression scope for any resultingemail.unsubscribedevent.Case-sensitive,
[a-z0-9_.-]{1,64}. The wildcard*is reserved for cross-topic suppressions and is not a valid topic value on a send.Use topics as coarse stream buckets, not per-message identifiers. Anypost enforces a per-team cap on the number of distinct
topicvalues used per rolling hour; exceeding it returns429withscope: "topic_cardinality".unsubscribeobject[optional]One-click unsubscribe behavior.
mode: "generate"— anypost mints a per-recipient signed token, injects RFC 8058List-UnsubscribeandList-Unsubscribe-Postheaders, and auto-populates the reservedunsubscribe_urltemplate variable (seevariables). Requirestopicto scope the suppression to a meaningful bucket. Use this for marketing / newsletter sends.mode: "none"(default for new teams) — no header injection, no auto-populated template variable. Use this for transactional sends that explicitly should NOT carry unsubscribe semantics (password reset, OTP).
Customer-supplied
List-Unsubscribeheader (Sendy / Mautic / in-house mailers) always passes through after a recoverable lint, regardless ofmode. The presence of a caller-supplied header is the opt-out signal — no explicit "passthrough" mode is needed.
Example request
curl https://api.anypost.com/v1/email \
-X POST \
-H "Authorization: Bearer $ANYPOST_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"from": "Acme Support <[email protected]>",
"to": [
"[email protected]"
]
}'Response body
On success (202), the response body is:
idstringPublic message identifier (
email_<uuidv7>).created_atstring (date-time)
{
"id": "email_018f4f3e-7b2c-7c80-8e21-1a3a4f5b6c7d",
"created_at": "2026-04-30T12:00:00.123000Z"
}Responses
| Status | Description |
|---|---|
202 | Message accepted for delivery. The response body contains the public message id and creation timestamp. If some recipients (but not all) were rejected at send time, this status is still returned — those failures are not yet surfaced in the response. Surfacing them via a rejected_recipients field is planned. |
400 | Validation failed for an unfixable reason — invalid email address, invalid base64 in an attachment, an oversized body, exceeding a count cap (recipients, headers, attachments), or every recipient being rejected at send time. Recoverable issues (CR/LF in subject or header values, reserved/malformed header names, path components or NUL in filenames) are sanitized in place rather than surfaced as errors. Response uses the canonical error shape. |
401 | Missing or invalid credentials. |
409 | A request with the supplied Idempotency-Key is already in progress. Wait and retry. |
413 | Request body exceeded the 5 MB gateway limit. Rejected at the transport layer before authentication or validation, so the response uses a shorter error shape than application-layer errors. The connection is closed after the response. |
422 | The supplied Idempotency-Key was previously used with a different request body. Either reuse the original request body or pick a new key. |
429 | Rate-limited, in one of two shapes — branch on whether the body has a scope field. (1) TeamRateLimitedError: the team's per-second request rate limit was exceeded. Retry after 1 second with lower concurrency. See Rate limits. (2) QuotaExceededError: a send-volume or cardinality limit was exceeded — your team's daily limit, your monthly quota (on a paid plan, once your included volume and any prepaid overage credits are spent), or the per-team cap on distinct campaign values per rolling hour. The flat body names the scope and retry_after_seconds; the Retry-After header carries the same value. See Sending limits. Nothing was accepted in either case. |
502 | Anypost's mail pipeline returned a server error, timed out, or was unreachable. Safe to retry with backoff. |
Send a batch of independent emails
POST /v1/email/batch
Submits 1–100 independent transactional messages in a single
HTTP request. Each item is the same shape as the body of
POST /v1/email — different froms, subjects, bodies,
recipients are all fine. Use this endpoint when you have N
logical messages to send; use POST /v1/email when you have
one logical message that goes to multiple recipients.
Validation: all-or-nothing
If any item fails per-item validation (bad email address,
oversized body, invalid attachment, etc.), the entire batch is
rejected with 400 and no message is sent. Field paths in the
error map are prefixed with the item index, e.g.
emails.3.subject.
Partial outcomes
Once validation passes, items proceed independently. Each item
may succeed (queued for delivery), fail at the domain-checks
stage (sender domain not on the API key allowlist or not
verified for the team), or fail because Anypost's mail
pipeline timed out, returned an error, or was unreachable.
The response surfaces each item's outcome individually under
data[], in request order.
The top-level HTTP status reflects the overall mix:
202 Accepted— every item queued.207 Multi-Status— some queued, some failed.502 Bad Gateway— every item failed and at least one failure was on Anypost's side (server error / timeout / unreachable). Safe to retry with backoff.400 Bad Request— either the batch failed validation outright, or every item failed for a caller-attributable reason (domain check or message rejection).
Sizing
The hard cap is 100 items per request, but the practical ceiling is the 5 MB request-body limit. Tiny text-only items hit 100 long before the body cap; batches that include attachments will hit the body cap well before 100 items — aim for ≤25 items when attaching files.
Idempotency
Pass an optional Idempotency-Key header to make retries
safe. The cached response includes the full data[] with
each item's id and created_at, so a retry of a 207
replays the original outcome verbatim — items that were
queued the first time are NOT re-sent. See the parameter
description for full semantics.
Parameters
Idempotency-Keystringin header[optional]Optional client-supplied key (1–255 printable-ASCII bytes) that de-duplicates retries of the same logical request. Scoped per team — the same key reused across
POST /v1/emailandPOST /v1/email/batchshares one cache entry, so different request shapes under the same key will collide.- Replay — if the same key is reused with the same request body within 24 hours, the original response (status, body) is returned verbatim. The message is not re-sent.
- Mismatch (different body) — if the same key is reused
with a different request body, the gateway returns
422 idempotency_mismatch. - Concurrent — if a request with the same key is still
in-flight, the second request returns
409 idempotency_concurrent. - Server errors —
5xxresponses are not cached, so a retry with the same key actually retries the operation.2xxand4xx(including207 Multi-Statuson batch) are cached and replayed.
Request body
Send as JSON with Content-Type: application/json.
emailsarray of EmailSendRequest[required]1–100 messages in the batch. When
defaultsis set, each entry inherits any field it does not specify.fromandsubjectmay be omitted on an entry ifdefaultssupplies them; the merged result must still satisfy the same shape asPOST /v1/email.tois always per-entry and cannot appear indefaults.defaultsEmailBatchDefaults[optional]Optional batch-wide defaults. Each field, when present, is applied to every entry in
emailsthat does not specify its own value.tois excluded — recipients are always per-entry.Merge semantics:
- Scalars (
from,subject,text,html,reply_to): the entry's value wins; otherwise the default is used. headers: shallow-merged. The entry's value wins on key collision.cc,bcc,attachments,tags: concatenated (defaults first, then the entry). The combined result is bounded by the same caps as a single send (50 total recipients acrossto+cc+bcc, 20 attachments, 10 tags after de-duplication). Putting a shared attachment indefaultslets a batch reach many recipients without repeating the bytes inbound — useful given the 5 MB request body limit.ip_pool: worth setting here rather than per entry when the whole batch belongs to one stream. It is validated per entry, so a default naming a pool you do not have fails only the entries that inherited it — a207, with entries carrying their own valid pool still queued.
- Scalars (
Example request
curl https://api.anypost.com/v1/email/batch \
-X POST \
-H "Authorization: Bearer $ANYPOST_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"emails": [
{
"from": "Acme Support <[email protected]>",
"to": [
"[email protected]"
]
}
]
}'Response body
On success (202), the response body is:
summaryobjectdataarray of EmailBatchItemResultPer-item outcome, in the same order as the request
emailsarray.data[i].index === ialways, but the explicitindexfield lets consumers re-key after filtering or sortingdata.
{
"summary": {
"total": 0,
"queued": 0,
"failed": 0
},
"data": [
{
"status": "queued",
"index": 0,
"id": "email_018f4f3e-7b2c-7c80-8e21-1a3a4f5b6c7d",
"created_at": "2026-04-30T12:00:00.123000Z"
}
]
}Responses
| Status | Description |
|---|---|
202 | Every item queued for delivery. |
207 | Mixed outcomes — some items queued, some failed. The data array reports each item's status; summary gives the totals. The top-level response is not a canonical error envelope; per-item failures are nested inside data[i].error. |
400 | Either the batch failed validation (response uses the canonical error envelope; errors paths are prefixed with the item index, e.g. emails.2.from), or every item failed at a caller-attributable stage (response uses the EmailBatchResponse shape with summary.queued equal to zero). |
401 | Missing or invalid credentials. |
409 | A request with the supplied Idempotency-Key is already in progress. Wait and retry. |
413 | Request body exceeded the 5 MB gateway limit. Rejected at the transport layer before authentication or validation, so the response uses a shorter error shape than application-layer errors. The connection is closed after the response. |
422 | The supplied Idempotency-Key was previously used with a different request body. Either reuse the original request body or pick a new key. |
429 | Rate-limited, in one of three shapes. (1) The team's per-second request rate limit was exceeded: the flat TeamRateLimitedError body, nothing queued, retry the whole batch after 1 second with lower concurrency. Note this counts requests, not messages — a batch costs one request regardless of size. See Rate limits. (2) A send-volume limit (daily, or monthly quota) was exceeded by the batch as a whole: the entire request is rejected, nothing is queued, and the flat QuotaExceededError body names the scope and retry_after_seconds (the Retry-After header carries the same value). Split the batch and retry after the reset. (3) Every item in the batch was denied by a label-cardinality cap (distinct campaign / topic / tag values): the body is the standard EmailBatchResponse with each item failed as quota_exceeded, and no Retry-After header. When only SOME items trip a cardinality cap, the response is a normal 207 — the offending items fail individually and the rest are queued. See Sending limits. |
502 | Every item failed and at least one failure was on Anypost's side (server error, timeout, or unreachable). Safe to retry the whole batch with backoff. Response uses the EmailBatchResponse shape so callers can still inspect per-item outcomes. |