Skip to content

Getting started

This guide takes you from zero to a delivered transactional email: create an API key, verify a sending domain with DKIM, register a sender identity, and send your first message with POST /api/v1/messages.

PufferPost is transactional-only: confirmations, receipts, alerts, reminders, notifications. Sends are always queued and rendered asynchronously, so the send endpoint returns 202 Accepted with a message id rather than blocking on delivery.

  • A PufferPost account and a workspace. The workspace is your sending context; it belongs to an account, which holds billing and team.
  • A domain you control the DNS for, so you can publish the DKIM/SPF/DMARC records.
  • An HTTP client. The examples use curl.

Base URL for every request:

https://pufferpost.com/api/v1

All API requests authenticate with a bearer API key:

Authorization: Bearer key_live_…

API keys are minted from the dashboard (Workspace → API keys), reserved to workspace Owner/Admin. A key is scoped, tied to one environment, and the plaintext secret is shown exactly once at creation. Only its hash is stored, so copy it immediately.

When you create a key you choose:

  • environment: live or test (defaults to live). The environment is baked into the key string: key_live_… or key_test_….
  • name: a human label (required).
  • scopes: what the key may do. Scopes are exact: messages:manage is not a thing, and :manage does not imply :read. The default for a new key is messages:send + messages:read.

Available scopes (request only what you need):

Resource Scopes
Messages messages:send, messages:read
Templates templates:read, templates:manage
Senders senders:read, senders:manage
Domains domains:read, domains:manage
Suppressions suppressions:read, suppressions:manage
Webhooks webhooks:read, webhooks:manage
Unsubscribe groups unsubscribe_groups:read, unsubscribe_groups:manage
Billing & usage billing:read, billing:manage, usage:read
Workspace workspace:read, workspace:manage
Contacts & recipients contacts:read, recipients:read, recipients:erase
Logs & audit audit:read, request_logs:read
Workflows workflows:trigger

For this walkthrough you need domains:manage, senders:manage, and messages:send.

The mint response returns the key metadata plus the one-time secret:

{
"id": "...",
"name": "Backend (production)",
"prefix": "key_live_AbCd1234",
"last4": "9f2e",
"scopes": ["messages:send", "messages:read"],
"environment": "live",
"revoked": false,
"last_used_at": null,
"created_at": "2026-06-09T10:00:00+00:00",
"secret": "key_live_AbCd1234…full-secret-shown-once…9f2e"
}

Verify the key works against the live API with the unauthenticated-friendly whoami check:

Terminal window
curl https://pufferpost.com/api/v1/whoami \
-H "Authorization: Bearer key_live_…"
{ "api_key": "key_live_AbCd1234" }

Use a key_test_… key while developing. Test keys exercise the same API surface without touching live sending reputation.

Before you can send, the domain in your from-address must be authenticated. Start verification with POST /api/v1/domains (scope domains:manage):

Terminal window
curl -X POST https://pufferpost.com/api/v1/domains \
-H "Authorization: Bearer key_live_…" \
-H "Content-Type: application/json" \
-d '{ "domain": "acme.com" }'

The call is idempotent on (workspace, domain) and returns 201 Created with the DNS records you must publish. Every record references only your own domain:

{
"id": "dom_01J…",
"domain": "acme.com",
"verified": false,
"dkim_status": "pending",
"dmarc_status": "pending",
"dns_records": [
{
"purpose": "DKIM",
"type": "TXT",
"host": "pp1._domainkey.acme.com",
"value": "v=DKIM1; k=rsa; p=MIGfMA0…"
},
{
"purpose": "SPF",
"type": "TXT",
"host": "mail.acme.com",
"value": "v=spf1 ~all"
},
{
"purpose": "DMARC",
"type": "TXT",
"host": "_dmarc.acme.com",
"value": "v=DMARC1; p=none;"
}
]
}

Publish each record at your DNS provider. Then ask PufferPost to re-check after the records propagate with POST /api/v1/domains/{id}/refresh:

Terminal window
curl -X POST https://pufferpost.com/api/v1/domains/dom_01J…/refresh \
-H "Authorization: Bearer key_live_…" \
-H "Content-Type: application/json" \
-d '{}'

When verified flips to true and dkim_status is verified, the domain is ready. DNS propagation can take from minutes to a few hours; refresh until it clears. List your domains any time with GET /api/v1/domains.

A sender is a specific from-address (optionally with a display name). Register one with POST /api/v1/senders (scope senders:manage). The from-address domain should be a domain you have verified in the previous step.

Terminal window
curl -X POST https://pufferpost.com/api/v1/senders \
-H "Authorization: Bearer key_live_…" \
-H "Content-Type: application/json" \
-d '{
"fromAddress": "receipts@acme.com",
"displayName": "Acme Receipts"
}'

Returns 201 Created. New senders start under_review, a reputation-protection gate:

{
"id": "snd_01J…",
"workspace_id": "ws_01J…",
"from_address": "receipts@acme.com",
"display_name": "Acme Receipts",
"state": "under_review",
"created_at": "2026-06-09T10:05:00+00:00"
}

A sender must be active before it can send. The from-address is unique per workspace: re-registering the same address returns 409 Conflict with code sender_exists. Check status with GET /api/v1/senders/{id}.

Once the sender is active, send with POST /api/v1/messages (scope messages:send). Messages are template-based: reference a template by its stable id (templateId, the tpl_… id), and pass render variables in data.

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-1234-receipt" \
-d '{
"from": "receipts@acme.com",
"to": "kai@example.com",
"templateId": "tpl_01J…",
"data": {
"order_id": "1234",
"total": "$49.00"
}
}'

A successful call returns 202 Accepted. The message is queued; rendering and delivery happen in the worker:

{
"id": "msg_01J…",
"status": "accepted"
}

On the Free plan the flow stops after processing, by design: the message is accepted, rendered and tracked, and you can watch its timeline in the dashboard, but it is never handed off to a real inbox. Expecting a test send to land in your own mailbox is the most common first-day surprise; a paid plan (Starter or Scale) is what turns on real delivery.

Field Required Notes
from yes Must match an active sender in this workspace.
to yes Single recipient email.
templateId yes The template’s stable tpl_… id.
data no Object of render variables for the template.
replyTo no Reply-To address.
cc no Up to 25 addresses.
metadata no Up to 50 string values (max 500 chars each); echoed back on the message.
attachments no Up to 3 attachments (filename, contentType, content).
locale no BCP-47 locale; resolves send → recipient contact → workspace default → en.
timezone no IANA timezone; resolves send → recipient contact → workspace default → UTC.
unsubscribeGroup no Subscription topic to honour; opted-out recipients are rejected.

Send the optional Idempotency-Key header to make retries safe. If the same key arrives again with the same request body, you get the original 202 response back instead of a second send. Reusing a key with a different body returns 409 Conflict (idempotency_key_reused).

Every send response carries IETF rate-limit headers so you can self-throttle:

RateLimit-Limit: 100
RateLimit-Remaining: 97
RateLimit-Reset: 1749463200

POST /api/v1/messages runs several checks before accepting. Common rejection codes (HTTP 422 unless noted):

Code Meaning
sender_not_available No active sender for from.
template_required Neither templateId nor template supplied.
template_not_found No matching template in this workspace.
recipient_suppressed to is on the suppression list.
recipient_unsubscribed to opted out of the given unsubscribeGroup.
sending_paused (403) Sending is paused for the workspace.
quota_exceeded (429) Monthly plan send limit reached.
rate_limited (429) Too many requests; back off and retry.

All errors share a stable envelope with a request_id you can quote in support:

{
"error": {
"code": "sender_not_available",
"message": "No active sender for \"receipts@acme.com\".",
"request_id": "req_01J…"
}
}

The send is asynchronous, so poll or subscribe to follow what happened next:

  • GET /api/v1/messages/{id} retrieves the message and its current status.
  • GET /api/v1/messages/{id}/events returns the delivery timeline (accepted, delivered, bounced, complained, …).