Skip to content

Suppressions & unsubscribe

The platform enforces two opt-out mechanisms on every send: a workspace suppression list (a hard block on a recipient) and subscription topics (per-topic opt-out, a.k.a. unsubscribe groups). Both are checked before a message is queued, and both feed the RFC 8058 one-click unsubscribe link stamped on every email.

All endpoints use the base URL https://pufferpost.com/api/v1 and bearer API-key auth. See /reference/api/ for the full reference.

A suppression entry blocks a single recipient address for the whole workspace. Entries are created two ways:

  • Automatically. A permanent (hard) bounce or a complaint on a delivered message adds the recipient with reason hard_bounce or complaint. This is reputation protection and happens with no action from you.
  • Manually. You add an address yourself (reason manual), or a recipient opts out via the hosted unsubscribe page (reason unsubscribe).

When you call POST /api/v1/messages, the recipient is checked against this list first. A suppressed recipient is rejected at accept time: the send never enters the queue and no email goes out. The API returns an error envelope with code recipient_suppressed:

{
"error": {
"code": "recipient_suppressed",
"message": "\"jane@example.com\" is on the suppression list.",
"request_id": "req_01J9Z…"
}
}

POST /api/v1/suppressions adds a recipient manually. Scope required: suppressions:manage.

Terminal window
curl -X POST https://pufferpost.com/api/v1/suppressions \
-H "Authorization: Bearer key_live_xxxxxxxx" \
-H "Content-Type: application/json" \
-d '{"recipient": "jane@example.com"}'

The only field is recipient, which must be a valid email address. The call is idempotent: re-suppressing an address that is already on the list keeps the original entry rather than creating a duplicate.

Response: 201 Created.

{
"id": "sup_01J9ZQ8F3K2R7M5N6P4T0V8W1X",
"recipient": "jane@example.com",
"reason": "manual",
"source": "api",
"created_at": "2026-06-09T10:21:44+00:00"
}

The reason reflects how the entry was created (hard_bounce, complaint, manual, or unsubscribe); source is a short provenance string (api, one-click, or the originating message id for event-driven entries).

GET /api/v1/suppressions returns the workspace’s suppression list, newest first. Scope required: suppressions:read.

Terminal window
curl https://pufferpost.com/api/v1/suppressions \
-H "Authorization: Bearer key_live_xxxxxxxx"

Response: 200 OK.

{
"data": [
{
"id": "sup_01J9ZQ8F3K2R7M5N6P4T0V8W1X",
"recipient": "jane@example.com",
"reason": "manual",
"source": "api",
"created_at": "2026-06-09T10:21:44+00:00"
},
{
"id": "sup_01J9ZP1A2B3C4D5E6F7G8H9J0K",
"recipient": "bounced@example.com",
"reason": "hard_bounce",
"source": "msg_01J9ZNX…",
"created_at": "2026-06-08T14:03:11+00:00"
}
]
}

POST /api/v1/suppressions/import adds up to 1000 addresses in one call, which is how you bring a do-not-send list with you when migrating from another provider. Scope required: suppressions:manage.

Terminal window
curl -X POST https://pufferpost.com/api/v1/suppressions/import \
-H "Authorization: Bearer key_live_xxxxxxxx" \
-H "Content-Type: application/json" \
-d '{ "recipients": ["jane@example.com", "bob@example.com"] }'

Response: 201 Created, with a per-row outcome in submission order and the number actually added.

{
"data": [
{ "recipient": "jane@example.com", "status": "created" },
{ "recipient": "bob@example.com", "status": "duplicate" }
],
"created": 1
}

An address already on the list is reported as duplicate and keeps its original entry and reason, so re-running an import is safe. Imported entries carry the manual reason. A malformed address fails the whole call with a 422 before anything is written.

DELETE /api/v1/suppressions/{id} removes a recipient once they are safe to send to again. Scope required: suppressions:manage.

Terminal window
curl -X DELETE https://pufferpost.com/api/v1/suppressions/sup_01J9ZQ8F3K2R7M5N6P4T0V8W1X \
-H "Authorization: Bearer key_live_xxxxxxxx"

Response: 204 No Content. The id is workspace-scoped: an id that belongs to another workspace returns 404 Not Found, never a cross-tenant delete.

Deleting a suppression that was created from a hard bounce or complaint is allowed, but do it only when you know the underlying problem is fixed. Sending to an address that keeps bouncing damages your sender reputation.

Every email the platform sends carries the headers required for one-click unsubscribe, so Gmail, Apple Mail, Outlook and friends render a native “Unsubscribe” affordance:

List-Unsubscribe: <https://pufferpost.com/unsubscribe?token=…>
List-Unsubscribe-Post: List-Unsubscribe=One-Click

The token is signed and carries the workspace, recipient, and (if set) the subscription topic. You do not build these headers; they are stamped automatically at send time.

The URL in the header is a hosted, public surface:

  • GET /unsubscribe?token=… renders a branded confirmation page, which is what a human clicking the link sees.
  • POST /unsubscribe is the RFC 8058 one-click action. The recipient’s email client posts it automatically; the confirmation page’s button posts here too.

A valid token opts the recipient out: of one subscription topic if the message was tagged with one, otherwise globally (a unsubscribe-reason suppression). If the token’s topic no longer exists, it falls back to a global suppression so the recipient is always honoured. The action is idempotent.

Tokens are valid for a year from the send, so an old link cannot be replayed indefinitely from a forwarded message or a log. A recipient who follows an expired one gets a page naming your workspace and telling them the link in your most recent email to them still works, since every send carries a fresh token. A forged token gets a plain error page. Nothing about this needs handling on your side.

You don’t call these endpoints yourself; they exist so recipients always have a working opt-out without you hosting anything.

The header covers mail clients that render a native unsubscribe control. For a link the recipient can see and click, write {{ unsubscribe_url }} in the body and PufferPost fills in the same URL the header carries:

  • In a template it is a reserved render variable; your own unsubscribe_url in data cannot override it.
  • In an inline body that exact token is substituted literally. It is the only placeholder an inline body understands.

The seeded Footer section already contains one, so any template composing it has a compliant footer without you doing anything.

Subscribe to the unsubscribed webhook. It fires for both shapes of opt-out and carries the recipient plus the group that was turned off, or null when they opted out of everything.

A workspace-wide opt-out also appears in GET /api/v1/suppressions with reason unsubscribe, so it can be reconciled by polling. A topic opt-out does not appear there — subscription topics have no membership endpoint — so for those the webhook is the only signal.

A subscription topic lets a recipient opt out of one category of mail (for example “Receipts” or “Shipping updates”) without being globally suppressed. Topics are per-workspace.

POST /api/v1/unsubscribe-groups. Scope required: unsubscribe_groups:manage.

Terminal window
curl -X POST https://pufferpost.com/api/v1/unsubscribe-groups \
-H "Authorization: Bearer key_live_xxxxxxxx" \
-H "Content-Type: application/json" \
-d '{"code": "receipts", "name": "Receipts & invoices"}'

Fields:

  • code: a stable machine identifier you reference when sending. Max 64 chars; letters, numbers, hyphens and underscores only.
  • name: a human-readable label shown on the hosted unsubscribe page.

Response: 201 Created.

{
"id": "usg_01J9ZR4M5N6P7Q8R9S0T1U2V3W",
"code": "receipts",
"name": "Receipts & invoices",
"created_at": "2026-06-09T10:25:02+00:00"
}

A duplicate code in the same workspace returns 409 Conflict.

GET /api/v1/unsubscribe-groups returns them all; GET /api/v1/unsubscribe-groups/{id} returns one. Scope required: unsubscribe_groups:read.

Terminal window
curl https://pufferpost.com/api/v1/unsubscribe-groups \
-H "Authorization: Bearer key_live_xxxxxxxx"

Response: 200 OK.

{
"data": [
{
"id": "usg_01J9ZR4M5N6P7Q8R9S0T1U2V3W",
"code": "receipts",
"name": "Receipts & invoices",
"created_at": "2026-06-09T10:25:02+00:00"
}
]
}

PATCH /api/v1/unsubscribe-groups/{id} changes the display name. Scope required: unsubscribe_groups:manage.

Terminal window
curl -X PATCH https://pufferpost.com/api/v1/unsubscribe-groups/usg_01J9ZR4M5N6P7Q8R9S0T1U2V3W \
-H "Authorization: Bearer key_live_xxxxxxxx" \
-H "Content-Type: application/json" \
-d '{ "name": "Receipts and invoices" }'

The code is immutable: it is baked into every unsubscribe link already sent, and into the sends that reference the topic, so only the name a recipient sees on the hosted page changes.

DELETE /api/v1/unsubscribe-groups/{id}. Scope required: unsubscribe_groups:manage.

Terminal window
curl -X DELETE https://pufferpost.com/api/v1/unsubscribe-groups/usg_01J9ZR4M5N6P7Q8R9S0T1U2V3W \
-H "Authorization: Bearer key_live_xxxxxxxx"

Response: 204 No Content. Like suppressions, the id is workspace-scoped; an id outside your workspace returns 404.

Tag a send with a topic by passing its code as unsubscribeGroup in the POST /api/v1/messages body:

Terminal window
curl -X POST https://pufferpost.com/api/v1/messages \
-H "Authorization: Bearer key_live_xxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"from": "no-reply@acme.com",
"to": "jane@example.com",
"templateId": "tpl_…",
"data": { "order": "1234" },
"unsubscribeGroup": "receipts"
}'

When unsubscribeGroup is set, accept-time validation does two extra checks before queuing:

  • If no topic with that code exists in the workspace, the send is rejected with code unknown_unsubscribe_group.
  • If the recipient has opted out of that topic, the send is rejected with code recipient_unsubscribed.
{
"error": {
"code": "recipient_unsubscribed",
"message": "\"jane@example.com\" has unsubscribed from \"receipts\".",
"request_id": "req_01J9Z…"
}
}

A per-topic opt-out is scoped: the recipient stays eligible for your other topics and for untagged mail. By contrast, the global suppression list (above) blocks the recipient for everything. See Sending email for the full send payload.

  • Suppression is global within a workspace; topic opt-out is not. A recipient on the suppression list is blocked for every send. A recipient who opted out of one topic only loses mail tagged with that topic; untagged sends and other topics still go through.
  • Suppressed and unsubscribed sends never reach the queue. They’re rejected synchronously at POST /api/v1/messages with recipient_suppressed, recipient_unsubscribed, or unknown_unsubscribe_group. No msg_… id is created and nothing is enqueued.
  • The code, not the id, is what you send. unsubscribeGroup takes the topic’s code (e.g. receipts), never its usg_… id.
  • Auto-suppressions are reputation protection. Hard-bounce and complaint entries are created from real delivery events. Only delete one once you’re confident the address is deliverable again.
  • Manual adds are idempotent; topic codes must be unique. Re-suppressing an address is a no-op that returns the existing entry, but creating a topic with a code that already exists returns 409.
  • You never build the List-Unsubscribe header or host the unsubscribe page. Both are produced for you; tagging a send with a topic simply scopes what the recipient’s one-click opt-out removes.