Unsubscribe
Every campaign message carries an unsubscribe link in its body and the RFC 8058 header pair that puts a native Unsubscribe button in Gmail and Outlook. You do not add them, and you cannot remove them.
Both are per recipient, both carry the same token, and they are deliberately two different URLs:
Body footer: https://mail.acme.com/unsubscribe/<token>
List-Unsubscribe: <https://mail.acme.com/api/unsubscribe/<token>>
List-Unsubscribe-Post: List-Unsubscribe=One-Click
The token
A token is base64url("<contactId>.<campaignId>.<sig>"), where sig is
HMAC-SHA256("<contactId>.<campaignId>") keyed by your instance's
APP_SECRET. About 140 characters, and nothing is stored anywhere.
Stateless on purpose. A stored token means one row per recipient per campaign — 50 000 rows for a 50 000-recipient send, plus a retention story — bought in exchange for being able to revoke a link whose only power is to remove consent. That is never the dangerous direction.
What the token authorises is exactly one thing: turning one contact's subscription off. Nothing resubscribes, nothing writes a suppression, and nothing reads anything back — so a forged request achieves precisely what the recipient could achieve by pressing the button themselves.
Rotating APP_SECRET invalidates every outstanding unsubscribe link. Mail
already in inboxes keeps a token the new secret will not verify, and those
recipients land on "this link didn't work" instead of unsubscribing. This is
the same blast radius rotation already has for every stored AWS and Cloudflare
credential (Self-hosting), and it is a reason to treat
APP_SECRET as permanent rather than a reason to avoid rotating a compromised
one. If you must rotate, expect a window as long as people keep old campaign
mail.
The token is the credential, so nothing on this surface logs it: not the
routes, not the service, not an error message, and not a response body. Every
answer is cache-control: no-store, private, x-robots-tag: noindex, nofollow
and referrer-policy: no-referrer — a shared cache, a search index or someone
else's Referer log holding a copy would each be a working unsubscribe link
for that recipient.
The GET/POST split, and why it exists
A GET never unsubscribes anybody. This is the single most important property on the page.
Corporate mail security products — Defender, Proofpoint, Mimecast — fetch every link in an incoming message before a person sees it. If a GET unsubscribed, a scanner would silently unsubscribe recipients who never touched the message, and the first symptom would be a customer asking why their list is evaporating — by which time it has happened to everyone whose employer runs a scanner. This is not hypothetical; it is the standard failure of one-click unsubscribe implementations.
So:
| Request | Does |
|---|---|
GET /unsubscribe/<token> | Renders a page with a button. Reads only. |
POST from that page's button | Unsubscribes, then redirects back to the page. |
POST /api/unsubscribe/<token> | RFC 8058 one-click. Unsubscribes. Always 200. |
GET /api/unsubscribe/<token> | 302 to the page. Mutates nothing — this is the one a scanner following the header will hit. |
Why the two URLs differ
The body link points at /unsubscribe/:token because it is for humans: a page
with an explicit button, safe for anything to fetch.
List-Unsubscribe names /api/unsubscribe/:token because RFC 8058 requires
the URI in that header to accept a POST, and a Next.js App Router segment
holding a page cannot also export a POST handler. Pointing the header at the
page would make Gmail's and Outlook's native button answer 405 — a silently
broken one-click, which is the expensive kind of broken.
List-Unsubscribe-Post is never emitted without List-Unsubscribe. On its own
it is a header no client acts on, and its absence beside a List-Unsubscribe
is what makes the native button disappear and leaves the spam button as the
recipient's only way out.
The page
Three states, all 200:
- Still subscribed — the address, who it is from, and a button. It says "Nothing has changed yet", which is the visible half of the split: whoever opened the link, person or scanner, has unsubscribed nobody.
- Already unsubscribed — the same confirmation someone gets a second after pressing the button. The page renders from current consent rather than from what the last POST returned, so it is idempotent by construction.
- The link does not work — one generic message. A forged signature and a deleted contact are deliberately indistinguishable, or the page becomes a way to ask which contact ids exist.
It never answers 404. That would tell scanners and crawlers the sender's
links are broken and tell the reader nothing the words do not.
The one-click endpoint
POST /api/unsubscribe/:token is unauthenticated and deliberately not
CSRF-protected. That is a considered exception, not an oversight, and it must
not be "fixed": RFC 8058 requires an unauthenticated cross-origin POST issued
by a mail client. There is no session to bind a token to, and no origin anyone
could predict — Gmail, Outlook and every other client post from infrastructure
nobody has seen. The HMAC in the path is the authorisation, and the blast
radius above is what makes that safe.
It answers 200 to everything, including a forged token, a rotated secret
and a deleted contact, with a fixed body identical for every outcome. A mail
client shown an error teaches the recipient to press the spam button instead,
and a complaint costs a sender orders of magnitude more reputation than an
unsubscribe does.
The one exception is the rate limiter, which answers 429 with
Retry-After. That is a statement about load rather than about the token,
and it is the one refusal a conforming client should retry. Answering 200
while dropping the write would leave someone still subscribed after being told
they were not.
The limit is a burst of 20 per IP, refilling at 20 per minute. It is not brute-force protection — nobody guesses a 256-bit HMAC — it is there for the mundane case: one broken mail client or one scanner retrying the one-click POST in a loop, where each call is a write, an audit row and a webhook fan-out.
It is held in memory, per process, so a two-instance deployment allows twice
the rate. A request with no client IP is not limited at all: behind a proxy
that sets x-real-ip or x-forwarded-for every request has an identity, and
without one no request does — funnelling them all into a single shared bucket would let one
looping client deny everyone else's unsubscribe. Run this behind a proxy that
sets the header.
What unsubscribing actually does
It sets subscribed = false on that contact in every contact book of the
team, not only the campaign's, with the reason recorded as
campaign:<campaignId>. The person said stop, not "stop for book A".
It writes no suppression. Leaving a newsletter must not stop that person's
password resets, and for a receipt that is a legal problem rather than a
support one. Consent and deliverability are two different lists and Sendsprite
keeps them apart everywhere — see Contacts. If you want mail
to an address to stop entirely, that is
sendsprite.suppressions.add({ email, reason: "unsubscribe" }) —
POST /api/v1/suppressions over REST.
It fires a contact.unsubscribed webhook per affected row,
carrying data.contact, and writes a contacts.unsubscribe audit row naming
the campaign that prompted it. Unsubscribing someone who is already
unsubscribed changes nothing and fires nothing.
The unsubscribe link is never wrapped in click tracking, so leaving a campaign is not counted as engagement with it and the link has no extra redirect hop.
Running your own unsubscribe page
If you would rather host the page yourself — your own branding, your own preference centre — you need two things.
1. Set List-Unsubscribe yourself on your own sends. It is one of very few
headers Sendsprite deliberately allows you to set (see
Sending); the rest are refused because Sendsprite or SES set
them. Point it at your own URL, and add List-Unsubscribe-Post: List-Unsubscribe=One-Click beside it if your endpoint accepts a POST — both
headers or neither.
import { Sendsprite } from "sendsprite";
const sendsprite = new Sendsprite();
await sendsprite.emails.send({
from: "Acme <hello@mail.acme.com>",
to: ["ada@example.com"],
subject: "This month at Acme",
html: "<p>…</p>",
headers: {
"List-Unsubscribe": "<https://acme.com/unsubscribe?u=abc123>",
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
},
});
The same object is the body of POST /api/v1/emails.
2. Record the unsubscribe from your handler, with a full key:
const { unsubscribed } = await sendsprite.contacts.unsubscribe({
email: "ada@example.com",
reason: "footer link",
});
// unsubscribed === 2: every book of the team. Pass `bookId` to narrow it.
Over REST:
curl -X POST https://sendsprite.com/api/v1/contacts/unsubscribe \
-H "Authorization: Bearer ss_live_..." \
-H "Content-Type: application/json" \
-d '{ "email": "ada@example.com", "reason": "footer link" }'
# → 200 {"unsubscribed": 2}
It is idempotent — an address already out, or in no book at all, answers
{"unsubscribed": 0} with a 200 — so the SDK retries it. And it writes no
suppression: it is contacts.unsubscribe, not suppressions.add, for the
reason above.
Two things to carry across from the built-in surface, because they are the parts people get wrong:
- Make your own page's GET read-only too. Scanners will fetch your link exactly as they fetch ours.
- Answer
200to a mail client whatever happens. Save the honest error for the human-facing page, where somebody can act on it.
A campaign's own footer link cannot be repointed at your page — it is appended
by the renderer, not authored — so this route applies to mail you send through
emails.send() (POST /api/v1/emails) rather than to campaigns.