Email suppression list: what blocks receipts, and what blocks only campaigns
Published Updated 15 min readBy the Rasket team
Written for developers, AI automation builders and deliverability specialists

An email suppression list is the set of addresses a sending system will not mail: hard bounces, spam complaints, unsubscribes and addresses you add by hand. On Rasket, a bounce, complaint or manual entry blocks every message, receipts and password resets included, while an unsubscribe blocks only campaigns and automations.
Here is the support ticket that follows. A customer with a Yahoo Mail address reports your spring sale as spam in March. In June they buy a kettle, your code sends the receipt and gets back 200 and an id, and nothing arrives. The complaint put them on the list, and the list covers receipts. Each case below is shown on a real request and its response.
Does a suppression block receipts and password resets?
It depends on why the address is on the list and how the message was sent. Every row has an origin, and the table is the whole rule. “Receipts” here means anything sent with POST /emails, the SMTP relay, the dashboard or an Inbox reply.
| Origin | Added when | Receipts and resets | Campaigns and automations | suppressed.reason on a stopped send |
|---|---|---|---|---|
| bounce | A permanent bounce comes back | Blocked | Blocked | previous_bounce |
| complaint | A spam report comes back | Blocked | Blocked | previous_complaint |
| manual | You add it in the dashboard or over the API | Blocked | Blocked | manual |
| unsubscribe | The person unsubscribes from a campaign | Sent | Blocked | unsubscribed |
An unsubscribe refuses marketing; the person still expects their receipt. A hard bounce means the mailbox is gone. A complaint stops everything, because the person told their mailbox provider they did not want your mail. The list is per team, covers every domain and key the team owns, and is checked when the request arrives and again just before the message leaves. Rows never expire. There is no per-domain or per-stream list, so a complaint about a campaign blocks receipts from every domain the team sends from. Separate sending domains still help your reputation, as the transactional vs marketing post explains, but they do not split this list.
Without code: when a customer says a receipt never came, open Emails → Suppressions, search their address, read its origin, and use the table on lifting a row to decide; deleting the row lifts it. No row means the cause is elsewhere, and tracing a missing email takes it from there.
- Bounces. Our mail servers label each bounce
Permanent,TransientorUndeterminedfrom the receiving server’s reply. OnlyPermanentadds a row, for the one recipient it names.Transient(a full mailbox, a server asking you to retry) andUndeterminednever do, however many there are, and Rasket has no threshold for them. A rule you can apply yourself: count only bounces about the mailbox (a4.2.xcode), not4.7.xpolicy deferrals, and suppress an address after two or more in a row spread over at least two weeks, as the bounce post does in code. - Complaints. A feedback loop is how a mailbox provider tells a sender that someone pressed “Report spam”. Complaints reach the list only through the loops our mail servers receive. Consumer Outlook.com and Hotmail mailboxes and Yahoo report each one, and each adds a row for that address; those are the loops we can confirm. Gmail never names who complained: as Google documents it, its loop reports spam rates per
Feedback-IDidentifier, so a Gmail user who reports you keeps getting receipts and campaigns. Rasket does not set per-campaignFeedback-IDfields on your mail, so do not expect a per-campaign split there either. Watch the rate as the spam complaint rate guide describes. - Unsubscribes. Campaign and automation mail carries the RFC 8058 one-click header (the campaigns docs say so; the guide explains it). Using it, or “unsubscribe from all” on the preference page, writes the row and marks the contact unsubscribed.
- Matching. Addresses are compared trimmed and without case, and
ada+shop@is notada@. RFC 5321 says implementations must treat the part before the@as case-sensitive but discourages relying on it; mailbox providers in practice ignore case, so Rasket matches without it as a product choice.
One gap, stated plainly: Rasket does not read the enhanced status code (RFC 3463) before suppressing. A policy refusal such as 5.7.1, which usually says more about your mail or your reputation than the address (unlike 5.1.1, no such mailbox), is suppressed like a dead mailbox when it arrives as Permanent. We advise the opposite for your own handler, so treat a jump in bounce rows as an incident: recovering from a wave of bad bounces lifts them once the cause is fixed.
Receipts sent by an automation count as marketing
An automation is a workflow you build under Automations in the dashboard: a trigger, such as a custom event or a connected store’s order-placed event, then steps such as “send email”. Every email it sends is marketing. It carries an unsubscribe link and skips contacts marked unsubscribed and addresses with an unsubscribe row. An order confirmation built as an automation therefore never reaches a customer who unsubscribed from your promotions.
The rule: send receipts, password resets and anything else a customer is owed from your own code, with POST /emails or the SMTP relay, and keep automations for mail a person may opt out of. Order emails your store platform sends itself are not automation sends either: through the SMTP relay they count as transactional.
A store example: one receipt, three customers
Ada unsubscribed from your promotions. Ben reported a sale email as spam. Cy’s old work address hard-bounced at your previous provider and came over when you migrated, which is why its row names no source email. The script sends each of them the same receipt and reads back what happened. Reading the email back needs a full-access key; the last run shows what a sending-access key gets instead.
// Send one order receipt, then read back what happened to it.
const API = process.env.RASKET_API_URL ?? "https://api.rasket.com";
const headers = {
authorization: `Bearer ${process.env.RASKET_API_KEY}`,
"content-type": "application/json",
};
const [to, order] = process.argv.slice(2); // node receipt.mjs <address> <order number>
async function read(path) {
const response = await fetch(`${API}${path}`, { headers });
if (!response.ok) throw new Error(`GET ${path}: ${response.status} ${await response.text()}`);
return response.json();
}
const sent = await fetch(`${API}/emails`, {
method: "POST",
headers: { ...headers, "idempotency-key": `order-${order}-receipt` },
body: JSON.stringify({
from: process.env.RECEIPTS_FROM, // e.g. "Shop <orders@yourstore.com>"
to: [to],
subject: `Receipt for order ${order}`,
text: `Thanks for your order. Order ${order} is paid.`,
}),
});
if (!sent.ok) throw new Error(`${sent.status} ${await sent.text()}`);
const { id } = await sent.json();
console.log(sent.status, id);
const email = await read(`/emails/${id}`);
console.log("last_event:", email.last_event);
const events = await read(`/emails/${id}/events`);
for (const event of events.data.filter((event) => event.type === "email.suppressed")) {
console.log(event.type, JSON.stringify(event.data.suppressed));
}$ node receipt.mjs "$ADA" 4042 # unsubscribed from promotions
200 01a14b90-0dbb-7d3d-99fb-afddefb71f3a
last_event: queued
$ node receipt.mjs "$BEN" 4043 # reported a sale email as spam
200 01a14b90-0e7f-7a4a-9375-9d1f0ca833bf
last_event: suppressed
email.suppressed {"type":"Suppressed","reason":"previous_complaint"}
$ node receipt.mjs "$CY" 4044 # hard-bounced at your previous provider
200 01a14b90-0f4d-760c-8bee-2e79cd70ee2d
last_event: suppressed
email.suppressed {"type":"Suppressed","reason":"previous_bounce"}
$ RASKET_API_KEY="$SENDING_ACCESS_KEY" node receipt.mjs "$ADA" 4050
200 01a14b90-1063-77d6-b784-87c03f6d6c32
Error: GET /emails/01a14b90-1063-77d6-b784-87c03f6d6c32: 401 {"statusCode":401,"name":"restricted_api_key",…}All three sends succeed. Ada’s receipt is queued; Ben’s and Cy’s are recorded and stopped. No wait is needed before the read: the list is checked inside the send request, so the verdict is stored before the 200 comes back. A row added between the request and the moment the message leaves is caught by the second check, and shows up later, as an email.suppressed event on the same email and its webhook. Show Ben the receipt in his account and leave his row alone unless he asks you to email him. Ask Cy for a working address; the old row only blocks the old one.
What a suppressed send returns
A suppressed email is not an error: 200, an id, nothing sent, and nothing counted toward your monthly email quota. In a send to several people the others still get it. GET /emails/{id} reads last_event: "suppressed", GET /emails/{id}/events lists one email.suppressed per stopped recipient, and a webhook endpoint receives the same event (events reference). This one is from an earlier run of the same script:
{
"type": "email.suppressed",
"created_at": "2026-10-17T07:56:10.347Z",
"data": {
"email_id": "01a148dc-76ef-7672-9631-d5b72ac49c8b",
"from": "Shop <orders@yourstore.com>",
"to": ["ben@customer.example"],
"subject": "Receipt for order 1043",
"message_id": "",
"created_at": "2026-10-17T07:56:10.351Z",
"suppressed": { "type": "Suppressed", "reason": "previous_complaint" }
}
}suppressed.reason takes the four values in the first table. A fifth, invalid_recipient, comes from separate recipient checks and adds a cause: a reserved or misspelt domain, a domain whose DNS says it takes no mail, or an address that hard-bounced as non-existent for any Rasket team in the last 180 days. A 4xx would make every order flow handle “the order worked but the receipt request failed”, and the ones that forgot would break checkout; that is why a suppression is a 200. When a customer says a message never arrived, tracing it step by step starts with this check.
If an agent sends the mail, the same applies. The MCP send_email tool returns only the new id and replayed, so an agent that stops there reports success. Add https://api.rasket.com/mcp to your MCP client as an HTTP server; it signs in through OAuth, where a team admin approves the scopes (the agent post walks through it). Grant emails:read as well and tell the agent to call get_email on that id; with suppressions:read it can also say why. These are the real tool results for Ben. MCP events carry occurred_at and recipient, where the webhook carries created_at and data.to:
// tools/call send_email {"to":["ben@customer.example"],"subject":"Your password reset link",…}
{"id":"01a14b89-d6e8-7cef-be2f-1e6f7e09b4ef","replayed":false}
// tools/call get_email {"email_id":"01a14b89-d6e8-7cef-be2f-1e6f7e09b4ef"}
{"id":"01a14b89-d6e8-7cef-be2f-1e6f7e09b4ef","subject":"Your password reset link",
"last_event":"suppressed","source":"api",
"events":[{"type":"email.suppressed","occurred_at":"2026-10-17T20:24:47.045Z","recipient":"ben@customer.example"}],…}
// tools/call list_suppressions {"search":"ben@customer.example"}
{"object":"list","has_more":false,"data":[{"id":"b9181429-3bd7-42a5-b4f7-6551ba70578d",
"email":"ben@customer.example","origin":"complaint","source_id":null,…}]}
// What the agent should then say
Not sent: ben@customer.example is on your suppression list because they reported your mail as spam.OAuth scopes are narrower than keys: an agent that only checks the list needs suppressions:read and nothing else. It can add suppressions with suppressions:write but has no tool that removes one.
When an unsubscribe hides a later bounce or complaint
Rasket keeps one row per address, and the first one written stays, so a later manual entry can never downgrade a bounce or complaint. The side effect: if the first row is an unsubscribe, a later hard bounce or complaint leaves it as it is, and receipts keep going to a dead mailbox or to someone who reported you. POST /suppressions for that address answers 201 with the existing row’s id and changes nothing. It is a known limitation. Without code, delete the row in the dashboard and add the address again; it comes back as manual. In code, do the same from your webhook endpoint. Rasket sends one bounce or complaint event per recipient it can identify, so data.to holds the one address the event is about; an event naming several is about the whole message. Pass the raw request body and the svix-id, svix-timestamp and svix-signature headers to handle, which checks the signature before anything touches the list (the webhooks post builds the check by hand):
// An unsubscribe row that later bounces or complains becomes a manual row that blocks everything.
// npm install svix: the signing scheme is Svix-compatible, so its verifier checks ours.
import { Webhook } from "svix";
const API = process.env.RASKET_API_URL ?? "https://api.rasket.com";
const headers = {
authorization: `Bearer ${process.env.RASKET_API_KEY}`,
"content-type": "application/json",
};
const webhook = new Webhook(process.env.RASKET_WEBHOOK_SECRET);
// rawBody is the request body as a string, before any JSON parsing; verify throws on a bad signature.
export async function handle(rawBody, requestHeaders) {
webhook.verify(rawBody, {
"svix-id": requestHeaders["svix-id"],
"svix-timestamp": requestHeaders["svix-timestamp"],
"svix-signature": requestHeaders["svix-signature"],
});
return escalate(JSON.parse(rawBody)); // svix 2.2+ returns nothing from verify, so parse here
}
export async function escalate(event) {
const hardBounce = event.type === "email.bounced" && event.data.bounce?.type === "Permanent";
if (!hardBounce && event.type !== "email.complained") return null;
// An event naming several addresses is about the whole message: leave it alone.
if (event.data.to.length !== 1) return null;
const [address] = event.data.to;
const path = `${API}/suppressions/${encodeURIComponent(address)}`;
const lookup = await fetch(path, { headers });
if (lookup.status === 404) return null; // not suppressed, nothing to upgrade
if (!lookup.ok) throw new Error(`lookup failed: ${lookup.status}`);
const row = await lookup.json();
if (row.origin !== "unsubscribe") return null;
const removed = await fetch(path, { method: "DELETE", headers });
if (!removed.ok) throw new Error(`delete failed for ${address}: ${removed.status}`);
const added = await fetch(`${API}/suppressions`, {
method: "POST",
headers,
body: JSON.stringify({ email: address }),
});
// A retry of this event finds no row and stops, so this address needs adding by hand.
if (!added.ok) throw new Error(`re-add failed for ${address}: ${added.status}`);
return address;
}Fed signed test events, it left an unsubscribe row alone on a soft bounce, an open and an event naming two addresses, made it manual on a permanent bounce and on a complaint, skipped an address with no row, and threw WebhookVerificationError on a forged signature, on svix 2.8.0. It does not treat a missing row as one to add, because a row you lifted on purpose would come back. Rasket waits 10 seconds for your endpoint to answer and retries a failure up to nine more times over about 15.7 hours (the retry schedule). This handler makes at most three calls, well inside that; if yours does more, queue the work and answer 200 first. Between its two calls the address is briefly unsuppressed, and the delete fires suppression.removed and an audit entry like any removal, so a sync that mirrors those webhooks should expect a remove and an add a moment apart. The contact stays unsubscribed, so campaigns keep skipping them.
When a must-deliver message hits a suppression
There is no per-message override and no flag on POST /emails that sends past the list. The only way through is DELETE /suppressions/{id or address}, which lifts the row for every kind of mail. So check before a message that must arrive:
// Before a message that must arrive, ask whether it can.
const API = process.env.RASKET_API_URL ?? "https://api.rasket.com";
const headers = { authorization: `Bearer ${process.env.RASKET_API_KEY}` };
async function deliverable(address) {
const response = await fetch(`${API}/suppressions/${encodeURIComponent(address)}`, { headers });
if (response.status === 404) return { ok: true };
if (!response.ok) throw new Error(`lookup failed: ${response.status}`);
const row = await response.json();
return { ok: row.origin === "unsubscribe", row };
}
for (const address of process.argv.slice(2)) {
console.log(address, JSON.stringify(await deliverable(address)));
await new Promise((resolve) => setTimeout(resolve, 200));
}$ node must-deliver.mjs "$ADA" "$CY" "$NEW_CUSTOMER"
ada@… {"ok":true,"row":{"object":"suppression","id":"67f8e709-d325-4174-bb2f-81dd5e2ce180","origin":"unsubscribe",…}}
cy@… {"ok":false,"row":{"object":"suppression","id":"7abb5df3-529f-4979-b776-2fd3a91c035f","origin":"bounce",…}}
new@… {"ok":true}This lookup needs a full-access key. A key has one of three access levels: full, sending, or store (which only a connected shop uses), and only full access reads the list or an email. The 401 restricted_api_key in the store example names that access level, not a bad key: branch on its name and do not rotate the key. If your send path holds a sending-access key, keep it that way: run the check in a separate service that holds the full-access key, or skip it and act on the email.suppressed webhook instead.
| Origin | Lift it? | Instead |
|---|---|---|
| unsubscribe | No need | It does not stop receipts. |
| bounce | Only if the person confirms the mailbox works | Ask for another address. A dead mailbox bounces again and writes a new row. |
| complaint | Only if the person asks you to email them | Show receipts in their account. For a password reset, use another verified channel, such as SMS. |
| manual | When your reason has gone | Check your own notes; the row stores no reason. |
The audit log records who lifted a row, not why, so keep the customer’s request. Send again with a new idempotency key: the old one returns the first, suppressed answer.
# Cy confirmed the mailbox works again. Lift the row, then send once with a new key.
API="${RASKET_API_URL:-https://api.rasket.com}"
curl -s -X DELETE "$API/suppressions/$(node -p 'encodeURIComponent(process.argv[1])' "$CY")" \
-H "Authorization: Bearer $RASKET_API_KEY"
# {"object":"suppression","id":"7abb5df3-529f-4979-b776-2fd3a91c035f","deleted":true}
node receipt.mjs "$CY" 4044-resend
# 200 01a14b90-14e1-7e21-a5b0-40ba63e95263
# last_event: queuedManaging and importing the list
In the dashboard, Emails → Suppressions searches, filters by origin and date, opens a row’s source email, deletes, exports a CSV, and adds up to 100 pasted addresses. The suppressions reference covers the six API calls and the batch rules.
Import an old provider’s list before your first send. Audience → Import & migrate keeps bounce and complaint origins and turns unsubscribes into unsubscribed contacts, so receipts still reach them (migration docs). Over the API, every address you add becomes manual and blocks all mail, so add only hard bounces and complaints to batch/add, bring unsubscribes in as contacts, and leave soft bounces behind:
# An unsubscribe from the old provider: a contact who opted out, not a suppression.
API="${RASKET_API_URL:-https://api.rasket.com}"
curl -s -X POST "$API/contacts" -H "Authorization: Bearer $RASKET_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "email": "dee.optout@yourstore.com", "unsubscribed": true }'
# {"object":"contact","id":"01a148b1-7aaf-7a21-a7c5-3a035445f66b"} 201The list stops only mail Rasket sends. To keep a CRM or another sender in step, mirror the suppression.added and suppression.removed webhooks into it, then filter Emails → Suppressions by complaint and check those addresses against the customers your receipts must reach.
Recovering from a wave of bad bounces
Because any permanent refusal adds a row, a DNS change that breaks your authentication, or a blocklisting, can suppress addresses that work. Verify new records before you switch (the domains docs), and alert on a jump in suppression.added webhooks, which fire for every new row. If it happens, fix the cause, open a few source emails from the window to confirm the bounces are 5.7.x policy refusals, then lift the window’s bounce rows. To be exact, read each row’s source email with GET /emails/{source_id}/events and keep any row whose bounce says 5.1.1. A truly dead address will bounce again and come back; for a large window, resume campaign mail to the lifted addresses in smaller batches over a few days, so those bounces arrive in small numbers.
// List the bounce rows added in a time window; with --remove, lift them in batches of 100.
// node recover.mjs 2026-10-15T06:00:00Z 2026-10-15T06:10:00Z [--remove]
const API = process.env.RASKET_API_URL ?? "https://api.rasket.com";
const headers = {
authorization: `Bearer ${process.env.RASKET_API_KEY}`,
"content-type": "application/json",
};
const [start, end, flag] = process.argv.slice(2);
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const rows = [];
let after;
do {
const query = new URLSearchParams({ origin: "bounce", start_date: start, end_date: end, limit: "100" });
if (after) query.set("after", after);
const listed = await fetch(`${API}/suppressions?${query}`, { headers });
if (!listed.ok) throw new Error(`list failed: ${listed.status} ${await listed.text()}`);
const page = await listed.json();
rows.push(...page.data);
after = page.has_more ? page.data.at(-1).id : undefined;
await sleep(200);
} while (after);
for (const row of rows) console.log(row.created_at, row.email, row.source_id);
console.log(`${rows.length} bounce rows in the window`);
if (flag === "--remove") {
for (let i = 0; i < rows.length; i += 100) {
const ids = rows.slice(i, i + 100).map((row) => row.id);
const response = await fetch(`${API}/suppressions/batch/remove`, {
method: "POST",
headers,
body: JSON.stringify({ ids }),
});
if (!response.ok) throw new Error(`remove failed: ${response.status} ${await response.text()}`);
const { data } = await response.json();
console.log(response.status, data.filter((item) => item.deleted).length, "removed");
await sleep(200);
}
}$ node recover.mjs 2026-10-15T06:00:00Z 2026-10-15T06:10:00Z --remove
2026-10-15T06:02:09.000Z customer129@customer.example null
…
2026-10-15T06:00:00.000Z customer0@customer.example null
130 bounce rows in the window
200 100 removed
200 30 removedRun it without --remove first. Rows written by a bounce name the email in source_id; imported and hand-added rows show null. The 200 ms pauses here and in must-deliver.mjs keep a script under the API’s 10 requests per second; the rate limits post covers the 429 and its Retry-After.
Frequently asked questions
Is it legal to keep sending receipts to someone who reported spam?
In the US, the FTC treats a message whose only purpose is a transaction, such as a receipt or an account notice, as exempt from most CAN-SPAM provisions, so it needs no unsubscribe link, and it reads that category narrowly. Rasket stops receipts to complainers for your sending reputation, not because CAN-SPAM requires it. Other countries have their own rules.
Why does POST /suppressions answer 201 for an address that is already on the list?
The single add shares its code with the batch add, which returns the existing row's id for an address already suppressed, and both answer 201. So the status does not tell you whether a row was created. Compare the returned id with a lookup, or read the origin with GET /suppressions/{address}.
Do suppressed contacts count toward my plan's contact allowance?
No. A contact is billable only when it is not unsubscribed, is subscribed to at least one topic and is not on the suppression list, whatever the row's origin. A suppressed contact stays in your audience as a free profile, so you keep its history without paying for it.
How should my app tell a user that their password reset email was not sent?
Look the address up before you show the usual "check your inbox" message. If it is suppressed by a bounce or a complaint, say that you cannot email that address and offer a way forward: change the address after another check, or contact support. Do not show the reset link on screen, because the email is the proof that the person owns the address.
Is there a read-only API key that can check the list from my send path?
No. A key has full access, sending access or store access, and only full access reaches the suppression list. Keep the lookup in a service that holds the full-access key, or rely on the email.suppressed webhook after the send. An AI agent connected through the MCP server's OAuth can be granted suppressions:read alone.
Does a bounce on a message with several recipients suppress all of them?
No. A bounce or complaint is recorded once per recipient it names, and only that address gets a row. The email.bounced and email.complained webhooks follow the same rule: each event's data.to holds the one address it is about. An event that lists several is about the whole message, and a handler that changes rows should skip it.
Sources
- Email sender guidelines — Google, read 2026-10-17
- Feedback Loop (Gmail spam feedback loop) — Google, read 2026-10-17
- RFC 8058: Signaling One-Click Functionality for List Email Headers — IETF, read 2026-10-17
- CAN-SPAM Act: A Compliance Guide for Business — Federal Trade Commission, read 2026-10-17
- RFC 5321: Simple Mail Transfer Protocol (section 2.4) — IETF, read 2026-10-17
- Webhooks: signing, retries and endpoints — Rasket Docs, read 2026-10-17
- RFC 3463: Enhanced Mail System Status Codes — IETF, read 2026-10-17
Related
- How to lower your email spam complaint rate — What a good email spam complaint rate is, how Gmail and Yahoo measure it, how to read it on a small list, and the steps that bring it down.
- How to set up one-click unsubscribe (RFC 8058) — Add the List-Unsubscribe and List-Unsubscribe-Post headers, sign both with DKIM and answer the POST: exact headers, a tested Node handler and curl checks.
- Suppressions — Addresses we will not send to, and why.
- Hard bounce vs soft bounce: what to do with each — A hard bounce is permanent, a soft bounce temporary. How to tell them apart by the enhanced status code, what Rasket suppresses for you, and what is up to you.
- Transactional vs marketing email: how to send each — How to tell transactional email from marketing email, the US and UK rules for mixed messages, separate sending subdomains, and how to send each kind.
- Email webhooks explained: events and signatures — Email webhooks explained: the delivery events, the payload, HMAC signature verification, retries, and complete tested receivers in Node and Python.
- Email list cleaning: sunset inactive subscribers — Define inactive subscribers when Apple inflates opens, spot scanner clicks, run a re-engagement flow, and sunset the rest without blocking their receipts.