Skip to content

Errors, idempotency & rate limits

Every PufferPost endpoint shares the same conventions for errors, safe retries, throttling, and paging through lists. Learn them once and they apply across the whole API. Base URL: https://pufferpost.com/api/v1. All requests authenticate with a bearer API key; see Authentication.

Whenever a request fails, the body is a single JSON object with an error key. The shape is stable across endpoints, so you can parse it generically:

{
"error": {
"code": "validation_failed",
"message": "Unprocessable Entity",
"request_id": "01J9ZK7H8N3Q4R5S6T7V8W9X0Y"
}
}
  • code: a stable, machine-readable string. Switch on this, not on message.
  • message: a short human-readable summary. Internal exception details are never leaked; for unhandled errors this is just the HTTP status text.
  • request_id: a unique ID for this request. The same value is returned in the X-Request-Id response header. Quote it in support requests and use it to trace a single call.

Validation errors carry field-level detail

Section titled “Validation errors carry field-level detail”

When a request body fails validation (HTTP 422), the envelope adds a violations array so you can map errors back to specific fields:

{
"error": {
"code": "validation_failed",
"message": "Unprocessable Entity",
"request_id": "01J9ZK7H8N3Q4R5S6T7V8W9X0Y",
"violations": [
{ "field": "to", "message": "This value is not a valid email address." },
{ "field": "from", "message": "This value should not be blank." }
]
}
}

The API maps HTTP status to code as follows:

Status code Meaning
400 bad_request Malformed request (e.g. invalid JSON).
401 (see note) Missing or invalid API key.
403 insufficient_scope The API key lacks the scope this endpoint requires.
404 not_found No such resource in this workspace.
405 method_not_allowed Wrong HTTP method for this path.
422 validation_failed Request body or query params failed validation (+ violations).
429 rate_limited Throttled; slow down (see Rate limiting).

POST /api/v1/messages has the most rules of any endpoint, so it has the most codes. The same set applies per item inside a POST /api/v1/messages/batch response, where an item carries its own error object instead of failing the batch.

code Status Meaning
sending_paused 403 Sending is paused for this workspace.
invalid_recipient 422 The to value is not a usable address.
recipient_suppressed 422 The address is on your suppression list.
recipient_unsubscribed 422 The address opted out of the unsubscribeGroup you sent under.
unknown_unsubscribe_group 422 No unsubscribe group with that id in this workspace.
sender_not_available 422 No active, verified sender matches the from address (see Domains & senders).
invalid_header 422 A headers entry is outside the X- allow-list, or X-Mailer* is reserved.
content_conflict 422 Both a templateId and inline subject + body were supplied; send one or the other.
template_required 422 Neither was supplied.
template_not_found 422 No template with that tpl_… id in this workspace.
template_has_no_version 422 The template exists but has never been published, so there is nothing to render.
invalid_payload 422 data does not satisfy the template’s data contract.
invalid_contract 422 The template’s stored contract is not a usable schema; fix it on the template.
section_not_found 422 The template references a reusable section that no longer exists.
idempotency_key_reused 409 The Idempotency-Key was reused with a different request body.
idempotency_in_flight 409 A request with the same Idempotency-Key is still being processed.
quota_exceeded 429 Monthly plan send limit reached (with RateLimit-* headers).
code Status Where
invalid_schema 422 PUT /api/v1/templates/{id}/contract, when the body is not a usable JSON Schema.
invalid_locale 422 Creating a template or adding a version with an unrecognised locale.
version_unavailable 404 GET /api/v1/messages/{id}/rendered, when the template version behind the message is gone.
render_failed 422 GET /api/v1/messages/{id}/rendered and template previews, when the stored content cannot be rendered.
legal_hold 409 An erasure request refused because the data is under legal hold.

Note on 401: authentication failures (missing/invalid bearer token) return a minimal body, {"error": "Unauthorized"}, rather than the full code/message/request_id envelope, because the request never reaches the application. Treat a 401 as “fix your credentials” before parsing further.

POST /api/v1/messages is the one write you must be able to retry safely after a timeout or dropped connection. Send an Idempotency-Key header, any unique string you generate per logical send (a UUID is ideal):

Terminal window
curl https://pufferpost.com/api/v1/messages \
-H "Authorization: Bearer key_live_xxxxxxxx" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 7d8f3e2a-1c4b-4e9a-9f2d-8b6c1a0e5f33" \
-d '{
"from": "receipts@acme.com",
"to": "customer@example.com",
"templateId": "tpl_01J9...",
"data": { "order_id": "A-1042" }
}'
{
"id": "msg_01J9ZK8M2P4R6T8V0X2Z4B6D8F",
"status": "accepted"
}

How it behaves:

  • First call: the message is accepted (202 Accepted) and PufferPost stores the key together with the response and a fingerprint of the request body (from, to, templateId, template, data).
  • Retry with the same key and the same body: PufferPost replays the stored response verbatim (same 202, same id). No second message is enqueued. This is what makes a blind retry safe.
  • Reuse the same key with a different body: rejected with 409 Conflict and code idempotency_key_reused. Keys are not reusable across distinct sends; mint a new one per message.
{
"error": {
"code": "idempotency_key_reused",
"message": "This Idempotency-Key was used with a different request."
}
}
  • Retry while the first call is still in flight: rejected with 409 Conflict and code idempotency_in_flight. Wait and retry; the completed response is replayed once the first call finishes.

The key is scoped to your workspace. Omitting the header is allowed but disables replay protection: without it, a retried request may enqueue a duplicate send.

Batch sending does not take an Idempotency-Key. POST /api/v1/messages/batch ignores the header, so a retried batch re-accepts every message in it. If a batch has to be retry-safe, send its messages individually with one key each. The Mailer transports already do this for a multi-recipient email: they derive a separate key per recipient and post each message on its own. The SDK’s sendBatch() posts the batch endpoint as-is, so pair it with send() plus a key where a duplicate would matter.

Two distinct throttles apply, and both surface IETF RateLimit-* headers so clients can self-pace.

POST /api/v1/messages is rate-limited per API key. Every response on this endpoint, success or failure, carries the current window state:

HTTP/1.1 202 Accepted
RateLimit-Limit: 100
RateLimit-Remaining: 97
RateLimit-Reset: 1749470400
  • RateLimit-Limit: requests permitted in the window.
  • RateLimit-Remaining: requests left in the current window.
  • RateLimit-Reset: Unix timestamp (seconds) when the window refills.

When you exhaust the window you get 429 Too Many Requests:

{
"error": {
"code": "rate_limited",
"message": "Too many requests; slow down."
}
}

Back off until RateLimit-Reset, then retry. The same Idempotency-Key makes that retry safe.

Separately, your account has a monthly send allowance from its plan. When it’s reached, sends return 429 with code quota_exceeded, and the RateLimit-* headers describe the monthly counter: RateLimit-Reset is the start of next month.

{
"error": {
"code": "quota_exceeded",
"message": "Monthly send limit reached for this plan."
}
}

This isn’t solved by waiting a few seconds. Upgrade the plan or wait for the monthly reset. To see it coming, read GET /api/v1/usage (scope usage:read):

{
"period": "2026-09",
"period_label": "September 2026",
"emails_sent": 493,
"emails_sandboxed": 37,
"monthly_limit": 15000,
"remaining": 14507
}

The counters are per account, so every workspace on it draws from the same allowance.

Sandbox sends do not spend the allowance. A send made with a test key, or any send from an account that has not subscribed yet, is metered on its own counter and shown beside the allowance rather than inside it, so rehearsing a template costs you nothing. That counter is capped at the same monthly number, so an over-tested workspace gets quota_exceeded with "Monthly sandbox send limit reached for this plan." while its real allowance stays untouched.

List endpoints (GET /api/v1/messages, /api/v1/contacts, /api/v1/messages/{id}/events, and the other collections) return results newest-first and page with an opaque cursor, not page numbers. Two query parameters:

  • limit: items per page, 1–100, default 25. Out-of-range values fail with 422 validation_failed.
  • cursor: the opaque token from a previous page’s next_cursor. Omit it for the first page.

Every list response is wrapped in a data array plus a next_cursor:

Terminal window
curl "https://pufferpost.com/api/v1/messages?limit=2" \
-H "Authorization: Bearer key_live_xxxxxxxx"
{
"data": [
{
"id": "msg_01J9ZK8M2P4R6T8V0X2Z4B6D8F",
"status": "delivered",
"to": "customer@example.com",
"from": "receipts@acme.com",
"subject": "Your receipt",
"template_id": "tpl_01J9...",
"created_at": "2026-06-09T10:14:22+00:00"
},
{
"id": "msg_01J9ZK7C1N3Q5S7U9W1Y3A5C7E",
"status": "accepted",
"to": "another@example.com",
"from": "receipts@acme.com",
"subject": "Welcome",
"created_at": "2026-06-09T10:13:58+00:00"
}
],
"next_cursor": "bXNnXzAxSjlaSzdDMU4zUTVTN1U5VzFZ"
}

Fetch the next page by passing that token back:

Terminal window
curl "https://pufferpost.com/api/v1/messages?limit=2&cursor=bXNnXzAxSjlaSzdDMU4zUTVTN1U5VzFZ" \
-H "Authorization: Bearer key_live_xxxxxxxx"

When next_cursor is null, you’ve reached the last page. Treat the cursor as opaque: don’t parse, construct, or modify it. On GET /api/v1/messages you can combine paging with exact-match filters (status, recipient, from, templateId) and an ISO-8601 range (createdAfter, createdBefore); keep the same filters on every page request.

A refused call never becomes a message, so it has no row under Messages and nothing to retrieve by id. It is in the request log instead: Dashboard, your workspace, API logs. Every authenticated /api/v1 call is recorded there, rejections included, with the method, path, status, request_id, headers, the request body and the full error response, kept for 30 days.

A line for a send names the template it referenced, so a template that is currently failing is visible from the list without opening each call. Open the row for the error envelope itself, which lists every violation by path.

Bodies are redacted before they are stored. to, data, subject, html, metadata and similar fields are masked, so the log shows the shape of a request and what was wrong with it, never the recipient or the values. Nothing personal is kept in a debugging surface, and no credentials are: the Authorization header is dropped outright and never stored.

  • Don’t switch on message. It’s human-facing and may change. Branch on code (and HTTP status).
  • 401 is the odd one out. It returns {"error": "Unauthorized"}, not the full envelope; there’s no request_id to quote.
  • Two kinds of 429. rate_limited means wait seconds; quota_exceeded means wait until next month or upgrade. Read code, not just the status.
  • Always pair retries with Idempotency-Key. Retrying a POST /api/v1/messages without one risks a duplicate send.
  • Cursors are workspace- and filter-bound. Carry your filters across pages and never hand-craft the token.
  • A rejected send leaves no message, only a log line. Look for it under API logs, not Messages; the payload itself is masked there.

For the full per-endpoint reference, see /reference/api/.