---
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 STOP, START, 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 by their own rules. Treat these events as inputs to your own records. See [Registration and consent](/providers/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), the original `body`, `keyword`, `providerId`, and `source`, which says who detected it:

- `"provider"`: the provider flagged it. Only Twilio does this, in `OptOutType`, and only with Advanced Opt-Out enabled on the Messaging Service.
- `"keyword"`: SMS SDK matched the whole message, ignoring case, surrounding spaces, and a trailing `.`, `!`, or `?`. "Stop!" matches. "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 text as `message.received`, then 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 { db } from "./db";

export const sms = createSmsClient({
  adapters: [telnyx({ apiKey: process.env.TELNYX_API_KEY ?? "", from: "+15550100002" })],
  beforeSend: async ({ message }) =>
    (await db.suppressions.has(message.to)) ? { kind: "reject", reason: "Recipient opted out." } : { kind: "allow" },
});
```

Sending to 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 tries another provider. See [Fallback and retries](/sending/fallback-and-retries). Add the number to your 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 before adding a reply, or the recipient gets two.

```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}`,
  });
}
```

The idempotency key comes from the inbound event, so a repeated webhook cannot send a second reply.

## What else is your job

Your app also collects and stores consent before texting anyone, registers senders (US A2P 10DLC, toll-free verification, short codes, and sender IDs where required), and follows quiet hours, content rules, and country requirements. [Registration and consent](/providers/registration-and-consent) links to each provider's rules. They change, so check them directly.

## Next steps

- [Block and log sends](/advanced/policy-and-hooks)
- [Registration and consent](/providers/registration-and-consent)
