Skip to content
Docs menu

Suppressions

Why an address gets suppressed, exactly what a send does when it hits one, checking and managing the list by hand, one-click unsubscribe, and what transactional overrides.

zSender keeps one suppression list per tenant: addresses it will not deliver to. After this page you'll be able to tell why an address landed on it, know exactly what a POST /v1/emails/send does when one of its recipients is suppressed (this is the part most integrations get wrong, because a 200 here does not mean everyone was delivered to), look an address up or manage the list by hand, and know precisely which two reasons transactional: true can override.

Why an address is suppressed

There are exactly four reasons, and the list is per tenant, not shared across them:

  • HARD_BOUNCE: the mail server reported a permanent delivery failure for this address.
  • COMPLAINT: an ISP feedback-loop (FBL/ARF) report says a recipient marked a message as spam.
  • UNSUBSCRIBE: the recipient clicked (or their mail client auto-submitted) a one-click unsubscribe link.
  • MANUAL: you added the address yourself, through POST /v1/suppressions.

All tenants share one sending IP, so a hard bounce or a spam complaint against one tenant's list is treated as a reputation risk to every tenant on it, which is why those two reasons, below, can never be overridden by anything a request sends.

What a send does when it hits a suppressed address

Over the HTTP API, a suppressed recipient is dropped from that one send (silently, as far as the mail server is concerned) and the call still answers 200. The recipient is never submitted to the mail server, is not counted against your monthly quota, and is listed in the response's suppressed array with its reason:

Send response with one suppressed recipient
{
  "message_id": "abc123@yourdomain.com",
  "messages": {
    "ok@example.com": {
      "id": 42,
      "token": "tok_..."
    }
  },
  "suppressed": [
    {
      "email": "opt-out@example.com",
      "reason": "UNSUBSCRIBE"
    }
  ]
}

If every recipient on the send is suppressed, the call still answers 200: nothing is submitted to the mail server at all, message_id comes back null, and suppressed lists everyone you sent to. There is no separate status code and no flag anywhere else on the envelope-level success. A 200 proves the request was well-formed, not that mail went out. If your integration only checks the HTTP status, this is the failure mode that hides in your logs: always read suppressed back.

Over the SMTP relay the shape is different, because SMTP's vocabulary is per-message rather than per-recipient. A send where every recipient is suppressed is rejected with 550 All recipients are suppressed: nothing was delivered, and the reply says so. A send where only some are suppressed is relayed to the rest and answers a normal 250, with no per-recipient detail: there is no SMTP equivalent of the HTTP response's suppressed array, and the message record kept on our side lists only the recipients that were actually sent to, so looking it back up afterwards doesn't recover who was dropped. If you send over SMTP and need to know whether a recipient is suppressed, check it beforehand with GET /v1/suppressions/:email (next section), or send that message through the HTTP API instead, where the response tells you.

Check one address

GET /v1/suppressions/:email answers 200 either way: {suppressed: false} for an address that isn't on the list, never a 404. A lookup that can give a real answer isn't an error condition, so don't write an error branch around this call expecting one: a clean address is a normal 200, distinguished only by the suppressed field in the body. This route (like GET /v1/suppressions) only needs the read permission, and, unlike the write routes below, a test-mode key can call it.

Check an address
curl "https://api.zsender.io/v1/suppressions/opt-out@example.com" \
  -H "Authorization: Bearer YOUR_API_KEY"

Full field list for the suppressed shape, plus GET /v1/suppressions's filters and pagination: /docs/api/suppressions.

Add and remove

POST /v1/suppressions adds an address (reason defaults to MANUAL when omitted); DELETE /v1/suppressions/:email removes one. Both need the send permission, and both are a live-mode surface: a test-mode (zst_<prefix>_<secret>) key gets a 403 test_mode_restricted on either call: suppression exists to protect a real recipient, and a test send never reaches one. See Test mode for the rest of what a test key can and can't do.

curl -X POST https://api.zsender.io/v1/suppressions \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"email": "do-not-email@example.com", "reason": "MANUAL"}'

curl -X DELETE https://api.zsender.io/v1/suppressions/do-not-email@example.com \
  -H "Authorization: Bearer YOUR_API_KEY"

DELETE answers {deleted: 0 | 1} (0 if the address wasn't on the list, 1 if it was removed) rather than an empty 204.

One-click unsubscribe

Every send gets RFC 8058 one-click unsubscribe headers unless it's transactional (next section). Over the HTTP API, List-Unsubscribe and List-Unsubscribe-Post are always the ones zSender builds and signs: both names are in the reserved-header set, so setting either yourself through the headers field is a 400, not a silent override. Over the SMTP relay, where you write the raw MIME yourself, it's the other way round: if the message you hand zSender already has its own List-Unsubscribe header, zSender leaves it alone and adds neither header: advertising RFC 8058 one-click over a URL it doesn't control would be worse than not offering it.

The confirmation flow is two routes: GET /u/:token renders a safe HTML confirmation page with no side effect, and POST /u/:token (the one-click request itself, the same request mail clients like Gmail and Apple Mail fire automatically) suppresses the address with reason UNSUBSCRIBE. Neither route needs authentication; the token itself (embedding only the tenant and the recipient, signed, with no expiry) is the credential, and the POST is rate-limited per IP against abuse.

What transactional overrides

transactional: true on a send overrides suppression for exactly two of the four reasons: UNSUBSCRIBE and MANUAL. Both mean "stop sending me your list mail," which a password reset or a receipt is not. HARD_BOUNCE and COMPLAINT are never overridden (transactional or not, that recipient is still dropped and still reported in suppressed) because those two protect the shared sending IP's reputation, not the recipient's inbox preference.

The SMTP form

Over the SMTP relay the equivalent is the header X-Zsender-Transactional: true, which zSender strips before relaying: it's a control header addressed to zSender itself, not part of the message the recipient sees.

The full effect of transactional (it also skips the unsubscribe-header offer entirely, and does not touch open or click tracking, which nothing at the header level can) is covered on Sending email.