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

All 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:

  • environmentlive 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):

ResourceScopes
Messagesmessages:send, messages:read
Templatestemplates:read, templates:manage
Senderssenders:read, senders:manage
Domainsdomains:read, domains:manage
Suppressionssuppressions:read, suppressions:manage
Webhookswebhooks:read, webhooks:manage
Unsubscribe groupsunsubscribe_groups:read, unsubscribe_groups:manage
Billing & usagebilling:read, billing:manage, usage:read
Workspaceworkspace:read, workspace:manage
Contacts & recipientscontacts:read, recipients:read, recipients:erase
Logs & auditaudit:read, request_logs:read
Workflowsworkflows: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://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):

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

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

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

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

FieldRequiredNotes
fromyesMust match an active sender in this workspace.
toyesSingle recipient email.
templateIdone of theseStable tpl_… id (preferred).
templateone of theseTemplate slug, as an alternative to templateId.
datanoObject of render variables for the template.
replyTonoReply-To address.
ccnoUp to 25 addresses.
metadatanoUp to 50 string values (max 500 chars each); echoed back on the message.
attachmentsnoUp to 3 attachments (filename, contentType, content).
localenoBCP-47 locale; falls back to the workspace default, then en.
timezonenoIANA timezone; falls back to the workspace default, then UTC.
unsubscribeGroupnoSubscription 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: 100
RateLimit-Remaining: 97
RateLimit-Reset: 1749463200

What can stop a send

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

CodeMeaning
sender_not_availableNo active sender for from.
template_requiredNeither templateId nor template supplied.
template_not_foundNo matching template in this workspace.
recipient_suppressedto is on the suppression list.
recipient_unsubscribedto 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 current status.
  • GET /v1/messages/{id}/events — the delivery timeline (accepted, delivered, bounced, complained, …).

Next steps