Skip to content

Implementing one-click unsubscribe (RFC 8058) correctly

One-click unsubscribe needs two headers, a DKIM signature that covers them, and an endpoint that accepts a POST without a login. Build it step by step.

Koltrix Team5 min read
A computer screen filled with source code
Photo by Chris Ried on Unsplash
On this page(10 sections)
  1. What one-click unsubscribe is
  2. The two headers
  3. Step 1: generate a token per recipient and list
  4. Step 2: add the headers when building the message
  5. Step 3: build the endpoint
  6. What about GET?
  7. Step 4: honor the request quickly
  8. Step 5: scope the unsubscribe sensibly
  9. Testing the full flow
  10. Checklist
  11. Key takeaways

Gmail and Yahoo require bulk senders to support one-click unsubscribe on marketing and subscribed messages. The mechanism is defined in RFC 8058 and takes two headers, one DKIM signature and one endpoint, but each piece has details that commonly go wrong.

This guide builds it from the headers down to the handler.

What one-click unsubscribe is

The older List-Unsubscribe header (RFC 2369) lets a message advertise how to unsubscribe, typically with a URL or a mailto: address. The problem was the URL: mailbox providers could not safely fetch it automatically, because a plain GET to a link might trigger anything, and security scanners that pre-fetch links would unsubscribe people by accident.

RFC 8058 fixes that by defining a POST-based flow. The message says, in effect, "send an HTTP POST with this exact body to this URL and the recipient is unsubscribed, no further interaction needed." Mailbox providers then show their own unsubscribe button near the sender name, and when the user clicks it, the provider makes the POST on their behalf.

The two headers

List-Unsubscribe: <https://example.com/unsubscribe/opaque-token>, <mailto:[email protected]?subject=opaque-token>
List-Unsubscribe-Post: List-Unsubscribe=One-Click

Rules from RFC 8058:

  • List-Unsubscribe must contain an HTTPS URI. A mailto: URI alone does not qualify for one-click. Including a mailto: alongside the HTTPS URI is allowed and gives clients a fallback.
  • List-Unsubscribe-Post must contain exactly List-Unsubscribe=One-Click.
  • The URI must identify the recipient and list on its own. The provider will not send cookies or ask the user anything, so the URL needs an opaque token that maps to the subscription.
  • Both headers must be covered by a valid DKIM signature. RFC 8058 requires that the message have a valid DKIM signature covering at least List-Unsubscribe and List-Unsubscribe-Post. Without that, a receiver cannot trust that the URL was put there by the sender.

Step 1: generate a token per recipient and list

The token should be:

  • Unguessable, so nobody can unsubscribe other people by iterating values.
  • Specific to one recipient and one list or category.
  • Long-lived, since people unsubscribe from messages weeks or months later.

Two common approaches work well. One is a random identifier stored in a table that maps it to the subscription. The other is a signed value, such as an HMAC over the subscriber ID and list ID with a server secret, which needs no lookup table but must be verified carefully.

import hmac, hashlib, base64

def unsub_token(subscriber_id: str, list_id: str, secret: bytes) -> str:
    msg = f"{subscriber_id}:{list_id}".encode()
    sig = hmac.new(secret, msg, hashlib.sha256).digest()[:16]
    return base64.urlsafe_b64encode(msg + b"." + sig).decode().rstrip("=")

Avoid putting a plain email address in the URL. It leaks the address into logs and referrers and makes tokens guessable.

Step 2: add the headers when building the message

Insert both headers before DKIM signing happens. If you sign in your own code, make sure both header names are in the h= list. If your email provider signs for you, check the documentation: some providers add these headers themselves, and some let you set them per message. Verify on a real message that both appear in the DKIM signature's h= tag.

The visible unsubscribe link in the message body is a separate requirement. Gmail and Yahoo also expect a clearly visible link, and it can point to a preferences page that requires a click, since a human is using it.

Step 3: build the endpoint

The endpoint receives a POST with a body of List-Unsubscribe=One-Click, sent as application/x-www-form-urlencoded (or occasionally as multipart/form-data). Requirements:

  • Accept POST without authentication, cookies or CSRF tokens. The provider will not have them.
  • Do not redirect to a login page or require any further interaction.
  • Process the unsubscribe and return success, a 200 or 202.
  • Be idempotent. Providers may retry, and users may click twice.
@app.post("/unsubscribe/<token>")
def one_click_unsubscribe(token):
    sub = verify_token(token)          # returns None if invalid
    if sub is None:
        return ("", 200)               # do not reveal token validity
    suppress(sub.subscriber_id, sub.list_id, source="one-click")
    return ("", 200)

Returning success even for unknown tokens avoids leaking which tokens are valid. Log invalid tokens for debugging.

What about GET?

A GET to the same URL is typically a human who copied the link, or a security scanner fetching every URL it finds. RFC 8058 exists precisely because automated fetches made GET-based unsubscribe links unsafe, so perform the unsubscription on the POST and answer a GET with a confirmation page whose button sends the POST.

Step 4: honor the request quickly

Gmail asks bulk senders to process unsubscribe requests within 48 hours, and Yahoo within 2 days. The simplest way to meet that is to suppress synchronously in the request handler, so the next send already excludes the address. If your sending platform caches audience segments, make sure those caches respect suppressions added in the meantime.

Step 5: scope the unsubscribe sensibly

Decide what one click removes the person from:

Scope When it fits
This specific list or newsletter You run several distinct lists with separate consent
This category (all marketing) Marketing messages share a consent basis
All non-essential email Small senders with one marketing stream

Never let a marketing unsubscribe suppress transactional mail such as receipts and password resets. Those are not subscribed messages and do not carry List-Unsubscribe headers in the first place.

Testing the full flow

  1. Send a marketing message to a personal Gmail and Yahoo account.
  2. View the original and confirm both headers are present and listed in the DKIM h= tag, with dkim=pass.
  3. Simulate the provider's request:
curl -i -X POST \
  -H "Content-Type: application/x-www-form-urlencoded" \
  --data "List-Unsubscribe=One-Click" \
  https://example.com/unsubscribe/opaque-token
  1. Confirm a 200 response and that the subscriber is suppressed in your database.
  2. Confirm a GET to the same URL shows a confirmation page rather than unsubscribing.

Checklist

  • HTTPS URI in List-Unsubscribe, optionally with a mailto: fallback.
  • List-Unsubscribe-Post: List-Unsubscribe=One-Click present.
  • Both headers covered by a passing DKIM signature.
  • Opaque, unguessable per-recipient tokens; no plain email addresses in URLs.
  • Endpoint accepts unauthenticated POST, does not redirect, returns 200, is idempotent.
  • GET shows a confirmation page.
  • Suppression applied immediately, well within two days.
  • A visible unsubscribe link in the body as well.

Key takeaways

  • RFC 8058 defines one-click unsubscribe as a POST to an HTTPS URL advertised in List-Unsubscribe, flagged by List-Unsubscribe-Post.
  • DKIM must cover both headers, or receivers cannot trust them.
  • The endpoint must work without login or interaction, and a plain GET should show a confirmation page rather than unsubscribe.
  • Gmail and Yahoo require it for bulk marketing and subscribed mail and expect unsubscribes honored within two days.

Start with Koltrix

Your domain, one inbox, and an API that sends.

A team inbox where AI sorts and drafts (nothing is sent without your click), plus the transactional API and SMTP relay your product sends with. 7 days free, no card.

SharePost on XLinkedIn