DEV Community

Cover image for Handling WhatsApp Opt-Outs in Code: Consent as Data, STOP Keywords and One Send Gate
WAPGROWAI for WAPGROWAI

Posted on

Handling WhatsApp Opt-Outs in Code: Consent as Data, STOP Keywords and One Send Gate

Every WhatsApp platform handles "STOP" somewhere. The hard part isn't recognising the word; it's making the answer stick everywhere. A contact who opted out on Monday mustn't get Tuesday's scheduled campaign, an automation rule, a chatbot's promo or a message your CRM sends through the API. This post models WhatsApp consent as data, parses opt-out keywords safely, and puts a single send gate in front of every sender, with tests for each case, including the ones our own product still gets wrong.

The snippets are small, self-contained versions of the patterns, and each was run against the cases shown.

What the rules ask for

Meta's opt-in requirements say businesses must get opt-in before messaging people, clearly state the business's name, and give clear instructions for opting out, then honour them. WhatsApp's Business Messaging Policy says you must respect requests to stop "either on or off WhatsApp". And people can also stop marketing messages from a business in WhatsApp itself; Meta then processes your send but doesn't deliver it.

In code, that means three things: know each contact's consent, change it when they say so, and check it on every send.

Consent is a state and a history

// Consent is a state per contact plus an append-only history of how it got there.
export type State = "in" | "out" | "unknown";
export type Scope = "marketing" | "all";
export type Category = "marketing" | "utility" | "authentication" | "service";
export type ConsentEvent = { scope: Scope; state: State; at: Date; source: string };
export type Consent = { marketing: State; all: State; history: ConsentEvent[] };

export const newConsent = (): Consent => ({ marketing: "unknown", all: "unknown", history: [] });

export const record = (c: Consent, scope: Scope, state: State, source: string, at = new Date()): Consent => ({
  ...c,
  [scope]: state,
  history: [...c.history, { scope, state, at, source }],
});

// An imported "yes" counts; a blank cell is "we don't know", never "yes".
export const fromImport = (cell: string | undefined, at = new Date()): Consent => {
  const v = (cell ?? "").trim().toLowerCase();
  if (["yes", "y", "true", "1"].includes(v)) return record(newConsent(), "marketing", "in", "import", at);
  if (["no", "n", "false", "0"].includes(v)) return record(newConsent(), "marketing", "out", "import", at);
  return newConsent();
};

// Marketing needs an explicit yes. Everything else is allowed unless they stopped all
// messages, except a reply to something they just asked, which always goes.
export const canSend = (c: Consent, category: Category): boolean => {
  if (category === "service") return true;
  if (c.all === "out") return false;
  return category === "marketing" ? c.marketing === "in" : true;
};
Enter fullscreen mode Exit fullscreen mode
  • Three states, not a boolean. in, out and unknown. A boolean has to guess what an empty field means, and it usually guesses "yes".
  • Two scopes. "Stop offers" (marketing) and "stop everything" are different requests. A customer who stops offers still expects their order updates.
  • History, not just state. Every change records when, and from where: checkout, import, keyword, chatbot. When someone asks "why am I getting this?", you can answer.
  • Imports don't invent consent. A blank cell is unknown. Ours treats it as allowed, which is the kind of default that quietly fills a marketing list with people who never said yes.

canSend encodes the policy. Marketing needs an explicit in. Utility and authentication messages are allowed unless the person stopped everything; that assumes you collect consent for order updates at checkout, so tighten it if you don't. A service reply, answering a message the customer just sent, always goes.

Parsing STOP safely

import { record, type Consent } from "./consent.ts";

export type Intent = { scope: "marketing" | "all"; state: "in" | "out" } | null;

// Whole-message matches only: "please don't stop" is not an opt-out.
const KEYWORDS: Record<string, Intent> = {
  STOP: { scope: "marketing", state: "out" },
  UNSUBSCRIBE: { scope: "marketing", state: "out" },
  "OPT OUT": { scope: "marketing", state: "out" },
  "STOP ALL": { scope: "all", state: "out" },
  START: { scope: "marketing", state: "in" },
  SUBSCRIBE: { scope: "marketing", state: "in" },
};

export const parseIntent = (text: string): Intent => {
  const normalised = text.trim().replace(/[\s_-]+/g, " ").replace(/[.!]+$/, "").toUpperCase();
  return KEYWORDS[normalised] ?? null;
};

const CONFIRMATIONS = {
  "marketing:out": "Done. You won't get offers from us on WhatsApp. Order updates will still come. Reply START to get offers again.",
  "all:out": "Done. We won't message you on WhatsApp unless you write to us first. Reply START to get offers again.",
  "marketing:in": "You're subscribed to our offers on WhatsApp. Reply STOP anytime.",
} as const;

// Apply a keyword and say what changed. A repeat STOP changes nothing and gets no second reply.
export const applyKeyword = (c: Consent, text: string, at = new Date()): { consent: Consent; reply: string | null } => {
  const intent = parseIntent(text);
  if (!intent) return { consent: c, reply: null };

  let next = c;
  if (intent.state === "in") {
    if (c.marketing === "in" && c.all !== "out") return { consent: c, reply: null };
    if (c.all === "out") next = record(next, "all", "unknown", "keyword:START", at);
    next = record(next, "marketing", "in", "keyword:START", at);
  } else {
    if (c[intent.scope] === "out") return { consent: c, reply: null };
    next = record(next, intent.scope, "out", `keyword:${text.trim().toUpperCase()}`, at);
  }
  return { consent: next, reply: CONFIRMATIONS[`${intent.scope}:${intent.state}` as keyof typeof CONFIRMATIONS] };
};
Enter fullscreen mode Exit fullscreen mode
  • Whole message only. "STOP" is an opt-out; "please don't stop" and "STOP sending me the wrong size" aren't. Normalise case, spaces, hyphens and trailing punctuation, then match exactly.
  • Confirm it. The customer just messaged you, so the 24-hour window is open and a plain-text reply is allowed. Say what stopped, what didn't, and how to come back. Our platform doesn't send this confirmation today, and a silent STOP leaves customers unsure it worked.
  • Confirm once. A second STOP changes nothing and gets no second reply. Otherwise a customer who types it twice gets two messages about not getting messages.
  • Make a way back. START opts back in, including after STOP ALL. Without it, the only way back is someone editing the contact by hand.

One gate for every sender

import { canSend, type Category, type Consent } from "./consent.ts";

export type Outgoing = { contactId: string; category: Category; source: "campaign" | "drip" | "rule" | "chatbot" | "api" | "inbox" };
export type Outcome = { status: "sent"; id: string } | { status: "skipped"; reason: "opted_out" | "no_consent" };

// The one door every message goes through. Checking consent when a campaign is
// built isn't enough: someone can reply STOP between scheduling and sending,
// and rules, chatbots and API sends never build an audience at all.
export const createSendGate = (
  getConsent: (contactId: string) => Consent,
  deliver: (msg: Outgoing) => Promise<string>,
) => async (msg: Outgoing): Promise<Outcome> => {
  const consent = getConsent(msg.contactId);
  if (!canSend(consent, msg.category)) {
    const stopped = consent.all === "out" || consent.marketing === "out";
    // Skipped, not failed: a skip is the system working.
    return { status: "skipped", reason: stopped ? "opted_out" : "no_consent" };
  }
  return { status: "sent", id: await deliver(msg) };
};
Enter fullscreen mode Exit fullscreen mode

This is the part most systems get wrong, ours included. WAPGROWAI checks the opt-out when a campaign or drip builds its recipient list. That catches most cases, but not all of them:

  • Between scheduling and sending. The recipient list is fixed when the campaign is scheduled, so someone who replies STOP an hour later still gets it.
  • Senders without an audience. Automation rules, sequences, chatbot messages and API sends never build a recipient list, so they never see the flag.

The fix is structural: one function that every outbound message passes through, checking consent at the moment of sending. Then a new sender can't forget, because there's no other way out. Report a blocked message as skipped, not failed: it's the system doing its job, and mixing it into failures hides real errors.

What the tests check

  • Keywords: STOP in any case, with spaces, hyphens or a full stop, opts out; "please don't stop", "STOP sending me the wrong size" and "stopped" don't.
  • History: STOP records keyword:STOP with its time, and replies once; a second STOP adds nothing and sends nothing.
  • START: opts back in, even after STOP ALL.
  • Policy: marketing is blocked for unknown and out; utility is blocked only after STOP ALL; a service reply always goes.
  • Imports: a blank or missing consent cell is unknown, and marketing to it is skipped.
  • Timing: a contact who replies STOP after a campaign was scheduled is skipped when it sends, while the others are delivered.
  • Every sender: rule, chatbot, API and drip marketing sends to an opted-out contact are all skipped; a new contact with no consent is skipped as no_consent; a utility message to someone who only stopped offers is sent.

Checklist

  • Store consent as in, out or unknown, per scope, with a history.
  • Never let a blank field mean yes.
  • Match opt-out keywords on the whole message, normalised.
  • Confirm a change once, and offer a way back.
  • Check consent at send time, in one gate every sender uses.
  • Report blocked sends as skipped, not failed.
  • Honour opt-outs that arrive by phone, email or in person, through the same record.

WAPGROWAI is built by DI Solutions, a custom software and AI development company. This series covers what we've learned building WAPGROWAI on the WhatsApp Cloud API. Related: compiling customer segments to SQL and building a rules engine for WhatsApp automation.

Top comments (0)