Suppressions
Lists that stop the next send — bounces, complaints, unsubscribes — scoped per campaign or across the whole workspace.
A suppression is a standing instruction not to mail an address. Bounces, complaints, and unsubscribes create them automatically; you can add your own. Every send is filtered against them before it goes anywhere, and the addresses that were dropped come back in the response’s suppressed array.
This is deliverability plumbing, not a nicety: mailbox providers judge you on whether you keep sending to addresses that have already bounced or complained.
Two axes
Suppressions have a key (which list) and a scope (how strongly).
Keys
Every entry belongs to a list. * is the workspace-wide list; any other key is one you name.
{ "suppression_key": "product-updates" }
A send that names product-updates is checked against that list and *. A send that names nothing is checked against * alone.
That gives you the behaviour you want without any bookkeeping: someone who opts out of product-updates stops getting product updates and keeps getting their receipts, while a hard bounce lands on * and stops everything.
* (workspace-wide)
product-updates
onboarding-drip
Use one key per thing a person could reasonably opt out of separately. A tenant identifier works well when you’re sending on behalf of your own customers.
Scopes
| Scope | Blocks |
|---|---|
all |
Every send. The default, and what bounces and complaints produce. |
optional |
Only sends marked unsubscribe: true. Marketing stops; the password reset still arrives. |
optional is what an unsubscribe link should create. all is what a complaint must create.
Spaces
A space keeps lists of its own. A send from a space is checked against its space’s lists and the workspace’s — so a platform’s * list still stops everything for every customer, while a customer’s opt-outs stay theirs.
| A send from… | Is checked against |
|---|---|
The workspace (space_id: null) |
The workspace’s * and the workspace’s key list |
| A space | The above, plus the space’s * and the space’s key list |
What the automatic entries do follows from that: a complaint on a send from a space lands on the space’s list, under the send’s key; a hard bounce lands on the workspace’s * list, because the address exists for nobody and no other customer should try it either.
Add to a space’s list with space_id. Entries carry space_id, null for the workspace’s own, and suppressions.list takes space.
{ "addresses": ["grace@example.com"], "key": "product-updates", "scope": "optional", "space_id": "spc_..." }
Marking a send as optional
The unsubscribe flag on a send or reply declares the message non-essential. It adds one-click unsubscribe headers, and it opts the message into optional suppressions:
await aiinbx.emails.send({
from: "Acme <news@example.com>",
to: "grace@example.com",
subject: "What shipped in August",
html: newsletter,
suppression_key: "product-updates",
unsubscribe: true,
})
Adding suppressions
Up to 1000 addresses per call:
const { data } = await aiinbx.suppressions.add({
addresses: ["grace@example.com", "charles@example.com"],
key: "product-updates",
scope: "optional",
note: "Unsubscribed from the in-app preferences page",
})created = client.suppressions.add(
addresses=["grace@example.com"],
key="product-updates",
scope="optional",
note="Unsubscribed from the in-app preferences page",
)curl https://api.aiinbx.com/api/v2/suppressions \
-H "Authorization: Bearer $AI_INBX_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"addresses": ["grace@example.com"],
"key": "product-updates",
"scope": "optional",
"note": "Unsubscribed from the in-app preferences page"
}'key defaults to * and scope to all — so the terse form, { "addresses": [...] }, blocks those addresses everywhere. Adding an address that’s already listed updates the existing entry rather than duplicating it, and never narrows a scope that’s already wider.
Managing suppressions requires a full scope key.
What’s on an entry
addressstring
The suppressed address, lowercased.
stringkeystring
Which list. `*` is workspace-wide.
stringspace_idstring | null
The space whose sends the entry stops; null stops every space's.
string | nullreason"complaint" | "bounce" | "unsubscribe" | "manual"
How the entry got here.
"complaint" | "bounce" | "unsubscribe" | "manual"scope"all" | "optional"
Everything, or optional mail only.
"all" | "optional"notestring | null
Free text, for entries you added.
string | nullblocksnumber
How many sends this entry has actually stopped.
numberlast_blocked_atstring | null
When it last stopped one.
string | nullsource_email_idstring | null
The message whose bounce, complaint, or unsubscribe created it.
string | nullblocks and last_blocked_at are the honest measure of a list. An entry with hundreds of blocks means a code path is repeatedly trying to mail someone who has opted out — worth finding.
Listing and filtering
for await (const entry of aiinbx.suppressions.list({
key: "product-updates",
reason: "unsubscribe",
})) {
console.log(entry.address, entry.blocks)
}for entry in client.suppressions.iter(key="product-updates", reason="unsubscribe"):
print(entry["address"], entry["blocks"])curl "https://api.aiinbx.com/api/v2/suppressions?key=product-updates&reason=unsubscribe" \
-H "Authorization: Bearer $AI_INBX_API_KEY"Filter by key, reason, or address (a substring search across addresses).
Removing one
await aiinbx.suppressions.remove("sup_...")
Removing an unsubscribe entry because the person asked to be re-subscribed is fine. Removing one because your open rate dropped is not.
Reacting to the events
The three webhook events that create suppressions are worth handling in your own database too, so your UI reflects reality:
switch (event.type) {
case "email.bounced":
if (event.data.permanent) {
await markUndeliverable(event.data.recipients, event.data.reason)
}
break
case "email.complained":
await markComplained(event.data.recipients)
break
case "email.unsubscribed":
await recordOptOut(event.data.address, event.data.key, event.data.scope)
break
}
email.unsubscribed carries a source of link, one_click, or reply — the last meaning the recipient wrote back asking to stop, which was recognized as an opt-out.
Checking before you send
You don’t need to. Filtering happens server-side on every send, and the result comes back in suppressed:
const email = await aiinbx.emails.send(payload)
if (email.suppressed.length) {
await recordSkipped(email.suppressed)
}
If every recipient is suppressed, the send fails with no_recipients rather than sending an email to nobody.