Docs/API reference

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/email and POST /v1/email/batch shares 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 errors5xx responses are not cached, so a retry with the same key actually retries the operation. 2xx and 4xx (including 207 Multi-Status on 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 the From: 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 the To: 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 rendered To: header. Combined with cc and bcc, 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 (see to). 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-To header. Accepts a single string or an array (maximum 10). Each entry accepts a bare address or RFC 5322 name-addr form (see to).

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_id is 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-rendered html/text bodies and, if the request omits its own subject, the template's subject.

Cannot be combined with inline html or text — pick one source of body content per send. The lookup is scoped to the authenticated team; a template_id from another team is indistinguishable from a missing template (both return 400 with errors.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 400 and an errors.template_id message stating it has no published content. Publish a draft via POST /v1/templates/{id}/publish first.

textstring[optional]

Plain-text body. UTF-8. Maximum 1,000,000 characters. At least one of text, html, or template_id is required. text and html may both be set (sent as a multipart/alternative message); neither may be combined with template_id.

htmlstring[optional]

HTML body. UTF-8. Maximum 1,000,000 characters. At least one of text, html, or template_id is required. Cannot be combined with template_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_id or order_id. Anypost enforces a per-team cap on the number of distinct tag values used per rolling hour; exceeding it returns 429 with scope: "tag_cardinality". For per-message correlation use the id returned 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 }} in subject, 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 an unsubscribe.mode = "generate" send: it always renders so the reserved unsubscribe_url marker 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/email bindings, and the reserved unsubscribe_url variable.

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 campaign values: per rolling hour, and a wider cap per rolling 72 hours (the lifetime of a campaign's delivery queues). Exceeding either returns 429 with scope: "campaign_cardinality". For per-message correlation, use the id returned in the response. For free-form labels, use tags.

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, topic and campaign, this changes how the message is delivered. It is echoed on every event derived from this send as ip_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 422 listing 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 resulting email.unsubscribed event.

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 topic values used per rolling hour; exceeding it returns 429 with scope: "topic_cardinality".

unsubscribeobject[optional]

One-click unsubscribe behavior.

  • mode: "generate" — anypost mints a per-recipient signed token, injects RFC 8058 List-Unsubscribe and List-Unsubscribe-Post headers, and auto-populates the reserved unsubscribe_url template variable (see variables). Requires topic to 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-Unsubscribe header (Sendy / Mautic / in-house mailers) always passes through after a recoverable lint, regardless of mode. 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:

idstring

Public message identifier (email_<uuidv7>).

created_atstring (date-time)
{
  "id": "email_018f4f3e-7b2c-7c80-8e21-1a3a4f5b6c7d",
  "created_at": "2026-04-30T12:00:00.123000Z"
}

Responses

StatusDescription
202Message 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.
400Validation 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.
401Missing or invalid credentials.
409A request with the supplied Idempotency-Key is already in progress. Wait and retry.
413Request 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.
422The supplied Idempotency-Key was previously used with a different request body. Either reuse the original request body or pick a new key.
429Rate-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.
502Anypost'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/email and POST /v1/email/batch shares 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 errors5xx responses are not cached, so a retry with the same key actually retries the operation. 2xx and 4xx (including 207 Multi-Status on 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 defaults is set, each entry inherits any field it does not specify. from and subject may be omitted on an entry if defaults supplies them; the merged result must still satisfy the same shape as POST /v1/email. to is always per-entry and cannot appear in defaults.

defaultsEmailBatchDefaults[optional]

Optional batch-wide defaults. Each field, when present, is applied to every entry in emails that does not specify its own value. to is 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 across to+cc+bcc, 20 attachments, 10 tags after de-duplication). Putting a shared attachment in defaults lets 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 — a 207, with entries carrying their own valid pool still queued.

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:

summaryobject
dataarray of EmailBatchItemResult

Per-item outcome, in the same order as the request emails array. data[i].index === i always, but the explicit index field lets consumers re-key after filtering or sorting data.

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

StatusDescription
202Every item queued for delivery.
207Mixed 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.
400Either 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).
401Missing or invalid credentials.
409A request with the supplied Idempotency-Key is already in progress. Wait and retry.
413Request 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.
422The supplied Idempotency-Key was previously used with a different request body. Either reuse the original request body or pick a new key.
429Rate-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.
502Every 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.