[object Object]

← back to Butlr

twilio: fix StatusCallbackEvent — repeated param not space-string

fc2ef8e5d4eb07cee83cd4d370f5702aabf77fa8 · 2026-05-12 17:28:35 -0700 · SteveStudio2

Twilio's REST API rejects 'initiated ringing answered completed' as
a single string with error 21626 (Invalid Events) and silently falls
back to firing only the 'completed' webhook. This is exactly why our
/twilio/status/:id route never received ringing/answered events all
day. Fix: use URLSearchParams.append() to pass StatusCallbackEvent
as a repeated param.

Found by checking Monitor v1 Alerts API on the 23:45 UTC calls today.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF

Files touched

Diff

commit fc2ef8e5d4eb07cee83cd4d370f5702aabf77fa8
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Tue May 12 17:28:35 2026 -0700

    twilio: fix StatusCallbackEvent — repeated param not space-string
    
    Twilio's REST API rejects 'initiated ringing answered completed' as
    a single string with error 21626 (Invalid Events) and silently falls
    back to firing only the 'completed' webhook. This is exactly why our
    /twilio/status/:id route never received ringing/answered events all
    day. Fix: use URLSearchParams.append() to pass StatusCallbackEvent
    as a repeated param.
    
    Found by checking Monitor v1 Alerts API on the 23:45 UTC calls today.
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
    EOF
---
 lib/twilio.js | 10 +++++++++-
 1 file changed, 9 insertions(+), 1 deletion(-)

diff --git a/lib/twilio.js b/lib/twilio.js
index 78d841e..c6b7b43 100644
--- a/lib/twilio.js
+++ b/lib/twilio.js
@@ -160,18 +160,26 @@ async function dialCall(call) {
     log('LIVE mode requested but Twilio creds missing:', auth.error || 'no_phone_number_or_public_url');
     return { ok: false, error: 'twilio_creds_missing', detail: auth.error };
   }
+  // Twilio requires StatusCallbackEvent to appear as a REPEATED param
+  // (StatusCallbackEvent=initiated&StatusCallbackEvent=ringing&…), NOT
+  // a single space-separated string. Passing a string makes Twilio reject
+  // ALL events except the implicit `completed`, which is why our
+  // /twilio/status/:id route never fires for ringing/answered/etc. Fix:
+  // use URLSearchParams.append() per value.
   const body = new URLSearchParams({
     To: call.business_phone,
     From: fromNum,
     Url: `${publicUrl}/twilio/twiml/${call.id}`,
     StatusCallback: `${publicUrl}/twilio/status/${call.id}`,
     StatusCallbackMethod: 'POST',
-    StatusCallbackEvent: 'initiated ringing answered completed',
     MachineDetection: 'DetectMessageEnd',
     AsyncAmd: 'true',
     AsyncAmdStatusCallback: `${publicUrl}/twilio/amd/${call.id}`,
     AsyncAmdStatusCallbackMethod: 'POST',
   });
+  for (const evt of ['initiated', 'ringing', 'answered', 'completed']) {
+    body.append('StatusCallbackEvent', evt);
+  }
   try {
     const resp = await fetch(`https://api.twilio.com/2010-04-01/Accounts/${auth.accountSid}/Calls.json`, {
       method: 'POST',

← bc26177 deploy.conf: lock in data/ excludes — rsync had wiped real c  ·  back to Butlr  ·  audio: finalize-recordings.js + diagnostics that found Twili 4a77606 →