Skip to content

Suppressions

The per-team list of addresses PostStack will not send to, how an address gets on it, how it comes off, and the endpoints to manage it yourself.

What suppression does#

Every address on the list is blocked for your whole team — every domain, API key, SMTP credential and broadcast. Addresses are stored lowercased and matched case-insensitively, so Alice@Example.com and alice@example.com are the same entry.

  • Transactional sends (POST /emails, POST /emails/batch and SMTP): suppressed addresses are removed from to, cc and bcc before the message is queued, and an email.suppressed webhook lists the ones that were dropped. If every recipient is suppressed — or every to recipient is — the send is refused with 422 and { "error": "All recipients are suppressed" }, and no webhook fires.
  • Broadcasts: suppressed contacts are skipped when the recipient list is built. Nothing is reported per address.

Removing an address takes effect on the next send; there is no cache to wait out.

How an address gets suppressed#

Each entry carries the reason it was added:

ReasonAdded whenExpires
hard_bounceA permanent bounce says the address itself is dead: bounce category invalid_mailbox, dns_failure or unknown, or a permanent mailbox_full (e.g. Gmail's 552 5.2.2). Only the recipient the bounce report names is suppressed (every to recipient if the report names none). Spam-block, policy, authentication and content rejections do not suppress — the mailbox is usually fine and the problem is the message or the sender.After 180 days, if the domain still has MX records
repeated_soft_bounceThree distinct messages to the address bounced (soft or hard, any category) within 30 days with no open or click on any of them, or one message to it has been deferred for 48 hours with 4.2.1 (mailbox unavailable) or 4.2.2 (mailbox full). Cannot be set through the API.After 180 days, if the domain still has MX records
complaintA recipient marked a message as spam (feedback loop report). Every to address on that message is suppressed.Never
unsubscribeThe recipient used the unsubscribe link or their mail client's one-click unsubscribe (List-Unsubscribe).Never
manualYou added it through the API or the dashboard.Never

“Expires” is the 180-day sunset: once revalidateAfter passes, a daily sweep checks the recipient's domain. If it still has MX records the entry is removed and the address gets one more chance — a mailbox that is still dead bounces again and is re-suppressed immediately. If the domain has no MX the entry stays and the check is pushed out another 180 days.

The reason you pick decides whether it expires

An entry you add with reason: "hard_bounce" gets the same 180-day sunset as one PostStack added. To block an address for good, use manual, complaint or unsubscribe.

Unsubscribing a contact through the API (see Contacts) only sets the contact's unsubscribed flag; it does not add a suppression. Transactional sends to that address still go out. Add a manual or unsubscribe suppression if you want those blocked too.

Endpoints#

Base URL https://api.poststack.dev. Authenticate with Authorization: Bearer sk_live_…. Reading the list needs the email:read scope, changing it needs email:send; both full-access and sending-access API keys have both. Validation errors answer 400 with { "error": "<field>: <message>" }.

List suppressions#

One page of the team's suppression list, newest entries first (ties broken by id, so pages are stable). To get the whole list at once, use the CSV export.

GEThttps://api.poststack.dev/suppressions
Request
const { data, meta } = await poststack.suppressions.list({
  page: 1,
  per_page: 100,
});
{
  "data": [
    {
      "id": 812,
      "teamId": 42,
      "email": "bounced@example.com",
      "reason": "hard_bounce",
      "revalidateAfter": "2027-03-18T10:00:00.000Z",
      "createdAt": "2026-09-19T10:00:00.000Z",
      "updatedAt": "2026-09-19T10:00:00.000Z"
    },
    {
      "id": 813,
      "teamId": 42,
      "email": "complained@example.com",
      "reason": "complaint",
      "revalidateAfter": null,
      "createdAt": "2026-09-21T14:30:00.000Z",
      "updatedAt": "2026-09-21T14:30:00.000Z"
    }
  ],
  "meta": {
    "page": 1,
    "perPage": 20,
    "total": 24,
    "totalPages": 2
  }
}

Query parameters

pageintegerdefault 1
Page number, starting at 1.
per_pageintegerdefault 20
Entries per page, 1–100.

Response fields

data[].idinteger
Entry id.
data[].teamIdinteger
Your team id.
data[].emailstring
The suppressed address, lowercased.
data[].reason"hard_bounce" | "repeated_soft_bounce" | "complaint" | "unsubscribe" | "manual"
Why it was suppressed — see the table above.
data[].revalidateAfterstring (ISO 8601) | null
When the entry becomes eligible for the 180-day sunset. null for entries that never expire.
data[].createdAtstring (ISO 8601)
When the address was suppressed.
data[].updatedAtstring (ISO 8601)
Last change to the entry.
metaobject
page, perPage, total (entries on the whole list) and totalPages.

Status codes

StatusMeaning
200The page of entries.
400page or per_page is not a positive integer, or per_page is over 100.
401Missing, invalid or revoked API key.
403No team on the credential, the team is suspended, or an OAuth token lacks the scope ({ "error": "insufficient_scope" }).
500Unexpected server error: { "error": "Failed to list suppressions" }. Safe to retry.

Add a suppression#

Suppress one address. If the address is already on the list the call still answers 201, and the existing entry — including its reason — is left unchanged. To change a reason, remove the entry and add it again.

POSThttps://api.poststack.dev/suppressions
Request
await poststack.suppressions.add({
  email: 'block-this@example.com',
  reason: 'manual',
});
HTTP/1.1 201 Created

{
  "success": true
}

Body parameters

emailstringrequired
The address to suppress. Must be a valid email address; stored lowercased.
reason"hard_bounce" | "complaint" | "unsubscribe" | "manual"required
Why you are suppressing it. hard_bounce entries expire after 180 days; the others are permanent. repeated_soft_bounce is reserved for PostStack.

Response fields

successboolean
Always true.

Status codes

StatusMeaning
201The address is suppressed (newly, or it already was).
400email is missing or not a valid address, or reason is missing or not one of the four values.
401Missing, invalid or revoked API key.
403No team on the credential, the team is suspended, or an OAuth token lacks the scope ({ "error": "insufficient_scope" }).
429More than 30 requests in a minute from this credential. Wait for the number of seconds in Retry-After.
500Unexpected server error: { "error": "Failed to add suppression" }. Safe to retry.

Remove a suppression#

Take an address off the list so sends to it resume. Answers 200 whether or not the address was on the list, so the call is safe to repeat. An address that is still dead will be re-suppressed by its next bounce.

DELETEhttps://api.poststack.dev/suppressions/:email
Request
// The SDK URL-encodes the address for you.
await poststack.suppressions.remove('block-this@example.com');
{
  "success": true
}

Path parameters

emailstringrequired
The address, URL-encoded once (@ → %40, % → %25). Matched case-insensitively.

Response fields

successboolean
Always true.

Status codes

StatusMeaning
200The address is not (or no longer) suppressed.
401Missing, invalid or revoked API key.
403No team on the credential, the team is suspended, or an OAuth token lacks the scope ({ "error": "insufficient_scope" }).
429More than 30 requests in a minute from this credential. Wait for the number of seconds in Retry-After.
500Unexpected server error: { "error": "Failed to remove suppression" }. Safe to retry.

Import suppressions#

Add up to 10,000 addresses in one request — for moving a suppression list over from another provider. Addresses are trimmed, lowercased and de-duplicated; invalid ones are counted and skipped rather than failing the request. Existing entries are left unchanged. Not wrapped by the SDKs; call it over HTTP.

POSThttps://api.poststack.dev/suppressions/import
Request
curl -X POST https://api.poststack.dev/suppressions/import \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "entries": [
      { "email": "old-bounce@example.com", "reason": "hard_bounce" },
      { "email": "opted-out@example.com", "reason": "unsubscribe" }
    ]
  }'
HTTP/1.1 201 Created

{
  "added": 2,
  "skipped": 0,
  "invalid": 1
}

Body parameters

entriesobject[]required
1–10,000 entries.
entries[].emailstringrequired
The address. Invalid addresses are counted in invalid, not rejected.
entries[].reason"hard_bounce" | "complaint" | "unsubscribe" | "manual"default manual
Why it is suppressed. hard_bounce entries expire after 180 days.

Response fields

addedinteger
New entries written.
skippedinteger
Valid addresses that were already on the list.
invalidinteger
Entries whose email is not a valid address.

Status codes

StatusMeaning
201Import finished; read the counts.
400entries is missing, empty or over 10,000, an entry has an empty email, or a reason is not one of the four values.
401Missing, invalid or revoked API key.
403No team on the credential, the team is suspended, or an OAuth token lacks the scope ({ "error": "insufficient_scope" }).
429More than 10 requests in a minute from this credential. Wait for the number of seconds in Retry-After.
500Unexpected server error: { "error": "Failed to import suppressions" }. Safe to retry.

Export suppressions#

The whole list as a CSV download named suppressions.csv, oldest entry first, with the header row email,reason,created_at. Not paginated. Cells that start with a spreadsheet formula character are escaped so the file is safe to open in Excel. Not wrapped by the SDKs.

GEThttps://api.poststack.dev/suppressions/export
Request
curl https://api.poststack.dev/suppressions/export \
  -H "Authorization: Bearer sk_live_..." \
  -o suppressions.csv
email,reason,created_at
bounced@example.com,hard_bounce,2026-09-19T10:00:00.000Z
complained@example.com,complaint,2026-09-21T14:30:00.000Z

Status codes

StatusMeaning
200The CSV, as text/csv; charset=utf-8.
401Missing, invalid or revoked API key.
403No team on the credential, the team is suspended, or an OAuth token lacks the scope ({ "error": "insufficient_scope" }).
500Unexpected server error: { "error": "Failed to export suppressions" }. Safe to retry.

Count suppressions by reason#

How many addresses are on the list, per reason. Every reason is always present, with 0 when there are none. Not wrapped by the SDKs.

GEThttps://api.poststack.dev/suppressions/stats
Request
curl https://api.poststack.dev/suppressions/stats \
  -H "Authorization: Bearer sk_live_..."
{
  "stats": {
    "hard_bounce": 18,
    "complaint": 2,
    "manual": 3,
    "unsubscribe": 1,
    "repeated_soft_bounce": 0,
    "total": 24
  }
}

Response fields

stats.hard_bounceinteger
Entries with reason hard_bounce.
stats.repeated_soft_bounceinteger
Entries with reason repeated_soft_bounce.
stats.complaintinteger
Entries with reason complaint.
stats.unsubscribeinteger
Entries with reason unsubscribe.
stats.manualinteger
Entries with reason manual.
stats.totalinteger
All entries.

Status codes

StatusMeaning
200The counts.
401Missing, invalid or revoked API key.
403No team on the credential, the team is suspended, or an OAuth token lacks the scope ({ "error": "insufficient_scope" }).
500Unexpected server error: { "error": "Failed to get suppression stats" }. Safe to retry.

From other tools#

The Python and Go SDKs and the MCP server cover list, add and remove. Import, export and stats are HTTP-only (or the Suppressions page in the dashboard, which has Import and Export buttons).

from poststack import PostStack

client = PostStack(api_key="sk_live_...")

page = client.suppressions.list({"page": 1, "per_page": 100})
client.suppressions.add({"email": "block-this@example.com", "reason": "manual"})
client.suppressions.remove("block-this@example.com")

Troubleshooting#

A send fails with 422 "All recipients are suppressed"

Every recipient on the message — or every to recipient — is on the list. Look each one up in the export, check the reason, and remove the entry only if you know the address works now. Removing a complaint or unsubscribe entry means mailing someone who asked you to stop.

A recipient is missing from a delivered message

When only some recipients are suppressed the message still goes out to the rest, and the dropped addresses are listed in the suppressed_recipients field of the email.suppressed webhook. Subscribe to it on the Webhooks page if you need to know.

A suppression I removed came back

The next message to the address bounced again (or was reported as spam), so it was re-suppressed. The reason on the new entry tells you which.

Adding an address returned 201 but the reason did not change

Adding an address that is already suppressed is a no-op. Remove it first, then add it with the new reason.

Next steps

Was this page helpful?

Related