Skip to content
Claim your free business email
Deliverability

How to set up one-click unsubscribe (RFC 8058)

By Rasket TeamLast updated 17 min read

Written for developers and deliverability specialists

The short answer

One-click unsubscribe (RFC 8058) is two email headers: List-Unsubscribe, carrying an HTTPS URL that identifies the recipient and list, and List-Unsubscribe-Post: List-Unsubscribe=One-Click. A DKIM signature must cover both. The mail app sends a POST to that URL, and your server removes the address at once, with no login, confirmation page or redirect.

What one-click unsubscribe is

The standard behind the button is RFC 8058 (IETF, January 2017). It extends the List-Unsubscribe header of RFC 2369 (1998).[1][2] The old header had a flaw: “anti-spam software often fetches all resources in mail header fields automatically”, so a sender could not tell a person from a scanner, and senders added confirmation pages. One-click replaces that with a POST, which a mail receiver “MUST NOT perform” without the user’s consent.[1]

A marketing email carries List-Unsubscribe and List-Unsubscribe-Post headers signed by DKIM. When the reader presses the mail app’s unsubscribe button, the mailbox provider sends an HTTPS POST with the body List-Unsubscribe=One-Click to the URL in the header, and the sender’s server removes the address and answers 200 without a redirect.Your emailList-UnsubscribeList-Unsubscribe-PostDKIM covers bothMail appReader pressesUnsubscribe(with their consent)Your serverVerifies the tokenRemoves the addressAnswers 200, no redirectPOST List-Unsubscribe=One-ClickRFC 8058, in three steps
The headers travel with the message; the unsubscribe itself is one HTTPS POST from the mailbox provider to your server. Nothing in it needs the reader to visit your site.

The reason to want it is in the RFC too: users “do not make a clear distinction between unsubscription and junk”, and when leaving is hard “the recipient’s alternative is to report mail from the sender as junk”.[1] We know of no published controlled measurement of how many complaints one-click prevents; the case rests on that reasoning and on Google’s own statement in the next section. How complaints add up is in lowering your spam complaint rate.

Check before you build. If a newsletter tool sends your marketing, open one of its messages in Gmail with Show original and look for List-Unsubscribe-Post: List-Unsubscribe=One-Click, then for both header names in the h= list of the DKIM-Signature (the headers that signature covers). If both are there, the tool already does this. If you send through Rasket, the last section says which of your mail gets them.

Who requires one-click unsubscribe

Gmail requires it of bulk senders, those sending close to 5,000 or more messages a day to Gmail accounts.[4] That bulk-sender status stays with a domain once reached (the rest of the list is in the Gmail and Yahoo sender requirements).[4] The guidelines apply it to “marketing messages and subscribed messages”, with a visible unsubscribe link in the body as well.[3] The FAQ excludes transactional mail such as password resets and reservation confirmations.[4] So treat a digest or product newsletter someone signed up for as in scope, and a receipt as out.

Yahoo asks bulk senders for a working list-unsubscribe header with one-click, calls the POST method “highly recommended” and mailto “acceptable”, and wants unsubscribes honoured within 2 days.[5] Microsoft’s requirements for senders of more than 5,000 emails a day are SPF, DKIM and DMARC; a functional unsubscribe link is among its “Additional Email Hygiene Recommendations”, and RFC 8058 is not named.[6] Outlook.com’s help says it “looks for subscription identifiers in the message header” to list a reader’s subscriptions, without saying which headers or whether it sends the POST.[23] We found no Apple page that says what Mail does with these headers. Since the other apps’ behaviour is undocumented, the safe default is the RFC 8058 pair with an HTTPS URL, plus a mailto if something reads that mailbox.

Google recommends fulfilling every unsubscribe within 48 hours. Missing that, or sending marketing without one-click, is on its list of failures that make delivery support and mitigations unavailable to a bulk sender.[4] US law is slower: CAN-SPAM says “you must honor a recipient’s opt-out request within 10 business days”.[8] An endpoint that acts on the request at once never comes near either deadline. Missing one-click does not bounce your mail: Google says “we don’t automatically reject messages or mark messages as spam” for it, though unwanted mail without it is “more likely to be reported as spam by recipients”. Since November 2025 Gmail has been “ramping up its enforcement on non-compliant traffic”, with temporary and permanent rejections, and the error codes the same FAQ lists cover authentication, reverse DNS and TLS, none of them unsubscribe.[4] Which of your messages count as marketing is settled in transactional vs marketing email.

The List-Unsubscribe and List-Unsubscribe-Post headers

RFC 8058 asks for two header fields. List-Unsubscribe “MUST contain one HTTPS URI” and may add a non-HTTP one such as mailto:. List-Unsubscribe-Post “MUST contain the single key/value pair” List-Unsubscribe=One-Click.[1]

The two headers on a marketing email
List-Unsubscribe: <https://example.com/u/k1.eyJsaXN0IjoibmV3cyIsInN1YnNjcmliZXIiOiI0MiJ9.xRnHq7OAki359N1wUTn8Tv1cUVdhu8TvG006a3ZU9z0>,
 <mailto:unsubscribe@example.com?subject=unsubscribe>
List-Unsubscribe-Post: List-Unsubscribe=One-Click

The token in that URL is real. The code below mints it from { list: "news", subscriber: "42" } under key k1 with the secret example-only-secret, so you can check it.

List-Unsubscribe

  • Angle brackets, comma-separated. Each URL sits inside < and >, and the URLs “have order of preference from left to right”.[2]
  • The URL names the person and the list. The POST carries nothing else, so the URL “MUST contain enough information to identify the mail recipient and the list”.[1]
  • It is hard to forge. It “SHOULD include an opaque identifier or another hard-to-forge component”.[1] A signed token (an HMAC over the list and subscriber ids) rejects a forgery without a database lookup. Never put a bare email address in the URL, and keep it out of click tracking: a rewritten or wrapped header URL redirects, which the next sections rule out.
  • The mailto is optional. Keep one only if something reads that mailbox. Yahoo accepts it,[5] and it does not satisfy Gmail on its own.[4]

List-Unsubscribe-Post

The value is fixed. The RFC limits it to “a single known key/value pair” so a malicious message cannot make a mail app submit some other form on someone else’s site.[1] Copy it byte for byte.

Sign both headers with DKIM

The message “MUST have a valid DomainKeys Identified Mail (DKIM) signature that covers at least the List-Unsubscribe and List-Unsubscribe-Post headers”, so both names appear in the signature’s h= tag. Without that, the receiver “SHOULD NOT offer a one-click unsubscribe” for the message.[1]

What to look for in the received message
DKIM-Signature: v=1; a=rsa-sha256; d=example.com; s=mail;
 h=from:to:subject:date:message-id:list-unsubscribe:list-unsubscribe-post;
 bh=...; b=...

So the headers must exist before the message is signed. A relay or plugin that adds them afterwards produces headers the signature does not cover, and an outbound gateway that appends a disclaimer after signing breaks the signature altogether. Sign after the last hop that changes the message.

A signature over one List-Unsubscribe says nothing about a second one added in transit: RFC 6376 warns that “an agent would be incorrect to infer that all instances of a header field are signed just because one is”. The fix it gives is to list each name once more than it occurs, as in h=...:list-unsubscribe:list-unsubscribe:..., so that an added instance makes the signature fail (§5.4, §8.15).[11]

On your own MTA, the signer also needs both names in its list of headers to sign. rspamd’s default sign_headers already includes list-unsubscribe and list-unsubscribe-post.[24] OpenDKIM’s SignHeaders defaults to the fields the DKIM specification says should be signed, a list written in 2011, before List-Unsubscribe-Post existed, so set it yourself with both names, and add them to OversignHeaders to oversign.[25] Nodemailer 10.1.2, signing inside your app, has the same gap: its default h= covered list-unsubscribe but not list-unsubscribe-post when we sent through it, and it signs a repeated name only once. Either way, the h= tag of a received message is the proof. For bulk mail, Gmail also wants the From domain aligned with the SPF or the DKIM domain.[3] The background is in DKIM, SPF and DMARC explained.

send.mjs: add both headers and sign them, with nodemailer
import { readFileSync } from "node:fs";
import nodemailer from "nodemailer";
import { mintToken } from "./one-click.mjs";

// streamTransport signs the message without sending it; put your SMTP settings here instead.
const transport = nodemailer.createTransport({
  streamTransport: true,
  dkim: {
    domainName: "example.com",
    keySelector: "mail",
    privateKey: readFileSync("dkim.pem", "utf8"),
    // Nodemailer's default list, plus the List-Unsubscribe-Post it leaves out. It signs each
    // name once even if you repeat it, so it cannot oversign: an MTA signer can.
    headerFieldNames:
      "From:Sender:Reply-To:Subject:Date:Message-ID:To:Cc:MIME-Version:Content-Type:" +
      "Content-Transfer-Encoding:Content-ID:Content-Description:Resent-Date:Resent-From:" +
      "Resent-Sender:Resent-To:Resent-Cc:Resent-Message-ID:In-Reply-To:References:List-Id:" +
      "List-Help:List-Unsubscribe:List-Subscribe:List-Post:List-Owner:List-Archive:" +
      "List-Unsubscribe-Post",
  },
});

const url = "https://example.com/u/" + mintToken({ list: "news", subscriber: "42" });
const info = await transport.sendMail({
  from: "News <news@example.com>",
  to: "reader@example.net",
  subject: "This week",
  text: "Hello. Unsubscribe: " + url,
  headers: {
    "List-Unsubscribe": "<" + url + ">",
    "List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
  },
});

let raw = "";
for await (const chunk of info.message) raw += chunk;
console.log(/ h=([^;]+)/.exec(raw.replace(/\r\n[ \t]+/g, " "))[1]); // the signed header names

Handling the one-click POST request

When the reader presses unsubscribe, the receiver POSTs the header’s value to your URL. Gmail’s guidelines show the request; with the token above it looks like this:[3]

The request a one-click unsubscribe sends
POST /u/k1.eyJsaXN0IjoibmV3cyIsInN1YnNjcmliZXIiOiI0MiJ9.xRnHq7OAki359N1wUTn8Tv1cUVdhu8TvG006a3ZU9z0 HTTP/1.1
Host: example.com
Content-Type: application/x-www-form-urlencoded
Content-Length: 26

List-Unsubscribe=One-Click

RFC 8058 says receivers “SHOULD” send the body as multipart/form-data and “MAY” send it urlencoded, so parse both. The rules for your answer:[1]

  1. No redirect. “The mail sender MUST NOT return an HTTPS redirect.”[1] Answer directly with a 2xx; the handler below uses 200.
  2. No login and no confirmation. The POST “MUST NOT include cookies, HTTP authorization, or any other context information”.[1] A step that waits for a click never completes.
  3. Never unsubscribe on GET. Scanners fetch header URLs on their own.[1] The same URL can show a page with a button on GET.
  4. Idempotent. Removing someone already removed still answers 200.

Here is a complete handler in plain JavaScript for Node 22 or later, written against the web-standard Request and Response, so the same function runs in a Next.js route handler or Hono. It needs Buffer and node:crypto: in Next.js, keep the default Node.js runtime rather than Edge; elsewhere, an edge runtime needs its Node.js compatibility mode. It counts bytes as it reads, so a chunked body with no Content-Length still stops at 16 KB with a 413. A body that is not a form gets a 400, not a 500.

The caller passes the client’s IP address as clientIp; the handler never reads X-Forwarded-For, because any client can write that header. On a Node server, pass the socket address. In Hono, pass getConnInfo(c).remote.address where your runtime’s adapter provides it. In a Next.js route handler, pass the header your host documents as set by its own proxy, or null.

one-click.mjs: mint and verify the token, answer GET and POST
import { createHmac, timingSafeEqual } from "node:crypto";

// Every key that may still verify, by id. To rotate: add k2, mint with k2, keep k1 here.
const KEYS = { k1: process.env.UNSUBSCRIBE_SECRET }; // long random strings, kept server-side
const CURRENT = "k1";
if (!KEYS[CURRENT]) throw new Error("Set UNSUBSCRIBE_SECRET");

const MAX_BODY = 16384; // RFC 8058's body is 26 bytes; a page form is a few hundred

const sign = (keyId, payload) =>
  createHmac("sha256", KEYS[keyId]).update(keyId + "." + payload).digest("base64url");

// token = keyId "." base64url(JSON {list, subscriber}) "." base64url(HMAC-SHA256 of both)
export function mintToken(who) {
  const payload = Buffer.from(JSON.stringify(who)).toString("base64url");
  return CURRENT + "." + payload + "." + sign(CURRENT, payload);
}

export function verifyToken(token) {
  const [keyId, payload, mac, extra] = token.split(".");
  if (!Object.hasOwn(KEYS, keyId) || !KEYS[keyId] || !payload || !mac || extra !== undefined) {
    return null;
  }
  const given = Buffer.from(mac);
  const expected = Buffer.from(sign(keyId, payload));
  if (given.length !== expected.length || !timingSafeEqual(given, expected)) return null;
  return JSON.parse(Buffer.from(payload, "base64url").toString()); // { list, subscriber }
}

// Reads at most MAX_BODY bytes, whether or not the request declared a Content-Length.
async function readForm(request) {
  const chunks = [];
  let size = 0;
  if (request.body) {
    for await (const chunk of request.body) {
      size += chunk.byteLength;
      if (size > MAX_BODY) return "too-large";
      chunks.push(chunk);
    }
  }
  const type = request.headers.get("content-type") ?? "";
  try {
    // multipart/form-data or urlencoded; any other type throws
    return await new Response(Buffer.concat(chunks), { headers: { "content-type": type } }).formData();
  } catch {
    return null;
  }
}

const html = (body) =>
  new Response("<!doctype html><meta charset=utf-8><body>" + body + "</body>", {
    headers: { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" },
  });

// unsubscribe(who, how) must be idempotent: removing someone already removed is a success.
// Keep it fast, or have it queue the change: the answer waits for it.
// clientIp comes from the caller: the socket address, or what your own proxy vouches for.
export async function handleOneClick(request, token, unsubscribe, clientIp = null) {
  const who = verifyToken(token);
  if (who === null) return new Response(null, { status: 404 });

  // GET and HEAD never change anything: link scanners fetch these URLs.
  if (request.method === "GET" || request.method === "HEAD") {
    return html('<form method="post"><button name="confirm" value="yes">Unsubscribe</button></form>');
  }
  if (request.method !== "POST") {
    return new Response(null, { status: 405, headers: { Allow: "GET, HEAD, POST" } });
  }

  const form = await readForm(request);
  if (form === "too-large") return new Response(null, { status: 413 });
  if (form === null) return new Response(null, { status: 400 });

  const how = {
    at: new Date().toISOString(),
    userAgent: request.headers.get("user-agent"),
    ip: clientIp,
  };
  if (form.get("List-Unsubscribe") === "One-Click") {
    await unsubscribe(who, { ...how, source: "one-click" });
    return new Response(null, { status: 200 }); // no body, no redirect
  }
  if (form.get("confirm") === "yes") {
    await unsubscribe(who, { ...how, source: "page" });
    return html("<p>You are unsubscribed.</p>");
  }
  return new Response(null, { status: 400 });
}

How long tokens should last, and what to log

RFC 8058 sets no lifetime for the URL. CAN-SPAM sets a floor: an opt-out mechanism “must be able to process opt-out requests for at least 30 days after you send your message”, which rules out short-lived tokens.[8] Mail sits in inboxes for years, and an old message is where a reader looks for the button. Our rule: give tokens no expiry, and rotate keys instead. The handler’s KEYS map is the rotation: add k2, mint new mail with it, and leave k1 in the map for as long as mail signed with it may still be read.

For each unsubscribe, store the time, the list, the source (one-click or the page) and the request’s IP address and user agent. The handler passes these to unsubscribe(). Read the last two with care. The POST comes from the mail receiver with no cookies or other context,[1] so they name the system that sent it, which may be a provider’s server rather than the reader’s device. What the law asks you to keep is in the rules by region.

The body link need not be one-click: when the headers are present, it “can link to a preferences web page that you specify”.[4] Google describes the header as removing someone “only from the mailing list associated with the message”,[4] and suggests letting people leave lists one at a time or all at once, as options that “should not replace one-click unsubscribe”.[3]

That is the problem for a sender with several topics. If your token names one topic, a POST can only remove that topic, while the reader may believe they left everything, and Gmail now files your other topics in spam for them. Two designs hold up:

  • One-click removes all marketing; the page does topics. Nobody keeps getting mail after pressing Gmail’s button. This is what Rasket does.
  • One-click removes the topic in the token. Choose this only if each topic is plainly a separate list to the reader. Google’s Postmaster Tools help says “Each list should have a unique List-ID or ‘From:’ address.”[7]

How to test your List-Unsubscribe-Post setup

Save one-click.mjs and this small server beside it. It serves /u/<token> on port 18058 and streams each body to the handler, with a Set standing in for your database.

server.mjs: the handler on http://localhost:18058
import { createServer } from "node:http";
import { Readable } from "node:stream";
import { handleOneClick } from "./one-click.mjs";

const PORT = 18058;
const removed = new Set(); // stands in for your database
async function unsubscribe(who, how) {
  removed.add(who.list + ":" + who.subscriber); // adding twice is harmless
  console.log("unsubscribed", who, how);
}

createServer(async (req, res) => {
  const match = /^\/u\/([^/?]+)$/.exec(req.url ?? "");
  if (match === null) return res.writeHead(404).end();
  const request = new Request("http://localhost:" + PORT + req.url, {
    method: req.method,
    headers: Object.entries(req.headers).filter(([, value]) => typeof value === "string"),
    // Streamed, not buffered here: the handler stops reading at its own limit.
    body: req.method === "POST" ? Readable.toWeb(req) : undefined,
    duplex: "half",
  });
  const response = await handleOneClick(request, match[1], unsubscribe, req.socket.remoteAddress);
  res.writeHead(response.status, Object.fromEntries(response.headers));
  res.end(Buffer.from(await response.arrayBuffer()));
}).listen(PORT, () => console.log("listening on http://localhost:" + PORT));

Then run these eight requests from the same folder. Each one prints what to expect, and the script stops with a message if the server could not take the port. All eight passed when we ran them on Node 24.

test.sh: run with bash test.sh or zsh test.sh
export UNSUBSCRIBE_SECRET=example-only-secret
URL=http://localhost:18058/u
node server.mjs &
SERVER=$!
sleep 1
kill -0 $SERVER 2>/dev/null || { echo "server.mjs did not start (is port 18058 taken?)"; exit 1; }
TOKEN=$(node -e 'import("./one-click.mjs").then(m => console.log(m.mintToken({ list: "news", subscriber: "42" })))')

echo "1. What Gmail sends: expect 200 and no Location header"
curl -si -X POST "$URL/$TOKEN" \
  -H 'Content-Type: application/x-www-form-urlencoded' --data 'List-Unsubscribe=One-Click'

echo; echo "2. The multipart body RFC 8058 says receivers SHOULD send: expect 200"
curl -si -X POST "$URL/$TOKEN" -F 'List-Unsubscribe=One-Click'

echo; echo "3. A forgery, subscriber 43 with 42's signature: expect 404, nobody removed"
FORGED=k1.$(node -e 'console.log(Buffer.from(JSON.stringify({ list: "news", subscriber: "43" })).toString("base64url"))').${TOKEN##*.}
curl -si -X POST "$URL/$FORGED" --data 'List-Unsubscribe=One-Click'

echo; echo "4. What a link scanner sends: expect 200 with a page, nobody removed"
curl -si "$URL/$TOKEN"

echo; echo "5. The wrong content type: expect 400, not 500"
curl -si -X POST "$URL/$TOKEN" -H 'Content-Type: text/plain' --data 'hello'

echo; echo "6. 100 kB, chunked, no Content-Length: expect 413"
head -c 100000 /dev/zero | tr '\0' 'a' | curl -si -X POST "$URL/$TOKEN" \
  -H 'Content-Type: application/x-www-form-urlencoded' -H 'Transfer-Encoding: chunked' --data-binary @-

echo; echo "7. The page's own button: expect 200 and 'You are unsubscribed.'"
curl -si -X POST "$URL/$TOKEN" --data 'confirm=yes'

echo; echo "8. A forged X-Forwarded-For: expect 200, and the log shows ::1 or 127.0.0.1, not 203.0.113.9"
curl -si -X POST "$URL/$TOKEN" -H 'X-Forwarded-For: 203.0.113.9' --data 'List-Unsubscribe=One-Click'

kill $SERVER

Then check a real message end to end:

  1. Read the raw headers. In Show original, confirm both headers, an HTTPS URL, a DKIM pass, and both names in the h= list.
  2. Replay the POST. Run request 1 against the URL copied from that header. Expect 200, no Location header, and the address off the list.
  3. Watch Postmaster Tools. Its Compliance status dashboard shows one-click unsubscribe and “Honor unsubscribe” rows for bulk senders only, and for the primary domain only, with subdomain mail counted in.[7]

Do not use the button as your test. Gmail shows it “only for messages that pass Google’s automated eligibility checks”, which also depend on increasing volume gradually, so a correct setup can show no button on a new domain.[4]

One-click unsubscribe in Rasket

Every campaign recipient’s message carries the List-Unsubscribe headers,[15] and the send step of an automation applies them too, abandoned-cart and win-back flows included.[16]

  • What one click does. The header’s link is a one-click global unsubscribe for that contact.[15] A global unsubscribe wins over every topic, and the hosted preference page is where a contact changes single topics instead.[17]
  • What your app hears. The address gets a suppression with origin unsubscribe.[18] The suppression.added webhook fires when an address is added to the suppression list,[19] so an address already suppressed, by an earlier bounce for instance, raises no second event. The contact’s unsubscribed field is the lasting record.[12]
suppression.added after a one-click unsubscribe
{
  "type": "suppression.added",
  "created_at": "2026-10-17T09:12:03.418Z",
  "data": {
    "id": "0199f1c2-5a7e-7b10-9c3d-2e4f6a8b0c1d",
    "email": "reader@example.com",
    "origin": "unsubscribe",
    "source_id": null,
    "created_at": "2026-10-17T09:12:03.418Z"
  }
}

Verify each delivery before acting on it. Rasket signs webhooks in the Svix format: an HMAC-SHA256 of svix-id, svix-timestamp and the raw body, keyed with your endpoint’s whsec_ secret and sent in svix-signature.[13] This is the whole check, with no dependencies:

webhook.mjs: verify a Rasket webhook and handle suppression.added
import { createHmac, timingSafeEqual } from "node:crypto";

// Returns the event, or null unless Rasket signed this exact body in the last five minutes.
export function verifyWebhook(rawBody, headers, secret, now = Date.now() / 1000) {
  const id = headers.get("svix-id");
  const timestamp = headers.get("svix-timestamp");
  const signatures = headers.get("svix-signature");
  const key = Buffer.from((secret ?? "").replace(/^whsec_/, ""), "base64");
  // An unset secret must fail closed, never verify against an empty key.
  if (key.length === 0 || !id || !timestamp || !signatures) return null;
  if (!/^\d+$/.test(timestamp) || Math.abs(now - Number(timestamp)) > 300) return null;
  const expected = createHmac("sha256", key).update(id + "." + timestamp + "." + rawBody).digest();
  // Space-separated "v1,<base64>" entries: two of them while a secret rotates.
  const valid = signatures.split(" ").some((entry) => {
    const [version, signature] = entry.split(",");
    const given = Buffer.from(signature ?? "", "base64");
    return version === "v1" && given.length === expected.length && timingSafeEqual(given, expected);
  });
  if (!valid) return null;
  try {
    return JSON.parse(rawBody);
  } catch {
    return null;
  }
}

// A fetch-style POST handler, for a Next.js route handler, Hono or server.mjs above.
export async function handleRasketWebhook(request, markUnsubscribed) {
  const secret = process.env.RASKET_WEBHOOK_SECRET;
  // The raw text, never request.json(): the signature covers the exact bytes.
  const event = verifyWebhook(await request.text(), request.headers, secret);
  if (event === null) return new Response(null, { status: 400 });
  if (event.type === "suppression.added" && event.data.origin === "unsubscribe") {
    // A retry resends the same svix-id: dedupe on it, or keep this idempotent.
    await markUnsubscribed(event.data.email);
  }
  return new Response(null, { status: 200 });
}

The unsubscribe URL is on a Rasket domain, not yours.[20] Every Rasket sender’s links share that domain, so its reputation is shared too: a link-blocklist listing of it would touch every customer’s unsubscribe links at once. Campaigns have no setting for your own unsubscribe domain. If your own domain in that URL matters more, send that mail from infrastructure you control with the handler above.

POST /emails does not take them: the Emails API reference lists “any List-Unsubscribe*” among the headers that “are refused”.[14] The SMTP relay docs say “Your own List-Unsubscribe headers are dropped, the same as over the API.”[21] So a digest moved from your own server to the relay loses its one-click header, and neither path has an option to pass the headers through. Such mail can carry an unsubscribe link in the body, and a newsletter sent that way needs one.[22] It still has no one-click header, which Gmail expects on subscribed mail. A digest or agent-written newsletter therefore belongs in a campaign: POST /broadcasts takes your own HTML, prints any contact property as a merge variable, and POST /broadcasts/{id}/send sends it.[15]

Frequently asked questions

Is a one-click unsubscribe the same as a spam complaint?

No. An unsubscribe is a POST to your own server that takes the reader off your list. A complaint is the reader pressing Report spam; some mailbox providers pass it back through feedback loops, and Gmail reports complaints only in aggregate, in Postmaster Tools. What a complaint does to later mail, order emails included, is in our spam complaint rate guide.

Can the unsubscribe URL be on a different domain from my From address?

Yes. RFC 8058 asks for one HTTPS URI and a DKIM signature over both headers, and Google's sender FAQ says nothing about the URL's domain. Readers rarely see the header URL, because the mail app shows a button instead.

Why would a one-click POST arrive for someone who says they never unsubscribed?

RFC 8058's security section says the scheme lets anyone with access to a message unsubscribe its recipient. A forwarded newsletter, a shared mailbox or an assistant with delegated access can press the button. That is the accepted trade-off; the signed token only stops people who never had the message.

Do I need a List-Id header alongside List-Unsubscribe?

Not for one-click. RFC 8058 requires only the two List-Unsubscribe headers and a DKIM signature over them, and neither Google's sender guidelines nor its FAQ mention List-Id. But Google's Postmaster Tools help says each list should have a unique List-ID or From address, so a sender with several lists on one From address should add List-Id (RFC 2919).

What happens to a campaign that is already sending when someone unsubscribes partway through?

That depends on where your sender checks. Read the contact's unsubscribe state in the same query that loads each recipient just before you render their message, not once when the list is drawn up, or a large send keeps mailing people after they left.

Can I ask people why they are leaving before I unsubscribe them?

Not as a condition. The one-click POST has no page at all, and the FTC says CAN-SPAM forbids charging a fee, asking for personally identifying information beyond an email address, or requiring any step beyond replying or visiting a single web page. An optional question asked after the unsubscribe has happened is fine.

Sources

25 primary sources, each read on the date shown.

See all sources
  1. 1.RFC 8058: Signaling One-Click Functionality for List Email Headers — IETF (January 2017),
  2. 2.RFC 2369: The Use of URLs as Meta-Syntax for Core Mail List Commands and their Transport through Message Header Fields — IETF (July 1998),
  3. 3.Email sender guidelines — Gmail Help, Google,
  4. 4.Email sender guidelines FAQ — Gmail Help, Google,
  5. 5.Sender Requirements & Recommendations — Yahoo Sender Hub,
  6. 6.Strengthening Email Ecosystem: Outlook’s New Requirements for High‐Volume Senders — Microsoft Defender for Office 365 Blog (2 April 2025),
  7. 7.Postmaster Tools dashboards — Gmail Help, Google,
  8. 8.CAN-SPAM Act: A Compliance Guide for Business — U.S. Federal Trade Commission,
  9. 9.Manage email subscriptions from a single location in Gmail — Google Workspace Updates (8 July 2025),
  10. 10.Manage your subscriptions in Gmail — Gmail Help, Google,
  11. 11.RFC 6376: DomainKeys Identified Mail (DKIM) Signatures — IETF (September 2011),
  12. 12.Contacts API reference — Rasket,
  13. 13.Webhooks API reference — Rasket,
  14. 14.Emails API reference — Rasket,
  15. 15.Campaigns API reference — Rasket,
  16. 16.Automations — Rasket,
  17. 17.Topics API reference — Rasket,
  18. 18.Suppressions API reference — Rasket,
  19. 19.Events — Rasket,
  20. 20.Campaigns — Rasket,
  21. 21.SMTP relay — Rasket,
  22. 22.Sending newsletters — Rasket,
  23. 23.How to manage email subscriptions in Outlook.com — Microsoft Support,
  24. 24.DKIM signing module — Rspamd documentation,
  25. 25.opendkim.conf(5): configuration file for opendkim — OpenDKIM manual page, via ManKier,

Keep reading

Written by the Rasket Team. First published ; last checked against its sources . See something out of date? How we correct guides.

Send email that earns its place

Authenticated mail, one-click unsubscribe, and campaigns measured apart from your transactional mail, set up in minutes.