Getting started
Goal: from an empty Meta account to a first WhatsApp message sent from Rust, with errors handled the way the library expects.
Example: send_message.rs. Agent skills: meta-whatsapp-rs-setup, meta-whatsapp-rs-errors.
1. On Meta's side
Do these once. Paths in the App Dashboard move from time to time; the linked pages are the authority.
| # | Step | You end up with |
|---|---|---|
| 1 | Register as a Meta developer, then in the App Dashboard create an app with the Connect with customers through WhatsApp use case, attached to a business portfolio | an app id |
| 2 | In the use case's API Setup, connect an existing WhatsApp Business Account (WABA) or create one; Meta gives you a test business number | a WABA id and a phone number id |
| 3 | Send the sample hello_world template from that page to your own WhatsApp, then reply to it from your phone | an open 24-hour customer service window for tests |
| 4 | In Business Settings → System users, add a system user, assign it your app (manage app) and your WABA (full control), and generate a token with whatsapp_business_messaging and whatsapp_business_management (business_management only if your code manages the portfolio itself) | a long-lived system user token |
| 5 | App Dashboard → App settings → Basic | the app secret (server-side only) |
| 6 | Pick a random verify token, deploy your webhook endpoint (webhooks.md), then in the use case's Configuration panel enter the callback URL and verify token and subscribe to at least messages | webhooks arriving |
| 7 | Before real traffic: verify your business (it raises messaging limits) and add a payment method in WhatsApp Manager | production readiness |
Meta's pages: get-started, access-tokens, permissions, webhooks/create-webhook-endpoint, messaging-limits.
Notes that save a day of debugging:
- The token the API Setup page generates is a user token that expires within hours. Use it for the first test only.
- An employee system user sees nothing until you give it access to each WABA; an admin sees every WABA of the portfolio. Missing access shows up as Graph error
200(ErrorKind::Permission), not HTTP 403. - The webhook callback must be HTTPS with a valid certificate; self-signed ones are not supported. In development, put a tunnel in front.
- A CMS that onboards other businesses is a Tech Provider (or a Solution Partner paying for them with its credit line) and needs more (business verification, App Review for Advanced access, a Facebook Login for Business configuration): see embedded-signup.md.
Keep the token, app secret and verify token in your secret manager, never in the repository or the database.
2. Add the dependency
meta-whatsapp-rs is not on crates.io yet (no release has been decided; the workspace is publish = false). Depend on it by git revision:
[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"] }
anyhow = "1"- The repository is public: Cargo fetches it without credentials.
- Rust 1.98.1 or newer, edition 2024.
- Defaults are
reqwest(the HTTP transport),memory(in-process stores) andsinks. Addpostgres,redis,axum(webhook router and SSE),typst(documents),flows-endpointorffmpeg(media conversion, with anffmpegyou install) as needed;fullenables all. The table is in the README. - Types from sqlx, axum and redis cross the API (
PgPool,axum::Router, a redis connection). Use the versions meta-whatsapp-rs was built with, re-exported:meta_whatsapp_rs::adapters::store::postgres::sqlx(featurepostgres),meta_whatsapp_rs::webhooks::axum(featureaxum) andmeta_whatsapp_rs::adapters::store::redis(featureredis), as the examples do; then there is nothing to pin. If you need axum features meta-whatsapp-rs does not turn on, addaxum = "0.8"with them yourself: Cargo builds one axum 0.8 for both.rediss://needs no redis feature of yours:meta_whatsapp_rs::adapters::store::RedisTlsdoes the TLS.
3. Send a first message
use meta_whatsapp_rs::prelude::*;
let client = meta_whatsapp_rs::client(std::env::var("WA_TOKEN")?)?;
let messages = client.messages(PhoneNumberId::new(std::env::var("WA_PHONE_NUMBER_ID")?)?);
let to = Recipient::phone("+16505551234"); // E.164, with the `+`
let hello = TemplateMessage::new("hello_world", "en_US");
let sent = messages.send(&OutboundMessage::template(to, hello)).await?;
println!("accepted as {:?}", sent.message_id());Run the full program with WA_TOKEN=… WA_PHONE_NUMBER_ID=… WA_TO=+16505551234 cargo run -p meta-whatsapp-rs --example send_message.
meta_whatsapp_rs::client(token)builds a client on the production transport (rustls, HTTP/2,HTTPS_PROXYhonoured), Graph API v25.0, a 30 s timeout and the default retry policy. Build one at startup and clone it: cloning is cheap and shares the connection pool.messages(…)takes the phone number id, not the phone number. Ids are distinct types (PhoneNumberId,WabaId, …), so a WABA id cannot be passed where a phone number id is expected. The numeric ones (PhoneNumberId,WabaId,BusinessId,AppId,MediaId,TemplateId,ProductItemId,ProductSetId) are built withnew, which refuses anything but digits (PhoneNumberId::new(id)?orid.parse::<PhoneNumberId>()?).- Meta's account model evolution (phase 1 from H2 2026) splits a WABA in two: a Messaging Account keeps the WABA id and holds templates, billing and webhook subscriptions, and a new WhatsApp account (WAAC) holds the phone number. Your ids and calls keep working. When one number belongs to several Messaging Accounts your access token can send from (one per partner, or per billing currency), name the one billed with
OutboundMessage::messaging_account_id: without it Meta may bill another one or refuse the send, and it is required in that case from phase 2 (H1 2027). - Always write the
+. Meta reads a number without it as local to your business number's country, so the message reaches someone else. A&stris not aRecipient: build one withRecipient::phone,Recipient::user(a business-scoped user id, BSUID) orRecipient::group. - A successful
sendmeans Meta accepted the message. Delivery, reads and failures arrive later as status webhooks keyed by the returned id. - Free-form messages (text, media, interactive) only reach a customer who wrote to you in the last 24 hours; templates reach anyone who opted in. Outside those 24 hours Meta may refuse a free-form send at once (
ErrorKind::CustomerServiceWindowClosed,131047) or accept it and never deliver it, reporting the failure later in the message'sfailedstatus webhook (on 2026-10-26 a text sent outside the window was accepted and not delivered). Check the window before sending (the CMS inbox does) rather than trusting anOk. - Meta's sample template
hello_worldis sent only from Meta's public test numbers; from your own number Meta refuses it with131058(ErrorKind::SampleTemplateNotAllowed). Test with one of your own approved templates there.
4. Handle errors
Every fallible call returns meta_whatsapp_rs::Result<T>. Branch on err.kind(), an ErrorKind classified from Meta's error code, never on the message text or the HTTP status. ErrorKind is non-exhaustive: keep a _ arm.
| Variant | Means | Typically |
|---|---|---|
Error::Validation(v) | refused locally; nothing was sent; v.field names the JSON path | fix the input |
Error::Api(_) | Meta answered with a Graph error; err.graph() gives it | branch on kind() |
Error::Transport(_), Error::Http { .. } 5xx | no usable answer | a send may have gone out |
Error::Decode { .. } | a 2xx body of an unexpected shape (for sends without the body, which names the recipient; for lists too, since paging links can carry the token) | Meta accepted it: treat a send as sent |
Error::Step { step, .. } | a multi-step flow (onboarding) stopped at step | see embedded-signup.md |
Error::Credit(_) | a Solution Partner credit line step stopped (refused, busy, to reconcile, a revocation part-way); err.credit() gives it, also through Step | its own is_retryable() and may_have_been_sent(): see embedded-signup.md |
use meta_whatsapp_rs::client::messages::Messages;
use meta_whatsapp_rs::prelude::*;
/// What the order service does next.
enum Next {
Accepted(MessageId), // wait for status webhooks
FixInput(String), // refused locally, nothing was sent
FixTemplate, // not approved in that language, or wrong parameters
Rejected(Error), // Meta refused it: nothing went out
Reconcile, // may have been sent: check status webhooks first
}
async fn notify_shipped(messages: &Messages, to: Recipient, order_no: &str) -> Next {
let template = TemplateMessage::new("order_shipped", "en_US").body([Parameter::text(order_no)]);
let msg = OutboundMessage::template(to, template)
.callback_data(format!("order:{order_no}")); // echoed on status webhooks
match messages.send(&msg).await {
Ok(sent) => sent.message_id().cloned().map_or(Next::Reconcile, Next::Accepted),
Err(Error::Validation(v)) => Next::FixInput(v.field),
Err(e) => match e.kind() {
ErrorKind::TemplateNotFound | ErrorKind::TemplateParameterMismatch => Next::FixTemplate,
_ if !e.may_have_been_sent() => Next::Rejected(e),
_ => Next::Reconcile,
},
}
}Error::may_have_been_sent() is false when Meta provably did nothing (a Graph error on a 4xx response, a throttling error on any status, a local validation error, a connection that never opened) and true for a timeout, any other 5xx or non-4xx status (with or without a Graph error) or an unreadable response.
Retries: "could succeed later" is not "safe to repeat"
- The client already retries idempotent requests (reads, deletes, POSTs that set a value) with jittered backoff: 3 retries, 250 ms base, 8 s cap (
RetryPolicy::default();RetryPolicy::NONEturns it off). - A send is replayed only when Meta proves it did nothing (
Error::may_resend:RateLimitedorPairRateLimited, the Business Use Case limits80008,80009and80014excepted, an HTTP 429, or131057, the account in maintenance, on a 4xx). A timeout or a 5xx (a throttling code excepted) on a send is returned, never replayed: a duplicate order confirmation or OTP is worse than an error. Do not wrap sends in your own blind retry loop either. err.is_retryable()answers "could the same request succeed later", not "is it safe to send again". It is deliberatelyfalsefor 131049 (per-user marketing limit) and 131048 (spam rate limit).- To retry a send from a job queue, resend only when
!err.may_have_been_sent()(and the cause is fixed or transient); for anything else first look for a status webhook carrying yourcallback_data.
The full ErrorKind table, with codes and advice, is in the skill's error-kinds reference.
5. One client, many merchants
A multi-tenant service builds a client without a token and derives one per merchant:
let client = meta_whatsapp_rs::client_builder()?.build()?; // no default token
let merchant = client.with_token(merchant_token); // an AccessToken, e.g. from the TokenVault
merchant.messages(phone_number_id).send(&msg).await?;with_token shares the transport, endpoint and retry policy; only the token differs. Never build a client per request.
6. An endpoint meta-whatsapp-rs does not wrap
Use the client's request builders, so authentication, retries, error decoding and the credential host allowlist still apply:
let info: serde_json::Value = client
.get_at(&[waba_id.as_str()]) // one path segment per element, percent-encoded
.query("fields", "id,name")
.send()
.await?;Never client.get(&format!("{id}/…")): the literal-path builders split on /, so an id read from a database or a webhook could address another Graph object with your token.
The token is only ever attached to the configured Graph endpoint (scheme, host and port), to https://lookaside.fbsbx.com, where Meta's media download URLs point (default port), and to one URL on https://api.facebook.com, GET /{business id}/parent-bsuid-accounts (the Parent BSUID Accounts API, which Meta serves there: client.business(id).parent_bsuid_account()). client.request_url(method, url) to any other origin or URL, *.whatsapp.net and every other path on api.facebook.com included, is refused with Error::Validation on url before anything is sent. So is a credential set by hand (.header("authorization", …), Proxy-Authorization, Cookie, or a user name or password in the URL): a token goes through .bearer(…) or .oauth(…), which the same check covers. Redirects cannot widen this: the stock transport follows no redirect that would carry the token (one that keeps scheme, host and port), so a 3xx from that URL to another path is an Error::Http, and a redirect to another origin is followed without the token.
What meta-whatsapp-rs does not do
- No tenant model: it knows WABAs and phone number ids, not your merchants.
- No job queue, outbox or retry scheduler for sends.
- No opt-in or opt-out registry: recording consent is yours (marketing-and-commerce.md).
- Payments, Solution Partner APIs other than credit lines, conversation routing: coverage.md rows 28–32.