Build Proposal · For Review

SMS for Boom Media

Where it lives in the 14-app suite, what Twilio actually costs, how the 10DLC reseller model works, and what to charge. Read the ruling in §1 and the compliance reality in §3 — those two decide everything else.

Drafted 2026-07-17 · Updated 2026-07-24 (added §2·5 dashboard/Next.js request path + §6·5 billing/ROI/tracking; next pilots = Big Mike's Bail Bonds & Fern House) · Twilio account already live
⚠ ACTION FIRST — rotate your Twilio Auth Token. The token was pasted into chat in plaintext on 2026-07-17 and must be considered compromised. Anyone with it + your Account SID can send messages billed to you, buy numbers, and read your message history.

Fix: Twilio Console → Account → API keys & tokens → Auth Tokens → create secondary token → update any consumer → promote secondary to primary. Then never use the Auth Token in app code — create a scoped API Key (SK…) per app instead, so a leak revokes one app, not the account.

This is the third live credential exposed in chat (Bunny, HeyGen, Twilio). This is precisely what Doppler was provisioned for and it is still not wired into any repo. Recommend wiring Doppler before this build, not after.

01The ruling — SMS is not a 15th app

Checked against SaaS/.md/APP_BOUNDARIES.md and the one-app-per-job rule.

Verdict: a shared rail + one conversational owner

Build @boom/sms as a shared send rail that every app calls (like Resend for email — nobody calls email "an app"), and give the two-way conversation surface to Replyee. Every other app owns its own SMS use case, because each already owns that job today.

Do not create "Textee." A 15th app would need its own domain, Stripe products, support surface, 10DLC brand, and would immediately collide with Localey (review requests), Rewardee (offers), and Replyee (inbox).

"Add SMS" sounds like one feature. It's actually three different jobs, and conflating them is exactly what creates the overlap your boundaries doc exists to prevent:

Job 1 · Plumbing

Transactional / notification

"Your order is ready." "Appointment tomorrow at 2." "Your document is ready to sign."

Owner: nobody — it's infrastructure. Shared @boom/sms package. Each app sends its own.
Job 2 · Product

Two-way conversation

Customer texts back. A human (or the AI) replies from a shared inbox. Threads, assignment, takeover.

Owner: Replyee. It already owns the agent inbox, human takeover, and canned replies. SMS is one more channel into it.
Job 3 · Creep zone

Outbound campaigns

Blast to a list. Review requests, loyalty offers, promos, reminders.

Owner: the app that already owns that job. Split by purpose — see the table below. Never a generic "blast tool."

Why Replyee owns the conversation

This follows the exact shape of ruling #10 (website translation → Rankee): a capability every app wants, assigned to the one app whose job it already is, with fences around the rest.

Runner-up considered: Rewardee. It owns the customer list, which is where phone numbers live. But Rewardee's job is loyalty economics (points, stamps, gift cards), not conversation — and a one-way blast tool with no reply path is both lower value and a compliance liability (customers will reply, and someone must handle STOP). Rewardee gets to send via the rail; it doesn't own the inbox.

The fence — who sends what

Every app below sends through the one rail. None of them gets its own inbox, its own Twilio integration, or its own consent list.

AppIts SMS use case (allowed)Must NOT do
ReplyeeOwns the channel. Two-way inbox, AI auto-reply, human takeover, number provisioning UI, consent dashboard, STOP/HELP handlingSend review requests or loyalty offers — it hosts the thread, it doesn't own those campaigns
LocaleyReview-request texts + follow-ups (it already owns review-request campaigns)Any inbox. Replies land in Replyee. Never gate on rating (FTC)
RewardeePoints balance, reward-earned, offer/win-back texts, gift-card deliveryAsk for a public review — that's Localey. Staff recognition stays internal
DasheePortal notifications per vertical — appointment/court-date reminders, invoice due, job statusMarketing blasts
Signnee"Document ready to sign" + signature reminders w/ magic linkAnything not tied to a signature request
Aprovee"Approval needed" nudges to the approverClient-facing marketing
B.O.O.Order status / ready-for-pickup (parent product, same rail)
DisplayeeScreen-offline alert to the owner (internal ops)Customer-facing anything
ComplieeNone. Email digest is sufficient
Addee · Bloggy · Rankee · Posttee · QRcodee · AssisteeNone. No customer-messaging jobPosttee especially: SMS is not a social channel. Don't let it creep in
The constraint that forces one rail: TCPA consent and opt-out are per phone number per brand — not per app. If a customer texts STOP to a Rewardee offer, that opt-out must immediately suppress Localey review requests and Dashee reminders to that same number. If each app kept its own list, one STOP would be honored in one app and violated by three others. That is a per-text statutory-damages exposure ($500–$1,500 per message under the TCPA), and it is the single strongest argument in this document. One consent ledger, or don't build it.
This is NOT greenfield — a codebase scan (2026-07-17) found you're already partway built. No app sends SMS yet (zero Twilio imports; the only working outbound layer is Resend email), but the scaffolding is real:
  • BOO already has an SMS campaign feature that stores but never sends. boo-v2/src/app/api/portal/marketing/sms/route.ts queues campaigns into restaurant_settings and its own comment says "It sends for real once an SMS number is connected." You just connected one (+1 561-782-8290).
  • Localey's schema is already SMS-ready. lc_review_requests has channel check (channel in ('email','sms')) + a destination column. The dispatcher cron already branches on channel — it just does continue on 'sms' with the comment "SMS is deferred (Agency tier)." The abstraction exists; only the sender is missing.
  • BOO already has per-client Twilio credential columns in restaurant_settings (twilio_account_sid, twilio_auth_token, twilio_from_number) and a status endpoint that detects them. Per-client wiring is half-designed already.
  • Phone numbers are already stored in Localey (lc_customers.phone), Rewardee (rewardee_members.phone), Dashee, Displayee, and BOO (customers.phone). The data SMS needs is sitting there.
  • The proven pattern to copy: Localey/src/lib/resend.ts — a lazy provider singleton + typed send*() functions, driven by a queue table + cron. @boom/sms should mirror it exactly, as a sendSms() sibling.
  • Replyee is the one genuinely net-new piece. Its "channels" are Supabase Realtime topics, not messaging media — replyee_messages has no channel/phone column and the SalesIQ plan never mentions SMS. Adding SMS there is real plumbing (a channel discriminator + inbound webhook), which is why Phase 3 is the biggest estimate. Not a blocker, just honest.
Net effect: the review-request and BOO-campaign use cases are closer to "finish the wiring" than "build from scratch." The consent ledger and the Replyee inbox are the only substantial new builds.

02How it works — the shared send rail

One rail, one consent ledger, one inbox, many senders. Every app in the suite (and B.O.O.) sends through the same @boom/sms service — nobody imports Twilio directly, nobody keeps their own consent list.

Senders — each owns its purpose, none owns the pipe

LocaleyReview requests + follow-ups
RewardeeLoyalty offers, points, gift cards
B.O.O.Order status / ready-for-pickup
DasheeAppointment / court-date reminders
Signnee"Ready to sign" + reminders
AproveeApproval-needed nudges
ReplyeeTwo-way conversation channel
▼   every send is one function call: sendSms()   ▼
@boom/sms the shared rail — the ONLY place Twilio is imported
1 · NormalizeE.164 or reject
2 · Consent gateopted-out? → block. marketing? → require opt-in
3 · Quiet hoursno marketing 9pm–8am local
4 · Opt-out textappend "Reply STOP" to marketing
5 · Count segmentsGSM-7 / UCS-2 · cost + emoji trap
6 · Sendvia the client's Messaging Service
⛁ One shared consent ledger (sms_consent, keyed per client + phone) sits under every send. A STOP to any app writes here once and instantly suppresses every other app for that number — the single mechanism that keeps the whole suite TCPA-safe.
Twilio — one Messaging Service & 10DLC brand per clientsegment-billed at ≈ $0.015 all-in · §3–§4
Customer561-xxx-xxxx
▲   reply / STOP flows back up the same rail   ▲
Inbound webhook → @boom/smskeyword (STOP/START) → ledger  ·  real message → Replyee inbox (AI drafts / human takes over)

Campaigns split by purpose — not a new "blast app"

The rail is shared plumbing; the campaigns are owned by whichever app already owns that job. That's how "add SMS" stays inside the one-app-per-job rule instead of spawning a 15th app.

Review requests→ Localey
Schema already SMS-ready
Offers & loyalty→ Rewardee
Points, win-back, gift cards
Order / status→ B.O.O.
Route already half-wired
Two-way chat→ Replyee
Owns the inbox + RAG auto-reply
Reminders / nudges→ Dashee · Signnee · Aprovee
Transactional, per vertical

Message paths, traced

Any appLocaley / Rewardee / Dashee…
@boom/smsconsent check → segment count → send
TwilioMessaging Service (per client)
Customer561-xxx-xxxx
Customer replies"STOP" or a real question
Twilio webhooksignature-verified
@boom/smskeyword? → ledger. else → thread
Replyee inboxAI drafts / human takes over

Shared schema (new tables in shared Supabase, prefix sms_)

-- One consent record per (client, phone). THE compliance backbone.
create table sms_consent (
  id            uuid primary key default gen_random_uuid(),
  client_id     uuid not null,              -- the business (tenant)
  phone         text not null,              -- E.164 only: +15617828290
  status        text not null default 'pending', -- pending|opted_in|opted_out
  -- Proof of consent. If you can't produce this, you have no defense.
  source        text not null,              -- web_form|checkout|keyword|import|verbal
  source_detail jsonb,                         -- {url, ip, user_agent, form_snapshot_id}
  consent_text  text not null,              -- EXACT wording shown at opt-in
  opted_in_at   timestamptz,
  opted_out_at  timestamptz,
  unique (client_id, phone)
);
create index on sms_consent (phone) where status = 'opted_out';

-- Every message in/out. Audit trail + billing source of truth.
create table sms_messages (
  id            uuid primary key default gen_random_uuid(),
  client_id     uuid not null,
  thread_id     uuid,                          -- FK → Replyee thread
  direction     text not null,              -- outbound|inbound
  app           text not null,              -- localey|rewardee|dashee|replyee|boo…
  phone         text not null,
  body          text not null,
  segments      int  not null default 1,     -- what you actually bill on
  encoding      text,                          -- gsm7|ucs2  ← see the emoji trap, §4
  twilio_sid    text unique,
  status        text,                          -- queued|sent|delivered|undelivered|failed
  error_code    int,                           -- 30007 = carrier filtered (spam flag)
  cost_usd      numeric(10,5),
  created_at    timestamptz default now()
);

-- Per-client Twilio wiring (one Messaging Service per client, §3)
create table sms_clients (
  client_id            uuid primary key,
  twilio_subaccount_sid text,
  messaging_service_sid text,
  phone_number          text,
  brand_sid             text,   -- TCR brand
  campaign_sid          text,   -- TCR campaign
  registration_status   text,   default 'unregistered',
  monthly_included      int,    -- plan allowance
  business_hours        jsonb   -- quiet-hours enforcement, §4
);

The rail — one function every app calls

// packages/sms/src/send.ts  —  the ONLY place Twilio is imported.
import twilio from 'twilio'

export async function sendSms({ clientId, phone, body, app, kind }: SendArgs) {
  // 1. E.164 or reject. No exceptions — bad formats fail silently at carriers.
  const to = toE164(phone)
  if (!to) throw new SmsError('INVALID_PHONE', phone)

  // 2. CONSENT GATE — the whole reason this is centralized.
  //    Transactional msgs tied to a user action may bypass marketing consent,
  //    but NEVER bypass an explicit opt_out. STOP means stop. Always.
  const consent = await getConsent(clientId, to)
  if (consent?.status === 'opted_out') throw new SmsError('OPTED_OUT', to)
  if (kind === 'marketing' && consent?.status !== 'opted_in')
    throw new SmsError('NO_MARKETING_CONSENT', to)

  // 3. Quiet hours — TCPA: no marketing before 8am / after 9pm LOCAL to the recipient.
  if (kind === 'marketing' && isQuietHours(to)) return queueForMorning(...)

  // 4. Append opt-out language to marketing. Carriers check for this.
  const text = kind === 'marketing' ? `${body}\n\nReply STOP to opt out.` : body

  // 5. Count segments BEFORE sending — this is your cost + the emoji trap (§4).
  const { segments, encoding } = countSegments(text)
  if (encoding === 'ucs2') warn('UCS-2: 70 chars/segment — 2.3x cost', { app, text })

  // 6. Send via the CLIENT'S messaging service (not a raw number) — §3.
  const { messaging_service_sid, twilio_subaccount_sid } = await getClient(clientId)
  const client = twilio(process.env.TWILIO_API_KEY_SID,      // SK… key, NOT the auth token
                        process.env.TWILIO_API_KEY_SECRET,
                        { accountSid: twilio_subaccount_sid })

  const msg = await client.messages.create({
    to, body: text,
    messagingServiceSid: messaging_service_sid,
    statusCallback: `${process.env.SMS_WEBHOOK_BASE}/api/sms/status`,
  })

  await logMessage({ clientId, app, phone: to, body: text, segments, encoding,
                    twilio_sid: msg.sid, direction: 'outbound' })
  return msg.sid
}

Inbound webhook — STOP handling is non-negotiable

// apps/replyee/app/api/sms/inbound/route.ts
export async function POST(req: Request) {
  // ALWAYS validate the signature. Without this, anyone can forge an opt-in
  // or inject messages into a client's inbox.
  if (!validateTwilioSignature(req)) return new Response('forbidden', { status: 403 })

  const { From, Body, To } = await parseForm(req)
  const clientId = await clientIdForNumber(To)
  const word = Body.trim().toUpperCase()

  // Twilio auto-handles STOP/HELP by default, but you MUST mirror it into your
  // own ledger — otherwise your apps keep queueing sends Twilio silently drops,
  // and your "delivered" numbers lie to you.
  if (['STOP','STOPALL','UNSUBSCRIBE','CANCEL','END','QUIT'].includes(word)) {
    await setConsent(clientId, From, 'opted_out')   // suppresses ALL apps, instantly
    return twiml()  // empty — Twilio sends the confirmation itself
  }
  if (['START','YES','UNSTOP'].includes(word)) {
    await setConsent(clientId, From, 'opted_in', { source: 'keyword', consent_text: word })
    return twiml()
  }

  // Real message → Replyee thread. AI drafts; human can take over.
  const thread = await upsertThread({ clientId, channel: 'sms', contact: From })
  await appendInbound(thread.id, Body)
  await notifyAgents(thread.id)

  if (await aiAutoReplyEnabled(clientId)) {
    const answer = await ragAnswer(clientId, Body)   // ← existing Replyee engine
    if (answer.confidence > 0.7) return twiml(answer.text)
  }
  return twiml()  // silence → a human answers in the inbox
}

2·5How it reaches the client's dashboard — the Next.js request path

The most common question: "is SMS an API through the SaaS app?" Yes — every text in and out goes through an API route inside the SaaS app. There is no direct browser-to-Twilio call and no separate SMS server. The dashboard is just React pages in the same Next.js app talking to its own API routes.

One app, three layers — the browser never touches Twilio

Each SaaS app (Replyee, Dashee, Localey…) is a Next.js App Router project. Its Route Handlers (app/api/sms/*/route.ts) are the API. The client's dashboard is React inside that same app; when it needs to send or read a text, it calls one of those routes. Twilio credentials live only on the server (Doppler/env) — a leaked browser bundle can never send a text or read a thread.

So "SMS in the dashboard" is not a new integration surface — it's the same @boom/sms rail from §02, fronted by Next.js API routes and a few React panels, scoped per-tenant by the logged-in session.

The request path, traced both ways

Outbound — staff clicks Send in the dashboard:

Dashboard UIReact page · /dashboard/messages
fetch POST/api/sms/send
Route Handlerauth session · scope client_id
@boom/smsconsent gate · segments · send
Twilio → phone561-xxx-xxxx

Inbound — customer replies:

Customer → Twilioreply or STOP
Webhook POST/api/sms/inbound · signature-verified
Supabasewrite to sms_messages
Supabase Realtimepushes to open dashboard
Dashboard updates liveno refresh, no polling

The three layers

Layer 1 · Browser

Presentation — React in the SaaS app

Inbox, composer, consent panel — pages/components in the Next.js app, shown as a route (Replyee /inbox, Dashee /messages).

Holds no secrets. Only ever calls your own /api/* routes. Can be embedded into the client's own site via iframe/subdomain.
Layer 2 · Server

API — Next.js Route Handlers

app/api/sms/send, /inbound, /status, /numbers, /consent. This is "the API through the SaaS app."

Auth every call against the Supabase session, resolve client_id from the session (never from the request body), enforce RLS. Twilio keys live here only.
Layer 3 · Shared

Rail — @boom/sms

The one package the API routes call. Consent gate → segment count → Twilio send → log.

The only place the Twilio SDK is imported across the whole suite. Every app's dashboard funnels through it, so one STOP suppresses all.

The API route the dashboard calls — make "it's an API" concrete

// app/api/sms/send/route.ts — a Next.js Route Handler. Runs ONLY on the server.
// This is the "API through the SaaS app": the dashboard POSTs here, never to Twilio.
import { sendSms } from '@boom/sms'

export async function POST(req: Request) {
  const session = await getSession(req)          // Supabase auth — who is logged in?
  if (!session) return unauthorized()
  const clientId = session.client_id            // tenant scope from the SESSION, not the body

  const { phone, body, app } = await req.json()
  const sid = await sendSms({ clientId, phone, body, app, kind: 'transactional' })

  return Response.json({ ok: true, sid })      // the browser only ever sees this JSON
}
// dashboard component (client) — no Twilio, no secrets, just a call to your own API.
await fetch('/api/sms/send', {
  method: 'POST',
  body: JSON.stringify({ phone, body }),   // client_id is NOT sent — the server derives it
})

Where the client actually sees it — standalone or embedded

Per the suite's model, apps are embedded into the client's own website, not sold as standalone logins. SMS follows the same two shapes:

Standalone dashboard route

The client logs into Dashee (their portal) or Replyee (the inbox), and SMS is a tab — /messages or /inbox. Simplest path; nothing to embed. This is how the pilot goes live.

Embedded into their site

A route/iframe from the SaaS app dropped into the client's existing site (WordPress/Next), authenticated with a scoped token, so "text your customers" lives inside the dashboard they already use. The data + API are still the SaaS app — the embed is just the window.
Live updates without polling. The dashboard subscribes to Supabase Realtime on sms_messages (scoped to its client_id). When the inbound webhook writes a new reply, it appears in the open inbox instantly — no refresh, no 5-second poll. Same mechanism Replyee already uses for web chat, which is why SMS becomes "just another channel" in that inbox.
Why it can never be client-side. Four things force the API to sit on the server: (1) the Twilio key can't ship to a browser; (2) the consent/STOP gate must be non-bypassable — a client-side send could skip it and trigger TCPA liability; (3) inbound webhook signature verification needs the server secret; (4) multi-tenant isolation is enforced by session + Supabase RLS at the API, never trusted from the browser. "SMS through the SaaS app's API" isn't a preference — it's the only compliant shape.

Next up — two dashboards to light up: Big Mike's Bail Bonds & Fern House

Both ride the exact path above: their dashboard (Dashee) → the app's /api/sms/* routes → @boom/sms → their own number under their own 10DLC brand, all sharing the one consent ledger.

High-risk vertical

Big Mike's Bail Bonds

Dashboard shows: defendant / co-signer list, court dates, check-in status, balance owed. SMS lives as a Messages tab in their Dashee portal; replies route to the Replyee inbox.

SMS use (transactional): court-date reminders, check-in confirmations, payment-due nudges, "call your agent." No opt-out line legally required on true transactional msgs, but still logged + STOP-honored.

10DLC: bail/legal/collections is a high-scrutiny category — register the brand early, keep sample messages neutral (no threatening/collections tone), and keep toll-free as the fallback if the brand is rejected. This is the ideal pilot precisely because it's the hardest case — if it clears, everything else will.
Sensitive vertical

Fern House (recovery / sober-living)

Dashboard shows: resident / alumni roster, appointments, group & meeting schedule, check-in status, events. SMS as a Messages / Reminders tab in their Dashee portal.

SMS use (mostly transactional care-coordination): appointment & group/meeting reminders, daily check-ins, event notices, alumni engagement.

⚠ Health-adjacent — handle with care: messages must never disclose treatment status or anything revealing to someone glancing at the phone. Require explicit written opt-in, plain non-clinical language, and no PHI in the body. If Fern House is a HIPAA-covered entity or we touch PHI, a BAA + tighter controls apply — flag and confirm before go-live. 10DLC: describe the use case as "appointment reminders / care coordination," which registers cleanly with a solid opt-in.
What's identical for both: each gets its own local number under its own 10DLC brand (never shared — §03), both write to the one shared sms_consent ledger so a STOP is honored suite-wide, and both surface through the same Next.js /api/sms/* routes in their Dashee dashboard. The only per-client work is the 10DLC registration and their opt-in form — the rail, the API, and the dashboard panels are already shared.

03The 10DLC reality — read this before anything else

This is not a technical problem. It's a registration problem, and it's the thing that will actually slow you down.

You cannot just start texting. Sending application-to-person (A2P) messages to US numbers from a 10-digit local number like your +1 561-782-8290 requires registering a Brand and a Campaign with The Campaign Registry (TCR). Unregistered A2P traffic gets filtered or blocked by carriers — often silently, with error 30007. Your messages just don't arrive, and nobody tells the customer.

You are an ISV, not a direct customer

Because you send on behalf of your clients' businesses, Twilio classifies you as an ISV / reseller. That changes the model:

You're already halfway there. Your console shows two accounts — Boom Media and a bail-bonds client — so you've already started isolating clients. Check whether that client account is a true subaccount under Boom Media or a separate standalone account. Subaccounts are what you want: consolidated billing, one API key, and per-client compliance isolation. Separate accounts mean separate bills and separate everything.
⚠ Bail bonds is a high-risk vertical. TCR and carriers scrutinize (and frequently reject) campaigns for bail, lending, debt collection, and payday-adjacent businesses. a bail-bonds client is your existing Twilio client and your Dashee flagship — do not assume its campaign will sail through. Register it early as the pilot precisely because it's the hardest case: if it passes, everything else will. Budget for a rejection + resubmit cycle, and consider toll-free as its fallback.

Also note the SHAFT rules (Sex, Hate, Alcohol, Firearms, Tobacco): restaurant clients texting drink promos can trip alcohol filtering. Keep BOO promo texts food-led.

What each client must give you (the real onboarding cost)

The $4.50 fee is trivial. This list is the actual friction — it's a form your client has to fill out, and it's why self-serve onboarding (Phase 4) matters:

Business identity

Legal business name (exactly as on the EIN letter — mismatches are the #1 rejection cause) · EIN/Tax ID · business type · address · website · industry · a contact name + email + phone

Campaign details

Use-case type · description of what you'll text · 2+ sample messages (must match what you actually send) · opt-in description · proof of opt-in (a screenshot/URL of the form showing the consent checkbox) · opt-out instructions

Approval typically takes 1–3 business days (sometimes longer with vetting). Plan for it in your client onboarding sequence — don't sell "live today."

Local long code vs toll-free — pick per client

Local 10DLC (561…)Toll-Free
RegistrationTCR Brand + CampaignToll-free verification form
One-time cost~$19.50 (brand + campaign vetting)$0
Monthly$1.15 number + $1.50–$10 campaign$2.15 number only
Approval time1–3 days~3–5 days
ThroughputTrust-score gated (2k/day T-Mobile at low-volume)High out of the gate
Looks localYes — 561 area codeNo — reads as a business line
Best forLocal businesses texting local customers (your whole book)High-volume, or a fallback when a brand gets rejected

Recommendation: local 10DLC as the default — a West Palm customer answers a 561 number and ignores an 800 number, and that conversion difference dwarfs the ~$5/mo delta. Keep toll-free in your pocket for rejected brands and any high-volume client.

Do you buy a number for each client, or do they share yours?

Each client gets their OWN number. Never share.

Your +1 561-782-8290 is Boom Media's number — use it for Boom's own texts and as the pilot/test sender. Every client business gets a separate number you buy for them (~$1.15/mo each), registered under their 10DLC brand. This isn't a preference — three hard reasons force it:

1. It's illegal to mix brands on one number. 10DLC ties a number to one registered brand/campaign. Texting for Big Mike's and Healing Arts off the same number violates the registration. 2. Consent is per-number. A customer who opted into Big Mike's did not opt into Healing Arts — shared numbers make every send a consent violation. 3. It looks like spam (snowshoeing). One number blasting for many businesses is the exact pattern carriers filter. Your delivery would collapse.

What "buying a number" actually is

In Twilio: Phone Numbers → Buy a number → filter by area code (561) + SMS capability → buy (~$1.15/mo, instant). Or via API — which is what the build does for you (below). The number is yours immediately, but can't deliver A2P traffic until its 10DLC brand+campaign are approved. Buying ≠ ready to send.

Built for you — one-click provisioning

The build includes POST /api/sms/numbers: it searches available 561 numbers, buys the one you pick, points its inbound webhook at the app, and writes it to that client's sms_clients row — all in one call. So onboarding a client is "pick a number → click buy," not a Twilio-console safari. Registration is still the gated step.
Should the client's number match their real business line? No — and it shouldn't. A2P texting numbers must be separate from a voice line the business already publishes (Twilio can't text-enable a number it doesn't host, and you don't want two-way texts hitting their landline). Buy a new local number in their area code. If they want texts to appear to come from their known number, that's "hosted SMS / number porting" — more setup, rarely worth it; a fresh local number is what everyone does.
Reselling / billing the number: the $1.15/mo is a Twilio cost to you, folded into the client's plan price (the $29/$59/$99 tiers already absorb it). You do not resell Twilio numbers as a line item — you sell "SMS on your Boom plan" and the number is an implementation detail. One client, one number, one brand, billed as one add-on.

04What it actually costs

Twilio list pricing, verified 2026-07-17. Carrier fees are pass-through and are the line everyone forgets.

$0.0083
Twilio / SMS segment
+$0.003
Carrier fee (typical)
≈$0.015
Plan at this, all-in
Line itemCostCadenceNotes
SMS outbound$0.0083per segmentSame for long code & toll-free
SMS inbound$0.0083per segmentYou pay to receive replies too
Carrier fees$0.0025–$0.007per messagePass-through, on top. Varies by carrier (AT&T/T-Mo/VZ)
MMS outbound$0.022per message+carrier ≈ $0.03. Images cost ~2.5x — use sparingly
Local number$1.15/monthPer client
Toll-free number$2.15/monthPer client
Brand — Low-Volume Standard$4.50one-timePer client. ≤2,000 segs/day T-Mobile, up to 5 campaigns
Brand — Standard$46one-timeIncludes secondary vetting → higher throughput. Only if a client needs volume
Campaign vetting$15one-timePer campaign, charged at vetting
Campaign monthly$1.50–$10/monthLow-volume mixed $1.50 · marketing ≈$10 — use-case dependent

Per-client one-time

≈ $19.50
Low-Volume Standard brand ($4.50) + campaign vetting ($15). Charge a $25 setup fee and it pays for itself.

Per-client recurring (before usage)

$3 – $11 /mo
Number ($1.15) + campaign ($1.50 mixed → $10 marketing). Pick the mixed use case where honest — it's a 6x difference.
⚠ The emoji trap — this silently doubles your cost. A segment is 160 characters in GSM-7. Add a single emoji, a curly apostrophe (' from Word/Google Docs), an em-dash, or a “smart quote,” and the whole message switches to UCS-2 — 70 characters per segment. A 150-character message costs 1 segment; paste in one 🎉 and it costs 3. That's a 2.3x cost increase from an invisible character.

Mitigations, all in the rail: normalize curly→straight quotes on the way in · count segments before send and surface it in the composer UI · warn on UCS-2 · block emoji in bulk campaign templates by default. Multi-part GSM-7 messages are 153 chars/segment (not 160) because 7 bytes go to the concatenation header — count properly, don't estimate.

Worked examples (what a real client costs you)

Client profileTexts/moUsageFixedYour costYou chargeMargin
Small — appointment reminders only250$3.75$2.65$6.40$2978%
Typical — reminders + review requests500$7.50$3.15$10.65$4978%
Active — + loyalty offers (marketing campaign fee)1,000$15.00$11.15$26.15$5956%
Heavy — restaurant, two-way + promos2,500$37.50$11.15$48.65$9951%

Usage @ $0.015/segment all-in, assuming 1 segment/message. Fixed = number + campaign fee. Inbound replies count — a two-way client's real volume runs ~30% above outbound-only, which is why the heavy tier's margin is the one to watch.

Portfolio math. 10 clients at the "typical" profile ≈ $107/mo Twilio cost$490/mo revenue$383/mo net. 25 clients ≈ $960/mo net. Against your ~$150–260/mo total infra run cost, SMS is the highest-margin add-on in the suite — it needs no new servers, no new droplet, and rides entirely on existing Supabase + Vercel.

05Sample messages

Every template stays under 160 GSM-7 chars where possible. Marketing needs opt-out language; transactional doesn't.

Localey — review request

Big Mike's · +1 561-782-8290
Hi Sarah — thanks for visiting Big Mike's today! Mind leaving us a quick review? boom.link/r/x7k2 148 chars · 1 segment · $0.015
Done! Food was great
That means a lot — see you soon! 🙏 ⚠ emoji → UCS-2 → 1 seg (fine, short) but avoid in templates

FTC fence: never branch on predicted rating. No "how did we do? if 4-5★ → Google, else → private form." That's review-gating and it's exactly the practice Localey's guardrail exists to prevent.

Rewardee — loyalty offer (marketing)

Big Mike's Rewards
You've got 240 pts — that's a free entrée! Redeem by Sunday: boom.link/p/9f3

Reply STOP to opt out. 108 chars · 1 segment · needs opted_in + $10/mo marketing campaign
STOP
You have been unsubscribed and will receive no further messages. ↳ auto-sent by Twilio · ledger updated · ALL apps now suppressed for this number

That STOP now blocks Localey and Dashee from texting this number too. That's the whole point of the shared ledger.

Dashee — appointment / court-date reminder

a bail-bonds client
Reminder: court date Tue Jul 21, 9:00am, Palm Beach County Courthouse, Rm 302. Questions? Reply here. Transactional — no opt-out line required · high-risk vertical, see §3
Can I reschedule?
Let me check with your agent — one moment. ↳ AI low confidence → routed to human in Replyee inbox

Replyee — AI auto-reply after hours

Healing Arts Wellness · 9:42pm
are you open sunday?
We're open Sun 10am–4pm! Book here: boom.link/b/ha2 ↳ RAG answer, confidence 0.91 → auto-sent, no human needed
whats a deep tissue cost
Deep tissue is $95/60min or $135/90min. Want me to book you? ↳ from knowledge base · this is the upsell that justifies the price

This is the demo that sells the product. Not "blast texts" — "your phone answers itself at 10pm."

The opt-in form — you need this before you can register

TCR requires proof of opt-in. This exact form (screenshot + live URL) is what you submit. It's also what saves you in a TCPA dispute, so log the snapshot.

<!-- Required elements. Every one of these is load-bearing. -->
<label>
  <input type="checkbox" name="sms_consent">   ← UNCHECKED by default. Pre-checked = invalid consent.
  I agree to receive text messages from Big Mike's at the number provided.
  Msg & data rates may apply. Msg frequency varies.
  Reply STOP to cancel, HELP for help.
  <a href="/privacy">Privacy Policy</a> · <a href="/terms">Terms</a>
</label>

06What to charge

Matches your existing $29/$59/$99 ladder. Sold as a Replyee add-on, not a separate product.

SMS Starter
$29/mo
  • 250 texts included
  • 1 local number
  • Two-way inbox
  • Consent + STOP handling
  • $0.05/text overage
  • No AI auto-reply
≈78% margin
SMS Growth
$59/mo
  • 1,000 texts included
  • 1 local number
  • Two-way inbox
  • AI auto-reply (RAG)
  • Campaign sends (Localey / Rewardee)
  • $0.04/text overage
≈56% margin
SMS Pro
$99/mo
  • 2,500 texts included
  • Multiple numbers
  • Everything in Growth
  • MMS
  • Priority throughput (Standard brand)
  • $0.03/text overage
≈51% margin
+ $25 one-time "SMS registration & setup" fee on every plan. It covers the ~$19.50 TCR pass-through, and — more importantly — it makes the 1–3 day approval wait feel like a service you're performing rather than a delay you're causing. Frame it as "we handle carrier registration for you," because you genuinely are.

Why these numbers

6·5Billing, metering & ROI — who tracks what

"How do I bill them, track ROI, and which SaaS is tracking all of this?" — three different jobs, three owners, zero overlap. The tiers in §06 are the prices; this section is who meters, charges, and reports them.

Three jobs, three owners

The meter is shared infrastructure (the sms_messages table). The biller is Replyee (it owns the SMS product + Stripe). The ROI dashboard is Dashee (what the client sees). Your margin is an internal reconciliation view. Same pattern as everything else in the suite: shared plumbing, product-owner charges, dashboard app reports.

JobOwnerData sourceAnswers the question
Metering — counting usageShared infrasms_messagesHow many segments went out, what did they cost me?
Billing — charging the clientReplyee + Stripemeter rollup → StripeWhat do I invoice this client this month?
Cost / margin — internalBoom internal viewcost_usd vs StripeAm I making money on this client?
ROI reporting — client-facingDasheesms_messages + app outcomesIs the client making money? (the renewal argument)

1 · The meter — sms_messages is the single source of truth

Every message already logs everything billing needs (§02 schema): segments, encoding, cost_usd, status, app, client_id. You bill off this table — never off Twilio's console or memory. You control it, it's per-client and per-app, and you can show it to the client in a dispute. A monthly rollup is one query:

-- Usage per client for the current billing period → drives Stripe + Dashee.
select client_id,
       count(*)          as messages,
       sum(segments)     as segments,       -- what you actually bill on
       sum(cost_usd)     as twilio_cost,    -- what Twilio charged YOU
       count(*) filter (where status = 'delivered') as delivered,
       count(*) filter (where direction = 'inbound')  as replies
from sms_messages
where created_at >= date_trunc('month', now())
group by client_id;

2 · The biller — Replyee owns the Stripe side

SMS is sold as a Replyee add-on (§06), so the money lives in Replyee — which already has Stripe wired. Keeping it in one place is deliberate: if Localey, Rewardee, and Dashee each billed their own SMS, a client with three of them would be charged three times for one rail.

Meter rollupsum(segments) this period
Over allowance?segments − included
Stripe usage recordreported per client
Client invoicebase + overage + (setup once)

3 · Your cost & margin — the internal reconciliation view

The cost_usd column is what Twilio charged you, per message. Monthly, per client: Stripe revenue − summed cost_usd − fixed (number $1.15 + campaign $1.50–$10) = net margin. That's the §04 worked-examples table, made live. Two things it catches that Twilio's dashboard won't:

4 · The ROI dashboard — Dashee shows the client the value

Dashee is the client-facing dashboard (per boundaries), so ROI reporting lives there — reading the same shared tables, joined to the app that owns each outcome. This is the renewal argument: not "you sent 800 texts," but "those texts produced X."

What Dashee shows the clientWhere it comes from
Texts sent · delivery rate · replies · opt-out ratesms_messages (the meter)
Reviews collected from review-request textsjoin to Localey (lc_review_requests)
Offers redeemed from loyalty textsjoin to Rewardee
Appointments confirmed / no-shows avoidedDashee reminder → confirmation reply
Orders / pickups from status textsjoin to B.O.O.
ROI story

Big Mike's Bail Bonds

Court-date reminders sent → % that reply "confirmed" → no-show / FTA reduction. A single avoided failure-to-appear (bond forfeiture) pays for the plan for a year — that's the number that renews them.

ROI story

Fern House

Appointment & meeting reminders sent → check-in / attendance completion rate → fewer missed sessions. Framed as care-coordination outcomes, never anything that discloses treatment status (§2·5).

Net: one meter (shared), one biller (Replyee + Stripe), one client-facing report (Dashee), one internal margin view. No app double-bills, and the same sms_messages rows feed all four — you meter once and everything else is a read.

07Implementation guide — standing up the shared rail

The numbered path from zero to a live send, ordered so the compliance backbone exists before the first text goes out. Do not reorder. Estimates are engineering effort; the real clock is the 1–3 day 10DLC approval in step 2.

This is the strategy-level path. For the click-by-click operational runbook — every Twilio console action, the exact env-var block, the SQL migration, webhook URLs, and a check-off progress tracker — follow the companion SMS Setup Runbook. This section is the map; the runbook is the turn-by-turn. They stay in sync: the steps below are the same phases, grouped for the plan.
1
~1 hr

Twilio account — rotate the token & secure it in Doppler

Before any code. Rotate the leaked Auth Token (see the red banner at the top), then create a scoped API key (SK… + secret) so a future leak revokes one app, not the account. Store TWILIO_API_KEY_SID / TWILIO_API_KEY_SECRET / the new auth token (webhook validation only) in Doppler — never in .env.local or code. Confirm whether a bail-bonds client is a true subaccount (you want one subaccount per client).

2
~1 day + 1–3 day wait

Register the 10DLC brand + campaign(s)

Set Boom Media's identity to ISV Reseller or Partner, then for the pilot client create a Secondary Customer Profile → Brand (Low-Volume Standard) → Campaign, and provision their own local number. Pilot on a bail-bonds client — it's the high-scrutiny vertical, so if it clears, everything else will. Start this early; the carrier approval is the real bottleneck. Budget one reject+resubmit and keep toll-free as the fallback.

3
~2 days

Build & publish @boom/sms — the rail + consent ledger + opt-out

The three sms_* tables (migration onto shared Supabase) · sendSms() with the non-bypassable consent gate · segment counter (GSM-7/UCS-2, 153-char multipart) · inbound webhook with signature validation + STOP/HELP/START handling that writes the shared ledger · status callbacks. Twilio is imported only here. Nothing else ships until this does — the ledger and opt-out path are the whole safety model.

4
~3 days

Wire each app to call the rail

Every sender calls the same sendSms() — no app gets its own Twilio integration or consent list. The anchor build is the Replyee SMS channel: add channel: 'sms' to the thread model, land SMS threads in the agent inbox beside web chat, point the RAG engine at inbound texts with a confidence gate, add the number-provisioning + consent-dashboard UI. This is the sellable product ("your texts answer themselves at 10pm"). Resolve the cross-app client_id mapping here so STOP suppresses everywhere.

5
~2 days

Migrate the already-built SMS onto the rail

Not greenfield — finish the wiring. Localey: lc_review_requests already has the channel in ('email','sms') column and the dispatch cron already branches on it (currently continue-skipped with "SMS deferred") — swap that skip for a sendSms() call. B.O.O.: api/portal/marketing/sms/route.ts already queues campaigns but never sends ("sends for real once a number is connected") — point it at the rail. Add the opt-in form, and replies route to the Replyee inbox. Highest ROI, least new code — this is where the first real texts go out.

6
~3 days

Client resale & billing tiers

Remaining senders (Rewardee offers · Dashee reminders · Signnee nudges) + Stripe products for the $29 / $59 / $99 tiers (§06) with metered overage and a per-app usage meter, plus the $25 one-time setup fee that covers TCR pass-through. Then the self-serve 10DLC registration form (EIN / address / samples / opt-in proof → TrustHub API) — only worth building at ~5+ SMS clients; register manually until then.

7
~1 day

Test + go-live checklist

Before a single real customer: opt your own number in → send a test → confirm it arrives and logs to sms_messages. Then the STOP test — the one that matters: text STOP, confirm it flips you to opted_out and the next send is blocked across apps. Verify segment counts render in the composer, quiet-hours queueing works, inbound webhook signature passes (exact URL match), and delivered-vs-sent is tracked (watch for silent 30007 filtering). Only after STOP suppresses reliably do you turn on the pilot client's campaign.

Open questions for you:
  1. Is a bail-bonds client a true subaccount under Boom Media, or a standalone account? (Decides whether we consolidate or migrate.)
  2. Has any 10DLC brand/campaign registration already been submitted on either account? If that client account is already texting, it may already be registered — that changes Phase 2 entirely.
  3. Doppler first? Recommend wiring Doppler before this build so the Twilio key never lands in a .env.local — you'd be adding a fourth secret to the pile that's already leaked three.
  4. Which client is the pilot if not that client account? A restaurant (BOO, high volume, SHAFT risk) and a wellness client (Healing Arts, clean vertical) are the other natural candidates.

08Risks, honestly

🔴 TCPA is the real exposure

$500–$1,500 per message, and it's a favorite of serial plaintiffs. One client importing a purchased list could generate five figures of liability. Mitigation: the consent gate is not bypassable in code (no "force" flag, ever) · consent proof stored per record · contractually push list-quality responsibility onto the client in the add-on terms.

🔴 One bad client can suspend everyone

Shared-account spam → Twilio suspends the primary account → every client's messaging dies at once. Mitigation: subaccount per client from day one (Type #1). This is why Phase 1 isn't optional.

🟡 Silent carrier filtering

Error 30007 — message accepted, billed, never delivered, no bounce. Looks like success. Mitigation: track delivered vs sent per client, alert on a delivery-rate drop, surface it in the dashboard. Don't trust "sent."

🟡 Support load is real

A two-way inbox means customers expect replies. If a client doesn't staff it, they'll blame the product. Mitigation: AI auto-reply at Growth+ · set response-time expectations in the auto-reply · after-hours message.

🟡 Registration rejections

Bail bonds especially. A rejected brand = a client who paid setup and can't send. Mitigation: register the pilot before selling · toll-free fallback · don't take payment until the brand is approved.

🟢 Cost overrun is bounded

Worst case is the emoji/UCS-2 trap at ~2.3x, on a base of pennies. Even a 3x blowout on the heavy tier keeps margin positive. This is the least of your problems — the compliance items above are what matter.

09Bottom line

Recommendation

Build it — SMS is the highest-margin add-on available to you (~$383/mo net at 10 clients, ~$960 at 25) and it needs no new infrastructure. But build it as @boom/sms + a Replyee channel, not as a 15th app.

The work is ~14 days across 6 phases. The hard part isn't the code — it's the 10DLC registration per client, and that's a process problem you should pilot on a bail-bonds client now, before you sell a single SMS plan.

Do these two things before writing any code: rotate the leaked auth token, and wire Doppler so the replacement doesn't leak too.