| title | Quickstart |
|---|---|
| description | Build your first WhatsApp bot in minutes |
This guide will help you create a simple WhatsApp bot that responds to messages. You'll learn the core concepts and have a working bot by the end.
Add the dependencies — one crate is enough; whatsapp-rust re-exports the entire stack:
[dependencies]
whatsapp-rust = { git = "https://github.com/oxidezap/whatsapp-rust", rev = "d804acaec1439e54c43c129e2f90d45016506abe" }
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }Here's a minimal bot that responds to "ping" messages:
use whatsapp_rust::prelude::*;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let bot = Bot::builder()
.with_backend(SqliteStore::open("whatsapp.db").await?)
.on_qr_code(|code, _timeout| async move {
println!("Scan this QR code with WhatsApp:\n{code}");
})
.on_message(|ctx| async move {
if ctx.message.text_content() == Some("ping") {
if let Err(e) = ctx.reply("pong").await {
eprintln!("Failed to reply: {e}");
}
}
})
.build()
.await?;
// Runs until logout or client.shutdown() from another task.
let reason = bot.run().await;
println!("Session ended: {reason:?}");
Ok(())
}let backend = SqliteStore::open("whatsapp.db").await?;This creates a SQLite database file named whatsapp.db in your current directory. The session will persist across restarts.
let bot = Bot::builder()
.with_backend(backend).on_qr_code(|code, _timeout| async move {
println!("QR Code:\n{code}");
})
.on_message(|ctx| async move {
// ctx.message, ctx.info, ctx.reply(...), ctx.react(...)
println!("From {}: {:?}", ctx.info.source.sender, ctx.message.text_content());
})
.on_connected(|_client| async {
println!("Connected successfully!");
})Available typed registrars: on_message, on_qr_code, on_pair_code, on_connected, on_logged_out. Use on_event or on_event_for as catch-alls for events without a typed registrar. Multiple handlers of any kind accumulate — registering a second one no longer silently replaces the first.
on_message delivers a ready MessageContext with ctx.reply(text), ctx.reply_quoting(text), ctx.react(emoji), and ctx.send_message(msg) helpers.
.build()
.await?;
bot.run().await;bot.run().await runs the bot on the current task until it disconnects or logs out. To run in the background instead, use bot.spawn(), which returns a BotHandle with client(), shutdown(), and abort().
Let's extend the bot to respond to "ping" with "pong" using the on_message helper and ctx.reply:
use whatsapp_rust::prelude::*;
.on_qr_code(|code, _timeout| async move {
println!("QR Code:\n{code}");
})
.on_message(|ctx| async move {
if ctx.message.text_content() == Some("ping") {
if let Err(e) = ctx.reply("pong").await {
eprintln!("Failed to send reply: {e}");
}
}
})msg.text_content()— Extract text from any message type (conversation, extended text, etc.)ctx.reply(text)— Send a plain-text reply in the same chat without quotingctx.reply_quoting(text)— Send a plain-text reply that quotes the received messagectx.react(emoji)— React to the received messagectx.send_message(msg)— Send an arbitrarywa::Messageto the source chatclient.send_text(jid, text)— Send a plain-text message from aClientreferenceinfo.source.chat— The JID (identifier) of the chat where the message came frominfo.source.sender— The JID of the user who sent the message
For raw protobuf access (advanced cases), build a wa::Message directly:
use whatsapp_rust::prelude::*;
.on_message(|ctx| async move {
if ctx.message.text_content() == Some("ping") {
let mut reply = wa::Message::default();
reply.conversation = Some("pong".to_string());
if let Err(e) = ctx.send_message(reply).await {
eprintln!("Failed to send reply: {e}");
}
}
})Or with the MessageBuilderExt helpers:
use whatsapp_rust::prelude::*; // re-exports MessageBuilderExt
let msg = wa::Message::text("pong");
let msg_with_quote = wa::Message::text_with_context("pong", ctx.build_quote_context());The bot automatically generates QR codes when not authenticated. Display them with on_qr_code:
.on_qr_code(|code, timeout| async move {
println!("Scan this QR code (valid {}s):\n{code}", timeout.as_secs());
})Alternatively, link using a phone number and 8-digit code:
use whatsapp_rust::prelude::*;
use whatsapp_rust::pair_code::PairCodeOptions;
let bot = Bot::builder()
.with_backend(SqliteStore::open("whatsapp.db").await?)
.with_pair_code(PairCodeOptions {
phone_number: "15551234567".to_string(),
..Default::default()
})
.on_pair_code(|code, _timeout| async move {
println!("Enter this code on your phone: {code}");
})
.build()
.await?;PairCodeOptions derives companion_platform_id and companion_platform_display from the device's PlatformType by default (Chrome with Chrome (Linux) for the stock web profile). You can override the wire id when needed:
use whatsapp_rust::pair_code::PairCodeOptions;
use whatsapp_rust::wacore::companion_reg::CompanionWebClientType;
PairCodeOptions {
phone_number: "15551234567".to_string(),
show_push_notification: true,
custom_code: Some("ABCD1234".to_string()), // or None for random
// `None` auto-derives from `Device.device_props.platform_type`.
platform_id: Some(CompanionWebClientType::Chrome),
}cargo runScan the QR code with WhatsApp on your phone:
- Open WhatsApp on your phone
- Go to Settings → Linked Devices
- Tap "Link a Device"
- Scan the QR code displayed in your terminal
cargo runYou should see:
Connected successfully!
The repository includes a demo bot example (examples/demo.rs) that supports CLI arguments for authentication:
cargo run --example demo # QR code pairing only
cargo run --example demo -- --phone 15551234567 # Pair code + QR code (concurrent)
cargo run --example demo -- -p 15551234567 # Short form
cargo run --example demo -- -p 15551234567 --code MYCODE12 # Custom 8-char pair code
cargo run --example demo -- -p 15551234567 -c MYCODE12 # Short formThe demo bot responds to 🦀ping with a quoted 🏓 Pong! reply, edits the reply to append the send latency, and supports media ping/pong via CDN reuse.
on_message delivers a ready MessageContext, so you no longer need to extract it from Arc<Event> manually. Use typed handler functions for cleaner separation:
use whatsapp_rust::prelude::*;
.on_message(|ctx| async move {
handle_message(ctx).await;
})Then define focused handler functions:
async fn handle_message(ctx: MessageContext) {
if ctx.message.text_content() == Some("ping") {
if let Err(e) = ctx.reply("pong").await {
eprintln!("Failed to send: {e}");
}
}
}MessageContext provides convenience methods including send_message (auto-targets the source chat), reply, reply_quoting, react, build_quote_context, edit_message, and revoke_message.
You can also forward media instantly by reusing the original CDN fields — no download or re-upload needed:
Constructing a message with a sub-message field (like `image_message` below) requires `MessageField`. `whatsapp-rust` re-exports it directly from `prelude`, so `use whatsapp_rust::prelude::*` is enough — no extra `Cargo.toml` entry needed. See [Installation](/installation#add-to-your-project)./// Reuses the original CDN blob, only swaps the caption.
/// Instant regardless of file size.
fn build_media_reply(message: &wa::Message) -> Option<wa::Message> {
let base = message.get_base_message();
if let Some(img) = base.image_message.as_option() {
return Some({
let mut value = wa::Message::default();
value.image_message = MessageField::some({
let mut value = img.clone();
value.caption = Some("Received your image!".to_string());
value
});
value
});
}
None
}See the media forwarding guide for more details.
Use spawn() to run the bot in the background while your code continues, and shutdown() for a clean stop:
use whatsapp_rust::prelude::*;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let bot = Bot::builder()
.with_backend(SqliteStore::open("whatsapp.db").await?)
.on_message(|ctx| async move {
let _ = ctx.reply("hello").await;
})
.build()
.await?;
let handle = bot.spawn(); // bot runs in the background; client is available via handle.client()
whatsapp_rust::shutdown_signal().await;
let report = handle.shutdown().await;
let stopped = matches!(
report.run,
BotRunOutcome::Completed(RunCompletionReason::ShutdownRequested)
);
let shutdown = &report.shutdown;
let drained = matches!(shutdown.inbound, DrainOutcome::Completed)
&& matches!(shutdown.outbound, DrainOutcome::Completed)
&& matches!(shutdown.signal_settle, DrainOutcome::Completed);
if !stopped
|| !drained
|| shutdown.device.is_err()
|| shutdown.message_secrets.failed_batches != 0
|| shutdown.message_secrets.first_error.is_some()
{
return Err(std::io::Error::other(format!("incomplete shutdown: {report:?}")).into());
}
Ok(())
}BotHandle also exposes abort() as an escape hatch that skips the flush. The example returns an error if the run did not stop as requested, a tracked drain did not complete, or persistence reported a failure. A completed drain does not prove successful delivery, and this report does not join every detached callback or stop concurrent producers. See the Bot API.
Add the logging dependencies alongside the pinned whatsapp-rust and Tokio dependencies above:
log = "0.4"
env_logger = "0.11"This example logs events and responds with text or reused media:
use log::{error, info};
use whatsapp_rust::prelude::*;
const PING_TRIGGER: &str = "🦀ping";
const PONG_TEXT: &str = "🏓 Pong!";
const REACTION_EMOJI: &str = "🏓";
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info"))
.format(|buf, record| {
use std::io::Write;
writeln!(
buf,
"{} [{:<5}] [{}] - {}",
whatsapp_rust::wacore::time::now_utc().format("%H:%M:%S"),
record.level(),
record.target(),
record.args()
)
})
.init();
let store = SqliteStore::open("whatsapp.db").await?;
info!("SQLite backend initialized");
let bot = Bot::builder()
.with_backend(store)
.on_qr_code(|code, _timeout| async move {
println!("\n{code}\n");
})
.on_connected(|_client| async {
info!("Bot connected!");
})
.on_logged_out(|_info| async {
error!("Bot was logged out!");
})
.on_message(|ctx| async move {
handle_message(ctx).await;
})
.build()
.await?;
info!("Starting bot...");
let reason = bot.run().await;
println!("Session ended: {reason:?}");
Ok(())
}
async fn handle_message(ctx: MessageContext) {
// Try CDN-reuse media reply first (instant, no download needed)
if let Some(reply) = build_media_pong(&ctx.message) {
if let Err(e) = ctx.send_message(reply).await {
error!("Failed to send media pong: {e}");
}
return;
}
if ctx.message.text_content() == Some(PING_TRIGGER) {
if let Err(e) = ctx.react(REACTION_EMOJI).await {
error!("Failed to send reaction: {e}");
}
if let Err(e) = ctx.reply_quoting(PONG_TEXT).await {
error!("Failed to send pong: {e}");
}
}
}
/// Reuses the original CDN blob, only swaps the caption.
/// Instant regardless of file size — no download or re-upload.
fn build_media_pong(message: &wa::Message) -> Option<wa::Message> {
let base = message.get_base_message();
if let Some(img) = base.image_message.as_option()
&& img.caption.as_deref() == Some(PING_TRIGGER)
{
return Some({
let mut value = wa::Message::default();
value.image_message = MessageField::some({
let mut value = img.clone();
value.caption = Some(PONG_TEXT.to_string());
value
});
value
});
}
if let Some(vid) = base.video_message.as_option()
&& vid.caption.as_deref() == Some(PING_TRIGGER)
{
return Some({
let mut value = wa::Message::default();
value.video_message = MessageField::some({
let mut value = vid.clone();
value.caption = Some(PONG_TEXT.to_string());
value
});
value
});
}
None
}whatsapp-rust uses the log crate with module-specific targets for fine-grained filtering. You can use RUST_LOG to control which components emit log output.
| Target | Description |
|---|---|
Client/Keepalive |
Keepalive ping/pong and dead socket detection |
Client/Recv |
Incoming frame processing (unmarshal, decompress) |
Client/Send |
Outgoing message encryption and dispatch |
Client/OfflineSync |
Offline message sync progress and timing |
Client/AppState |
App state sync (contacts, settings, etc.) |
Client/AccountSync |
Account-level sync operations |
Client/PairCode |
Pair code authentication flow |
Client/Pair |
QR code pairing flow |
Client/Receipt |
Receipt processing (read, delivered, played) |
Client/TcToken |
Trusted contact token operations |
Client/Group |
Group metadata and participant operations |
Client/Contacts |
Contact sync and lookup |
Client/Business |
Business profile updates |
Client/PDO |
Peer Data Operations — message recovery from primary phone via bare-JID peer messaging and retry fallback |
Client/IQ |
IQ stanza send/receive |
Client/Ack |
Stanza acknowledgment |
Client/Status |
Status/story operations |
Client/Picture |
Profile picture updates |
Client/UnifiedSession |
Session establishment |
Blocking |
Block/unblock operations |
Chatstate |
Typing indicator events |
PresenceHandler |
Presence update processing |
AppState |
App state patch encoding/decoding |
Bot/PairCode |
Bot-level pair code handling |
# Show only connection and messaging logs
RUST_LOG="Client/Keepalive=debug,Client/Send=debug,Client/Recv=trace" cargo run
# Debug offline sync issues
RUST_LOG="Client/OfflineSync=debug" cargo run
# Quiet mode: only errors and warnings
RUST_LOG="warn" cargo run
# Verbose: all client internals at debug level
RUST_LOG="debug" cargo runYou can also run the bot using Docker instead of compiling locally:
docker build -t whatsapp-rust .
docker run -v ./data:/data whatsapp-rustSession data is stored in the /data directory inside the container. Mount a volume to persist it across restarts. The container shuts down gracefully on docker stop — the bot disconnects cleanly from WhatsApp before exiting. See the installation guide for more details.
The repository includes a benchmark example at examples/benchmark.rs that you can use for quick integration-level performance testing. It uses an in-memory backend and supports a custom WebSocket URL via the WHATSAPP_WS_URL environment variable:
cargo run --example benchmark --features danger-skip-tls-verifyFor end-to-end numbers, run the bench-integration suite — it drives real scenarios (connect, send, receive, reconnect) against a mock server, so point MOCK_SERVER_URL at one before you start:
# Requires a mock server (e.g., Bartender)
MOCK_SERVER_URL="wss://127.0.0.1:8080/ws/chat" \
cargo bench -p bench-integrationFor low-level protocol numbers, run the divan suites in wacore — CI tracks the same ones through CodSpeed. They cover the full send/receive pipeline (DM and group messaging with various participant counts), binary protocol encoding, Signal Protocol operations, and reporting token generation:
# Default workspace members only — this leaves out bench-integration,
# which needs the mock server above
cargo bench
# The voip-gated suites need their feature turned on
cargo bench -p wacore --features voipSee the wacore benchmarks documentation for details on each suite, allocation optimizations, and CI integration.
Learn about different message types and how to send them Upload and download images, videos, and documents Create and manage WhatsApp groups Explore all available client methodsBefore changing the dependency, read the Rust migration guide. Its newer merged contracts and experimental proposals are separate from this page's source pin.