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.
The error envelope
Section titled “The error envelope”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 onmessage.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 theX-Request-Idresponse 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." } ] }}Error codes by status
Section titled “Error codes by status”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). |
Codes on a send
Section titled “Codes on a send”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). |
Codes on the other endpoints
Section titled “Codes on the other endpoints”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 fullcode/message/request_idenvelope, because the request never reaches the application. Treat a 401 as “fix your credentials” before parsing further.
Idempotency
Section titled “Idempotency”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):
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, sameid). No second message is enqueued. This is what makes a blind retry safe. - Reuse the same key with a different body: rejected with
409 Conflictand codeidempotency_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 Conflictand codeidempotency_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.
Rate limiting
Section titled “Rate limiting”Two distinct throttles apply, and both surface IETF RateLimit-* headers so clients can self-pace.
Per-key send rate (429 rate_limited)
Section titled “Per-key send rate (429 rate_limited)”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 AcceptedRateLimit-Limit: 100RateLimit-Remaining: 97RateLimit-Reset: 1749470400RateLimit-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.
Monthly plan quota (429 quota_exceeded)
Section titled “Monthly plan quota (429 quota_exceeded)”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.
Cursor pagination
Section titled “Cursor pagination”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, default25. Out-of-range values fail with422validation_failed.cursor: the opaque token from a previous page’snext_cursor. Omit it for the first page.
Every list response is wrapped in a data array plus a next_cursor:
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:
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.
Finding a request that was refused
Section titled “Finding a request that was refused”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.
Gotchas
Section titled “Gotchas”- Don’t switch on
message. It’s human-facing and may change. Branch oncode(and HTTP status). - 401 is the odd one out. It returns
{"error": "Unauthorized"}, not the full envelope; there’s norequest_idto quote. - Two kinds of 429.
rate_limitedmeans wait seconds;quota_exceededmeans wait until next month or upgrade. Readcode, not just the status. - Always pair retries with
Idempotency-Key. Retrying aPOST /api/v1/messageswithout 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/.