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.
Checked against SaaS/.md/APP_BOUNDARIES.md and the one-app-per-job rule.
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:
"Your order is ready." "Appointment tomorrow at 2." "Your document is ready to sign."
@boom/sms package. Each app sends its own.Customer texts back. A human (or the AI) replies from a shared inbox. Threads, assignment, takeover.
Blast to a list. Review requests, loyalty offers, promos, reminders.
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.
Replyee/.md/SALESIQ_UPGRADE_PLAN.md is heading toward multi-channel. SMS is the natural second channel; the inbox, agent presence, and canned replies are sunk cost.Every app below sends through the one rail. None of them gets its own inbox, its own Twilio integration, or its own consent list.
| App | Its SMS use case (allowed) | Must NOT do |
|---|---|---|
| Replyee | Owns the channel. Two-way inbox, AI auto-reply, human takeover, number provisioning UI, consent dashboard, STOP/HELP handling | Send review requests or loyalty offers — it hosts the thread, it doesn't own those campaigns |
| Localey | Review-request texts + follow-ups (it already owns review-request campaigns) | Any inbox. Replies land in Replyee. Never gate on rating (FTC) |
| Rewardee | Points balance, reward-earned, offer/win-back texts, gift-card delivery | Ask for a public review — that's Localey. Staff recognition stays internal |
| Dashee | Portal notifications per vertical — appointment/court-date reminders, invoice due, job status | Marketing blasts |
| Signnee | "Document ready to sign" + signature reminders w/ magic link | Anything not tied to a signature request |
| Aprovee | "Approval needed" nudges to the approver | Client-facing marketing |
| B.O.O. | Order status / ready-for-pickup (parent product, same rail) | — |
| Displayee | Screen-offline alert to the owner (internal ops) | Customer-facing anything |
| Compliee | None. Email digest is sufficient | — |
| Addee · Bloggy · Rankee · Posttee · QRcodee · Assistee | None. No customer-messaging job | Posttee especially: SMS is not a social channel. Don't let it creep in |
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).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.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.lc_customers.phone), Rewardee (rewardee_members.phone), Dashee, Displayee, and BOO (customers.phone). The data SMS needs is sitting there.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_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.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
sendSms() ▼@boom/sms the shared rail — the ONLY place Twilio is importedsms_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.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.
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
);
// 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
}
// 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
}
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.
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.
Outbound — staff clicks Send in the dashboard:
Inbound — customer replies:
Inbox, composer, consent panel — pages/components in the Next.js app, shown as a route (Replyee /inbox, Dashee /messages).
/api/* routes. Can be embedded into the client's own site via iframe/subdomain.app/api/sms/send, /inbound, /status, /numbers, /consent. This is "the API through the SaaS app."
client_id from the session (never from the request body), enforce RLS. Twilio keys live here only.@boom/smsThe one package the API routes call. Consent gate → segment count → Twilio send → log.
// 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
})
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:
/messages or /inbox. Simplest path; nothing to embed. This is how the pilot goes live.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.
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.
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.
Dashboard shows: resident / alumni roster, appointments, group & meeting schedule, check-in status, events. SMS as a Messages / Reminders tab in their Dashee portal.
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.
This is not a technical problem. It's a registration problem, and it's the thing that will actually slow you down.
30007. Your messages just don't arrive, and nobody tells the customer.
Because you send on behalf of your clients' businesses, Twilio classifies you as an ISV / reseller. That changes the model:
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:
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 10DLC (561…) | Toll-Free | |
|---|---|---|
| Registration | TCR Brand + Campaign | Toll-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 time | 1–3 days | ~3–5 days |
| Throughput | Trust-score gated (2k/day T-Mobile at low-volume) | High out of the gate |
| Looks local | Yes — 561 area code | No — reads as a business line |
| Best for | Local 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.
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.
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.Twilio list pricing, verified 2026-07-17. Carrier fees are pass-through and are the line everyone forgets.
| Line item | Cost | Cadence | Notes |
|---|---|---|---|
| SMS outbound | $0.0083 | per segment | Same for long code & toll-free |
| SMS inbound | $0.0083 | per segment | You pay to receive replies too |
| Carrier fees | $0.0025–$0.007 | per message | Pass-through, on top. Varies by carrier (AT&T/T-Mo/VZ) |
| MMS outbound | $0.022 | per message | +carrier ≈ $0.03. Images cost ~2.5x — use sparingly |
| Local number | $1.15 | /month | Per client |
| Toll-free number | $2.15 | /month | Per client |
| Brand — Low-Volume Standard | $4.50 | one-time | Per client. ≤2,000 segs/day T-Mobile, up to 5 campaigns |
| Brand — Standard | $46 | one-time | Includes secondary vetting → higher throughput. Only if a client needs volume |
| Campaign vetting | $15 | one-time | Per campaign, charged at vetting |
| Campaign monthly | $1.50–$10 | /month | Low-volume mixed $1.50 · marketing ≈$10 — use-case dependent |
' 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.| Client profile | Texts/mo | Usage | Fixed | Your cost | You charge | Margin |
|---|---|---|---|---|---|---|
| Small — appointment reminders only | 250 | $3.75 | $2.65 | $6.40 | $29 | 78% |
| Typical — reminders + review requests | 500 | $7.50 | $3.15 | $10.65 | $49 | 78% |
| Active — + loyalty offers (marketing campaign fee) | 1,000 | $15.00 | $11.15 | $26.15 | $59 | 56% |
| Heavy — restaurant, two-way + promos | 2,500 | $37.50 | $11.15 | $48.65 | $99 | 51% |
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.
Every template stays under 160 GSM-7 chars where possible. Marketing needs opt-out language; transactional doesn't.
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.
That STOP now blocks Localey and Dashee from texting this number too. That's the whole point of the shared ledger.
This is the demo that sells the product. Not "blast texts" — "your phone answers itself at 10pm."
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>
sms_consent.consent_text — if you change the form later, old records must keep the old text. That's why it's a column, not a config lookup.Matches your existing $29/$59/$99 ladder. Sold as a Replyee add-on, not a separate product.
"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.
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.
| Job | Owner | Data source | Answers the question |
|---|---|---|---|
| Metering — counting usage | Shared infra | sms_messages | How many segments went out, what did they cost me? |
| Billing — charging the client | Replyee + Stripe | meter rollup → Stripe | What do I invoice this client this month? |
| Cost / margin — internal | Boom internal view | cost_usd vs Stripe | Am I making money on this client? |
| ROI reporting — client-facing | Dashee | sms_messages + app outcomes | Is the client making money? (the renewal argument) |
sms_messages is the single source of truthEvery 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;
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.
segments − allowance from the rollup and report it to Stripe as a usage record; Stripe adds it to the invoice at your overage rate ($0.03–0.05).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:
cost_usd vs the actual Twilio invoice each month — a gap means the emoji/UCS-2 trap (§04) is silently inflating segments, or a template regressed to UCS-2.30007 carrier filtering: you're paying to send texts that never arrive. Surface it before the client notices.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 client | Where it comes from |
|---|---|
| Texts sent · delivery rate · replies · opt-out rate | sms_messages (the meter) |
| Reviews collected from review-request texts | join to Localey (lc_review_requests) |
| Offers redeemed from loyalty texts | join to Rewardee |
| Appointments confirmed / no-shows avoided | Dashee reminder → confirmation reply |
| Orders / pickups from status texts | join to B.O.O. |
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.
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).
sms_messages rows feed all four — you meter once and everything else is a read.
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.
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).
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.
@boom/sms — the rail + consent ledger + opt-outThe 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.
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.
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.
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.
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.
.env.local — you'd be adding a fourth secret to the pile that's already leaked three.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."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.