zeroclaw-labs/zeroclaw · error · anyhow::Error

Twilio call failed: {body}

Error message

Twilio call failed: {body}

What it means

Raised in the Twilio arm of VoiceCallChannel::execute_outbound_call when POST {api_base}/Accounts/{account_id}/Calls.json answers non-2xx. Authentication is HTTP Basic with account_id/auth_token from [channels.voice_call.<alias>]; the form body carries To, From, a StatusCallback webhook, and Timeout. place_call reaches this only when require_outbound_approval is off (otherwise it returns PENDING_APPROVAL). The raw Twilio error body (XML/JSON with a code and message) is included.

Source

Thrown at crates/zeroclaw-channels/src/voice_call.rs:160

                    self.api_base_url(),
                    self.config.account_id
                );
                let resp = self
                    .client
                    .post(&url)
                    .basic_auth(&self.config.account_id, Some(&self.config.auth_token))
                    .form(&[
                        ("To", to_number),
                        ("From", &self.config.from_number),
                        ("StatusCallback", &webhook_url),
                        ("Timeout", &self.config.max_call_duration_secs.to_string()),
                    ])
                    .send()
                    .await?;

                if !resp.status().is_success() {
                    let body = resp.text().await.unwrap_or_default();
                    bail!("Twilio call failed: {body}");
                }

                let json: serde_json::Value = serde_json::from_str(&resp.text().await?)?;
                let call_sid = json["sid"].as_str().unwrap_or("unknown").to_string();
                ::zeroclaw_log::record!(
                    INFO,
                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                        .with_attrs(::serde_json::json!({"call_sid": call_sid, "to": to_number})),
                    "outbound call placed via Twilio"
                );
                Ok(call_sid)
            }
            VoiceProvider::Telnyx => {
                let url = format!("{}/calls", self.api_base_url());
                let resp = self
                    .client
                    .post(&url)
                    .bearer_auth(&self.config.auth_token)

View on GitHub (pinned to 88bb9c8533)

Solutions

  1. Look up the numeric Twilio error code inside the body text; it names the exact violation.
  2. For 401, correct account_id and auth_token in [channels.voice_call.<alias>].
  3. Use E.164 for both numbers and ensure from_number is purchased/owned by that account.
  4. On trial accounts, verify the destination number in the console first.
  5. Set webhook_base_url to a publicly reachable HTTPS URL instead of the localhost default.

Example fix

# before — From number not owned by this account; webhook defaults to localhost
[channels.voice_call.desk]
account_id = "ACxxxxxxxx"
auth_token = "xxxx"
from_number = "+15550009999"

# after
[channels.voice_call.desk]
account_id = "ACxxxxxxxx"
auth_token = "xxxx"
from_number = "+15550001111"          # purchased on this account
webhook_base_url = "https://bots.example.com"
Defensive patterns

Strategy: try-catch

Validate before calling

// E.164 check on both numbers before placing the call.
fn is_e164(n: &str) -> bool {
    let b = n.as_bytes();
    b.len() >= 12 && b[0] == b'+' && b[1..].iter().all(|c| c.is_ascii_digit())
}

assert!(is_e164(to_number) && is_e164(&config.from_number));

Type guard

fn is_e164_number(n: &str) -> bool {
    let b = n.as_bytes();
    b.len() >= 12 && b[0] == b'+' && b[1..].iter().all(|c| c.is_ascii_digit())
}

Try / catch

match channel.place_call(&to).await {
    Ok(sid) if sid.starts_with("PENDING_APPROVAL:") => Ok(sid), // approval flow
    Ok(sid) => Ok(sid),
    Err(err) if err.to_string().contains("Twilio call failed") => {
        // body embeds Twilio's numeric code: 401=auth, 21211=bad To,
        // 21212/21214=From not owned, 21207=unverified trial destination.
        // All are config/state errors — surface for fix, do not blind-retry.
        Err(err.context("check account_id/auth_token, E.164 numbers, and From ownership"))
    }
    Err(err) => Err(err),
}

Prevention

When it happens

Trigger: 401 for a wrong auth_token or account_id; Twilio error 21211 for a malformed To number; 21212/21214 when from_number is not a number purchased on that account; 21207 for unverified trial-account destinations; callback-URL errors when the configured webhook_base_url is not reachable over HTTP(S).

Common situations: The auth token was rotated in the Twilio console but not in config. From-numbers borrowed from another subaccount. Trial accounts calling unverified numbers. The webhook defaulting to http://localhost when webhook_base_url is unset, which Twilio rejects for status callbacks.

Related errors


AI-assisted analysis of zeroclaw-labs/zeroclaw@88bb9c8533 (2026-08-23). Data as JSON: /api/errors/c3fd1cace8c7c539. Report an issue: GitHub.