Repository navigation
VAPI-4041: send Twilio's action params from /bw/continue - #31
Merged
Merged
Conversation
- Dial: map transferComplete to DialCallStatus/DialBridged, and join the dialed leg's transferDisconnect (new /bw/transfer-leg) for DialCallSid and DialCallDuration, waiting up to transferLegWaitMs (default 400 ms). - Record: send RecordingUrl, RecordingSid, RecordingDuration from recordComplete and register the recording with the Recordings facade. - Gather: on no digits or speech, resume the document after that Gather instead of requesting the action, unless actionOnEmptyResult="true".
✅ Snyk checks have passed. No issues have been found so far.
💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse. |
Review fixes for the Dial action join: - A multi-number Dial reported whichever leg disconnected first, usually a cancelled loser. Now all of the Dial's legs are considered and the one that answered wins; if the parent bridged but the winner has not reported, no leg is reported rather than a loser. - Leftover or late leg events were applied to the next Dial on the call. Each Dial now gets a key (<doc nonce>.<index>) on both its transferCompleteUrl and its legs' transferDisconnectUrl, so legs only join their own Dial. The wait ends early once every expected leg has reported, and a call's unread legs are dropped on disconnect.
On a live multi-number Dial, Bandwidth reported the losing leg (cut off when another number answered) as cause "hangup" with no answerTime, not "cancel". Leg selection already keys on answerTime, but when such a leg was the one reported, the action got DialCallStatus=completed with DialBridged=false. A "hangup" cause now means completed only for an answered leg; an unanswered one is canceled. The multi-number tests use the observed event shape.
- Report the legs, not the parent's inference, once every expected leg has reported: a parent cause of "hangup" only means "the answered leg is still on its way" while legs are missing. - A redelivered transferDisconnect for the same leg replaces the earlier one instead of counting twice toward the expected legs, which could end the wait before the answered leg arrived. - Release a call's remembered TwiML (up to MAX_TWIML_BYTES) on disconnect. - Use secondsBetween for the disconnect and Calls GET durations and one helper for recording durations; drop event and leg fields nothing reads. - Type translateVerb's rewrite as RewriteUrl so the Gather/Dial context can't be lost by a narrower signature.
cguthrie-bw
approved these changes
Oct 7, 2026
ajrice6713
approved these changes
Oct 8, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
/bw/continuenow answers each verb'sactionwith the params Twilio would send forDial,Record, andGather, so customer handlers branch the same way they did on Twilio.Dialaction reporting considers all of a Dial's legs, keyed per Dial, instead of whichever leg's disconnect arrives first. A multi-number Dial reports the answered leg, and a second Dial on the same call can't inherit an earlier Dial's leg.Gatherresult no longer requests the action (Twilio semantics);actionOnEmptyResult="true"requests it withDigits="".AGENTS.mdand the verb matrix notes describe the new behavior.Motivation / Context
Customer
actionhandlers forDial,Record, andGatherexpect Twilio's request params (DialCallStatus,RecordingSid,Digits, etc.). Bandwidth's callback events don't have the same shape or ordering guarantees, so/bw/continuehas to translate them and, forDial, join two separate events before it can answer faithfully. Tracked in VAPI-4041.Alternatives Considered
<doc>.<index>) scope every leg to the Dial it belongs to, regardless of timing.Reviewer Notes
DialCallSid/DialCallDurationcome from the dialed leg'stransferDisconnect, received at the new/bw/transfer-leg. Bandwidth documents no ordering between that and the parent'stransferComplete, so the action waits up totransferLegWaitMs(default 400 ms). The wait ends early once a leg that answered has reported, or all N legs the Dial rang have.answerTimewins. On a live multi-number Dial, Bandwidth reported the losing leg ascause: "hangup"with noanswerTime(notcancel), so ahangupcause maps tocompletedonly for an answered leg; an unanswered one iscanceled. If none answered, all expected legs haven't reported yet, and the parent's cause ishangup(the transfer bridged, but the answered leg is still on its way), no leg is reported rather than a cancelled loser. Otherwise the last leg to report is used. A redelivered event for the same leg replaces the earlier one, so it can't count twice toward the expected legs.<doc>.<index>on both itstransferCompleteUrl(&dial=…&legs=N) and its targets'transferDisconnectUrl(?dial=…). URLs minted before this change carry no key and share one fallback bucket per call./bw/transfer-legreturns 400 for a malformed dial key rather than putting it in the fallback bucket./bw/disconnect, a call's unread legs and its remembered TwiML document (up toMAX_TWIML_BYTES) are released, so neither accumulates. The call record itself stays, for the Calls API.CallRecord.lastTwiml), held in memory only; see Breaking Changes for the fallback.Record: Bandwidth'srecordCompletehas no equivalent of Twilio'sDigits, so it is omitted.Breaking Changes
Gather(no digits, no speech) no longer requests theaction. The verbs after theGatherrun instead, matching Twilio. Handlers that relied on aDigits=""POST needactionOnEmptyResult="true"on theGather.gatherUrlandtransferCompleteUrlcarry extra query params (&gather=N,&onEmpty=1,&dial=<doc>.<index>,&legs=N).translateTwiml'stransferLegUrloption is a function(dialIndex) => string, and therewriteUrlcallback receives a thirdRewriteContextargument. BXML-only callers are unaffected.Tests
Added
/bw/continue transferComplete -> Dial action› emits transferDisconnectUrl on the dialed number pointing at /bw/transfer-leg with credstransferDisconnectUrlwith webhook creds, and the action URL names the same Dial and its leg counttransferDisconnectsupplies the fieldstransferCompletelacksDialCallStatusvaluestransferLegWaitMshangupalone implies a bridged, completed DialDial action picks the right leg› reports the answered leg even when the cancelled leg reported firsthanguphangup(the live loser shape) maps tocanceled,DialBridged=false/bw/disconnectclears legs no wait consumed/bw/transfer-leg› rejects a malformed dial keydialparam → 400/bw/transfer-leg› requires webhook auth and safe idsparentCallId→ 400/bw/continue recordComplete -> Record action› sends RecordingUrl, RecordingSid, RecordingDuration and registers the recording/bw/continue gather with no input› resumes the document after the Gather instead of requesting the action (Twilio semantics)/bw/disconnectGather action context for the rewriter› passes a 1-based document index and the actionOnEmptyResult flag for each GatherresumeAfterGather› emits only the verbs after the Nth GatherDial transferDisconnectUrl› stamps the server's transfer-leg URL and callback creds on every target when a Dial has an actiondialIndex/dialTargets; each Dial's targets get that Dial's leg URLModified
POST /bw/initiate› rewrites Gather action through /bw/continuegatherUrlnow ends in&gather=1Test Plan
npm run typechecknpm test, includingtest/bxml-generator-band-conformance.test.tswith thebandCLI installedband bxml rawaccepted the BXML for multi-number Dial, repeated Dial,actionOnEmptyResultGather, and speech Gathercompleted,DialBridged=true,DialCallSid,DialCallDurationno-answer,DialBridged=falseDialCallStatus. Not testable with a mobile phone: carriers send a declined call to voicemail, which answers. Covered by unit tests.Recordaction params, and the recording can be fetchedactionOnEmptyResult="true"postsDigits=""