Skip to content

Sending email

The send endpoint accepts a transactional message and returns immediately. PufferPost never sends synchronously in the request path: the API validates and persists the message, returns 202 Accepted with a message id, and rendering plus delivery happen on an async worker. Track progress afterwards via the message and its event timeline.

All requests use bearer API-key auth and the base URL https://pufferpost.com/api/v1. See Authentication for keys and scopes; sending requires a key with the messages:send scope, and reading requires messages:read.

The examples below are curl, but PHP applications can skip the plumbing: pufferpost/sdk wraps every call here in a typed client, and pufferpost/symfony-mailer plugs PufferPost into Symfony Mailer (and so into Laravel) as a transport. See SDK & Mailer transports.

POST /api/v1/messages

PufferPost is template-first: every send references a stored template by its stable id (templateId, the tpl_… id) and supplies the render variables in data. The subject and HTML come from the template version; they are not set inline on the request.

Field names are sent exactly as shown below (camelCase, e.g. templateId, replyTo, unsubscribeGroup).

Field Type Required Notes
from string yes A bare address or Name <address>. The address must match an active, verified sender in the workspace; see Sender display name.
to string yes Single primary recipient, as a bare address or Name <address>.
templateId string conditional The template’s stable tpl_… id. Required unless you send inline content.
data object no Render variables for the template. At most 64 KB of JSON.
subject string conditional Inline send only: the final subject line, ≤ 2000 chars, no line breaks. Not used with templateId.
html string conditional Inline send only: the final HTML body, ≤ 100,000 chars. Either body satisfies an inline send on its own.
text string no The plain-text alternative, ≤ 100,000 chars. Derived from html when omitted.
replyTo string no Reply-To header. Bare address or Name <address>.
cc array of string no Up to 25 addresses, each bare or Name <address>.
bcc array of string no Up to 25 addresses, each bare or Name <address>. Not visible to the other recipients.
headers object of string→string no Custom headers. X- names only, up to 10, each value ≤ 500 chars; X-Mailer* is reserved. Anything else is rejected with 422 invalid_header.
metadata object of string→string no Up to 50 keys; each value ≤ 500 chars; 16 KB of JSON in total. Echoed back on reads and webhooks.
unsubscribeGroup string no Subscription-topic id; opted-out or unknown recipients are rejected.
locale string no Per-send locale (e.g. de); see Locale and timezone.
timezone string no IANA zone (e.g. Europe/Berlin) for recipient-local date rendering.
attachments array no Up to 3 inline files; see below.

PufferPost is template-first, and a stored template is the better default: it is versioned, previewable and editable without a deploy. But you can also send inline by supplying subject and a body (html, text, or both) instead of templateId. An inline body is sent exactly as given: it is never run through the template engine, so {{ variables }} in it mean nothing.

The one exception is the unsubscribe link, below.

Terminal window
curl -X POST https://pufferpost.com/api/v1/messages \
-H "Authorization: Bearer key_live_…" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-9-confirmation" \
-d '{
"from": "no-reply@acme.com",
"to": "jane@example.com",
"templateId": "tpl_…",
"data": {
"name": "Jane",
"order_id": "o_9",
"total": "€42.00"
},
"replyTo": "support@acme.com",
"cc": ["audit@acme.com"],
"metadata": {
"order_id": "o_9",
"user_id": "123"
},
"unsubscribeGroup": "grp_receipts",
"locale": "de",
"timezone": "Europe/Berlin"
}'

202 Accepted: the message is queued for rendering and delivery.

{
"id": "msg_01J9Z2K3M4N5P6Q7R8S9T0V1W2",
"status": "accepted"
}

Every send response (success or rate-limited) carries IETF rate-limit headers so you can self-throttle:

RateLimit-Limit: 3
RateLimit-Remaining: 2
RateLimit-Reset: 1749470400

from takes either form:

{ "from": "no-reply@acme.com" }
{ "from": "Acme Padel Club <no-reply@acme.com>" }

The address is what gets checked: it must belong to an active, verified sender in the workspace. The display name is yours to choose per send, so one verified address can send as “Acme Receipts” and as “Acme Padel Club” without registering twice. Omit it and the sender’s own configured name is used instead. to works the same way, so a recipient can be addressed as Jane Doe <jane@example.com>. Display names are capped at 255 characters; a longer one is a 422.

Whichever name is used, it is recorded on the message and echoed back on reads as from_name (and to_name for the recipient), so you never have to send a real email to find out which name the recipient saw:

{
"from": "no-reply@acme.com",
"from_name": "Acme Padel Club"
}

Small inline files only. Each attachment is base64-encoded content plus a filename and contentType. Limits: up to 3 attachments; filename ≤ 255 chars with no path separators; contentType ≤ 100 chars. A single file may use the whole budget, but the combined base64 content is capped at 9 MB (roughly 6.7 MB of binary), and the request body itself at 30 MB. Go over either and the send is rejected with a 422 rather than accepted and failed later.

{
"from": "no-reply@acme.com",
"to": "jane@example.com",
"templateId": "tpl_…",
"data": { "name": "Jane" },
"attachments": [
{
"filename": "receipt.pdf",
"contentType": "application/pdf",
"content": "JVBERi0xLjQKJ…"
}
]
}

Attachment content is never echoed back on reads. Only filename and content_type appear in the message view.

POST /api/v1/workflows/trigger

A workflow is a small sequence you configure in the dashboard — wait, send, branch — that reacts to something happening in your product. The API side is one call: report that the event happened, for one recipient, with the data the workflow’s templates render from. Scope required: workflows:trigger.

Terminal window
curl -X POST https://pufferpost.com/api/v1/workflows/trigger \
-H "Authorization: Bearer key_live_…" \
-H "Content-Type: application/json" \
-d '{
"trigger": "trial_started",
"to": "jane@example.com",
"data": { "plan": "scale", "trial_ends_at": "2026-10-01T09:00:00+00:00" }
}'

202 Accepted, with the number of workflow runs the event started:

{ "started": 2 }
  • trigger is the event name you gave the workflow when you created it. Every active workflow in the workspace listening for that name starts a run, so started is 0 when nothing listens — a successful call that did nothing, not an error.
  • A recipient already mid-run in a workflow is not enrolled again. A repeated trigger for someone who is still waiting inside that workflow is skipped rather than duplicated, and is not counted in started. So started: 0 can also mean “they are already in it”.
  • data is carried into every message the run sends, so it must satisfy those templates’ data contracts. It is capped at 64 KB.
  • The run advances through its immediate steps during the call and pauses at the first wait; the rest resumes on schedule. Messages it sends are queued like any other send, so a 202 means accepted, never delivered. Each shows up in List messages with source: "workflow".

A send can carry a locale (BCP-47, e.g. nl) and a timezone (IANA, e.g. Europe/Amsterdam). They control how the template renders for this recipient:

  • locale picks the matching localized template variant and the locale used by the format_currency / format_date / format_datetime filters.
  • timezone is the wall-clock that date filters render in. Stored timestamps and every API response stay UTC (RFC 3339); only the rendered email is localised.

Resolution order, first non-empty wins:

send locale/timezone → the recipient contact's stored preference → workspace default → en / UTC

A locale/timezone you pass is remembered on the recipient’s contact, so a later send to the same address that omits them reuses the stored preference. Those values are returned on GET /api/v1/contacts/{id} as locale and timezone. To make a template actually render dates in the recipient’s zone, use the recipient-local date filters; see Templates → Recipient locale and timezone.

Every send carries the RFC 8058 one-click headers, whichever path it took:

List-Unsubscribe: <https://pufferpost.com/unsubscribe?token=…>
List-Unsubscribe-Post: List-Unsubscribe=One-Click

The token is signed against the workspace, the recipient and the unsubscribeGroup you passed, so a group-scoped send unsubscribes from that topic only. These headers are added last and cannot be overridden by your own headers.

For a visible in-body link, write {{ unsubscribe_url }} where you want it:

  • In a template, it is a reserved render variable. Supplying your own unsubscribe_url in data does not override it.
  • In an inline body, that exact token is substituted literally, in both html and text. It is the only {{ … }} an inline body understands; everything else is left alone.

Either way the link matches the token in the header, so the visible link and the one-click header always agree. Subscribe to the unsubscribed webhook to hear when someone uses it.

Pass an Idempotency-Key header to make a send safe to retry. The key is scoped to your workspace and is fingerprinted against the request body (from, to, templateId, data).

  • Same key, same body: the original 202 response is replayed; no second message is created.
  • Same key, different body: 409 Conflict with code idempotency_key_reused.
Terminal window
curl -X POST https://pufferpost.com/api/v1/messages \
-H "Authorization: Bearer key_live_…" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-9-confirmation" \
-d '{ "from": "no-reply@acme.com", "to": "jane@example.com", "templateId": "tpl_…" }'

Reuse the same key for every retry of the same logical send (network timeouts, worker restarts). Generate a fresh key for each new message.

POST /api/v1/messages/batch

Fan out up to 100 sends in one call. Each item is the exact same shape as a single send, and each is accepted or rejected independently: a bad recipient or missing template fails only that item. The whole batch is rejected only when the workspace is paused. Quota is enforced per item.

Batch does not accept an Idempotency-Key: the header is ignored here, so a retried batch re-accepts every message in it. Where a duplicate would matter, send those messages individually with one key each (Idempotency).

In PHP this endpoint is PufferPost\Sdk\Client::sendBatch(). The Symfony/Laravel transport sends one message per call, so batch fan-out is SDK-only (SDK & Mailer transports).

Terminal window
curl -X POST https://pufferpost.com/api/v1/messages/batch \
-H "Authorization: Bearer key_live_…" \
-H "Content-Type: application/json" \
-d '{
"messages": [
{
"from": "no-reply@acme.com",
"to": "jane@example.com",
"templateId": "tpl_…",
"data": { "name": "Jane" }
},
{
"from": "no-reply@acme.com",
"to": "blocked@example.com",
"templateId": "tpl_…",
"data": { "name": "Sam" }
}
]
}'

200 OK with a per-item result array. Each entry carries its index; accepted items have id, failed items have an error.

{
"data": [
{ "index": 0, "status": "accepted", "id": "msg_01J9Z2K3M4N5P6Q7R8S9T0V1W2" },
{
"index": 1,
"status": "error",
"error": { "code": "recipient_suppressed", "message": "\"blocked@example.com\" is on the suppression list." }
}
]
}

The Idempotency-Key header is not honoured on the batch endpoint. Use single sends when you need per-message replay protection.

GET /api/v1/messages/{id}

Retrieve a single message by its msg_… id. Messages are workspace-scoped: an id in another workspace returns 404 (message_not_found), never a cross-tenant leak.

Terminal window
curl https://pufferpost.com/api/v1/messages/msg_01J9Z2K3M4N5P6Q7R8S9T0V1W2 \
-H "Authorization: Bearer key_live_…"
{
"id": "msg_01J9Z2K3M4N5P6Q7R8S9T0V1W2",
"status": "delivered",
"to": "jane@example.com",
"to_name": "Jane Doe",
"from": "no-reply@acme.com",
"from_name": "Acme Receipts",
"reply_to": "support@acme.com",
"cc": ["audit@acme.com"],
"attachments": [
{ "filename": "receipt.pdf", "content_type": "application/pdf" }
],
"subject": "Your order is confirmed",
"template_id": "tpl_01J9Y0AA11BB22CC33DD44EE55",
"template_name": "Order confirmation",
"template_version": 3,
"data": { "name": "Jane", "order_id": "o_9" },
"metadata": { "order_id": "o_9", "user_id": "123" },
"tags": [],
"created_at": "2026-06-09T10:15:30+00:00",
"updated_at": "2026-06-09T10:15:41+00:00"
}

The read view uses snake_case keys (reply_to, template_id, content_type). subject is the rendered subject from the template version. status advances through the lifecycle: accepted → queued → sent → delivered, or one of bounced, complained, suppressed, errored. It moves forward only, so a late event never walks it back. Any bounce sets bounced, including a transient one such as a full mailbox or a greylisting host; only a permanent bounce also adds the recipient to your suppression list. Opens and clicks never change the status: read engagement from the message timeline (GET /api/v1/messages/{id}/events).

GET /api/v1/messages returns a workspace’s messages newest-first, cursor-paginated. Use ?limit= (1–100, default 25) and ?cursor= (the opaque next_cursor from the previous page). Optional exact-match filters: status, recipient, from, templateId, plus the createdAfter / createdBefore ISO-8601 range.

Terminal window
curl "https://pufferpost.com/api/v1/messages?status=bounced&limit=50" \
-H "Authorization: Bearer key_live_…"
{
"data": [ { "id": "msg_…", "status": "bounced", "to": "…" } ],
"next_cursor": "eyJpZCI6Im1zZ18…"
}

When next_cursor is null, you have reached the last page.

GET /api/v1/messages/{id}/rendered

The final subject, HTML and text of a sent message — the thing that actually landed in the inbox. Use it when output is disputed, or mid-incident, when the question is “what did we send?” rather than “did it arrive?”.

Terminal window
curl https://pufferpost.com/api/v1/messages/msg_01J9Z2K3M4N5P6Q7R8S9T0V1W2/rendered \
-H "Authorization: Bearer key_live_…"
{
"id": "msg_01J9Z2K3M4N5P6Q7R8S9T0V1W2",
"source": "template",
"template_id": "tpl_01J9Z4M0V0K3QH6S2W7Y8B5N3D",
"template_version": 3,
"to": "jan@example.com",
"to_name": "Jan Jansen",
"from": "no-reply@acme.com",
"from_name": "Acme Padel Club",
"reply_to": null,
"locale": "nl",
"timezone": "Europe/Amsterdam",
"subject": "Verplaatst naar woensdag 30 september 12:00",
"html": "<h1>Verplaatst</h1>…",
"text": "Verplaatst…"
}
  • to, to_name, from, from_name and reply_to are the addressing the message was built with, so “was my display name honoured?” is answerable here rather than by sending to a real inbox.
  • locale and timezone are the values the send resolved to, so a time that looks wrong can be traced without guesswork. A datetime in data carrying no offset was read as UTC and converted into that zone.
  • For a templated send the body is recomposed from the pinned version, the stored payload and the section snapshot taken when the send was accepted, so a template edited since does not change the answer. Rendered bodies are not kept as a second copy of recipient content.
  • source is inline for a send that supplied its own html; the stored body comes back, with {{ unsubscribe_url }} filled exactly as it was delivered.
  • Two failure cases: 409 version_unavailable when the template was deleted after the send, and 422 render_failed (with kind and line, like preview) when the pinned source no longer renders.

GET /api/v1/messages/{id}/events

The delivery timeline for a message, oldest first. Each entry has a type, an occurred_at (when the activity actually happened) and a recorded_at (when PufferPost ingested it).

Terminal window
curl https://pufferpost.com/api/v1/messages/msg_01J9Z2K3M4N5P6Q7R8S9T0V1W2/events \
-H "Authorization: Bearer key_live_…"
{
"data": [
{ "id": "evt_01J9Z2K3M4N5P6Q7R8S9T0V1W2", "type": "accepted", "occurred_at": "2026-06-09T10:15:30+00:00", "recorded_at": "2026-06-09T10:15:30+00:00" },
{ "id": "evt_01J9Z2K3M4N5P6Q7R8S9T0V1X3", "type": "sent", "occurred_at": "2026-06-09T10:15:33+00:00", "recorded_at": "2026-06-09T10:15:33+00:00" },
{ "id": "evt_01J9Z2K3M4N5P6Q7R8S9T0V1Y4", "type": "delivered", "occurred_at": "2026-06-09T10:15:41+00:00", "recorded_at": "2026-06-09T10:15:42+00:00" }
]
}

Event type values: accepted, queued, sent, delivered, opened, clicked, bounced, complained, suppressed, unsubscribed, errored. To receive these in real time instead of polling, subscribe with Webhooks.

A clicked entry also carries automatic, and automatic_confidence saying how firm that is: certain when the click itself gave the machine away (its user agent, the mail provider’s verdict, or arriving too soon after the send to have been anybody), probable when only the network it came from did. Mail-security gateways follow every link in a message to scan it, and some of them do it from ranges that serve a mail provider rather than one person. Count engagement on the entries where automatic is false; if you are chasing recipients who did not engage, count probable as engagement too, since a shared office line trips that rule as readily as a mail provider.

An opened entry is automatic only when the mail provider says so. An open means the tracking image in the message was loaded, and mail apps and security filters load it by themselves, on their own schedule, from their own infrastructure. Some providers report that they believe a particular fetch was a bot; when one does, the entry comes through with automatic: true and automatic_confidence: certain, and it is left out of the open counts in your dashboard.

Nothing else about an open is judged. A privacy relay fetching the image for somebody who is reading the message and the same relay fetching it for nobody are one request from one address, and no timing, address or user-agent rule can separate them, so we do not apply one. Most opens therefore arrive with automatic: false whether or not a person was there. Read those as an upper bound on how many people saw the email, and use clicks when you need engagement you can act on.

Open and click tracking is off until a workspace turns it on, in Settings. While it is on, the dashboard timeline for a message also shows, for each open and click, the IP address and user agent the recipient’s device reported, and the link that was followed without its query string. That device detail stays in the dashboard: the API and webhook payloads carry the event, not the device behind it.

Errors use the standard envelope ({ "error": { "code", "message", "request_id" } }); see Errors & idempotency. Common send rejections:

  • 422 template_required: no templateId was provided.
  • 422 template_not_found / template_has_no_version: no such template in the workspace, or it has no saved version to send. Save a version first.
  • 422 sender_not_available: from is not an active, verified sender. See Senders & domains.
  • 422 recipient_suppressed: to is on the suppression list (a prior bounce/complaint/unsubscribe).
  • 422 recipient_unsubscribed / unknown_unsubscribe_group: the recipient opted out of the unsubscribeGroup, or that group doesn’t exist.
  • 403 sending_paused: sending is paused for the workspace (e.g. reputation auto-pause).
  • 409 idempotency_key_reused: the Idempotency-Key was seen with a different body.
  • 429 rate_limited: too many sends; back off using the RateLimit-Reset header.
  • 429 quota_exceeded: the plan’s monthly send limit is reached.

Validation failures (422) include a violations array naming the offending fields. Full field reference is in the API reference.