Skip to content

Phone Call Routing

Inbound calls to the Twilio number are handled by the workers/phone Cloudflare Worker, which returns TwiML per call. The routing decision β€” forward vs voicemail β€” is driven entirely by database config editable at admin.theaccessible.org β†’ Health β†’ Phone Routing (/admin/phone). No code change or redeploy is needed to change the schedule, greeting, numbers, or holidays.

Behavior

On each incoming call the worker:

  1. Loads config from Supabase (phone_* tables, cached ~30s).
  2. Computes the current date/time in phone_settings.timezone (default America/Chicago).
  3. Decides the mode:
    • Holiday match for today β†’ use the holiday’s mode (usually voicemail).
    • Otherwise the weekday row: voicemail days always go to voicemail; forward days forward, honoring an optional time window (blank window = all day; outside the window β†’ voicemail).
  4. Forward β†’ dials phone_forward_numbers sequentially (hunt/failover) in sort_order; each rings for ring_timeout seconds; if none answer it falls through to voicemail.
  5. Voicemail β†’ speaks welcome_message, records (max max_recording_seconds). Delivery is designed so a voicemail is never lost:
    • When the recording completes (a guaranteed Twilio callback), the worker writes a durable row to phone_voicemails and emails the notification + recording link to voicemail_email β€” so the caller is reachable even if Twilio never produces a transcription.
    • When Twilio’s async transcription arrives (which may never happen), the worker stores it on the same row and sends a transcript follow-up email β€” but only if transcription actually succeeded (no noise email on failure).
    • All Resend sends retry on transient (429/5xx/network) failures; the phone_voicemails row is the recovery record if every email still fails.

Default schedule seeded by migration 221: Thursday–Sunday forward all day, Monday–Wednesday voicemail.

Call screening (press-to-accept)

When phone_settings.call_screening is on (default; migration 227) and the SCREEN KV namespace is bound, each forwarded <Number> carries a whisper (url="/voice/whisper") that runs on the answerer’s leg before bridging β€” the caller only hears ringing:

  1. The answerer hears β€œForwarded call from The Accessible dot org. Press any key to accept.”
  2. Key pressed β†’ /voice/whisper/accept writes a short-lived marker to KV keyed by that leg’s call SID, then returns empty TwiML so Twilio bridges.
  3. No key (e.g. a carrier / Google Voice voicemail auto-answered) β†’ the whisper <Hangup/>s that leg.

A dial that connects reports DialCallStatus=completed for both an accepted call and a declined/voicemail one, so /voice/hunt disambiguates via the marker (DialCallSid == the whisper leg’s CallSid): accepted β†’ hang up; not accepted β†’ keep hunting to the next number / voicemail. This stops an unattended voicemail from silently swallowing the call. Fail-open: if the KV binding is absent, forwarding is direct (no whisper); if a KV read errors, a completed dial is treated as accepted (connected) rather than re-dialing.

Voicemail audio (re-hosted + inbox)

Twilio stores each recording on its own servers, and that media URL requires Twilio credentials to play β€” so the raw link 401s for the email recipient, and the audio vanishes if Twilio deletes it. Instead, the worker owns a copy:

  1. On recording-complete it mints a per-voicemail capability token, stores it plus an r2_key and a full audio_url (https://<phone-worker>/voicemail/<call_sid>?t=<token>), and re-hosts the .mp3 into the phone-voicemails R2 bucket (best-effort, off the caller’s path). The Account SID is read out of the RecordingUrl, so the only Twilio secret the worker needs is TWILIO_AUTH_TOKEN.
  2. GET /voicemail/:callSid?t=<token> serves the audio. It validates the token against a live row, prefers the R2 copy, and falls back to streaming from Twilio (back-filling R2) if R2 doesn’t have it yet. It lives outside /voice/*, so it carries no Twilio signature β€” the token is the authorization.
  3. Both the notification email’s β€œListen” button and the /admin/phone inbox point at audio_url. Deleting a voicemail row instantly revokes the link (the token can no longer validate β†’ 404); the orphaned R2 object is reclaimed by the bucket’s lifecycle rule (set a ~90-day expiry).

The /admin/phone Voicemails section lists recordings newest-first with an inline player, transcript, unread badge, read/unread toggle, and delete. It reads the phone_voicemails table through the admin API (no R2 access needed there β€” which matters because the admin API’s prod path is AWS Lambda, with no R2 binding).

Fail-safe

If Supabase is unreachable, the worker serves the last-known-good config if it has one; otherwise it falls back to forwarding every call to the FORWARD_NUMBER wrangler var (or degrading to voicemail if that var is unset) β€” losing a call to a stale schedule is worse than briefly ignoring the schedule during an outage. The fallback paths log via console.warn/console.error.

Data model (migrations 221–222, 227_phone_call_screening, 228_phone_voicemail_audio)

TablePurpose
phone_settingsSingleton: greeting, timezone, ring timeout, voicemail email, max recording length, call_screening
phone_forward_numbersOrdered hunt list (E.164, label, sort_order, enabled)
phone_scheduleOne row per weekday (0=Sun): mode + optional [start_minute, end_minute) forward window
phone_holidaysSpecific-date overrides (date, name, mode)
phone_voicemailsDurable record of each voicemail (recording + transcription), independent of email delivery; upserted by call_sid. Migration 228 adds r2_key, audio_token, audio_url, duration_seconds, read_at for the re-hosted audio + inbox

All are service-role-only (RLS). The worker reads config + writes voicemails via the REST API with the service-role key; the admin API (/api/admin/phone/*) writes the config tables.

Twilio configuration

Point the number’s β€œA Call Comes In” webhook (POST) at:

https://<phone-worker-domain>/voice/incoming

The worker validates Twilio’s X-Twilio-Signature on all /voice/* routes (skipped only when ENVIRONMENT=development).

Deploy

  1. Apply the migrations to prod Supabase (this repo does not auto-push migrations β€” apply 221, 222, 227, and 228 individually; see the β€œProd migration drift” note).
  2. Create the R2 bucket (once), for the re-hosted voicemail audio, and set a lifecycle rule to expire objects (~90 days):
    Terminal window
    cd workers/phone
    wrangler r2 bucket create phone-voicemails
  3. Set worker secrets (once):
    Terminal window
    cd workers/phone
    wrangler secret put SUPABASE_SERVICE_ROLE_KEY --env production
    wrangler secret put TWILIO_AUTH_TOKEN --env production
    wrangler secret put RESEND_API_KEY --env production
    SUPABASE_URL and the fallback FORWARD_NUMBER / VOICEMAIL_EMAIL are plain vars in wrangler.toml. TWILIO_ACCOUNT_SID is an optional var (the worker otherwise derives it from the RecordingUrl).
  4. Deploy the worker: cd workers/phone && npm run deploy
  5. Deploy the API so /api/admin/phone/* exists in all three runtimes (routes are registered in index.ts, index-aws.ts, and server.ts):
    • Lambda: npm run deploy:lambda (from workers/api)
    • Node: npm run rebuild on 10.1.1.4
  6. Redeploy apps/web so the /admin/phone page ships.

Endpoints (/api/admin/phone, admin-only)

  • GET /config β€” full config in one payload
  • PUT /settings β€” greeting, timezone, ring timeout, voicemail email, max recording, call screening
  • PUT /schedule/:dayOfWeek β€” mode + forward window for one weekday
  • POST|PUT|DELETE /numbers[/:id] β€” manage the hunt list
  • POST|PUT|DELETE /holidays[/:id] β€” manage date overrides
  • GET /voicemails β€” inbox list (newest first; ?limit, ?offset, ?unread=true) + unread count
  • POST /voicemails/:id/read β€” mark read/unread ({ read: boolean })
  • DELETE /voicemails/:id β€” remove a voicemail (revokes its audio link)

Plus, on the phone worker (capability-token auth, not admin):

  • GET /voicemail/:callSid?t=<token> β€” stream the re-hosted voicemail audio

All destructive and configuration writes are recorded in the admin audit log (marking a voicemail read/unread is intentionally not logged β€” it’s routine and noisy).