DEV Community

Lalit Kumar Jangid
Lalit Kumar Jangid

Posted on

Your unsubscribe link works. Gmail still won't show 'Unsubscribe'. Here's what's missing

Your marketing emails have an unsubscribe link in the footer. You've clicked it. It works. Your sending dashboard still warns about "missing one-click unsubscribe", and Gmail never shows the little Unsubscribe button next to your sender name.

That's because the link in your footer and "one-click unsubscribe" are two different things. Since 2024, Gmail and Yahoo have required bulk senders to support one-click unsubscribe as defined in RFC 8058: two specific email headers plus an endpoint that unsubscribes someone with a single POST, no login, no confirmation page and no "are you sure?". If any of those pieces is missing or slightly wrong, mailbox providers treat your email as not having it.

This post walks through the full implementation: the headers, the endpoint (and why it must ignore GET), signed tokens, DKIM coverage, and how to test it with curl.

What one-click unsubscribe actually is

There are three parts, and you need all three:

  1. A List-Unsubscribe header containing an https URL for this recipient.
  2. A List-Unsubscribe-Post header with the exact value List-Unsubscribe=One-Click.
  3. An endpoint at that URL that accepts a POST and unsubscribes the person immediately.

When a reader clicks the mail client's built-in Unsubscribe button, the client (Gmail, Yahoo, Apple Mail) sends this request to your URL in the background:

POST /unsubscribe/c_8f3a2/9d1e7b... HTTP/1.1
Host: news.acme.com
Content-Type: application/x-www-form-urlencoded

List-Unsubscribe=One-Click
Enter fullscreen mode Exit fullscreen mode

No browser opens. Your reader never sees your site. If your endpoint returns success, they're unsubscribed.

Step 1: Add the two headers

On every marketing email (newsletters, campaigns, promotions), add:

List-Unsubscribe: <https://news.acme.com/unsubscribe/c_8f3a2/9d1e7b4c0a...>
List-Unsubscribe-Post: List-Unsubscribe=One-Click
Enter fullscreen mode Exit fullscreen mode

Details that matter:

  • The URL is per recipient. It has to identify who is unsubscribing without a login, which is why it carries a contact id and a signed token (step 3).
  • Use https. A mailto: address alone doesn't satisfy RFC 8058. You can list both (<https://...>, <mailto:...>), but only add a mailto: if something actually processes those emails.
  • Angle brackets are required around each URI.
  • Don't add these headers to transactional email. Receipts and password resets aren't subscriptions. An unsubscribe button on a password reset only confuses people.

Most email APIs let you set custom headers. In Node, with an API that accepts a headers object, it looks like this:

const unsubscribeUrl = buildUnsubscribeUrl(contact.id, campaign.id); // step 3

await sendEmail({
  from: "Acme <news@news.acme.com>",
  to: contact.email,
  subject: campaign.subject,
  html: campaign.html.replace("{{unsubscribe_url}}", unsubscribeUrl), // footer link too
  headers: {
    "List-Unsubscribe": `<${unsubscribeUrl}>`,
    "List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
  },
});
Enter fullscreen mode Exit fullscreen mode

Use the same URL in the footer link, so both paths go through one handler.

Step 2: Build the endpoint, and don't unsubscribe on GET

This is the part most implementations get wrong.

Corporate email security tools, antivirus scanners and link-preview bots visit every link in an email to check it for malware. They do it with GET, often within seconds of delivery. If your endpoint unsubscribes on GET, those scanners silently unsubscribe your readers, sometimes your whole B2B list, before anyone opens the email.

The safe design:

  • POST (from the mail client's button, or from your confirmation page): unsubscribe immediately.
  • GET (a human clicking the footer link, or a scanner): show a small page with a single Unsubscribe button that POSTs to the same URL. Change nothing.

Here's that in Express:

// unsubscribe.js
import express from "express";
import { verifyUnsubscribeToken } from "./tokens.js";
import { unsubscribeContact } from "./contacts.js";

const router = express.Router();
router.use(express.urlencoded({ extended: false }));

// GET: confirmation page only. Link scanners hit this; it changes nothing.
router.get("/unsubscribe/:contactId/:token", (req, res) => {
  res.set("Cache-Control", "no-store");
  res.send(`<!doctype html>
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Unsubscribe</title>
    <form method="post">
      <input type="hidden" name="List-Unsubscribe" value="One-Click">
      <p>Stop receiving marketing email from Acme?</p>
      <button type="submit">Unsubscribe</button>
    </form>`);
});

// POST: the RFC 8058 one-click request, or the button above.
router.post("/unsubscribe/:contactId/:token", async (req, res) => {
  const { contactId, token } = req.params;

  // Same response whether or not the token is valid or the contact exists,
  // so the endpoint can't be used to probe your list.
  if (verifyUnsubscribeToken(contactId, token)) {
    await unsubscribeContact(contactId); // idempotent: unsubscribing twice is fine
  }

  res.set("Cache-Control", "no-store");
  if (req.body["List-Unsubscribe"] === "One-Click" && !req.accepts("html")) {
    return res.sendStatus(200); // background request from a mail client
  }
  res.send("<!doctype html><p>You're unsubscribed. You won't get marketing email from us again.</p>");
});

export default router;
Enter fullscreen mode Exit fullscreen mode

What the mail client needs from you:

  • A 2xx response, quickly. Do the database write and return. Don't send a "sorry to see you go" email from this handler.
  • No redirects to a login page or another domain.
  • No cookies or session required. The request comes from the mail provider's servers, not your reader's browser.
  • Idempotency. Clients may send the request more than once. Unsubscribing an already-unsubscribed contact should just return 200.

In a Next.js App Router project, the same idea is a route handler at app/unsubscribe/[contactId]/[token]/route.js exporting GET (the confirmation page) and POST (the unsubscribe), with request.formData() to read the body.

Step 3: Sign the URL so nobody can unsubscribe someone else

The URL contains a contact id. Without a signature, anyone who guesses ids could unsubscribe your whole list with a loop of POST requests. An HMAC over the id (and the campaign, if you want per-campaign links) fixes that:

// tokens.js
import crypto from "node:crypto";

const SECRET = process.env.UNSUBSCRIBE_SECRET; // long random value, never in the email
const BASE = "https://news.acme.com/unsubscribe";

const sign = (contactId) =>
  crypto.createHmac("sha256", SECRET).update(`unsubscribe:${contactId}`).digest("base64url");

export const buildUnsubscribeUrl = (contactId) => `${BASE}/${encodeURIComponent(contactId)}/${sign(contactId)}`;

export function verifyUnsubscribeToken(contactId, token) {
  const expected = Buffer.from(sign(contactId));
  const given = Buffer.from(String(token));
  return given.length === expected.length && crypto.timingSafeEqual(given, expected);
}
Enter fullscreen mode Exit fullscreen mode

Two choices worth making on purpose:

  • Don't make the link expire. People unsubscribe from emails they received months ago. Gmail and Yahoo expect the link to keep working.
  • Prefix what you sign (unsubscribe:), so a token from another feature can never be replayed here.

Step 4: Make sure DKIM covers the headers

RFC 8058 says the List-Unsubscribe and List-Unsubscribe-Post headers must be covered by a valid DKIM signature. That proves your domain set them and nobody added them in transit. Mailbox providers may ignore the headers if they aren't signed.

Check a received email's raw source for the DKIM-Signature header and look at its h= tag, which lists the signed headers:

DKIM-Signature: v=1; a=rsa-sha256; d=news.acme.com; s=s1;
        h=From:To:Subject:Date:Message-ID:List-Unsubscribe:List-Unsubscribe-Post; ...
Enter fullscreen mode Exit fullscreen mode

If List-Unsubscribe and List-Unsubscribe-Post aren't in h=, check your email provider's settings or documentation for how custom headers are signed. And make sure the signature's d= domain is your own, aligned with your From domain. (If DMARC is failing even though DKIM passes, alignment is the usual reason.)

Step 5: Test it

1. Test the endpoint with curl, as a mail client would:

URL="https://news.acme.com/unsubscribe/c_8f3a2/9d1e7b4c0a..."

# The one-click request. Expect 200 and the contact unsubscribed.
curl -i -X POST "$URL" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  --data "List-Unsubscribe=One-Click"

# Sending it again must still return 200 (idempotent).
curl -i -X POST "$URL" --data "List-Unsubscribe=One-Click"

# A GET must NOT unsubscribe anyone: re-subscribe the test contact, then:
curl -i "$URL"     # expect the confirmation page, contact still subscribed

# A tampered token must change nothing.
curl -i -X POST "${URL%?}x" --data "List-Unsubscribe=One-Click"
Enter fullscreen mode Exit fullscreen mode

2. Check the headers on a real email. Send a campaign to a test Gmail address, open ⋮ → Show original, and confirm both headers are present, the URL is https, and DKIM's h= covers them.

3. Then wait for Gmail's button. Even with everything right, Gmail decides when to show its own Unsubscribe button next to your sender name, and a brand-new sender may not see it right away. The headers are what you control. Their display is up to the mailbox provider.

The rules this satisfies

For bulk senders (Google's threshold is about 5,000 messages a day to Gmail addresses), Gmail and Yahoo require that marketing and subscribed messages:

  • support one-click unsubscribe via RFC 8058 headers,
  • include a clearly visible unsubscribe link in the body, and
  • honor unsubscribe requests within two days.

Even below that threshold, one-click unsubscribe is worth doing. Every reader who unsubscribes with a click is a reader who didn't press Report spam instead, and spam complaints hurt your sender reputation far more than unsubscribes do.

Common mistakes, in one list

  • Unsubscribing on GET, so link scanners unsubscribe real readers.
  • A mailto: header with no https URL, or no List-Unsubscribe-Post header at all.
  • An endpoint that requires a login, sets a cookie or redirects before acting.
  • Links that expire after a few days.
  • Unsigned contact ids that let anyone unsubscribe anyone.
  • Headers not covered by the DKIM signature.
  • Sending a confirmation email after someone unsubscribes.
  • Adding unsubscribe headers to transactional email.

If you'd rather not build it

If you send campaigns through Cresca, the platform I'm building, this is already done for you: every campaign email carries both RFC 8058 headers with an HMAC-signed link, the endpoint unsubscribes on POST, and a GET only shows a confirmation page, so link scanners can't unsubscribe anyone. Transactional emails sent through the API don't get unsubscribe headers by default, because password resets and receipts aren't subscriptions.

Whichever way you go, test it with the curl commands above. It's the quickest way to be sure the button a reader clicks in Gmail actually works.


Have you been bitten by link scanners unsubscribing people, or found a mail client that handles one-click differently? Tell me in the comments.

Top comments (0)