zeroclaw-labs/zeroclaw · error · anyhow::Error
Twitter DM send failed ({status}): {err}
Error message
Twitter DM send failed ({status}): {err} What it means
Raised by TwitterChannel::send_dm when POST /2/dm_conversations/with/{recipient_id}/messages answers non-2xx. The recipient travels in the URL as a numeric user id (the channel routes recipients prefixed "dm:"), and the body carries only {text}; DMs allow up to 10000 chars per the channel's own routing note.
Source
Thrown at crates/zeroclaw-channels/src/twitter.rs:170
async fn send_dm(&self, recipient_id: &str, text: &str) -> anyhow::Result<()> {
let body = json!({
"text": text,
});
let resp = self
.http_client()
.post(format!(
"{TWITTER_API_BASE}/dm_conversations/with/{recipient_id}/messages"
))
.bearer_auth(&self.bearer_token)
.json(&body)
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status();
let err = resp.text().await.unwrap_or_default();
anyhow::bail!("Twitter DM send failed ({status}): {err}");
}
Ok(())
}
}
impl ::zeroclaw_api::attribution::Attributable for TwitterChannel {
fn role(&self) -> ::zeroclaw_api::attribution::Role {
::zeroclaw_api::attribution::Role::Channel(
::zeroclaw_api::attribution::ChannelKind::Twitter,
)
}
fn alias(&self) -> &str {
&self.alias
}
}
#[async_trait]View on GitHub (pinned to 88bb9c8533)
Solutions
- Regenerate the token with dm.write (and dm.read) scopes.
- Have the recipient follow the account or open DMs — X blocks DMs to closed recipients with 403.
- Verify the recipient value after "dm:" is the numeric user id, not a handle.
- For 429, respect the DM rate window before retrying.
Example fix
// before — dm: recipient holds a handle, not the numeric id
channel.send(&SendMessage { recipient: "dm:@alice".into(), content: text.into() }).await?;
// after — numeric user id resolved beforehand
channel.send(&SendMessage { recipient: "dm:2244994945".into(), content: text.into() }).await?; Defensive patterns
Strategy: retry
Validate before calling
// The dm: recipient must be a numeric user id.
fn is_twitter_user_id(recipient: &str) -> bool {
!recipient.is_empty() && recipient.chars().all(|c| c.is_ascii_digit())
}
if let Some(id) = msg.recipient.strip_prefix("dm:") {
assert!(is_twitter_user_id(id), "dm recipient must be the numeric user id");
} Type guard
fn is_numeric_user_id(v: &str) -> bool {
!v.is_empty() && v.chars().all(|c| c.is_ascii_digit())
} Try / catch
match twitter.send(&msg).await {
Ok(()) => Ok(()),
Err(err) => {
let t = err.to_string();
if t.contains("Twitter DM send failed (403)") {
// closed DMs or missing dm.write scope — retrying cannot help
Err(err.context("recipient must follow the account / open DMs; token needs dm.write"))
} else if t.contains("(429)") {
tokio::time::sleep(Duration::from_secs(60)).await;
twitter.send(&msg).await
} else {
Err(err)
}
}
} Prevention
- Request dm.write and dm.read scopes when the token is created.
- Resolve handles to numeric user ids before constructing "dm:" recipients.
- For bot use, ask users to follow the account or enable open DMs; closed-DM 403s are recipient-side and permanent.
When it happens
Trigger: 403 when the app lacks dm.write/dm.read scopes, 403 when the recipient does not follow the account or has DMs closed, 404 for a malformed or nonexistent recipient id, and 429 on DM rate limits.
Common situations: The token covers tweeting but not DMs. End users who never enabled open DMs cannot be messaged, so the bot fails exactly on the DM path while tweets work. Passing an @handle instead of the numeric user id as the dm: recipient.
Related errors
- Twitter users/me failed ({status}): {err}
- Twitter create tweet failed ({status}): {err}
- ElevenLabs TTS API error ({}): {}
- Google TTS API error ({}): {}
- Twilio call failed: {body}
AI-assisted analysis of zeroclaw-labs/zeroclaw@88bb9c8533 (2026-08-23).
Data as JSON: /api/errors/e86e1f7a8f1ceb8f.
Report an issue: GitHub.