Transactional SMS · Do These In Order

SMS Reminders — What You Need To Do

Everything to get transactional texts live for a client like a bail bondsman — court-date, check-in and payment reminders. Steps marked YOU need your hands on the Twilio console; DONE is already coded; BUILDING is what I'm writing right now. Tap a step to check it off.

← Back to Labs
Progress
0 / 0

Your account — reference

Twilio Account SIDAC386411e7f0b20bb01989ca253da7f22b
Boom Media number+1 561-782-8290 (Boom's own / pilot test sender only)
Example verticalBail bonds — a high-scrutiny vertical (the right stress test); the same flow works for any client
Message typeTransactional (reminders) — needs consent + opt-out, but NOT prior opt-in like marketing
Shared databasedb.boommedia.us (self-hosted Supabase)
Send rail (built)sendSms({ kind:'transactional' }) — Localey/src/lib/sms.ts
Reminder feature homeDashee (service-industry client portal) — building now
The one thing to understand: a transactional reminder ("your court date is Tuesday") is legally lighter than a marketing blast. The rail's consent gate lets it through as long as the number hasn't texted STOP — no prior opt-in required. But you still must (1) have the person's consent to be contacted, (2) identify the business, and (3) honor STOP. That's what every phase below protects. Bail is a carrier-scrutinized vertical, so we register carefully and keep a toll-free fallback.
Start Here · Plain English

What this is & where to click

Read this before touching the console — it clears up 90% of the confusion.

The big picture (one paragraph)

In the US you can't just start texting customers from a business number. Carriers (Verizon / AT&T / T-Mobile) require every business to be registered first — a one-time anti-spam step called A2P 10DLC ("business texting on a normal 10-digit number"). Register a client once (~1–3 days to approve) and they can text forever. That registration is the only hard part; everything else is a few clicks.

The 3 words you'll see — bundled into ONE wizard

Three layers of "prove the business is real, then say what you'll text":

Then: buy a phone number → drop it into a Messaging Service (a "sender box" tied to the Campaign) → the number can text.

Good news: Twilio walks you through all three in one guided flow called "A2P 10DLC" / "US A2P registration." You fill one wizard, not three separate screens.

You're likely in the wrong part of the console

Settings → Organization is NOT it. "Organization settings / API access / SSO" is enterprise-login stuff — ignore it entirely. SMS lives in the main Develop area.
The "Create an OAuth Application" screen is also NOT it. That wizard (grant type · client credentials · scope, under Organization API access) is for managing your org through code — nothing to do with texting. Close it. The "scoped API Key" this runbook mentions is a different, simpler thing (Account → API keys & tokens) and is optional to start.

Fastest way to find anything: use the search bar at the very top of the console and type the word — it jumps you straight there:

A2Pthe guided registration (Profile → Brand → Campaign)
Messaging Servicescreate the sender + attach your number
Buy a numberget the phone number
Trust Hubyour business profiles (the ID-check layer)

Menu path: left sidebar </> (Develop)MessagingRegulatory ComplianceA2P 10DLC. (The gear icon you're on now is Settings — wrong spot.)

Don't overthink the "reseller / subaccount" stuff yet

The runbook mentions ISV/Reseller and one-subaccount-per-client — that's an optimization for when you have several SMS clients. For your first client, just run the A2P wizard once in your main account and get one number sending. You can reorganize into subaccounts later; it won't block the pilot.

Do First · Security

Lock down the account

Before anything else — your credentials were exposed in chat.

Rotate the exposed Auth TokenYou
⚠ Urgent. Your Auth Token was pasted into chat in plaintext (2026-07-17). Anyone with it + your SID can send texts billed to you until it's rotated.
  1. Twilio Console → Account → API keys & tokens → Auth Tokens
  2. Request a secondary token
  3. Update anywhere the old token is used, then Promote the secondary to primary — the old one dies

→ API keys & tokens

Create a scoped API Key (never put the Auth Token in code)You

Same page → Create API key → name it boom-sms → type Standard. Copy the SID (SK…) and Secret (shown once — save it now).

Why: a scoped key is revocable per-app; if it leaks you kill one key, not the whole account. The rail reads TWILIO_API_KEY_SID / TWILIO_API_KEY_SECRET for exactly this.
Decide: one subaccount per clientYou

Console → account switcher (top-left) → View subaccounts. For the reseller model, create/keep one subaccount per client (each client gets its own subaccount).

Why: if one client's traffic gets flagged for spam, only their subaccount is suspended — not every other client you text. The rail already reads a per-client twilio_subaccount_sid from the sms_clients table.
Twilio · 10DLC Registration

Register the client as a transactional sender

This is the real bottleneck — brand + campaign approval takes ~1–3 business days (longer if bail gets extra vetting). Start it today while the console is open.

First: whose account? (and the Trial trap)You
Twilio will block you with: "You cannot register for A2P Messaging in a trial account. Please upgrade your account to register." A trial account can't register A2P and can only text verified numbers. Two ways past it:
  • Easiest — register in your PAID account (Boom Media) as the reseller. You register the client as a business under Boom Media; you never touch the client's trial account. No upgrade of the client account needed.
  • Or upgrade the client's own account off Trial ("Trial · Upgrade" → add a card) and register there — more isolation, but the client account then carries its own bill.
Recommendation: for the first client, register under Boom Media (paid). Switch into the Boom Media account (top-left account chooser) before starting.
Best first move — register Boom Media's OWN brand first. Do the whole flow once on your own company (Primary Business Profile: Boom Media LLC, EIN 45-3697926, your WPB address, "Low-Volume Standard" brand, "Customer Care" transactional campaign). It's paid, you have every detail, and it teaches you the exact steps with zero client dependency. Then each client is the same flow with a Secondary Business Profile instead of your Primary one.
Brand BEFORE Campaign (why the Campaigns list is empty)You

In Trust Hub → Registrations: a Campaign attaches to a Brand, and a Brand is built from a Business Profile. So an empty "A2P Campaigns" screen is normal — you need a Brand first. Order:

  1. A2P BRANDS tab → register a Brand. It walks you through a Business Profile — for a client you resell for, create a Secondary Business Profile with the client's legal name, EIN, and address.
  2. A2P CAMPAIGNS tab → Create A2P Campaign → select that Brand → transactional use case + sample texts (next steps below).
Set your business identity to "ISV / Reseller"You

Console → Trust Hub → your Primary Business Profile → Business Identity → "ISV Reseller or Partner."

Why: you text on behalf of clients' businesses, not your own. This unlocks registering each client as a Secondary Customer Profile with their own EIN.
Register the client Brand (Secondary Customer Profile)You

Trust Hub → A2P 10DLC → create a Secondary Customer Profile for the client → then a Brand (Standard / Low-Volume Standard, ~$4.50 one-time).

You'll need from the client — get it exactly right:

  • Legal business name exactly as on the EIN letter (mismatch = the #1 rejection reason)
  • EIN / Tax ID, registered business address, website URL, industry
  • An authorized-rep name + email
Register the Campaign — use case "Account Notification / Customer Care"You

Under the Brand → create a Campaign (~$15 one-time vetting + a small monthly). Pick the transactional use case — "Account Notification" or "Customer Care", not Marketing.

Campaign description (paste something like this):

Appointment and court-date reminders, check-in reminders, and payment/document notifications sent to the bail bond agency's own clients who provided their mobile number and consent at intake. No promotional content.

Sample messages (must match what you'll really send — carriers compare):

[Your Business Name]: Reminder — court date for [Name] is [Date] at [Time], [Courthouse]. Questions? Call [your office number]. Reply STOP to opt out.
[Your Business Name]: [Name], your weekly check-in is due by [Date]. Reply HELP for help, STOP to opt out.
[Your Business Name]: A payment of $[Amount] is due [Date]. Reply STOP to opt out.
Opt-in description (the field Twilio always asks for): "End users provide their mobile number and check an SMS-consent box on the bail application / indemnitor agreement at intake, agreeing to receive reminders. Consent is not a condition of service." Every sample includes the business name + STOP because carriers scan for both.
Bail is high-scrutiny — plan for it. Keep it strictly transactional (no "refer a friend", no promos). Budget for one reject + resubmit. If the 10DLC brand is rejected for the vertical, fall back to a Toll-Free number with verification for that client — same rail, different sender.
Buy the client's number + Messaging ServiceDone · API

Each client gets their own local number (~$1.15/mo) under their own Messaging Service — never shared. Two ways:

  • Manual: Console → Messaging → Services → create one for the client → add a number (area code 561, SMS enabled). Attach the number to the Campaign.
  • Via the rail (already coded): GET /api/sms/numbers?areaCode=561 searches available numbers (admin-guarded by CRON_SECRET). Record the MG… service SID + number on the client's sms_clients row.
Buying ≠ live. A number can't deliver A2P texts until its Brand + Campaign are approved. Buy it now; it starts delivering the moment registration clears.
Database · Deploy

Put the rail live

The send rail is written. These steps turn it on.

Run the shared SMS migration on db.boommedia.usYou

Run Localey/supabase/002_sms_shared.sql once against the shared Supabase (Studio SQL editor). It creates sms_clients, sms_consent, sms_messagesno app prefix because they're ecosystem-shared.

Why shared: TCPA opt-out is per (business, phone), not per app. One consent ledger means a STOP sent to any Boom app suppresses them everywhere.
Insert the client's wiring rowYou

One row in sms_clients per client, so the rail knows which number/brand to send from:

insert into sms_clients (client_id, display_name, twilio_subaccount_sid,
  messaging_service_sid, phone_number, brand_sid, campaign_sid, registration_status)
values ('<the client's tenant id>', 'Your Client Business Name',
  'AC…', 'MG…', '+1561…', 'BN…', 'CM…', 'approved');
The client_id decision. It must be the SAME id for this business across every app (so STOP is honored everywhere). For the Dashee reminder feature I'm building, I'll map Dashee's tenant id → this client_id. You just need to use that same id here.
Set the Twilio env vars (Localey + Dashee)You

In each app's environment (Vercel project settings — or Doppler, which you already provisioned and should finally wire):

TWILIO_ACCOUNT_SID=AC386411e7f0b20bb01989ca253da7f22b
TWILIO_API_KEY_SID=SK...          # from Phase 1
TWILIO_API_KEY_SECRET=...         # from Phase 1
TWILIO_AUTH_TOKEN=...             # the NEW rotated token (inbound webhook signature only)
TWILIO_FROM_NUMBER=+15617828290   # pilot fallback sender
SMS_WEBHOOK_BASE=https://dashee.online   # (or localey.online) — must match the webhook you set below
CRON_SECRET=...                   # already set; guards the numbers/dispatch routes
Doppler moment: this is another secret set. Wiring Doppler now means these never live in a .env.local again.
Deploy + point the number's webhooks at the appYou

Deploy the app (Vercel, git push). Then on the client's Messaging Service → Integration:

  • Inbound (a message comes in){SMS_WEBHOOK_BASE}/api/sms/inbound (POST)
  • Status callback{SMS_WEBHOOK_BASE}/api/sms/status
Must match exactly. The inbound signature check hashes this exact URL — a trailing slash or http/https mismatch makes every inbound STOP fail the 403 check, which breaks compliance.
Go Live

Consent, then first send

Even transactional texts need consent on file and a working STOP. This is also the proof Twilio asked for.

Capture consent at intakeBuilding

I'm building this into Dashee now: a contacts + consent capture screen where the client records each customer's mobile number with an SMS-consent checkbox (source, exact wording, timestamp) written to sms_consent via the existing POST /api/sms/consent.

For bail bonds the natural capture point is the indemnitor agreement / application at intake — add the consent line there and record it in Dashee.

This doubles as your 10DLC proof. A screenshot of the consent checkbox + wording is exactly what the campaign registration's "opt-in" field wants.
Rules that keep you out of court: never pre-check the box · consent can't be required to get bailed out · log the exact wording · never bulk-import an old client list as "opted-in." Transactional reminders to people who gave you their number at intake are fine; blasting a purchased list is not.
The reminder sender (contacts → schedule/send)Building

Building now in Dashee: from a contact, the client picks a reminder type (court date / check-in / payment), fills the details, and sends now or schedules it. It calls the shared rail:

sendSms({ clientId, phone, body, app: 'dashee', kind: 'transactional' })

The rail handles E.164, STOP suppression, per-client sender routing, segment/cost counting, and the audit log automatically.

Send a test to your own phone — then test STOPYou

Opt your own number in, send yourself a reminder. Confirm it arrives, the row lands in sms_messages, then text STOP and confirm you flip to opted_out and the next send is blocked.

The STOP test is the one that matters. If STOP suppresses the next send, your compliance backbone works. If it doesn't, fix it before a single real customer is texted.
Send the first real reminders for the pilot clientYou

With the campaign approved, consent captured, and the STOP test green — turn it on for your client and send real court-date/check-in reminders. Watch sms_messages delivery statuses for the first batch.

Later · Once The Pilot Works

Expand

Only after the pilot sends cleanly. Each is its own build.

Resolve the cross-app client_id, then add more sendersDecide

Rewardee offers, BOO order status, Localey review asks all call the same sendSms() + shared ledger. First lock the canonical client_id mapping (flagged in the migration) so STOP suppresses across every app.

Replyee two-way inbox + AI auto-replyDecide

Move the inbound webhook into Replyee, add a thread model, point the RAG engine at inbound texts — "your texts answer themselves at 10pm." This is what justifies the higher tier.

Stripe tiers + self-serve client registrationDecide

Three tiers ($29/$59/$99) + metered overage. Build a self-serve 10DLC registration form once you have ~5 SMS clients — until then, register them by hand as above.

The rail — already built (in Localey, shared)

Shared ledger SQLLocaley/supabase/002_sms_shared.sql
Send railsrc/lib/sms.ts — sendSms(), consent gate, segments, STOP words
Inbound + STOP webhooksrc/app/api/sms/inbound/route.ts
Delivery status webhooksrc/app/api/sms/status/route.ts
Consent write endpointsrc/app/api/sms/consent/route.ts
Number search/buysrc/app/api/sms/numbers/route.ts
Reminder feature + consent UIDashee — building now