WhatsApp Operators DailyThe Blueticks DispatchMonday, September 28, 2026
Productivity

How to Send WhatsApp Messages from Rust on Your Own Number (2026)

One authenticated POST from Rust with reqwest, Tokio, and serde. Then the parts that bite an Actix or Axum service: the encoder that ruins the path, the null waMessageKey, and a spawn fan-out that sends twice.

DRBy Daniel Roth · September 28, 2026 · 11 min read
How to Send WhatsApp Messages from Rust on Your Own Number (2026)

Your Rust service already knows the order shipped, the invoice is overdue, the job just finished. Getting that fact into WhatsApp is where it stalls, usually behind a Business Solution Provider contract nobody wants to sign for a few hundred notifications a day.

Here is the plain version. A whatsapp api rust integration is one authenticated POST to a REST endpoint, sending from the WhatsApp number you already own, linked as a device over WhatsApp Web or a managed 24/7 gateway. It is not Meta's Cloud API, so there are no templates, no Business verification, and no per-message billing anywhere below. Consent stays yours, and nobody can promise a number will never be actioned.

What is a WhatsApp API integration in Rust, really?

It is a single HTTP call from your own code to a number you already control. You POST to a REST endpoint, authenticate with a bearer key, and the message leaves from your linked WhatsApp account over WhatsApp Web or a hosted gateway. This is the own-number model, not Meta's Cloud API, so no templates and no per-message fees apply.

The distinction changes your whole integration. On the Cloud API path you register a number with Meta, get it approved, write and submit message templates, and pay per message. Meta deprecated conversation-based pricing on 1 July 2025, so the Cloud API now bills per message sent. On the own-number path you send from a phone your customers already have saved, in plain text, with a bt_live_ key.

Rust shares no runtime and no package ecosystem with the other languages in this series, so nothing carries over line for line. But the wire shape is identical: it is the same REST call another statically-typed backend makes. The Go guide and the Kotlin guide walk the same first send in their own idioms. This piece takes the idiomatic-Rust path: an async Tokio runtime, a reused reqwest::Client, serde for the body and response, and Option/Result doing the null-handling the compiler enforces. The auth model and why your own number works this way is the subject of the own-number REST API guide, so this piece stays on the Rust mechanics.

Hold onto one thing from the start. Sending from your own number is not a licence to blast. A loop firing unsolicited messages is the fastest way to get a number flagged under WhatsApp's Business Policy. Consent is yours to collect.

What do you need before your first send from Rust (Actix, Axum, or a Tokio worker)?

Four things: a recent stable Rust toolchain (1.75+ / edition 2021 is a safe floor), the Tokio async runtime, three crates (reqwest, tokio, serde/serde_json), and a WhatsApp number already linked to your Blueticks account with a bt_live_ key. The recipient goes in the URL path, not the JSON body. That one detail causes most first-attempt failures.

Here is the honest part this series can skip in Go and Kotlin but cannot skip in Rust. The standard library has no HTTP client, so a Rust first send genuinely needs dependencies: reqwest for the request, tokio for the runtime it awaits on, and serde/serde_json to build and parse JSON. There is no zero-dependency version. These are the standard, minimal crates for the job, and any Actix or Axum service already has all four in its tree.

The pre-flight, in order:

  1. Add the crates. In Cargo.toml: reqwest = { version = "0.12", features = ["json"] }, tokio = { version = "1", features = ["full"] }, serde = { version = "1", features = ["derive"] }, and serde_json = "1".
  2. Mint a key. Open dev.blueticks.co, sign in, create a key, copy it once. Keys are bearer tokens, so read them with std::env::var("BLUETICKS_API_KEY"), never a committed file.
  3. Link a number. Blueticks drives your number, not a Meta-provisioned one. If you already connected a phone in the app, you are done.
  4. Check what you do not need. No Meta Business verification, no message templates, no per-message billing.

Now the trap. The endpoint is POST /v1/scheduled-messages/{chatId} on https://api.blueticks.co, and {chatId} is a path segment. A recipient is an E.164 number with a leading +, and a bare + in a path is ambiguous, so it has to be percent-encoded as %2B. You can also target a chat id directly, such as 120363...@g.us for a group. The same first send in shell, Python and Node lives in the language-agnostic walkthrough if you want to sanity-check the request outside Rust first.

How do you send your first WhatsApp message from Rust with reqwest, Tokio, and serde?

Build the body from a #[derive(Serialize)] struct, set Authorization: Bearer, and POST to the escaped path with one reqwest::Client inside a #[tokio::main] async function. Omit sendAt and it sends immediately. This is the send whatsapp message rust baseline; everything after it is hardening.

use serde::Serialize;
use std::time::Duration;

#[derive(Serialize)]
struct SendBody<'a> {
    // `type` is a Rust keyword, so rename the wire field explicitly.
    #[serde(rename = "type")]
    kind: &'a str,
    text: &'a str,
    // Omit sendAt entirely (skip when None) and the send is immediate.
    #[serde(rename = "sendAt", skip_serializing_if = "Option::is_none")]
    send_at: Option<String>,
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let api_key = std::env::var("BLUETICKS_API_KEY")?; // bt_live_YOUR_KEY
    let recipient = "+15551234567";

    // ONE client for the whole process (see the next section). Never per send.
    let client = reqwest::Client::builder()
        .connect_timeout(Duration::from_secs(5))
        .timeout(Duration::from_secs(15)) // whole-request read ceiling
        .build()?;

    // The recipient is a PATH segment: '+' -> %2B (see the escaping section).
    let url = format!(
        "https://api.blueticks.co/v1/scheduled-messages/{}",
        encode_path_segment(recipient),
    );

    // Build JSON from the struct, never format!: a quote in a name would
    // break a hand-concatenated payload.
    let body = SendBody {
        kind: "text",
        text: "Your invoice #4471 is due tomorrow.",
        send_at: None,
    };

    let resp = client
        .post(&url)
        .bearer_auth(&api_key)
        .header("Idempotency-Key", "invoice-4471-reminder")
        .json(&body) // sets Content-Type: application/json and serializes
        .send()
        .await?;

    println!("{} {}", resp.status(), resp.text().await?);
    Ok(())
}

Three things about that body. type is required and validation-only, so a bare {"text":"hi"} is rejected with a 400. Text tops out at 4,096 characters. A 2xx that returns a resource is wrapped in {"success":true,"data":{...}}, so the id lives at data.id, never at the top level.

Two notes on the Rust side. The #[serde(rename = "type")] is not decoration: type is a reserved keyword, so the field is named something else and mapped to the wire name. And .json(&body) both serializes the struct and sets Content-Type for you, using the reqwest JSON feature you enabled in Cargo.toml. The encode_path_segment helper is defined two sections down.

A smartphone face-down on a calm wooden desk beside a hand and a closed notebook, a WhatsApp send confirmed from a Rust workflow

Why reuse ONE reqwest::Client instead of building one per send?

Build one reqwest::Client for the process lifetime and reuse it. The reqwest docs state the client holds a connection pool internally and is Arc-based, so it should be created once and reused, and a .clone() is cheap because it shares the same pool. A fresh reqwest::Client::new() per send throws that pool away and pays a new TLS handshake on every message.

The correct shape is one client built with explicit timeouts, stored where the whole process can reach it: a once_cell::Lazy or std::sync::OnceLock static, or an app-state field in Axum or Actix. To hand it into a tokio::spawned task, clone it. The clone is not a new pool, it is another handle to the same one.

use std::time::Duration;

// Build ONCE. Client is Arc internally, so `.clone()` shares this pool.
let client = reqwest::Client::builder()
    .connect_timeout(Duration::from_secs(5))
    .timeout(Duration::from_secs(15))
    .pool_max_idle_per_host(20) // keep warm connections to one busy host
    .build()?;

The anti-pattern is quiet and common. Creating the client inside the send function, or inside a loop, looks harmless in a demo and starves you under load:

// WRONG: a new Client (and pool) per send. TLS handshake every message.
async fn send_bad(text: &str) -> reqwest::Result<()> {
    let client = reqwest::Client::new(); // throws away the pool on every call
    client
        .post("https://api.blueticks.co/v1/scheduled-messages/%2B15551234567")
        .json(&serde_json::json!({ "type": "text", "text": text }))
        .send()
        .await?;
    Ok(())
}

One async note. Do the send inside the Tokio runtime and stay on the async client. A synchronous HTTP client on the async path blocks a worker thread Tokio needs for everything else, stalling unrelated tasks on the same runtime. Keep .await on the async reqwest::Client.

How do you escape the recipient path segment without silently sending to the wrong number?

The leading + in an E.164 number must be percent-encoded to %2B because it sits in the URL path. Rust's reflex, the urlencoding crate or form_urlencoded, applies application/x-www-form-urlencoded rules and turns a space into +. That is correct for a query string and wrong for a path segment.

For a bare phone number you would never notice, because a plain number has no space to mangle. The day a chat id or a display value carries a character the two rules treat differently, a query encoder ships a + where the server expected %20, and the send resolves to the wrong recipient with no error. Use the percent-encoding crate against a path-segment set instead, per RFC 3986:

use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC};

// RFC 3986 path-segment escaping. NON_ALPHANUMERIC encodes '+' -> %2B,
// space -> %20, '@' -> %40. It over-encodes '.' and '-' too, which the
// server decodes back, which is safe for a path segment. Do NOT reach for a
// query/form encoder here: those turn a space into '+'.
fn encode_path_segment(recipient: &str) -> String {
    utf8_percent_encode(recipient, NON_ALPHANUMERIC).to_string()
}

// +15551234567       -> %2B15551234567
// 120363...@g.us     -> 120363...%40g.us

Or build the URL with a proper URL builder that applies path escaping when you push the recipient as a path segment. The point is the same either way: the recipient is a path segment, not a query parameter, so it needs path rules.

How do you model the response and survive a null waMessageKey with Option?

Model the envelope with #[derive(Deserialize)] structs and declare wa_message_key: Option<WaMessageKey>. On a scheduled send the engine has not dispatched yet, so waMessageKey comes back null, which deserializes to None. It only fills in later as an object with fromMe, remote, id, _serialized and participant. It is an object, never a bare key string.

This is the part that passes every test and breaks in production if you type it wrong. Declare wa_message_key: WaMessageKey (not optional) and the first scheduled send fails to deserialize on the null. Declare it Option<WaMessageKey> and the compiler will not let you touch the inner value without a match or if let Some(...). A stronger guard than a runtime null-check: a None cannot be dereferenced at all.

use serde::Deserialize;

#[derive(Deserialize, Debug)]
struct WaMessageKey {
    #[serde(rename = "fromMe")]
    from_me: bool,
    remote: Option<String>,
    id: Option<String>,
    #[serde(rename = "_serialized")]
    serialized: Option<String>,
    participant: Option<String>,
}

#[derive(Deserialize, Debug)]
struct MessageData {
    id: String,     // 24-char hex queue id, your handle
    status: String, // pending | confirmed | received | read | played | failed
    #[serde(rename = "waMessageKey")]
    wa_message_key: Option<WaMessageKey>, // null until dispatch -> None
}

#[derive(Deserialize, Debug)]
struct Envelope {
    success: bool,
    data: Option<MessageData>,
}

fn handle(response_body: &str) -> Result<(), serde_json::Error> {
    let env: Envelope = serde_json::from_str(response_body)?;
    let Some(data) = env.data else { return Ok(()) }; // 2xx with no payload
    let message_id = &data.id; // use this as your handle meanwhile
    if let Some(key) = &data.wa_message_key {
        // Only inside this block is the key guaranteed to exist.
        println!("queued {message_id}, wa key: {:?}", key.serialized);
    } else {
        println!("queued {message_id}, not dispatched yet");
    }
    Ok(())
}

The trimmed response on a scheduled send looks like this:

{ "success": true, "data": { "id": "6a1f...c2", "status": "pending", "waMessageKey": null } }

Two Rust wins here. serde ignores unknown fields by default, so a new server response field never breaks decoding as long as you do not add #[serde(deny_unknown_fields)]. And you can model status as an enum with #[serde(rename_all = "lowercase")] plus an #[serde(other)] Unknown variant, so a new status value maps to Unknown instead of failing the parse. Read data.id, the 24-character hex queue id, as your handle in the meantime. The status lifecycle a message moves through is pending, then confirmed, received, read, played, or failed.

How do you schedule a message for later and get sendAt right in Rust?

Add a camelCase sendAt field holding an RFC 3339 timestamp with an explicit offset. Build it with chrono::Utc::now() + chrono::Duration::hours(2) then .to_rfc3339(), which serialises a UTC instant with a +00:00 offset so the moment is unambiguous. The accepted window is roughly 10 seconds to 365 days ahead; anything outside is rejected at validation with a 400.

use chrono::{Duration, Utc};

// Utc::now() gives a UTC instant; to_rfc3339() writes an explicit offset.
let send_at = (Utc::now() + Duration::hours(2)).to_rfc3339();
// -> 2026-09-28T14:30:00+00:00

let body = SendBody { kind: "text", text: "Reminder", send_at: Some(send_at) };

The Rust-specific way this goes wrong is reaching for chrono::Local::now() or a naive NaiveDateTime. Local serialises your server's local offset, and a NaiveDateTime has no offset at all, so the same code schedules a different instant on a laptop in Berlin than in a UTC container. Always use Utc. The time crate works too: OffsetDateTime::now_utc().format(&Rfc3339). A second trap: compute sendAt, let the job sit in a queue for nine seconds, and dispatch lands inside the floor with 400 sendAt must be at least 10 seconds in the future. Compute it at send time, or leave a minute of headroom. Recurring cadences and deeper timezone hardening are a separate mechanism, covered in the scheduling-in-production guide.

Ready to wire this into your Actix or Axum service? Get a bt_live_ key and POST from a plain Tokio worker on the number you already own. No Meta Business verification, no message templates, no per-message fees. Point it at a live number and watch the first send land in seconds.

How do you make sends reliable from a Rust service: Tokio tasks, idempotency keys, and retries that never double-send?

Push the send off the request path into a tokio::spawned task, retry only 429 and 5xx (never a 400), carry a stable Idempotency-Key derived once from the business object, and cap concurrency with an Arc<Semaphore> so a batch does not fan out five hundred tasks wide. That combination is the whole game for a rust whatsapp integration that survives real traffic.

A tidy flat-lay with a closed laptop, a face-down phone, coffee, and one neat evenly-spaced row of identical pencils, an orderly Rust send workflow

use std::sync::Arc;
use std::time::Duration;
use tokio::sync::Semaphore;

async fn send_reliably(
    client: reqwest::Client,
    api_key: String,
    recipient: String,
    text: String,
    idem_key: String, // the SAME value on every attempt
) {
    let url = format!(
        "https://api.blueticks.co/v1/scheduled-messages/{}",
        encode_path_segment(&recipient),
    );
    let body = SendBody { kind: "text", text: &text, send_at: None };

    for attempt in 1..=5u32 {
        let res = client
            .post(&url)
            .bearer_auth(&api_key)
            .header("Idempotency-Key", &idem_key)
            .json(&body)
            .send()
            .await;

        match res {
            Ok(r) if r.status().is_success() => return,
            // Retry ONLY 429 + 5xx. A 400 is a bad body: it fails
            // identically forever, so retrying just burns attempts.
            Ok(r)
                if r.status() == reqwest::StatusCode::TOO_MANY_REQUESTS
                    || r.status().is_server_error() =>
            {
                tokio::time::sleep(Duration::from_secs(attempt as u64)).await;
            }
            Ok(_) => return, // other 4xx: give up, retrying will not help
            Err(_) => tokio::time::sleep(Duration::from_secs(attempt as u64)).await,
        }
    }
}

async fn send_batch(client: reqwest::Client, api_key: String, orders: Vec<(String, String)>) {
    // Cap in-flight sends so a 500-row batch doesn't leave in one angry burst.
    let gate = Arc::new(Semaphore::new(4));
    let mut handles = Vec::new();

    for (phone, order_id) in orders {
        // Acquire a permit BEFORE spawning: this throttles the fan-out itself.
        let permit = gate.clone().acquire_owned().await.unwrap();
        let client = client.clone(); // cheap: shares the one pool
        let api_key = api_key.clone();
        handles.push(tokio::spawn(async move {
            let _permit = permit; // held for the send, released on drop
            send_reliably(
                client,
                api_key,
                phone,
                format!("Your order {order_id} has shipped."),
                // Key derived ONCE from the business object. Every retry reuses it.
                // A per-attempt Uuid::new_v4() turns each retry into a NEW message.
                format!("order-{order_id}-shipped"),
            )
            .await;
        }));
    }
    for h in handles {
        let _ = h.await;
    }
}

Two rules keep the retry safe. First, retry only 429, 5xx and transport errors, never a 400; a 400 is a bad body and fails identically forever. Second, the Idempotency-Key is computed once from the order, ticket, or reminder (order-4471-shipped), so every attempt carries the identical value. A Uuid::new_v4() per attempt turns each retry into a new message. The key is capped at 64 characters and scoped per workspace: resend the same key with an identical body and the original response replays, returning the same 24-hex message id, so detect a replay by the id, not the status. A different body under the same key returns 409 Conflict, which means your key derivation has a bug.

The Semaphore is not only about throughput. A wide-open tokio::spawn fan-out, five hundred tasks firing as fast as they drain, is the burst pattern most likely to flag a number. Acquiring the permit before the spawn caps the real in-flight count. One production limit to design around: the free plan allows 5 requests per 6-hour window, shared across the REST and MCP surfaces, so a loop testing ten sends hits 429 on the sixth within a second. Any active subscription removes the ceiling. As one Rust operator running nightly shipping notifications put it, "the stable key was not about tidiness, it was the difference between one 'your order shipped' and three at 2am after a pod restart replayed the queue."

When should you use this vs the WhatsApp Business (Cloud) API instead?

Use the own-number path for transactional volume, real two-way conversations, and a number your customers already have saved. Use Meta's Cloud API and a Business Solution Provider when you need approved marketing templates at broadcast scale, an Official Business Account badge, or a multi-agent shared inbox. The trade is honest and runs both ways.

A calm split workspace with a small server rack on one side and a phone on the other, joined by one clean cable, one hosted endpoint reached from a Rust backend

The Rust-specific reason a hosted REST engine fits is maintenance. The maintained WhatsApp Web clients are Node projects, not Rust ones. Running your own from an Actix or Axum service means supervising a second runtime, a Node process and a browser profile, next to your Rust binary forever: a session store, a reconnect supervisor, and a pager rotation for something that is not your product. A hosted /v1 endpoint removes that whole tier, the argument the no-Meta-verification guide makes in full.

A two-way whatsapp bot rust build is the same POST plus a webhook receiver to read inbound messages, so the send half you have here does not change. The same first send exists in Go, Kotlin, Java, C# and PHP, and the bot-shaped variants live in the Python and Node.js guides.

One honest warning, because Tokio makes it easy to get wrong. tokio::spawn five hundred sends without a Semaphore and you fire them as fast as they drain. That burst is the pattern most likely to get a number flagged, whatever sent it, and WhatsApp's rules on automated and bulk messaging apply either way. Keep concurrency low, spread a batch over hours, and remember that consent is the sender's responsibility. Nobody can guarantee a number will never be actioned.

FAQ

Can I send a WhatsApp message from Rust without reqwest?

Not without replacing it with another HTTP client, because Rust's standard library has none. reqwest is the common choice and pairs naturally with Tokio and serde. You could hand-roll requests over hyper or a raw TLS socket, but that is more code for no gain. The realistic minimum for a first send is reqwest, tokio, and serde/serde_json.

Should I use reqwest or a lighter client like ureq from a Rust service?

Use reqwest if your service is already async on Tokio, which any Actix or Axum app is, because its client is async and holds a reusable connection pool. ureq is synchronous and simpler for a small CLI or a one-shot script, but calling a blocking client on an async runtime blocks a worker thread. Match the client to the runtime you already run.

Why does waMessageKey come back null, and how do I model it in Rust?

On a scheduled send the engine has not dispatched the message yet, so waMessageKey is null and only fills in later as an object with fromMe, remote, id, _serialized, and participant. Model it as Option<WaMessageKey> with #[serde(rename = "waMessageKey")] and read it behind if let Some(...). Use the 24-hex id as your handle meanwhile. Typing it as a non-optional struct fails to deserialize.

How do I stop a Tokio retry from sending the same WhatsApp message twice?

Send an Idempotency-Key header derived once from the business object (order-4471-shipped) so every retry reuses it. A replay returns the original response and the same 24-hex message id instead of sending again. A Uuid::new_v4() per attempt defeats the mechanism; the key must be stable across retries and is capped at 64 characters. Cap the fan-out with an Arc<Semaphore> so retries never stampede.

Do I need the Meta Cloud API to send WhatsApp from a Rust (Actix/Axum) backend?

No. The own-number path sends from a number you already control over WhatsApp Web or a hosted gateway, so there is no Meta Business verification, no message templates, and no per-message fees. Reach for the Cloud API and a Business Solution Provider only when you need approved marketing templates at broadcast scale or an Official Business Account badge.

Will sending from Rust get my WhatsApp number banned?

Nobody can guarantee it will not. You are messaging from your own number under WhatsApp's Business Policy and its rules on automated and bulk messaging, and a wide tokio::spawn fan-out firing unsolicited sends is the pattern most likely to get a number flagged. Message people who asked to hear from you, cap concurrency with a Semaphore, spread batches over hours, and stop when someone asks. Consent is the sender's responsibility.

Email

The Dispatch, every week.

One sharp WhatsApp growth tactic in your inbox each week. Joined by 238,000+ founders, marketers and support leads.

Free forever. No spam, unsubscribe in one click.