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:
- Loads config from Supabase (
phone_*tables, cached ~30s). - Computes the current date/time in
phone_settings.timezone(defaultAmerica/Chicago). - Decides the mode:
- Holiday match for today β use the holidayβs mode (usually voicemail).
- Otherwise the weekday row:
voicemaildays always go to voicemail;forwarddays forward, honoring an optional time window (blank window = all day; outside the window β voicemail).
- Forward β dials
phone_forward_numberssequentially (hunt/failover) insort_order; each rings forring_timeoutseconds; if none answer it falls through to voicemail. - Voicemail β speaks
welcome_message, records (maxmax_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_voicemailsand emails the notification + recording link tovoicemail_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_voicemailsrow is the recovery record if every email still fails.
- When the recording completes (a guaranteed Twilio callback), the worker
writes a durable row to
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:
- The answerer hears βForwarded call from The Accessible dot org. Press any key to accept.β
- Key pressed β
/voice/whisper/acceptwrites a short-lived marker to KV keyed by that legβs call SID, then returns empty TwiML so Twilio bridges. - 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:
- On
recording-completeit mints a per-voicemail capability token, stores it plus anr2_keyand a fullaudio_url(https://<phone-worker>/voicemail/<call_sid>?t=<token>), and re-hosts the.mp3into thephone-voicemailsR2 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 isTWILIO_AUTH_TOKEN. 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.- Both the notification emailβs βListenβ button and the
/admin/phoneinbox point ataudio_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)
| Table | Purpose |
|---|---|
phone_settings | Singleton: greeting, timezone, ring timeout, voicemail email, max recording length, call_screening |
phone_forward_numbers | Ordered hunt list (E.164, label, sort_order, enabled) |
phone_schedule | One row per weekday (0=Sun): mode + optional [start_minute, end_minute) forward window |
phone_holidays | Specific-date overrides (date, name, mode) |
phone_voicemails | Durable 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/incomingThe worker validates Twilioβs X-Twilio-Signature on all /voice/* routes
(skipped only when ENVIRONMENT=development).
Deploy
- Apply the migrations to prod Supabase (this repo does not auto-push
migrations β apply
221,222,227, and228individually; see the βProd migration driftβ note). - 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/phonewrangler r2 bucket create phone-voicemails - Set worker secrets (once):
Terminal window cd workers/phonewrangler secret put SUPABASE_SERVICE_ROLE_KEY --env productionwrangler secret put TWILIO_AUTH_TOKEN --env productionwrangler secret put RESEND_API_KEY --env productionSUPABASE_URLand the fallbackFORWARD_NUMBER/VOICEMAIL_EMAILare plain vars inwrangler.toml.TWILIO_ACCOUNT_SIDis an optional var (the worker otherwise derives it from the RecordingUrl). - Deploy the worker:
cd workers/phone && npm run deploy - Deploy the API so
/api/admin/phone/*exists in all three runtimes (routes are registered inindex.ts,index-aws.ts, andserver.ts):- Lambda:
npm run deploy:lambda(fromworkers/api) - Node:
npm run rebuildon 10.1.1.4
- Lambda:
- Redeploy
apps/webso the/admin/phonepage ships.
Endpoints (/api/admin/phone, admin-only)
GET /configβ full config in one payloadPUT /settingsβ greeting, timezone, ring timeout, voicemail email, max recording, call screeningPUT /schedule/:dayOfWeekβ mode + forward window for one weekdayPOST|PUT|DELETE /numbers[/:id]β manage the hunt listPOST|PUT|DELETE /holidays[/:id]β manage date overridesGET /voicemailsβ inbox list (newest first;?limit,?offset,?unread=true) + unread countPOST /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).