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.
Send a message
Section titled “Send a message”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.
Request
Section titled “Request”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" }'Response
Section titled “Response”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: 3RateLimit-Remaining: 2RateLimit-Reset: 1749470400Sender display name
Section titled “Sender display name”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"}Attachments
Section titled “Attachments”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.
Trigger a workflow
Section titled “Trigger a workflow”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.
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 }triggeris the event name you gave the workflow when you created it. Every active workflow in the workspace listening for that name starts a run, sostartedis0when 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. Sostarted: 0can also mean “they are already in it”. datais 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
202means accepted, never delivered. Each shows up in List messages withsource: "workflow".
Locale and timezone
Section titled “Locale and timezone”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:
localepicks the matching localized template variant and the locale used by theformat_currency/format_date/format_datetimefilters.timezoneis 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 / UTCA 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.
Unsubscribe links
Section titled “Unsubscribe links”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-ClickThe 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_urlindatadoes not override it. - In an inline body, that exact token is substituted literally, in both
htmlandtext. 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.
Idempotency
Section titled “Idempotency”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
202response is replayed; no second message is created. - Same key, different body:
409 Conflictwith codeidempotency_key_reused.
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.
Send a batch
Section titled “Send a batch”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).
Request
Section titled “Request”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" } } ] }'Response
Section titled “Response”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.
Read message status
Section titled “Read message status”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.
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).
List messages
Section titled “List messages”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.
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.
Read what the recipient received
Section titled “Read what the recipient received”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?”.
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_nameandreply_toare the addressing the message was built with, so “was my display name honoured?” is answerable here rather than by sending to a real inbox.localeandtimezoneare the values the send resolved to, so a time that looks wrong can be traced without guesswork. A datetime indatacarrying 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.
sourceisinlinefor a send that supplied its ownhtml; the stored body comes back, with{{ unsubscribe_url }}filled exactly as it was delivered.- Two failure cases:
409 version_unavailablewhen the template was deleted after the send, and422 render_failed(withkindandline, like preview) when the pinned source no longer renders.
Read the timeline
Section titled “Read the timeline”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).
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 and gotchas
Section titled “Errors and gotchas”Errors use the standard envelope ({ "error": { "code", "message", "request_id" } }); see Errors & idempotency. Common send rejections:
422template_required: notemplateIdwas provided.422template_not_found/template_has_no_version: no such template in the workspace, or it has no saved version to send. Save a version first.422sender_not_available:fromis not an active, verified sender. See Senders & domains.422recipient_suppressed:tois on the suppression list (a prior bounce/complaint/unsubscribe).422recipient_unsubscribed/unknown_unsubscribe_group: the recipient opted out of theunsubscribeGroup, or that group doesn’t exist.403sending_paused: sending is paused for the workspace (e.g. reputation auto-pause).409idempotency_key_reused: theIdempotency-Keywas seen with a different body.429rate_limited: too many sends; back off using theRateLimit-Resetheader.429quota_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.