WhatsApp Business Platform for Rust — a typed client for Meta's Cloud API and Business Management API, webhooks, pluggable storage/transport/sink adapters, and Typst-rendered documents.
Built for three jobs:
- Marketing for an e-commerce store: templates, the Marketing Messages API, In-App Signup opt-ins, catalogs and product messages.
- In-app chat in a CMS: each merchant onboards their own number with Embedded Signup; customer messages arrive by webhook, are stored per conversation and streamed live to the merchant's inbox.
- Authentication: WhatsApp OTP through authentication templates.
What is implemented, and what is not, is in
docs/coverage.md; the design is
docs/architecture.md. The API reference is the
rustdoc: cargo doc -p meta-whatsapp-rs --all-features --open.
meta-whatsapp-rs is not on crates.io (see Naming); depend on it from git,
pinned to a commit:
[dependencies]
meta-whatsapp-rs = { git = "https://github.com/vaam-apps/meta-whatsapp-rs", rev = "<commit>", features = ["axum", "postgres"] }
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }axum and sqlx types are part of the API (Router, PgPool), so use the
versions meta-whatsapp-rs was built with, re-exported: meta_whatsapp_rs::webhooks::axum
(feature axum) and meta_whatsapp_rs::adapters::store::postgres::sqlx (feature
postgres), as the examples do. If you need axum features meta-whatsapp-rs does not
turn on, add axum = "0.8" with them yourself: Cargo builds one axum 0.8
for both, so the types still match.
use meta_whatsapp_rs::prelude::*; brings in the client, ids, Recipient, the
message and template builders, the webhook pieces, the storage and sink
ports, and the inbox. The snippets below are excerpts of the runnable
programs in crates/meta-whatsapp-rs/examples/ (a test
keeps them in sync); there, env("X") is std::env::var("X") with the
variable's name in the error. Each example's header lists the variables it
reads and the command that runs it.
From send_message.rs. A
free-form message only reaches a customer who wrote to you in the last 24
hours; a template reaches anyone who opted in. Errors are branched on
ErrorKind, never on their text.
let client = meta_whatsapp_rs::client(env("WA_TOKEN")?)?;
let messages = client.messages(env("WA_PHONE_NUMBER_ID")?);
let to = Recipient::phone(env("WA_TO")?); // E.164, with `+`
let text = OutboundMessage::text(to.clone(), "Your order has shipped.");
match messages.send(&text).await {
Ok(sent) => println!("text accepted as {:?}", sent.message_id()),
Err(e) if e.kind() == ErrorKind::CustomerServiceWindowClosed => println!("window closed"),
Err(e) => return Err(e.into()),
}
let hello = TemplateMessage::new(
env_or("WA_TEMPLATE", "hello_world"),
env_or("WA_TEMPLATE_LANGUAGE", "en_US"),
);
let sent = messages.send(&OutboundMessage::template(to, hello)).await?;invoice_document.rs renders
an invoice PDF with Typst, uploads it and sends it as a document.
From embedded_signup.rs, a
server with the Facebook JavaScript SDK page. It is a Tech Provider
server (each merchant adds a payment method); a Solution Partner, whose
credit line pays, onboards with onboard_with_approval instead: see
Solution Partner mode.
When a merchant starts, bind the attempt to them and give the page the
FB.login options:
let state = signup.sessions.start(&tenant, ATTEMPT_TTL).await?;
let launch_options = LaunchOptions::new(signup.config_id.as_str()).to_json()?;When the page posts back the code, the WA_EMBEDDED_SIGNUP event and the
number's two-step verification PIN (the merchant's own, typed into the page),
redeem the state for the merchant your own authentication says is calling —
never a tenant named by the page or the URL — then onboard: exchange the
code, check the WABA and number with Meta, store the token encrypted,
subscribe the app, register the number.
// Exactly once, and only for the merchant who started the attempt.
if !signup.sessions.redeem(&state, &tenant).await? {
return Err(ApiError::StaleAttempt);
}
let onboarded = signup.onboarding.onboard(&request, &signup.vault).await;From cms_inbox.rs. Webhook events
are verified, deduplicated, recorded per conversation and published for
the live view:
let (live, _) = broadcast::channel(256);
let sink = FanoutSink::new()
.with(InboxSink::new(conversations.clone()))
.with(BroadcastSink::from_sender(live.clone()));
let handler = WebhookHandler::builder(
SignatureVerifier::new(vec![app_secret])?, // X-Hub-Signature-256
verify_token,
Arc::new(sink),
)
.dedup(DedupGuard::new(kv)) // Meta retries for 7 days: record each event once
.build();
let webhook = meta_whatsapp_rs::webhooks::router(Arc::new(handler)); // GET verify, POST deliverEvery /inbox route first asks who is calling (a bearer token in the
example, your session in your CMS) and whether that merchant owns the
number; only then is the merchant's token taken from the vault. Replies go
out as that merchant, and only inside the 24-hour window:
// Your own table says which numbers this tenant owns; ask it before
// touching the vault, whose tokens belong to every merchant.
if !state.tenants.owns(tenant, &number) {
return Err(ApiError::Forbidden);
}
// The business token Embedded Signup stored for this number's WABA.
let Some(merchant) = state.vault.get_by_phone_number(&number).await? else {
return Err(ApiError::NotConnected);
};
let key = inbox.key(body.contact);
// Refused locally, before any request, outside the 24-hour window.
let sent = inbox.reply(&key, Text::new(body.text).into()).await?;Both servers listen on 127.0.0.1 unless WA_BIND says otherwise, and
refuse to start without WA_TENANTS, the stand-in for your authentication
(bearer token → tenant, and tenant → phone numbers for the inbox). Replace
it with your own sessions and tenant table; keep the checks where they are.
From otp_login.rs. Codes are stored
as keyed hashes and bound to the sending number and the required
OtpConfig::namespace (the tenant the codes are for, so tenants sharing a
number never share codes), issuing is rate-limited per number, and verify
attempts are counted atomically:
let otp = OtpService::new(
meta_whatsapp_rs::client(env("WA_TOKEN")?)?,
env("WA_PHONE_NUMBER_ID")?,
OtpTemplate::new(env("WA_OTP_TEMPLATE")?, env_or("WA_OTP_LANGUAGE", "en_US")),
Arc::new(MemoryKvStore::new()), // Postgres or Redis with several instances
Arc::new(SystemClock),
OtpPepper::new(env("WA_OTP_PEPPER")?)?, // >= 32 bytes, not stored with the codes
OtpConfig::new(env("WA_OTP_NAMESPACE")?), // the tenant: required, never a default
)?;
let user = Recipient::phone(env("WA_TO")?); // strict E.164, with `+`
let issued = otp.issue(&user, PURPOSE).await; // Sent, CoolingDown or RateLimited
let verified = otp.verify(&user, PURPOSE, code.trim()).await?; // counts as an attemptA number that answers /commands, button taps and free text: the bot
feature's Bot is an EventSink behind the same webhook handler, with
prefixes and aliases, private/group/owner-only guards, a banned list,
per-user cooldowns, middleware, one compiled-in plugin per feature, a
generated /help, Meta's command menu, and Markdown replies converted to
WhatsApp formatting. Its Broadcast sends one message to many
recipients, each person once, paced per number under Meta's throughput,
with progress, a cancel and a report per recipient. Guide: docs/guides/bots.md.
| Example | What | Command |
|---|---|---|
send_message |
a text, then a template | cargo run -p meta-whatsapp-rs --example send_message |
invoice_document |
Typst invoice → upload → document message | cargo run -p meta-whatsapp-rs --example invoice_document --features typst |
embedded_signup |
onboarding server and launch page | WA_TENANTS=… WA_APP_ID=… WA_APP_SECRET=… WA_ES_CONFIG_ID=… cargo run -p meta-whatsapp-rs --example embedded_signup --features axum |
cms_inbox |
webhook endpoint, inbox, SSE, replies | WA_TENANTS=… WA_APP_SECRET=… WA_VERIFY_TOKEN=… cargo run -p meta-whatsapp-rs --example cms_inbox --features axum |
otp_login |
issue and verify a code | cargo run -p meta-whatsapp-rs --example otp_login |
embedded_signup and cms_inbox refuse to start without WA_TENANTS
(their headers show a one-line setup) and listen on 127.0.0.1 unless
WA_BIND says otherwise. Add postgres to the features and set DATABASE_URL to
run them on Postgres; with the same DATABASE_URL, WA_VAULT_KEY and
WA_TENANTS, the merchant you connect in the first is the one you chat as
in the second (list the connected number under that tenant's
phone_number_ids).
Apps in other stacks (a Medusa store, a CMS backend) use meta-whatsapp-rs
through meta-whatsapp-server, an HTTP service built on the library and
deployed next to them: one deployment per Meta app, many tenants, keys per
tenant or per platform, the /v1 REST API described by a committed OpenAPI
document (crates/meta-whatsapp-server/openapi/v1.json).
Milestone M1 is here: tenants, keys, the admin API, the platform's own
WABAs, numbers and business profiles, vault key rotation, sending
messages, media and templates (with idempotency keys and rate limits),
Meta's webhooks into the inbox and an event outbox polled with
GET /v1/events, health, metrics. The inbox routes, live events,
webhooks to your backend, Embedded Signup, OTP, the Docker image and the
TypeScript client come in the next milestones
(docs/design/server.md, section 9).
cargo build --release -p meta-whatsapp-server
export DATABASE_URL=postgres://… WA_APP_SECRET=… WA_VERIFY_TOKEN=…
export WA_VAULT_KEY="$(openssl rand -base64 32)" WA_OTP_PEPPER="$(openssl rand -hex 32)" # keep both
./target/release/meta-whatsapp-server admin create-admin-key # printed once
./target/release/meta-whatsapp-server serve # 127.0.0.1:8080 (Meta), :8081 (API)It refuses to start on an unsafe setting (a blank secret, no vault key,
memory storage in production). Run, configure, create tenants and keys,
make a first call: docs/guides/server.md. For the
coding agents of those apps: the meta-whatsapp-rs-server skill.
| Feature | Default | Adds |
|---|---|---|
reqwest |
yes | adapters::http::ReqwestTransport (rustls, HTTP/2) and the meta_whatsapp_rs::client() / meta_whatsapp_rs::client_builder() shortcuts |
memory |
yes | MemoryKvStore, MemoryConversationStore: tests, development, one instance |
sinks |
yes | channel, broadcast, fan-out, filter, fn and tracing sinks |
postgres |
PostgresKvStore, PostgresConversationStore, embedded migrations (sqlx) |
|
redis |
RedisKvStore |
|
axum |
webhooks::router (webhook endpoint) and webhooks::sse (live inbox stream) |
|
typst |
meta_whatsapp_rs::typst: invoice, receipt and voucher templates → PDF/PNG |
|
flows-endpoint |
WhatsApp Flows data-endpoint crypto (aws-lc-rs) | |
bot |
meta_whatsapp_rs::bot: a bot framework (commands, guards, cooldowns, middleware, plugins, Markdown replies, paced broadcasts) |
|
testing |
core::testing::ScriptedTransport for your own tests (enable in [dev-dependencies]) |
|
full |
all of the above |
| Crate | Role |
|---|---|
meta-whatsapp-rs |
Facade: depend on this. Re-exports the others (meta_whatsapp_rs::client, webhooks, adapters, core, typst, bot), a prelude, the CMS inbox, and the client() shortcut. Feature flags pick adapters. |
meta-whatsapp-core |
Error tree, ids, secrets, ports (HttpTransport, KvStore, ConversationStore, EventSink, Clock). |
meta-whatsapp-client |
Graph API client, one module per endpoint family; OTP service, Embedded Signup onboarding and token vault. |
meta-whatsapp-webhooks |
Signature/verify-token checks, typed payloads, normalized events, dedup, axum router + SSE. |
meta-whatsapp-adapters |
reqwest transport; memory, Postgres, Redis stores; channel/broadcast/fan-out sinks. |
meta-whatsapp-typst |
Typst → PDF/PNG (invoices, receipts, vouchers) for document and image messages. |
meta-whatsapp-bot |
Bot framework over webhooks: commands, guards, cooldowns, middleware, compile-time plugins, Markdown → WhatsApp formatting, paced broadcasts. |
meta-whatsapp-server |
The HTTP service (a binary, not a dependency): tenants, keys, the /v1 API over the facade. See Not writing Rust? Run the service. |
meta-whatsapp-server-core |
The service's framework-free core (not a dependency either): its domain, authorization order, error model as data, and the storage ports its memory and Postgres backends implement. No axum, sqlx or utoipa. |
just # list recipes
just ci # the gate CI runs: lint, check, test, skills-check, skills-ts, doc, features, deny, test-live
just test # unit and in-process tests (live adapter tests skip)
just test-live # adapter, inbox and service tests against real Postgres and Redis
just meta-docs # mirror Meta's docs locally (gitignored) for grepOpen the repo in the dev container (.devcontainer/) for a sandboxed
Claude Code environment: pinned toolchain, default-deny egress firewall,
Postgres and Redis sidecars.
Agents: see AGENTS.md. Claude Code project skills and agents
live in .claude/.
Coding agents in the repositories that use meta-whatsapp-rs (the store, the CMS) get
consumer skills from skills/: 28 small, task-shaped
skills (meta-whatsapp-rs is the map; meta-whatsapp-rs-send-messages, meta-whatsapp-rs-webhook-endpoint,
meta-whatsapp-rs-otp-login, meta-whatsapp-rs-cms-inbox, …). Install all of them with
npx skills add vaam-apps/meta-whatsapp-rs, or a subset with
npx skills add vaam-apps/meta-whatsapp-rs -s meta-whatsapp-rs -s meta-whatsapp-rs-cms-inbox. Each is stamped
with the meta-whatsapp-rs commit it was verified against, and just ci keeps them
true: their Rust blocks are excerpts of example files it compiles and
tests, and every Rust name they use must exist. A public API change
updates them in the same PR.
The project was called wa-rs until 2026-09-25, a name taken on crates.io
by an unrelated project; the owner renamed it (the CHANGELOG's "Renamed"
section maps every old name to its new one). The meta-whatsapp-* crate
names were free on crates.io that day, but nothing is published: the
workspace stays publish = false until a release is decided.
| Read | For |
|---|---|
| docs/guides/ | integrator guides: Meta setup, Embedded Signup, webhooks, CMS inbox, marketing, OTP login, documents, bots, production, the HTTP service |
| docs/coverage.md | what is implemented, per Meta feature |
| docs/parity.md | capability by capability against Zaileys and Meta's Cloud API, with the status of each |
| docs/categories.md | what is implemented, per Meta platform category |
| docs/roadmap.md | the plan to parity, in pull-request-sized items, and what waits for the owner |
| docs/architecture.md | the design spec: ports, error tree, security rules |
| OPEN_QUESTIONS.md | product decisions, all but one decided on 2026-09-26, and what the code does today (read before production) |
| CONTRIBUTING.md | setup, the just ci gate, review pipeline, docs/skills parity |
| docs/dev-environment.md | the Claude Code dev container and its firewall |
| CHANGELOG.md | what changed |
| skills/ | agent skills for code that uses meta-whatsapp-rs |
cargo doc --open -p meta-whatsapp-rs --all-features |
the API reference |
MIT