Skip to content
SMS SDK
Esc
↑↓navigate↵open⌘Jpreview
On this page

STOP, HELP, and opt-outs

Record opt-outs from STOP replies, answer HELP, and block sends to suppressed recipients. SMS SDK helps; compliance stays your app's job.

When someone replies STOP, you must stop texting them on every provider you use. SMS SDK turns opt-out and help replies into events and lets you block sends with beforeSend.

Opt-out and help events

An inbound message becomes one of these instead of message.received:

Event Comes from
recipient.opted_out Twilio OptOutType=STOP, or a whole-message keyword: STOP, STOPALL, UNSUBSCRIBE, CANCEL, END, QUIT
recipient.opted_in Twilio OptOutType=START only
recipient.help Twilio OptOutType=HELP, or a whole-message keyword: HELP, INFO

Each event has from, the person who texted, to, your number, plus the original body, keyword, providerId, and source. The source tells you who detected it:

  • "provider" means the provider flagged it. Only Twilio sends this signal, in OptOutType, and only when Advanced Opt-Out is enabled on the Messaging Service.
  • "keyword" means SMS SDK matched the whole message after trimming, ignoring case and a trailing ., !, or ?. “stop” and “Stop!” match. “Please stop texting me” does not.

Keyword matching is a convenience. Carriers and providers may treat other words or languages as opt-outs. Set detectKeywords: false to receive every inbound message as message.received and apply your own rules with detectKeyword() or your own matcher.

Record opt-outs

import { parseSmsWebhook, WebhookSignatureError } from "@opencoredev/sms-sdk/webhooks";
import { db } from "./db";

export async function POST(request: Request): Promise<Response> {
  try {
    const event = await parseSmsWebhook({
      provider: "twilio",
      request,
      publicUrl: "https://example.com/webhooks/sms/twilio",
      credentials: { authToken: process.env.TWILIO_AUTH_TOKEN ?? "" },
    });

    if (event.type === "recipient.opted_out") {
      await db.suppressions.add(event.from);
    } else if (event.type === "recipient.opted_in") {
      await db.suppressions.remove(event.from);
    }
    return new Response("<Response></Response>", { headers: { "content-type": "text/xml" } });
  } catch (error) {
    if (error instanceof WebhookSignatureError) {
      return new Response("Invalid signature", { status: 401 });
    }
    throw error;
  }
}

The suppression list lives in your database, so it covers every provider you send through.

Block sends to suppressed recipients

import { createSmsClient } from "@opencoredev/sms-sdk";
import { telnyx } from "@opencoredev/sms-sdk/telnyx";
import { twilio } from "@opencoredev/sms-sdk/twilio";
import { db } from "./db";

export const sms = createSmsClient({
  adapters: [
    twilio({
      accountSid: process.env.TWILIO_ACCOUNT_SID ?? "",
      authToken: process.env.TWILIO_AUTH_TOKEN ?? "",
      from: "+15550100001",
    }),
    telnyx({ apiKey: process.env.TELNYX_API_KEY ?? "", from: "+15550100002" }),
  ],
  fallback: "on-known-rejection",
  beforeSend: async ({ message }) =>
    (await db.suppressions.has(message.to)) ? { kind: "reject", reason: "Recipient opted out." } : { kind: "allow" },
});

A suppressed recipient throws PolicyRejectedError before any request, and fallback never runs.

A provider may also refuse a message because the recipient opted out with it directly: Twilio 21610, Telnyx 40300, or Vonage 1240. That rejection has category: "compliance", and SMS SDK never retries it or sends it through another provider. See Retries and fallback. Add the number to your suppression list when you see one:

import { ProviderRejectedError } from "@opencoredev/sms-sdk";
import { db } from "./db";
import { sms } from "./sms";

try {
  await sms.send({ to: "+14155550123", body: "Your weekly summary is ready." });
} catch (error) {
  if (error instanceof ProviderRejectedError && error.category === "compliance") {
    await db.suppressions.add("+14155550123");
  } else {
    throw error;
  }
}

Answer HELP

SMS SDK never replies on its own. Some providers answer HELP and STOP automatically for some sender types, so check your provider settings to avoid sending two replies. To reply yourself:

import { isE164 } from "@opencoredev/sms-sdk";
import type { SmsEvent } from "@opencoredev/sms-sdk/webhooks";
import { sms } from "./sms";

export async function answerHelp(event: SmsEvent): Promise<void> {
  if (event.type !== "recipient.help" || !isE164(event.from) || !isE164(event.to)) return;
  await sms.send({
    to: event.from,
    from: event.to,
    body: "Acme order updates. Reply STOP to unsubscribe. Help: support@example.com",
    idempotencyKey: `help-reply:${event.dedupeKey}`,
  });
}

What else you are responsible for

Your app also has to collect and store consent before texting anyone, register senders, and follow quiet hours, content rules, and country requirements. Sender registration covers US A2P 10DLC campaigns, toll-free verification, short code approval, and sender ID registration where required. Sender registration and consent links to each provider’s rules. They change, so check them directly.

Next steps