Air4 Media SMS (A2P 10DLC) — Client Onboarding & Approval Runbook
Status: LIVE. First campaign APPROVED 2026-05-31 after a long compliance fight. Outbound + inbound both working. This runbook captures the exact requirements + gotchas so onboarding the next client is fast, not another 6-rejection saga.
1. The model (read first)
- ONE Air4media brand + ONE approved A2P campaign covers the whole platform. Broad "Mixed" use case: "Air4 Media sends transactional/event SMS on behalf of its business customers who opt in via Air4-hosted forms." You do NOT register a new campaign per client.
- Each client gets a dedicated 10DLC number attached to the same Messaging Service (
MG37b5…), all under the one approved campaign. Pilot routes each client's outbound SMS through their assigned number. company_phone_numbersis the authoritative number → tenant registry. Every client number is a row there (company_id, phone_number, twilio_sid, number_type, purpose, is_active). Inbound + outbound routing both consult it. NEVER hardcode a number→company mapping in code.- Air4media's own internal SMS (MFA, system/monitor alerts) stays on AWS SNS — separate path, not Twilio.
- Exception — own-brand sender: if a client needs messages branded as their business name (not "Air4 Media") with their own consent language, that requires a SEPARATE Twilio brand + campaign. Heavier; only when explicitly needed. Default = ride the Air4media campaign.
Provider split
| Recipient | Provider | Examples |
|---|---|---|
| Platform → own staff/users (operational) | AWS SNS | MFA codes, monitor/QA alerts |
| Tenant → opted-in consumers (A2P) | Twilio | event photo delivery, pocket booth, follow-ups, 2-way |
Current registered assets
| Thing | Value |
|---|---|
| Brand | Air4media LLC — BN1b0174797046f80bd6fe6b19d4dd61d3 (TCR B5P00XP, APPROVED) |
| Campaign | Console CM12d058eebf1ad7e6862a8b1615da1667 / API QE2c6890da8086d771620e9b13fadeba0b (Mixed, APPROVED) |
| Messaging Service | MG37b5e35fed3c74500e313a308ddf88f9 |
| Numbers | +17606566636 ("760", PN88c993…) → Coachella Party (company 10), registered in company_phone_numbers. +18882981641 ("800", toll-free) = Air4media/AWS context. |
| Twilio creds | pilot/config/settings/api.php → $config['twilio'] (sid/token/phone_number; api_key_sid/secret preferred when present). Secrets in /opt/air4-private/api.secrets.php, www-data-only. |
2. Compliance requirements (MUST-haves for every client)
Exact things TCR/carriers check. Miss one → rejection.
- Opt-in form (Air4-hosted): required Phone field + an OPTIONAL, unchecked-by-default SMS consent
checkbox, separate from any Terms agreement. Form submits with or without the box.
- AirForms: add a
checkboxfield withfield_key = sms_consent. The renderer auto-adds a compliance footer with privacy/terms links when that field is present (form-render.php, markerairforms-sms-legal).
- AirForms: add a
- Consent text must include: brand name, message types, "Message frequency varies", "Reply STOP to opt out, HELP for help", "Msg & data rates may apply".
- Dedicated SMS Privacy page, VERBATIM (TCR string-matches): "Mobile information and messaging consent are not shared with third parties or affiliates for marketing or promotional purposes." + CTIA catch-all. (Air4media: https://air4.media/sms-privacy)
- Dedicated SMS Terms page with 9 CTIA elements + VERBATIM: "Consumer data or message opt-in information is not shared, sold, or bought by third parties or affiliates." (https://air4.media/sms-terms)
- Opt-in form PAGE must show visible privacy + terms links (footer auto-handles).
- Sample messages must MATCH the consent — no off-scope content (caused error 30896).
- message_flow (Console "How do end-users consent") ≤ 1024 chars. Hard limit.
- PrivacyPolicyUrl + TermsAndConditionsUrl set in CONSOLE only — the API ignores them.
3. Step-by-step: add a client to SMS
- Provision a 10DLC number in Twilio, attach to Messaging Service
MG37b5…. - Register it in
company_phone_numbers(company_id, phone_number E.164, twilio_sid, number_typelocal, purposeairchat, is_active 1, verification_statusapproved). THIS is what routes it to the tenant. - Opt-in form: ensure the client's Air4-hosted form has the
sms_consentcheckbox. Confirm the live form returns the privacy/terms links in raw HTML. - Privacy/Terms pages: reuse
/sms-privacy+/sms-terms, or client-specific pages with the same verbatim phrases. - Outbound routing: Pilot sends tenant SMS via
SmsService::send($to,$body,'Air4media',['company_id'=>N])→ routed to Twilio on the tenant's number. (Router build — see §7.) - Inbound: point the number's webhook (number SmsUrl + Messaging Service InboundRequestUrl) at the inbound handler — see §7. The registry handles tenant routing automatically.
- No new campaign needed. (Own-brand only: register a separate brand + campaign; repeat §2.)
4. Gotchas (hard-won — do not relearn)
- Twilio help pages are JS-rendered. Read via
r.jina.ai(curl https://r.jina.ai/https://help.twilio.com/...). - Error 30882 (Terms) is "ineligible for resubmission." Edit-resubmit can NOT clear it. Needs Trust & Safety to clear, or delete+recreate. Don't burn cycles.
- Campaign API
date_updated/campaign_statuslag badly. Diagnose from the errors array + Console. - WebFetch (Anthropic IP) gets 403 from air4.media — Cloudflare bot list, NOT a real block. Verify external
reachability via
r.jina.aiorcurl --resolve air4.media:443:<CF_edge_IP>. - Escalate to a human early. Twilio Support → Trust & Safety cleared the stuck 30882 and expedited (Ticket 27367007, Sachin Singh). After 2 identical auto-rejections, open a ticket.
5. Monitoring
storage/services/scripts/twilio-a2p-monitor.php— cron /30min, SMS+email to +17608085280 / [email protected] on campaign status change. State instorage/state/twilio-a2p-last.json.
6. Charging — SMS as a paid onboarding + subscription add-on
SMS is a paid capability. Two revenue components (final prices are Laurent's call; these are the model):
A. One-time onboarding fee (covers real setup work):
- Standard SMS onboarding (rides the Air4media campaign): number provisioning + opt-in form wiring + compliance pages + registry + routing. Suggested ~$99 one-time.
- Own-brand onboarding (separate Twilio brand + campaign for the client, incl. TCR vetting + the multi-rejection risk): materially more work. Suggested ~$499 one-time + pass-through TCR fees.
B. Recurring + usage:
- Monthly SMS add-on per client: number rental (~$1.15/mo 10DLC) + campaign share + margin → suggested ~$15/mo.
- Outbound: ~$0.0079/segment carrier → bill ~$0.02/segment; MMS ~$0.02 → bill ~$0.05. Or sell bundled "SMS credits".
Billing wiring (platform layer — NOT yet implemented; next build):
- Add platform SKUs in
Ubermedia.products(platform products sold to tenants — NOT tenantcrm_products). - Create an
smspackage in the package registry (kind=package), granted per company in Package Manager → projects tosite_module_activations(the real per-site gate). Gate sending by PLAN/package, not tenant. - On grant: provision number → register in
company_phone_numbers→ ensure opt-in form hassms_consent. - Meter usage via the billing engine (or an "SMS credits" counter on
companies).
7. Inbound (two-way) — receiving texts, photos & replies
- Handler:
pilot-beta/includes/handlers/sms-webhook-handler.php. Validates Twilio signature, logs tosms_inbound, handles STOP/HELP/START (TCPA, writessms_blocklist+sms_consent_events), routes to the tenant, creates/updates an AirChat conversation (channel=sms), and (if the tenant hasairchat_settings) generates an AI reply sent back out via Twilio. - Multi-tenant routing:
resolveRouting()consultscompany_phone_numbersFIRST (number → company_id + owner user + site), then falls back tocompanies.phone, then the api.php default. Registry is authoritative. - Wiring a number for inbound: set BOTH the number's
SmsUrlAND the Messaging ServiceInboundRequestUrlto the handler URL (UseInboundWebhookOnNumber=falseso the MG URL wins). Done for the 760 (2026; was previously pointed at Twilio's leftover demo URL — a number whose inbound still points atdemo.twilio.comsilently drops everything). - MMS: the handler collects
MediaUrl{0..N}+MediaContentTypeand stores them on the AirChat message. ⚠️ Twilio media URLs expire and require auth — they are NOT durable. See §8. - Hardening TODO: signature validation currently fails OPEN when no
X-Twilio-Signatureheader is present ("dev mode"). Enforce in production. Also deploy the handler + its deps topilot/and repoint to thepilot/URL (currently runs on the pilot-beta path).
8. MMS → DAM ingestion (SPEC — next build, not yet implemented)
Goal: a photo/video texted to a tenant's number lands in that tenant's DAM, multi-tenant, with metadata.
- Inbound MMS gives
MediaUrl{i}(Twilio-hosted, expiring, needs Account SID/Auth Token to fetch). - Ingestion must mirror
ftx-photographer-upload.php: download media → temp file →new MediaProcessor()→ read/stamp IPTC →generateThumbnail→ upload original + thumb to the tenant's Wasabi bucket →INSERT INTO dam_incoming(in the tenant's DAM DB viaDamDatabaseResolver::resolveByCompany). - Adaptation needed: the photographer model keys off
dam_photographer_config(a logged-in user). An SMS sender has no user/config. Define an SMS ingestion source:dam_incoming.source = 'sms', a synthetic destination/template for the tenant (or a per-tenant "SMS inbound" destination), photographer_user_id = the company owner, caption/keywords from the sender number + body. - Prerequisite: the tenant must have a DAM instance + bucket. COPA's DAM instance must exist before MMS
ingestion will work; otherwise fall back to storing the downloaded file in a tenant-scoped path + an
sms_inbound/AirChat record only. - Video caveat: US carrier MMS caps media ~600KB–1.2MB. Real video won't arrive via MMS — text back an upload-link instead.
Cross-ref:
storage/kb/sms-system-plan.md(older two-way/threading design draft).