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://app.pufferpost.com/v1. All requests authenticate with a bearer API key — see Authentication.
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
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
The generic envelope listener 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). |
Endpoint-specific codes are also returned where the operation has its own rules. On POST /v1/messages, for example, you may see:
code | Status | Meaning |
|---|---|---|
sending_paused | 403 | Sending is paused for this workspace. |
idempotency_key_reused | 409 | The Idempotency-Key was reused with a different request body. |
quota_exceeded | 429 | Monthly plan send limit reached (with RateLimit-* headers). |
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
POST /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://app.pufferpost.com/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." }}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.
Rate limiting
Two distinct throttles apply, and both surface IETF RateLimit-* headers so clients can self-pace.
Per-key send rate (429 rate_limited)
POST /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)
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. Check current consumption at any time via GET /v1/usage.
Cursor pagination
List endpoints (GET /v1/messages, /v1/contacts, /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://app.pufferpost.com/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://app.pufferpost.com/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 /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.
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 /v1/messageswithout one risks a duplicate send. - Cursors are workspace- and filter-bound. Carry your filters across pages and never hand-craft the token.
For the full per-endpoint reference, see /reference/api/.