---
title: "STOP, HELP, and opt-outs"
description: "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`.

:::warning[No compliance guarantee]
SMS SDK does not obtain consent, keep opt-out records, send required HELP or confirmation replies, or know the rules for each country and sender type. Providers and carriers may also act on STOP replies with their own rules. Treat these events as inputs to your own records. See [Sender registration and consent](/guides/sender-registration-and-consent).
:::

## 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

```ts
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

```ts
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](/sending/retries-and-fallback). Add the number to your suppression list when you see one:

```ts
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:

```ts
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](/guides/sender-registration-and-consent) links to each provider's rules. They change, so check them directly.

## Next steps

- [Policy checks and hooks](/sending/policy-and-hooks)
- [Sender registration and consent](/guides/sender-registration-and-consent)
