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://app.pufferpost.com/v1. See Authentication for keys and scopes; sending requires a key with the messages:send scope, and reading requires messages:read.
Send a message
POST /v1/messages
PufferPost is template-first: every send references a stored template by its stable id (templateId, preferred) or its slug (template), 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 (email) | yes | Must match an active, verified sender in the workspace. |
to | string (email) | yes | Single primary recipient. |
templateId | string | one of templateId/template | Stable tpl_… id. Takes precedence over template. |
template | string | one of templateId/template | Template slug. |
data | object | no | Render variables for the template. |
replyTo | string (email) | no | Reply-To header. |
cc | array of email | no | Up to 25 addresses. |
metadata | object of string→string | no | Up to 50 keys; each value ≤ 500 chars. 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 override (e.g. de); falls back to the workspace default. |
timezone | string | no | IANA zone (e.g. Europe/Berlin) for recipient-local date rendering. |
attachments | array | no | Up to 3 inline files; see below. |
There is no inline subject/html field — to send raw content, create a template for it first. See Templates.
Request
curl -X POST https://app.pufferpost.com/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", "template": "order-confirmation", "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
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: 1749470400Attachments
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, ~1 MB binary per file.
{ "from": "no-reply@acme.com", "to": "jane@example.com", "template": "order-confirmation", "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.
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, template, 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://app.pufferpost.com/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", "template": "order-confirmation" }'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
POST /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.
Request
curl -X POST https://app.pufferpost.com/v1/messages/batch \ -H "Authorization: Bearer key_live_…" \ -H "Content-Type: application/json" \ -d '{ "messages": [ { "from": "no-reply@acme.com", "to": "jane@example.com", "template": "order-confirmation", "data": { "name": "Jane" } }, { "from": "no-reply@acme.com", "to": "blocked@example.com", "template": "order-confirmation", "data": { "name": "Sam" } } ] }'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
GET /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://app.pufferpost.com/v1/messages/msg_01J9Z2K3M4N5P6Q7R8S9T0V1W2 \ -H "Authorization: Bearer key_live_…"{ "id": "msg_01J9Z2K3M4N5P6Q7R8S9T0V1W2", "status": "delivered", "to": "jane@example.com", "from": "no-reply@acme.com", "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.
List messages
GET /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://app.pufferpost.com/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 the timeline
GET /v1/messages/{id}/events
The delivery timeline for a message, oldest first. Each entry has a type, an occurred_at (when the activity happened, in provider time) and a recorded_at (when PufferPost ingested it).
curl https://app.pufferpost.com/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.
Errors and gotchas
Errors use the standard envelope ({ "error": { "code", "message", "request_id" } }) — see Errors & idempotency. Common send rejections:
422template_required— neithertemplateIdnortemplatewas 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— recipient opted out of, or theunsubscribeGroupdoesn’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.