Suppressions
Docs / Control plane

Suppressions

A suppression stops mail to an address — every message, or marketing mail only when the row came from an unsubscribe from marketing mail. Bounces, complaints and hand-added rows stop everything. List with filters, look one up, add one, remove one. Removal is audited. An entry that exists because of an erasure stops every message, has no expiry and is locked — removing it would let us mail an erased person again, and nobody can name the address to add it back.

Authentication

Authorization: Bearer spw_…

Read paths require suppressions:read. Create and delete require suppressions:write.

Keys rule — prefix, shown once, abilities never widen, and no key carries both messages:send and messages:read-content.

Endpoints

GET/v1/suppressions — list · ?email · ?reason · ?limit · ?cursor
GET/v1/suppressions/lookup — by address · ?email= required
POST/v1/suppressions — add · body: email, reason, detail?, scope?
GET/v1/suppressions/{id} — show one row
DELETE/v1/suppressions/{id} — remove · audited · may 409

reason on create is one of manual, bounce, complaint, unsubscribe. Email is lowercased. Re-adding an address returns 200 and updates that row, not a second one. already_suppressed is true only when the live row already blocks the requested scope. Reviving an expired row, and widening a marketing row to all, return 200 with already_suppressed: false and the updated row.

scope says what a row stops: all — every message, the default and what bounces and complaints write — or marketing — only messages sent with "marketing": true, which is what an unsubscribe from one of them writes. Adding an all block for an address that only unsubscribed from marketing widens that row; nothing narrows one.

List

Keyset page of every live suppression on this account. Filter with ?email= or ?reason=. Same cursor shape as every other list.

curl
curl "https://spoolway.com/api/v1/suppressions?limit=25" \
  -H "Authorization: Bearer spw_…"
PHP
$ch = curl_init("https://spoolway.com/api/v1/suppressions?limit=25");
curl_setopt_array($ch, [
  CURLOPT_RETURNTRANSFER => true,
  CURLOPT_HTTPHEADER => ["Authorization: Bearer " . getenv("SPOOLWAY_KEY")],
]);
$page = json_decode(curl_exec($ch), true);
Node
const res = await fetch("https://spoolway.com/api/v1/suppressions?limit=25", {
  headers: { Authorization: `Bearer ${process.env.SPOOLWAY_KEY}` },
});
const page = await res.json();
Python
import os, requests

page = requests.get(
    "https://spoolway.com/api/v1/suppressions",
    headers={"Authorization": f"Bearer {os.environ['SPOOLWAY_KEY']}"},
    params={"limit": 25},
).json()
Ruby
require "net/http"; require "json"

uri = URI("https://spoolway.com/api/v1/suppressions?limit=25")
req = Net::HTTP::Get.new(uri,
  { "Authorization" => "Bearer #{ENV["SPOOLWAY_KEY"]}" })
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
page = JSON.parse(res.body)

Each row is the object below. The list wraps those rows in data and adds next_cursor.

idInteger id of the row.
emailLowercased. An erased identity reads as erased:… rather than the address.
reasonOn create, one of manual, bounce, complaint, unsubscribe.
sourceWho wrote the row. This API stores api.
detailOptional note, or null.
scopeall stops every message. marketing stops only messages sent with "marketing": true. all is the default, and what bounces and complaints write. Adding an all block widens a marketing row. Nothing narrows one.
suppressed_atWhen the row started blocking, ISO 8601.
expires_atWhen the block ends, or null.
erasure_lockedTrue when the row exists because of an erasure. DELETE then returns 409.
The object
{
  "data": {
    "id": 1,
    "email": "[email protected]",
    "reason": "manual",
    "source": "api",
    "detail": "support ticket 12",
    "scope": "all",
    "suppressed_at": "2026-08-06T08:11:01+00:00",
    "expires_at": null,
    "erasure_locked": false
  }
}

Lookup

One row by address. ?email= is required. The 200 body is the same object. An address that is not suppressed, or whose row has expired, is 404 with message Not suppressed.

curl
curl "https://spoolway.com/api/v1/suppressions/[email protected]" \
  -H "Authorization: Bearer spw_…"
PHP
$ch = curl_init("https://spoolway.com/api/v1/suppressions/lookup?email=" . urlencode($email));
curl_setopt_array($ch, [
  CURLOPT_RETURNTRANSFER => true,
  CURLOPT_HTTPHEADER => ["Authorization: Bearer " . getenv("SPOOLWAY_KEY")],
]);
$row = json_decode(curl_exec($ch), true);
Node
const res = await fetch(
  `https://spoolway.com/api/v1/suppressions/lookup?email=${encodeURIComponent(email)}`,
  { headers: { Authorization: `Bearer ${process.env.SPOOLWAY_KEY}` } },
);
const row = await res.json();
Python
import os, requests

row = requests.get(
    "https://spoolway.com/api/v1/suppressions/lookup",
    headers={"Authorization": f"Bearer {os.environ['SPOOLWAY_KEY']}"},
    params={"email": email},
).json()
Ruby
require "net/http"; require "json"

uri = URI("https://spoolway.com/api/v1/suppressions/lookup?email=#{URI.encode_www_form_component(email)}")
req = Net::HTTP::Get.new(uri,
  { "Authorization" => "Bearer #{ENV["SPOOLWAY_KEY"]}" })
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
row = JSON.parse(res.body)

Create

Body: email, reason, and optional detail and scope. Email is lowercased. 201 returns the same object. already_suppressed is true only when the live row already blocks the requested scope. Reviving an expired row, and widening a marketing row to all, return 200 with already_suppressed: false and the updated row.

curl
curl -X POST https://spoolway.com/api/v1/suppressions \
  -H "Authorization: Bearer spw_…" \
  -H "Content-Type: application/json" \
  -d '{ "email": "[email protected]", "reason": "manual", "detail": "support ticket 12" }'
PHP
$ch = curl_init("https://spoolway.com/api/v1/suppressions");
curl_setopt_array($ch, [
  CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true,
  CURLOPT_HTTPHEADER => [
    "Authorization: Bearer " . getenv("SPOOLWAY_KEY"),
    "Content-Type: application/json",
  ],
  CURLOPT_POSTFIELDS => json_encode([
    "email" => "[email protected]",
    "reason" => "manual",
    "detail" => "support ticket 12",
  ]),
]);
$row = json_decode(curl_exec($ch), true);
Node
const res = await fetch("https://spoolway.com/api/v1/suppressions", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.SPOOLWAY_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    email: "[email protected]",
    reason: "manual",
    detail: "support ticket 12",
  }),
});
const row = await res.json();
Python
import os, requests

row = requests.post(
    "https://spoolway.com/api/v1/suppressions",
    headers={"Authorization": f"Bearer {os.environ['SPOOLWAY_KEY']}"},
    json={
        "email": "[email protected]",
        "reason": "manual",
        "detail": "support ticket 12",
    },
).json()
Ruby
require "net/http"; require "json"

uri = URI("https://spoolway.com/api/v1/suppressions")
req = Net::HTTP::Post.new(uri, {
  "Authorization" => "Bearer #{ENV["SPOOLWAY_KEY"]}",
  "Content-Type" => "application/json",
})
req.body = {
  email: "[email protected]", reason: "manual", detail: "support ticket 12",
}.to_json
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
row = JSON.parse(res.body)
200 revived
{
  "data": {
    "id": 2,
    "email": "[email protected]",
    "reason": "manual",
    "source": "api",
    "detail": "Re-added via API.",
    "scope": "all",
    "suppressed_at": "2026-08-07T09:00:00+00:00",
    "expires_at": null,
    "erasure_locked": false
  },
  "already_suppressed": false
}
200 widened
{
  "data": {
    "id": 3,
    "email": "[email protected]",
    "reason": "manual",
    "source": "api",
    "detail": "Added via API.",
    "scope": "all",
    "suppressed_at": "2026-08-07T09:05:00+00:00",
    "expires_at": null,
    "erasure_locked": false
  },
  "already_suppressed": false
}

Show

One row by id. The body is the same object. Cross-account is 404.

curl
curl https://spoolway.com/api/v1/suppressions/1 \
  -H "Authorization: Bearer spw_…"
PHP
$ch = curl_init("https://spoolway.com/api/v1/suppressions/" . $id);
curl_setopt_array($ch, [
  CURLOPT_RETURNTRANSFER => true,
  CURLOPT_HTTPHEADER => ["Authorization: Bearer " . getenv("SPOOLWAY_KEY")],
]);
$row = json_decode(curl_exec($ch), true);
Node
const res = await fetch(`https://spoolway.com/api/v1/suppressions/${id}`, {
  headers: { Authorization: `Bearer ${process.env.SPOOLWAY_KEY}` },
});
const row = await res.json();
Python
import os, requests

row = requests.get(
    f"https://spoolway.com/api/v1/suppressions/{id}",
    headers={"Authorization": f"Bearer {os.environ['SPOOLWAY_KEY']}"},
).json()
Ruby
require "net/http"; require "json"

uri = URI("https://spoolway.com/api/v1/suppressions/#{id}")
req = Net::HTTP::Get.new(uri,
  { "Authorization" => "Bearer #{ENV["SPOOLWAY_KEY"]}" })
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
row = JSON.parse(res.body)

Delete

Removes the row and writes an audit entry. The response is a message, not the object above.

curl
curl -X DELETE https://spoolway.com/api/v1/suppressions/1 \
  -H "Authorization: Bearer spw_…"
PHP
$ch = curl_init("https://spoolway.com/api/v1/suppressions/" . $id);
curl_setopt_array($ch, [
  CURLOPT_CUSTOMREQUEST => "DELETE", CURLOPT_RETURNTRANSFER => true,
  CURLOPT_HTTPHEADER => ["Authorization: Bearer " . getenv("SPOOLWAY_KEY")],
]);
$result = json_decode(curl_exec($ch), true);
Node
const res = await fetch(`https://spoolway.com/api/v1/suppressions/${id}`, {
  method: "DELETE",
  headers: { Authorization: `Bearer ${process.env.SPOOLWAY_KEY}` },
});
const result = await res.json();
Python
import os, requests

result = requests.delete(
    f"https://spoolway.com/api/v1/suppressions/{id}",
    headers={"Authorization": f"Bearer {os.environ['SPOOLWAY_KEY']}"},
).json()
Ruby
require "net/http"; require "json"

uri = URI("https://spoolway.com/api/v1/suppressions/#{id}")
req = Net::HTTP::Delete.new(uri,
  { "Authorization" => "Bearer #{ENV["SPOOLWAY_KEY"]}" })
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
result = JSON.parse(res.body)
200 deleted
{
  "message": "[email protected] removed — sending to it is allowed again."
}

Erasure lock

When a person is erased, their identity survives on the suppression list as an erased:… HMAC so the account can never mail them again. Those rows set erasure_locked: true. DELETE against one returns 409 with type: erasure_locked. Do not retry — the lock is the point.

409erasure locked
response body
{
  "message": "This entry survives an erasure request. Removing it would defeat the erasure, so it is locked.",
  "type": "erasure_locked"
}
What to do: stop. Removing the row would defeat the erasure. There is no override over the API.

Responses & failures

401bad or missing key
response body
{ "message": "Unauthenticated" }
What to do: mint or restore Authorization: Bearer spw_…
403key lacks suppressions:read or write
response body
{
  "message": "This API key does not have the \"suppressions:read\" ability. Abilities are chosen when the key is minted and cannot be widened — mint a new key that includes this ability.",
  "type": "forbidden_ability",
  "required_ability": "suppressions:read"
}
What to do: mint a key with the ability you need — abilities never widen after mint.
404lookup: address not suppressed (or expired)
response body
{
  "message": "Not suppressed"
}
What to do: treat as clear to send. Lookup deliberately 404s rather than returning an empty 200.
409erasure_locked
response body
{
  "message": "This entry survives an erasure request. Removing it would defeat the erasure, so it is locked.",
  "type": "erasure_locked"
}
What to do: do not retry. The identity must stay suppressed.