Skip to content

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.

#StepYou end up with
1Register 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 portfolioan app id
2In the use case's API Setup, connect an existing WhatsApp Business Account (WABA) or create one; Meta gives you a test business numbera WABA id and a phone number id
3Send the sample hello_world template from that page to your own WhatsApp, then reply to it from your phonean open 24-hour customer service window for tests
4In 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
5App Dashboard → App settings → Basicthe app secret (server-side only)
6Pick 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 messageswebhooks arriving
7Before real traffic: verify your business (it raises messaging limits) and add a payment method in WhatsApp Managerproduction 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:

toml
[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) and sinks. Add postgres, redis, axum (webhook router and SSE), typst (documents), flows-endpoint or ffmpeg (media conversion, with an ffmpeg you install) as needed; full enables 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 (feature postgres), meta_whatsapp_rs::webhooks::axum (feature axum) and meta_whatsapp_rs::adapters::store::redis (feature redis), as the examples do; then there is nothing to pin. 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. rediss:// needs no redis feature of yours: meta_whatsapp_rs::adapters::store::RedisTls does the TLS.

3. Send a first message ​

rust
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_PROXY honoured), 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 with new, which refuses anything but digits (PhoneNumberId::new(id)? or id.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 &str is not a Recipient: build one with Recipient::phone, Recipient::user (a business-scoped user id, BSUID) or Recipient::group.
  • A successful send means 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's failed status 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 an Ok.
  • Meta's sample template hello_world is sent only from Meta's public test numbers; from your own number Meta refuses it with 131058 (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.

VariantMeansTypically
Error::Validation(v)refused locally; nothing was sent; v.field names the JSON pathfix the input
Error::Api(_)Meta answered with a Graph error; err.graph() gives itbranch on kind()
Error::Transport(_), Error::Http { .. } 5xxno usable answera 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 stepsee 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 Stepits own is_retryable() and may_have_been_sent(): see embedded-signup.md
rust
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::NONE turns it off).
  • A send is replayed only when Meta proves it did nothing (Error::may_resend: RateLimited or PairRateLimited, the Business Use Case limits 80008, 80009 and 80014 excepted, an HTTP 429, or 131057, 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 deliberately false for 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 your callback_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:

rust
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:

rust
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.

Released under the MIT License.