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://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).

FieldTypeRequiredNotes
fromstring (email)yesMust match an active, verified sender in the workspace.
tostring (email)yesSingle primary recipient.
templateIdstringone of templateId/templateStable tpl_… id. Takes precedence over template.
templatestringone of templateId/templateTemplate slug.
dataobjectnoRender variables for the template.
replyTostring (email)noReply-To header.
ccarray of emailnoUp to 25 addresses.
metadataobject of string→stringnoUp to 50 keys; each value ≤ 500 chars. Echoed back on reads and webhooks.
unsubscribeGroupstringnoSubscription-topic id; opted-out or unknown recipients are rejected.
localestringnoPer-send locale override (e.g. de); falls back to the workspace default.
timezonestringnoIANA zone (e.g. Europe/Berlin) for recipient-local date rendering.
attachmentsarraynoUp 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

Terminal window
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: 3
RateLimit-Remaining: 2
RateLimit-Reset: 1749470400

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, ~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 202 response is replayed; no second message is created.
  • Same key, different body409 Conflict with code idempotency_key_reused.
Terminal window
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

Terminal window
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.

Terminal window
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: acceptedqueuedsentdelivered, 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.

Terminal window
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).

Terminal window
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:

  • 422 template_required — neither templateId nor template 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_availablefrom is not an active, verified sender. See Senders & domains.
  • 422 recipient_suppressedto is on the suppression list (a prior bounce/complaint/unsubscribe).
  • 422 recipient_unsubscribed / unknown_unsubscribe_group — recipient opted out of, or the unsubscribeGroup 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.