Sms A2p Client Onboarding

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_numbers is 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.

  1. 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 checkbox field with field_key = sms_consent. The renderer auto-adds a compliance footer with privacy/terms links when that field is present (form-render.php, marker airforms-sms-legal).
  2. Consent text must include: brand name, message types, "Message frequency varies", "Reply STOP to opt out, HELP for help", "Msg & data rates may apply".
  3. 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)
  4. 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)
  5. Opt-in form PAGE must show visible privacy + terms links (footer auto-handles).
  6. Sample messages must MATCH the consent — no off-scope content (caused error 30896).
  7. message_flow (Console "How do end-users consent") ≤ 1024 chars. Hard limit.
  8. PrivacyPolicyUrl + TermsAndConditionsUrl set in CONSOLE only — the API ignores them.

3. Step-by-step: add a client to SMS

  1. Provision a 10DLC number in Twilio, attach to Messaging Service MG37b5….
  2. Register it in company_phone_numbers (company_id, phone_number E.164, twilio_sid, number_type local, purpose airchat, is_active 1, verification_status approved). THIS is what routes it to the tenant.
  3. Opt-in form: ensure the client's Air4-hosted form has the sms_consent checkbox. Confirm the live form returns the privacy/terms links in raw HTML.
  4. Privacy/Terms pages: reuse /sms-privacy + /sms-terms, or client-specific pages with the same verbatim phrases.
  5. 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.)
  6. Inbound: point the number's webhook (number SmsUrl + Messaging Service InboundRequestUrl) at the inbound handler — see §7. The registry handles tenant routing automatically.
  7. 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_status lag 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.ai or curl --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 in storage/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 tenant crm_products).
  • Create an sms package in the package registry (kind=package), granted per company in Package Manager → projects to site_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 has sms_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 to sms_inbound, handles STOP/HELP/START (TCPA, writes sms_blocklist + sms_consent_events), routes to the tenant, creates/updates an AirChat conversation (channel=sms), and (if the tenant has airchat_settings) generates an AI reply sent back out via Twilio.
  • Multi-tenant routing: resolveRouting() consults company_phone_numbers FIRST (number → company_id + owner user + site), then falls back to companies.phone, then the api.php default. Registry is authoritative.
  • Wiring a number for inbound: set BOTH the number's SmsUrl AND the Messaging Service InboundRequestUrl to the handler URL (UseInboundWebhookOnNumber=false so the MG URL wins). Done for the 760 (2026; was previously pointed at Twilio's leftover demo URL — a number whose inbound still points at demo.twilio.com silently drops everything).
  • MMS: the handler collects MediaUrl{0..N} + MediaContentType and 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-Signature header is present ("dev mode"). Enforce in production. Also deploy the handler + its deps to pilot/ and repoint to the pilot/ 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 bucketINSERT INTO dam_incoming (in the tenant's DAM DB via DamDatabaseResolver::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).