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

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:

StatuscodeMeaning
400bad_requestMalformed request (e.g. invalid JSON).
401(see note)Missing or invalid API key.
403insufficient_scopeThe API key lacks the scope this endpoint requires.
404not_foundNo such resource in this workspace.
405method_not_allowedWrong HTTP method for this path.
422validation_failedRequest body or query params failed validation (+ violations).
429rate_limitedThrottled — 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:

codeStatusMeaning
sending_paused403Sending is paused for this workspace.
idempotency_key_reused409The Idempotency-Key was reused with a different request body.
quota_exceeded429Monthly 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 full code/message/request_id envelope, 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):

Terminal window
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, 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."
}
}

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

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, 1100, 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://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:

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

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