01The two headers, and what each one does
Two headers, added by the send path to every broadcast and to any other send whose domain has opted in. They are what turns “find the tiny grey link at the bottom” into a button in the mail client’s own interface, and that difference is the single largest lever you have on complaint rate.
List-Unsubscribe offers the routes; List-Unsubscribe-Post is the promise that makes the client show a native button. Gmail and Yahoo require the pair from bulk senders, and one without the other is decorative.
List-Unsubscribe: <https://…/u/<token>> List-Unsubscribe-Post: List-Unsubscribe=One-Click
| Header | Value your instance sends | What it does |
|---|---|---|
| List-Unsubscribe | <https://…/u/<token>> | A single, signed HTTPS route out. No mailto fallback: an unsubscribe@ mailbox is easy to advertise and easy to leave unmonitored, and a client that prefers it over the HTTPS route sends the reader’s request nowhere. |
| List-Unsubscribe-Post | List-Unsubscribe=One-Click | Promises the URL above will accept a POST and act on it with no further interaction. Exactly that string, from RFC 8058. |
| The token | signed tracking token: emailId + workspaceId, HMAC prefix | Self-validating, so the endpoint knows a request is genuine before it touches a database — and you cannot construct a link that unsubscribes someone else. |
Scoped to one message, so it identifies not just who is leaving but what they were reading when they decided to.
There used to be a mailto fallback. It is gone on purpose. A mailto:unsubscribe@<your domain> address is easy to advertise and easy to leave unmonitored, and a client that prefers it over the working HTTPS route sends the reader’s request straight into a mailbox nobody reads. One route that works beats two where one is a dead end.
02Why the POST endpoint returns plain text
The /u/ endpoint answers two different callers. A POST is a machine acting on a reader’s behalf, and gets plain text. A GET is a person who clicked a link, and gets a page. Everything odd-looking about the POST response follows from who is on the other end of it.
Nobody is looking at the POST response, so it contains nothing to look at: a 200, content-type: text/plain, and the word Unsubscribed.
# what a receiver sees POST /u/eyJlbWFpbElkIjoi… HTTP/1.1 200 OK content-type: text/plain Unsubscribed.
| What the POST path does not do | Why not |
|---|---|
| No form | A form is a request for a second action from someone who is not there. The unsubscribe would never be recorded, the client would report success because it got a 200, and the reader would keep receiving mail they have already told two systems they do not want. |
| No redirect | There is nothing to redirect to and nobody to follow it. A 302 is at best ignored and at worst treated as a failure to honour the request. |
| No confirmation screen, no “are you sure” | The reader already confirmed, in their client, before the POST was sent. Asking again would be asking the wrong party. |
- 200 with content-type: text/plain
- The body is the word Unsubscribed. and nothing else
- Acts immediately; the reader confirmed in their client
- Renders a small confirmation page
- Warns that anything already queued can take a few minutes to stop
- Also acts — no “click here to confirm” button, because clicking the link was the intent
An invalid or forged token gets a 400 and a plain sentence: This unsubscribe link is not valid. That is safe to be specific about, unlike the tracking pixel, because there is nothing to leak — a token either verifies against the signing secret or it does not, and saying so reveals nothing about which message ids exist.
03Offering preferences without hiding the exit
A preference centre is a genuinely good idea and a very common way to break the contract you just made. The rule that keeps it honest is short: unsubscribing must be one action on the page, and the header path has to keep working whatever the page does.
Build the preference page if you like — but the List-Unsubscribe URL must never route through it. Two surfaces, two callers, and only one of them has a person on the other end.
The case for preferences is real. Most people who unsubscribe are not rejecting you, they are rejecting the frequency or the category. Someone who wants the monthly product note but not three campaign sends a week will take that option if it is in front of them, and you keep a subscriber instead of losing one.
- Offers frequency and category choices, and “none of it” among them
- Is linked alongside the unsubscribe link, not instead of it
- Leaves the header path untouched
- Costs one action to leave from
- Requires signing in before you can leave
- Offers six frequency options and no “none”
- Is the only route out of the message
- Wants twelve categories unticked individually
- Adds a confirmation step after the confirmation step
Every item in the right-hand column converts a person who would have unsubscribed into a person who marks you as spam. The receiver does not record that you offered them choices, only that a reader called your mail junk.
Open your own message, find the unsubscribe, and count the actions between the click and being off the list. One is correct. Two is defensible if the second is a single confirm button. Anything requiring a login, a category-by-category pass, or a reason you must supply is not a preference centre, it is a maze — and the complaint rate will say so before the legal team does.
04Transactional mail gets headers too
Broadcasts always get the headers. Everything else — receipts, password resets, invoices, shipping notifications — only does if you turn it on for that sending domain. That is a reversal from a blanket “every message gets it,” and the reason is the header’s own effect: it is also what tells a mail client to present the message as mailing-list mail, the right read for a newsletter and the wrong one for a code someone is waiting on right now.
The toggle lives on the domain, not the message. A per-send flag set by hand is wrong in the sender’s favour every time; a per-domain setting made once, deliberately, is not the same failure mode.
- The domain also carries broadcasts, or you want every send to offer a one-click exit
- Your receipts and notifications double as an engagement channel worth leaving cleanly
- Verification codes, password resets, invitations — mail the reader is actively waiting on
- A mailing-list presentation is the wrong read for a message with no marketing content
- A visible unsubscribe link is still your call either way, in the body
| The message | Leave the headers off, or turn them on? |
|---|---|
| A receipt with a “you might also like” block | On — it is a campaign, whatever label it ships under. |
| A shipping notification with a referral offer | On — same reasoning. |
| A password reset from a dormant account | Off — the reader is mid-task and did not ask to hear from this domain generally. |
| A verification code or an invitation | Off — a mailing-list badge next to a one-time code reads as wrong, not helpful. |
The question is what the domain sends on average, not any one message — the setting is per-domain, not per-send.
The old advice still holds: separate your sending domains. Marketing volume on one, transactional on another. It decides the default for the headers on each, and it means a campaign’s complaint rate cannot decide whether password resets arrive — and it lets you look at two unsubscribe rates that mean different things instead of one that means nothing. See why email goes to spam for the rest of that argument.
05What an unsubscribe writes down
An unsubscribe writes two things: a suppression keyed on the normalised address, and a flag on the contact. They are separate on purpose, and knowing which is which is what makes the import question answerable.
The suppression is the authority and the contact flag is the reporting view — which is why suppression works for addresses that were never contacts at all.
- Stored per workspace, keyed on the address after normalisation
- Reason unsubscribe, with a source recording that it came from the one-click path
- Written for every recipient of the message the token identified
- Mirrored into a fast lookup the send path consults before it does anything else
- Checkable without loading a contact — plenty of mail goes to addresses that are not contacts
- Set only if the message was tied to a contact, with a timestamp
- Removes them from broadcasts: a page query filters on unsubscribed = 0 as it walks
- Is what makes subscribed mean something in a segment expression
- Idempotent: unsubscribing twice does not create a second suppression, and a retried POST is harmless
| Address | What happens |
|---|---|
| Ada@Example.com | Unsubscribes. The suppression is keyed on the normalised form, and the original is kept alongside it. |
| ada@example.com | Arrives on a later list and is still suppressed — the normalised key is the same one. |
The original is retained because when someone asks why they are still not receiving mail, the address they typed is the one they will quote at you.
This is the rule that will feel wrong at some point, usually when a colleague has a spreadsheet from a conference and three hundred of the addresses on it are suppressed. The suppression list is the record of people who told you to stop, and a CSV is not consent — it is a list of addresses. If someone genuinely opted back in, re-subscribe them individually, from a signal you could point to a year later.
The suppression is written the instant the request lands, but messages already accepted into the send queue are past that check. Someone who unsubscribes and then receives one more message a few minutes later has found the queue depth, not a defect. It is why the confirmation page says so in as many words, and it is worth having the same sentence in your own preference page if you build one.
What just happened
Every broadcast carries the List-Unsubscribe pair; other sends only do if the sending domain has opted in. Either way, the one-click endpoint answers a machine in plain text and a human in HTML, and an unsubscribe writes a suppression that the send path checks before it checks anything else. The thing most likely to bite you later is an import: a suppression is cleared only by an explicit re-subscribe, never as a side effect of uploading a CSV, and if you ever find yourself writing code to “merge” a spreadsheet over the suppression list, stop — that is the exact mechanism by which senders reach blocklists.