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

FieldTypeRequiredNotes
fromstring (email)yesMust match an active, verified sender in the workspace.
tostring (email)yesSingle primary recipient.
templateIdstringyesThe template’s stable tpl_… id.
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 (e.g. de); see Locale and timezone.
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://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",
"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

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",
"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.

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:

  • 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 /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.

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 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/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

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://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",
"templateId": "tpl_…",
"data": { "name": "Jane" }
},
{
"from": "no-reply@acme.com",
"to": "blocked@example.com",
"templateId": "tpl_…",
"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://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://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 actually happened) and a recorded_at (when PufferPost ingested it).

Terminal window
curl https://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: 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.