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/batchand SMTP): suppressed addresses are removed fromto,ccandbccbefore the message is queued, and anemail.suppressedwebhook lists the ones that were dropped. If every recipient is suppressed — or everytorecipient is — the send is refused with422and{ "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:
| Reason | Added when | Expires |
|---|---|---|
hard_bounce | A 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_bounce | Three 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 |
complaint | A recipient marked a message as spam (feedback loop report). Every to address on that message is suppressed. | Never |
unsubscribe | The recipient used the unsubscribe link or their mail client's one-click unsubscribe (List-Unsubscribe). | Never |
manual | You 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.
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.
https://api.poststack.dev/suppressionsconst { data, meta } = await poststack.suppressions.list({
page: 1,
per_page: 100,
});curl "https://api.poststack.dev/suppressions?page=1&per_page=100" \
-H "Authorization: Bearer sk_live_..."{
"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
pageintegerdefault1- Page number, starting at 1.
per_pageintegerdefault20- 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.
metaobjectpage,perPage,total(entries on the whole list) andtotalPages.
Status codes
| Status | Meaning |
|---|---|
| 200 | The page of entries. |
| 400 | page or per_page is not a positive integer, or per_page is over 100. |
| 401 | Missing, invalid or revoked API key. |
| 403 | No team on the credential, the team is suspended, or an OAuth token lacks the scope ({ "error": "insufficient_scope" }). |
| 500 | Unexpected 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.
https://api.poststack.dev/suppressionsawait poststack.suppressions.add({
email: 'block-this@example.com',
reason: 'manual',
});curl -X POST https://api.poststack.dev/suppressions \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{
"email": "block-this@example.com",
"reason": "manual"
}'{
"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_bounceentries expire after 180 days; the others are permanent.repeated_soft_bounceis reserved for PostStack.
Response fields
successboolean- Always true.
Status codes
| Status | Meaning |
|---|---|
| 201 | The address is suppressed (newly, or it already was). |
| 400 | email is missing or not a valid address, or reason is missing or not one of the four values. |
| 401 | Missing, invalid or revoked API key. |
| 403 | No team on the credential, the team is suspended, or an OAuth token lacks the scope ({ "error": "insufficient_scope" }). |
| 429 | More than 30 requests in a minute from this credential. Wait for the number of seconds in Retry-After. |
| 500 | Unexpected 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.
https://api.poststack.dev/suppressions/:email// The SDK URL-encodes the address for you.
await poststack.suppressions.remove('block-this@example.com');curl -X DELETE https://api.poststack.dev/suppressions/block-this%40example.com \
-H "Authorization: Bearer sk_live_..."{
"success": true
}Path parameters
emailstringrequired- The address, URL-encoded once (
@→%40,%→%25). Matched case-insensitively.
Response fields
successboolean- Always true.
Status codes
| Status | Meaning |
|---|---|
| 200 | The address is not (or no longer) suppressed. |
| 401 | Missing, invalid or revoked API key. |
| 403 | No team on the credential, the team is suspended, or an OAuth token lacks the scope ({ "error": "insufficient_scope" }). |
| 429 | More than 30 requests in a minute from this credential. Wait for the number of seconds in Retry-After. |
| 500 | Unexpected 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.
https://api.poststack.dev/suppressions/importcurl -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" }
]
}'{
"entries": [
{ "email": "old-bounce@example.com", "reason": "hard_bounce" },
{ "email": "opted-out@example.com", "reason": "unsubscribe" },
{ "email": "not-an-address" }
]
}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"defaultmanual- 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
| Status | Meaning |
|---|---|
| 201 | Import finished; read the counts. |
| 400 | entries is missing, empty or over 10,000, an entry has an empty email, or a reason is not one of the four values. |
| 401 | Missing, invalid or revoked API key. |
| 403 | No team on the credential, the team is suspended, or an OAuth token lacks the scope ({ "error": "insufficient_scope" }). |
| 429 | More than 10 requests in a minute from this credential. Wait for the number of seconds in Retry-After. |
| 500 | Unexpected 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.
https://api.poststack.dev/suppressions/exportcurl https://api.poststack.dev/suppressions/export \
-H "Authorization: Bearer sk_live_..." \
-o suppressions.csvemail,reason,created_at
bounced@example.com,hard_bounce,2026-09-19T10:00:00.000Z
complained@example.com,complaint,2026-09-21T14:30:00.000ZStatus codes
| Status | Meaning |
|---|---|
| 200 | The CSV, as text/csv; charset=utf-8. |
| 401 | Missing, invalid or revoked API key. |
| 403 | No team on the credential, the team is suspended, or an OAuth token lacks the scope ({ "error": "insufficient_scope" }). |
| 500 | Unexpected 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.
https://api.poststack.dev/suppressions/statscurl 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
| Status | Meaning |
|---|---|
| 200 | The counts. |
| 401 | Missing, invalid or revoked API key. |
| 403 | No team on the credential, the team is suspended, or an OAuth token lacks the scope ({ "error": "insufficient_scope" }). |
| 500 | Unexpected 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")client := poststack.NewClient("sk_live_...")
page, err := client.Suppressions.List(ctx, nil)
_, err = client.Suppressions.Add(ctx, &poststack.AddSuppressionInput{
Email: "block-this@example.com",
Reason: poststack.SuppressionManual,
})
_, err = client.Suppressions.Remove(ctx, "block-this@example.com")list_suppressions { "per_page": 100 }
add_suppression { "email": "block-this@example.com", "reason": "manual" }
remove_suppression { "email": "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.