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.gevell.com → 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).