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 /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.
Prerequisites
- A PufferPost account and a workspace (your sending context — a workspace 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://app.pufferpost.com/v1All API requests authenticate with a bearer API key:
Authorization: Bearer key_live_…1. Create an API key
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—liveortest(defaults tolive). The environment is baked into the key string:key_live_…orkey_test_….name— a human label (required).scopes— what the key may do. Scopes are exact:messages:manageis not a thing, and:managedoes not imply:read. The default for a new key ismessages: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:
curl https://app.pufferpost.com/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.
2. Verify a sending domain (DKIM)
Before you can send, the domain in your from-address must be authenticated. Start verification with POST /v1/domains (scope domains:manage):
curl -X POST https://app.pufferpost.com/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 — all provider-neutral, referencing 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 /v1/domains/{id}/refresh:
curl -X POST https://app.pufferpost.com/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 /v1/domains.
3. Register a sender
A sender is a specific from-address (optionally with a display name). Register one with POST /v1/senders (scope senders:manage). The from-address domain should be a domain you have verified in the previous step.
curl -X POST https://app.pufferpost.com/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 /v1/senders/{id}.
4. Send your first message
Once the sender is active, send with POST /v1/messages (scope messages:send). Messages are template-based: reference a template by its stable id (templateId, preferred) or its slug (template), and pass render variables in data.
curl -X POST https://app.pufferpost.com/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"}Request fields
| Field | Required | Notes |
|---|---|---|
from | yes | Must match an active sender in this workspace. |
to | yes | Single recipient email. |
templateId | one of these | Stable tpl_… id (preferred). |
template | one of these | Template slug, as an alternative to templateId. |
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; falls back to the workspace default, then en. |
timezone | no | IANA timezone; falls back to the workspace default, then UTC. |
unsubscribeGroup | no | Subscription topic to honour; opted-out recipients are rejected. |
Idempotency
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).
Rate-limit headers
Every send response carries IETF rate-limit headers so you can self-throttle:
RateLimit-Limit: 100RateLimit-Remaining: 97RateLimit-Reset: 1749463200What can stop a send
POST /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…" }}Track delivery
The send is asynchronous, so poll or subscribe to follow what happened next:
GET /v1/messages/{id}— retrieve the message and its currentstatus.GET /v1/messages/{id}/events— the delivery timeline (accepted, delivered, bounced, complained, …).
Next steps
- Sending messages —
cc, attachments, metadata, locale/timezone, and batch sends viaPOST /v1/messages/batch. - Templates — create versioned templates, preview with sample
data, and reference them by id or slug. - Domain authentication — DKIM, custom SPF and DMARC in depth.
- Webhooks — receive HMAC-signed delivery events instead of polling.
- Suppressions & unsubscribe groups — manage who you can send to.
- Full endpoint and field detail in the API reference.