Category-Scoped SMS Opt-Outs (A2P 10DLC) in a Multi-Tenant Platform

A single global SMS STOP is the wrong default. How I scoped A2P 10DLC opt-outs by category across inbound, outbound, CRM push, and UI badges.

Category-Scoped SMS Opt-Outs (A2P 10DLC) in a Multi-Tenant Platform
Contents

Here’s a compliance bug that never throws an exception, never pages you, and quietly makes your product worse: a customer texts STOP to cancel a promo, and now they also stop getting the “your technician is on the way” text they actually wanted. The code did exactly what it was told. The product still failed the person.

I hit this while owning the outbound and texting stack on a venture-backed AI voice platform for the home-services industry — an AI receptionist that answers calls and texts for contractors, books jobs into their field-service CRM, and runs outbound campaigns. The last big thing I shipped there was making SMS opt-out category-scoped instead of global. It sounds like a checkbox. It’s actually an end-to-end change that touches your inbound webhook, your outbound send path, your CRM sync, and your dashboard. This is how I did it, and why the obvious implementation is the wrong one.

What A2P 10DLC actually asks of you

If you send business SMS to US numbers over normal 10-digit long codes, you’re in the A2P 10DLC framework. You register a Brand and then one or more Campaigns with The Campaign Registry, and every campaign declares a use case: Customer Care, Marketing, 2FA, and so on. Carriers meter throughput and police content per campaign, not per company.

The registration and campaign-vetting side is a rabbit hole of its own — throughput tiers, use-case gotchas, and the paperwork that gets a Brand approved. I keep a running field guide to all of it at a2p.guide. Here I’ll assume you’re already registered and focus on the opt-out logic, which is where the interesting product decision hides.

That last point is the whole insight. The regulatory framework already treats an appointment reminder (Customer Care) and a “20% off a tune-up” blast (Marketing) as different things. Consent for one is not consent for the other. A homeowner who booked a plumber has an existing-relationship basis to receive service texts about that job; a marketing promo needs its own opt-in and its own opt-out. The categories aren’t cosmetic — they’re separate consent domains.

So when someone opts out, the honest question isn’t “did they say STOP?” It’s “STOP to what?”

Why global opt-out is the tempting wrong answer

The conventional move is one boolean on the contact: opted_out. Inbound STOP sets it true, START sets it false, every send checks it. It’s five lines, it’s obviously “safe” (you’ll never over-message an opt-out), and it passes review.

It’s also worse for the party who didn’t complain. The contractor still needs to send that reminder; the customer still wants it. Collapsing both categories into one flag means a marketing unsubscribe silently kills the transactional messages both sides depend on — and nobody finds out until a tech shows up to an empty house because the “running late” text never went.

The conventional move optimizes for “never send to an opt-out.” The right move optimizes for “honor exactly the opt-out they gave you.” A2P hands you the axis to tell them apart. I went with category-scoped, and the rest of this post is what that costs to build correctly.

The model: state keyed by (team, phone, category)

Two design constraints shaped this. First, it’s multi-tenant — every contractor is a team, and opt-out state has to be scoped per team or you leak one tenant’s suppression list into another’s sends. Second, this team’s convention was to keep enumerated values out of Postgres as enum types (migration velocity made ALTER TYPE a liability) and validate them with a TypeScript union in the app instead.

// packages/db — the category is the load-bearing dimension.
export const SMS_CATEGORY = ["customer_care", "marketing"] as const;
export type SmsCategory = (typeof SMS_CATEGORY)[number];

export type OptOutState = "opted_in" | "opted_out";

The state lives one row per (team_id, phone_e164, category), not one flag per contact:

create table sms_opt_out (
  team_id      bigint      not null,
  phone_e164   text        not null,
  category     text        not null,  -- validated by SmsCategory in app code
  state        text        not null,  -- 'opted_in' | 'opted_out'
  source       text        not null,  -- 'inbound_keyword' | 'crm_sync' | 'manual'
  updated_at   timestamptz not null default now(),
  primary key (team_id, phone_e164, category)
);

That composite key is the entire feature in one line: the same person can be opted_out of marketing and opted_in for customer_care at the same time, per tenant.

Inbound: scope the STOP to the campaign it answers

The hard part of inbound isn’t recognizing the keyword — it’s attributing it to a category. Carriers and Twilio enforce STOP at the number level, so the trick is to make the number carry the category. Each A2P campaign gets its own messaging service and its own sending numbers, which means the number an inbound STOP lands on tells you which category it’s answering. Carrier-level opt-out then scopes for free, and I mirror it into our own store so gating, CRM, and UI all read one source of truth.

// apps/web/app/api/webhooks/twilio/inbound-sms/route.ts
const OPT_OUT = new Set(["STOP", "STOPALL", "UNSUBSCRIBE", "CANCEL", "END", "QUIT"]);
const OPT_IN  = new Set(["START", "YES", "UNSTOP"]);

export async function POST(req: Request) {
  const form = await req.formData();
  const from = normalizeE164(String(form.get("From"))); // the customer
  const to   = String(form.get("To"));                  // OUR number
  const word = String(form.get("Body")).trim().toUpperCase();

  // The receiving number resolves the tenant AND the category,
  // because each category sends from its own campaign/numbers.
  const { teamId, category } = await resolveCampaignNumber(to);

  const state: OptOutState | null = OPT_OUT.has(word)
    ? "opted_out"
    : OPT_IN.has(word)
    ? "opted_in"
    : null;

  if (state === null) return handleConversationReply(teamId, from, to);

  await setOptOutState({ teamId, phone: from, category, state, source: "inbound_keyword" });
  await pushOptOutToCrm({ teamId, phone: from, category, optedOut: state === "opted_out" });
  return twiml(confirmationFor(category, state));
}

One honest edge case: if a tenant shares a number across categories, the To number stops disambiguating and you can’t cleanly scope a raw STOP. My fallback was to attribute the keyword to the category of the most recent outbound message in that thread — imperfect, but it degrades toward the intent the customer most likely had. The clean path is the constraint that made the whole thing tractable: one campaign, one messaging service, its own numbers. Keep them separate and the ambiguity never arises.

Outbound: one gate nobody can route around

Category-scoping is worthless if any single campaign path can send without checking. On a multi-tenant system with a lot of send surfaces — speed-to-lead callbacks, reminders, follow-ups — the only durable answer is to funnel every outbound SMS through one guard, the same way every team-scoped route goes through one verifyTeamAccess. The gate takes the category as a required argument, so you can’t call it without deciding which suppression list applies.

async function assertCanSend(
  teamId: string,
  phone: string,
  category: SmsCategory, // required — you can't send "uncategorized"
): Promise<void> {
  const state = await getOptOutState({ teamId, phone, category });
  if (state === "opted_out") {
    throw new SuppressedRecipientError(teamId, phone, category);
  }
}

Because sends run on durable background functions, the gate lives at the per-recipient step, not once at the top of a campaign. A campaign built an hour ago should still respect a STOP that arrived five minutes ago — you check at send time, per message, not at enqueue time.

Push it back to the CRM, and show it

Two pieces make this real for the person who has to trust it. First, the opt-out state gets pushed to the contractor’s field-service CRM telecom records, so the platform and the system the contractor stares at all day agree — an opt-out that’s true in one place and false in the other is a future “why did we text them?” incident. Second, it surfaces as a per-category badge on the contact view, so an ops person sees at a glance that a customer is opted out of Marketing but still reachable for Customer Care. A compliance state you can’t see is a compliance state you can’t trust.

Testing the part that silently rots

Opt-out logic is exactly the code that quietly breaks six months later when someone adds a third category. The tests that earn their keep aren’t the integration ones — they’re the boring table-driven checks on the two pure decisions: keyword → state, and (state, category) → can-send.

test.each([
  ["STOP",        "opted_out"],
  ["unsubscribe", "opted_out"], // case-insensitive
  ["START",       "opted_in"],
  ["thanks!",      null],       // a normal reply is NOT an opt-out
])("classifies %s", (body, expected) => {
  expect(classifyKeyword(body)).toBe(expected);
});

test("marketing opt-out still allows customer_care", async () => {
  await setOptOutState({ teamId, phone, category: "marketing", state: "opted_out", source: "manual" });
  await expect(assertCanSend(teamId, phone, "customer_care")).resolves.toBeUndefined();
  await expect(assertCanSend(teamId, phone, "marketing")).rejects.toThrow(SuppressedRecipientError);
});

That second test is the whole feature expressed as an assertion. If it ever goes red, the product regressed to the naive version — and you’ll know before a customer does.

Ship it spec-first

I shipped this design-spec-first: the design doc and implementation plan landed in the same PRs as the code. That’s not process theater. On a team where a hundred-plus contributors and their AI coding agents share one convention source, a compliance feature whose rules live only in someone’s head is a guaranteed future incident. The retrieval question — “which categories exist, how does STOP scope, where’s the gate?” — has to be answerable from the repo, not from me.

The lesson I keep relearning: the naive version of a compliance feature is usually the technically safe, product-wrong one. Global STOP never over-messages anyone — and quietly strips people of the texts they asked for. A2P 10DLC already models the distinction. The engineering is just refusing to flatten it.