Bots
Goal: a WhatsApp number that answers commands (/status 1234), taps on reply buttons and list rows, and free text, with formatted replies (images, reply buttons, lists and product carousels among them); built from plugins, one feature each, on the webhook endpoint you already run; that sends one message to many customers at the pace Meta allows (section 9); that sends at a time, surviving a restart (section 10); and that deletes the stored chat history by age and by a cap per chat (section 11).
Crate: meta-whatsapp-bot, re-exported as meta_whatsapp_rs::bot (feature bot, off by default; full includes it). Agent skill: meta-whatsapp-rs-bot, whose examples/bot.rs is compiled and tested by the gate; the snippets below are copied from it (the copies are not checked: on doubt, the example wins).
Each event goes through these steps, in this order:
WebhookHandler ─► DedupGuard ─► Bot (an EventSink<WebhookEvent>)
1. ban: a received message from a banned sender stops here; nothing below runs
2. match: `/name [sub] args` (or an image or video caption), or a tapped button /
list row whose id is a payload → ctx.invocation();
a `/name` no command has → ctx.unknown_command()
3. middleware, in registration order (any may stop the event; they see the match)
4. the command's guards: scope → owner → flags → cooldown → its handler
(a subcommand: its parent's scope and owner first);
else the unknown-command handler, if any; else the listenersComing from Zaileys: there, middleware runs for commands only, after their guards; here it runs for every event (the listeners' too), after the ban and the match and before the command's own guards.
[dependencies]
meta-whatsapp-rs = { git = "https://github.com/vaam-apps/meta-whatsapp-rs", rev = "<commit>", features = ["axum", "bot"] }The async extension points (Outbound, Middleware, Plugin, AccessPolicy, Cooldowns, Refusals, ErrorHandler, RateLimiter, Timer, ReportSink) are #[async_trait] traits. The attribute is re-exported as meta_whatsapp_rs::bot::async_trait, so you need no async-trait dependency of your own (write #[async_trait] on the impl: a native async fn there does not match the trait).
1. Commands
A command is a name, aliases, a description and guards around a handler, an async closure over the context (Ctx), registered here by a plugin's setup (section 4):
registrar.command(
Command::new("status", |ctx: Ctx| async move {
let Some(order) = ctx.args().get(0) else {
ctx.reply("Usage: /status <order number>").await?;
return Ok(());
};
// Markdown in, WhatsApp formatting out, split at 4096 characters.
ctx.reply_markdown(&format!("**Order {order}** has shipped."))
.await?;
Ok(())
})
.alias("s")
.usage("<order number>") // shown after the name in the help
.description("Where is my order?")
.cooldown(Duration::from_secs(10)) // per user, per business number
.payload("order_status"), // a reply button or list row with this id runs it too
);- Syntax. The default parser (
PrefixParser) takes/or the prefixes you give (.prefixes(["/", "!"])); the longest matching prefix wins. Arguments split on whitespace;"double quotes"(and the curly quotes phone keyboards type) keep a phrase together. - Case. Names compare as the parser gives them.
PrefixParserlowercases typed and registered names alike;PrefixParser::ignore_casewithfalsekeeps them as written. A parser of your own (CommandParser) decides both throughparseandnormalize. - Captions. The caption of an image or a video is read like a text (
Trigger::Caption); the media is inctx.message(). WithBotBuilder::commands_from_captions(false), a captioned/nameis a plain media message instead, for the listeners. - Buttons as commands.
Command::payloadregisters the id of a reply button, a list row or a template quick-reply button: tapping it runs the command with no arguments (Trigger::Payload). - Unknown commands. A
/nameno command has setsctx.unknown_command()(the name as the parser read it). WithBotBuilder::unknown_command(handler), that handler takes it; without one, the listeners get it like any text. Answering every unknown name is noisy in groups: checkctx.chat()first. - Replies.
ctx.reply(text)quotes the message (messages/contextual-replies). It goes to the group for a group message, else to the sender's business-scoped user id (BSUID), else to+<wa_id>. That choice is not a trait: to answer someone else or without the quote, build anOutboundMessageandctx.sendit.ctx.react(emoji)reacts to the message (messages/reaction-messages; Meta's group page lists no reactions, so one in a group may be refused). A sender is keyed the same way (BotSender::key): since 2026 a message may carry no phone number at all. Standby copies, your own echoes and synchronized history never run a command, and there is nothing to reply to in them. - Listeners.
Listen::Messagesgets every received message no command took: reactions, edits, deletions and system notices too, so check the content before an automatic answer (else a user's reaction to your reply gets a reply of its own).Listen::message_type("image")narrows to Meta'stype;Listen::event("status_updated")gets other events, and a kind not inWebhookEvent::KINDSfailsbuild. - Metadata.
Command::metadata(key, value)keeps your own data with the command (CommandInfo::metadata); the bot never reads it.
Subcommands and flags
A command can have subcommands, and a command or a subcommand can take flags (from the skill's example):
pub fn order_command() -> Command {
Command::new("order", |ctx: Ctx| async move {
ctx.reply("Try /order status <order number>").await?;
Ok(())
})
.description("Your orders")
.subcommand(
Command::new("status", |ctx: Ctx| async move {
// The arguments after `status`; a tap on the button carries none.
let order = ctx
.args()
.get(0)
.map_or("Your last order".to_owned(), |n| format!("Order {n}"));
let refreshed = if ctx.args().has_flag("refresh") {
" (refreshed)"
} else {
""
};
ctx.reply(format!("{order} has shipped{refreshed}."))
.await?;
Ok(())
})
.usage("<order number> [--refresh]")
.flag("refresh") // a mistyped `--refesh` is refused, with the usage
.cooldown(Duration::from_secs(10)), // its own, apart from `/order`'s
)
}- Subcommands.
/order status 42runsstatuswith42as its only argument (ctx.invocation()namesorderand, insubcommand,status). The subcommand is the first word after the name, typed outside quotes, compared like names (case-insensitive by default);/orderalone,/order 42,/order "status"or/order 42 statusrun the parent with every argument, so the parent is where an unknown subcommand gets its answer. A subcommand has its own aliases, usage, description, payloads, flags, guards and cooldown. The parent's scope and owner-only apply to it too (an owner-only/adminkeeps/admin banfor owners); its cooldown does not, and a subcommand's cooldown is kept under its own key (order status, besideorder's, which is unchanged). One level only: a subcommand with subcommands failsbuild. Meta's command menu lists commands, not subcommands. - Flags. A command that declares a flag (
Command::flag) reads flags:--nameand--name=valueare then flags, read withctx.args().has_flag(name)andctx.args().flag(name)(the last value given;--limit=is the empty value) or all of them in order withctx.args().flags();ctx.args().get(i)counts the positional arguments only, andctx.args().raw()is the text as typed. Kept small on purpose: no short-n(a negative number stays an argument), no--limit 5(that is the flaglimitand the argument5), names compared as typed. A quoted"--limit=5"is an argument, and--alone ends the flags. The value may be quoted (--note="two words"). An em dash (—limit=5) reads as--: iPhone keyboards type one for two hyphens; an em dash alone is punctuation, an argument. - A command without flags reads none. One that declares no flag (and does not take any, below) reads every word as a positional argument, as typed:
/say --hi thererunssaywith--hiandthere, so free text keeps its--words and is never refused. Each command and subcommand has its own set:/orderdeclaring--alldoes not make/order cancelread flags, nor the reverse. To unit-test a handler, build its arguments as the command reads them:Args::parse(no flags) orArgs::parse_with_flags. - Unknown flags are refused. A command that declares flags and is typed with one it does not declare does not run, and
ReplyRefusalsanswersUnknown option --x.with the usage hint and the flags it takes (Refusal::UnknownFlag): a typo such as--dryrunmust not run the real thing.Command::pass_unknown_flagsturns that off for one command (the flags are handed over inctx.args().flags(); alone, it makes a command read flags without declaring any). Write the flags inCommand::usagefor the help to show them.
2. Guards
The ban is checked first, before the match and the middleware; the command's own guards run after the middleware, in this order. A refusal goes to the bot's Refusals (by default, ReplyRefusals):
| Guard | Declared with | Refusal |
|---|---|---|
| banned sender | AccessList::ban (BSUID) or AccessList::ban_phone | silent; nothing runs, so no read receipt, typing indicator, command or listener either |
| wrong chat | Command::group_only, Command::private_only | a short reply |
| not an owner | Command::owner_only + AccessList::owner | silent |
| a flag it does not declare, on a command that declares flags | Command::flag (Command::pass_unknown_flags turns it off; a command with no flag reads none, so this never refuses it) | "Unknown option --x.", the usage hint and the flags it takes |
| cooldown running | Command::cooldown | "Please wait N s …", once per cooldown |
For a subcommand, the parent's scope and owner-only are checked first, then the subcommand's own guards. The flag check comes after the owner check, so a non-owner never learns an owner command's usage.
List owners and bans by BSUID: a username adopter arrives without a phone number, so a ban by phone number alone can be walked around. A BSUID belongs to one business portfolio (list the parent BSUID for several) and changes when the user changes phone number, so a ban is a filter, not a guarantee.
The cooldown is checked last, so a refused attempt starts none. It lives in the KvStore you pass (.cooldown_store(kv, clock), namespace wa.bot.cooldown, keys hashed so no phone number is stored): with Postgres or Redis, every instance shares it. A user who keeps trying during a cooldown is told once, not once per attempt: the first refusal writes a marker (wa.bot.cooldown.notice) that ends with the cooldown, and Refusal::CoolingDown says notify only for that one. Expired records are invisible at once; Redis deletes them itself, and with the memory, Postgres or SQLite store call purge_expired() now and then, as for the other typed stores. Owners, bans and cooldowns are traits (AccessPolicy, Cooldowns) when a list in code does not fit.
3. Middleware
Code around every event, after the ban and the match, before the command's guards. ctx.invocation() is already set, so a middleware can act for some commands only; one that does not call next.run(ctx) stops the event:
#[async_trait]
impl Middleware for OnlyOurNumber {
async fn handle(&self, ctx: Ctx, next: Next<'_>) -> meta_whatsapp_rs::Result<()> {
if ctx.phone_number_id() == Some(&self.0) {
next.run(ctx).await
} else {
Ok(()) // another merchant's number: not this bot's business
}
}
}Shipped: Logging (event kind, message type, duration and error kind; never content, senders or error text) and MarkRead (a read receipt, messages/mark-message-as-read; MarkRead::with_typing_indicator also shows "typing…" (typing-indicators), falling back to a plain receipt when Meta refuses it). ctx.insert(value) hands a value to later middleware and the handler.
4. Plugins
A Plugin is one feature: a name, a category (its section in the help; None puts it in the bot's default category), a description, hidden, and a setup that registers its commands, middleware and listeners. It must be Debug. Bot::builder().plugin(p) adds it; build() runs the setups in order.
No hot reload. Plugins are compiled in. Rust has no stable ABI, so a plugin loaded at run time must come from the exact same compiler and dependency versions or it is undefined behaviour, and loading code at run time is what an attacker who can write to disk wants. Ship a plugin as a crate, depend on it, redeploy to change it. Bot::unload runs each plugin's on_unload at shutdown; the bot then refuses events (SinkError::Closed) so Meta redelivers them to an instance still running.
5. Wiring it to the webhook
build is async (the plugins' setup run in it):
Bot::builder()
.client(client) // replies go out with this client's token
.pacer(pacer) // replies and read receipts share the number's budget with broadcasts
.prefixes(["/", "!"]) // `/` is what Meta's command menu sends
.access(AccessList::new().owner("US.13491208655302741918")) // BSUIDs, not phone numbers
.cooldown_store(kv, Arc::new(SystemClock)) // shared by every instance on the store
.middleware(Logging) // kinds and durations, never content
.middleware(MarkRead::with_typing_indicator())
.middleware(OnlyOurNumber(number))
.plugin(Orders)
.plugin(Admin)
.help_command() // `/help`, from the commands not hidden
.build() // async: the plugins' `setup` run here
.awaitlet verifier = SignatureVerifier::new(vec![app_secret])?;
let handler = WebhookHandler::builder(verifier, verify_token, Arc::new(bot))
.dedup(DedupGuard::new(kv)) // Meta retries for 7 days: a retry must not run a command twice
.build();
handler.check()?; // a blank verify token stops the boot, not Meta's verificationKeep the DedupGuard: Meta retries for 7 days, and without it a retry runs the command again.
A failing handler is logged and acknowledged by the default LogErrors, because an error answers 500 and Meta redelivers the whole batch, repeating every reply already sent in it. The price: a transient failure (the cooldown store or your AccessPolicy unreachable, a Graph 5xx on a reply) is acknowledged too, and that event is lost but for the log line. The webhook dead letters (open question 30, roadmap item L21a) take only an error a sink classifies as permanent, and the bot classifies none. An ErrorHandler of your own can keep the event (ctx.event()) and return Ok. It gets the context as the bot built it, after the ban check and the match, before the middleware: the command is known, values a middleware inserted are not. PropagateErrors opts into redelivery for idempotent handlers (a command with a cooldown is refused on its redelivery: the cooldown started before the failure).
Replies are free-form messages: Meta accepts them only within 24 hours of the user's last message (ErrorKind::CustomerServiceWindowClosed, 131047, logged by LogErrors). The message being answered opens that window, but a redelivery after an outage can arrive days later and still runs its command: when a stale command must not act, compare the message's timestamp with the clock in the handler (or a middleware), and reach the user later with a template.
Several merchants' numbers: the default ClientOutbound sends with one token. Implement Outbound to look the merchant's token up by business number (the token vault), after your own tenant checks, as the CMS inbox does. One outbound shared by several bots: BotBuilder::shared_outbound(Arc<dyn Outbound>). The builder's other trait parameters (parser, access, cooldowns, refusals, errors, middleware, Markdown renderer, help format) accept an Arc of an implementation too; handlers and plugins are registered by value.
6. Replies in the CMS inbox
In the CMS, the merchant's inbox should show what the bot answered. Two pieces, both in the skill's example: an Outbound that sends through Inbox::send (which checks the 24-hour window and, under Conversation Routing, who owns the thread: while another app does, a free-form reply is refused with ErrorKind::ThreadOwnedElsewhere, see cms-inbox.md; and records the sent message), and a sink that records each event in the inbox before the bot handles it.
#[async_trait]
impl Outbound for InboxOutbound {
async fn send(
&self,
from: &PhoneNumberId,
message: &OutboundMessage,
) -> meta_whatsapp_rs::Result<SendResponse> {
// The inbox keys a conversation as the bot addresses it: the
// group, else the BSUID, else the phone number without `+`.
let contact = match &message.recipient {
Recipient::Group(group) => group.to_string(),
Recipient::User(user) => user.to_string(),
Recipient::Phone(phone) => phone.trim_start_matches('+').to_owned(),
_ => return Err(ValidationError::new("recipient", "not a conversation").into()),
};
let key = ConversationKey::new(from.clone(), contact); // another number: refused
self.inbox.send(&key, message.clone()).await
}
async fn mark_read(
&self,
from: &PhoneNumberId,
message_id: &MessageId,
typing_indicator: bool,
) -> meta_whatsapp_rs::Result<()> {
self.receipts
.mark_read(from, message_id, typing_indicator)
.await
}
}#[async_trait]
impl EventSink<WebhookEvent> for InboxThenBot {
async fn deliver(&self, event: WebhookEvent) -> Result<(), SinkError> {
self.inbox.deliver(event.clone()).await?;
self.bot.deliver(event).await
}
}Record first, then the bot: Inbox::send checks the window against the stored conversation, so the message being answered must be stored before the reply. A FanoutSink of the InboxSink and the bot delivers to both at once, and a first message's reply could be refused as outside the window; put a FanoutSink around this pair instead, next to the broadcast sink of the live view (meta-whatsapp-rs-live-updates). Read receipts go through the client (ClientOutbound::mark_read): the inbox's own Inbox::mark_read is its unread count, not Meta's receipt.
7. Help and Meta's command menu
.help_command() adds /help ("Show the commands"), generated from the commands that are not hidden, grouped by category, in the CategoryHelp format (/name, /alias <usage> — description), each followed by a line per subcommand that is not hidden (/name sub, /name alias <usage> — description). The pieces are yours to change:
BotBuilder::help_command_with(name, description, formatter): another name and description (the menu shows the description) and aHelpFormatterof your own;BotBuilder::default_category: the section of commands without a category (defaultDEFAULT_CATEGORY, "General");- in a handler,
ctx.commands()andctx.help_sections()are the bot's lists, to write a help entirely your own (Bot::commandsandBot::help_sectionsoutside one).
Meta's slash-command menu (conversational components) is synced from the same list:
bot.sync_command_menu(client, number).await // at most 30 commands, each with a descriptionIt sends only commands (the welcome message and ice breakers stay as they are); Meta's page calls it "a list of commands to be configured" and does not say whether a command left out is removed, so send the whole list each time, as this does. The client's limits are checked first: at most 30 commands, names of at most 32 characters, descriptions of 1 to 256. A listed command without a description is an error, not a silent omission; take a command out with Command::menu(false). A tap in the menu sends /name, so keep / among the prefixes: a menu the bot's parser cannot read that way is an error too. Emoji in names and descriptions are left to the client's checks.
8. Markdown and rich replies
ctx.reply_markdown(md) renders with the bot's MarkdownRenderer and sends one message per part. The default, markdown::Renderer, converts CommonMark to WhatsApp formatting:
| Markdown | |
|---|---|
**bold**, headings | *bold* |
*italic*, _italic_ | _italic_ |
~~strike~~ | ~strike~ |
emphasis inside a word (foo**bar**baz) | the text alone: WhatsApp formats no part of a word |
| inline code, code blocks | `code`, ```block```; code holding a backtick (a block, or a piece of a block cut in two: a fence, or a backtick at an end) goes out as plain text, never a broken fence |
> quote | > quote |
| lists | • item, 1. item |
[text](url),  | text (url), alt (url); only http, https, mailto, tel or relative URLs, so a javascript: or data: one keeps just its text |
| tables | a monospace block: padded columns, else one header: value line per cell, else unpadded rows (below) |
A table is laid out as padded columns while a padded row is at most 60 characters wide (Renderer::table_max_width: a wider row wraps on a phone and its columns stop lining up), else as records (a header: value line per non-empty cell, a blank line between rows), and as either only while that text is at most twice the table's unpadded rows, or one message, whichever is larger (Renderer::table_max_growth); past both, as the unpadded rows themselves. Padding a thousand rows to one very wide cell, or repeating a long header on every record, would otherwise turn a few kilobytes of Markdown into thousands of messages, each a billable send.
Messages are cut between blocks (paragraphs, list items, code blocks, tables); a code block that fits in a message is never cut, and only a block longer than a whole message is split inside. Meta's limit is 4096 characters (the client's TEXT_BODY_MAX_CHARS) and does not say which unit it counts, so parts are measured in UTF-16 code units, never fewer than the characters the client counts: a part fits under either reading, and emoji-dense text makes more, shorter parts. Renderer::max_chars lowers the limit, never above 4096.
Text is left as written (NoEscape, the default), so what a reader copies from a reply (an email address, a coupon code, a /command, a www. link) is what you wrote. Meta documents no escape syntax for WhatsApp text, so a literal *, _ or ~ can still format there. WordJoinerEscape is the opt-in alternative: an invisible U+2060 WORD JOINER on each side of such a character keeps it from formatting, but it is copied along with the text (a copied john_doe@example.com or /add_item no longer works) and cuts WhatsApp's link detection short. Choose with Renderer::escape, and set the renderer with .markdown(renderer) on the builder; for other rules (another heading or table style), implement MarkdownRenderer and pass that.
Rich replies
ctx.reply_rich(&reply) sends a RichReply: Markdown and a few typed parts, in the order given, each as the message Meta documents for it (from the skill's order_command):
// Markdown, then reply buttons on its last message (a list from 4 to 10).
let reply = RichReply::new()
.markdown("Try /order status <order number>, or tap:")
.suggestions([Suggestion::command("order status", "Last order")]);
ctx.reply_rich(&reply).await?; // checked whole, then one message per part| Part | Sent as |
|---|---|
.markdown(md) | text messages, through the renderer above |
.image(url), .image_with_caption(url, caption) | an image message by link (messages/image-messages) |
.suggestions([..]), 1 to 3 | reply buttons (messages/interactive-reply-buttons-messages) |
.suggestions([..]), 4 to 10 | a list of one section (messages/interactive-list-messages), its button and section titled Options unless .list_button(label) says otherwise |
.products(cards) | a product carousel of 2 to 10 ProductCards from one catalog (catalogs/interactive-product-carousel-messages) |
- Bodies. Buttons, lists and carousels need a body: theirs is the last message of the Markdown part right before them, sent as their body rather than as a text of its own (cut first when it is longer than the body allows: 1024 characters for buttons and carousels, 4096 for a list, counted as the renderer counts, in UTF-16 units; cut as
markdown::splitcuts, at blank lines, then lines, then words, the pieces before the last sent as text first). With no Markdown part right before them, the reply is refused; to ask a separate question, make it that part (.markdown(answer).markdown("Anything else?").suggestions(..)). - Taps.
Suggestion::new(id, title)sendsidas is.Suggestion::command("order status", title)sends that command's firstCommand::payload, so the tap runs it (with no arguments: a tap carries none); a command with no payload is refused when the reply is built..description(..)shows in a list only. A suggestion may name a hidden or owner-only command: the user sees only its title, and the tap passes the command's guards like a typed command. - Images. The link must pass the renderer's URL rule (no
javascript:,data:or other scheme) and be what Meta can fetch: an absolutehttporhttpsURL with a host, so no relative,mailto:ortel:link either, and no user name or password (https://user:pass@…, which would be sent to Meta). Meta's page does not refusehttp; preferhttps. The refusal does not repeat the URL (a signed URL is a secret). - Nothing partial on a bad part. Every message is built and checked by the client's own validation (
OutboundMessage::validate) before the first is sent: more than 10 suggestions, a button title over 20 characters, a row title over 24, one card or eleven, a refused image URL, and nothing goes out. - A failed send stops the reply. The parts go out one by one; when one fails, the rest are not sent, and the error is an
Error::Stepnaming it (rich::STEP_TEXT,STEP_IMAGE,STEP_BUTTONS,STEP_LIST,STEP_PRODUCTS). The earlier parts were sent and are never resent (sends are not idempotent;Error::may_have_been_sentsays whether the failed one may have gone out). - Meta's group messaging page lists text, media and template messages only: buttons, lists and carousels sent to a group may be refused.
RichReply::to_messagesbuilds the messages without sending them.
9. Paced broadcasts
A campaign, an order-status sweep, a notice to every customer: one message to many recipients from one business number, sent no faster than the rate you give the number (below Meta's, see the headroom below), with progress, a cancel, retries and a report per recipient.
let broadcast = Broadcast::builder(number)
.to(customers)
.compose(|to: &Recipient| {
let template = TemplateMessage::new("spring_sale", "en_US"); // outside the 24-hour window
Ok(OutboundMessage::template(to.clone(), template).callback_data("spring-sale"))
})
.client(client)
.pacer(pacer) // the same one as the bot's: one budget per number
.build()?;
let handle = broadcast.handle(); // progress() and cancel(), from another task
let report = broadcast.run().await; // spawn it to do something else meanwhileBroadcastBuilder::content sends every recipient the same content; BroadcastBuilder::compose builds each one's message (their name in the template, a biz_opaque_callback_data to match the status webhooks). A message composed for someone else, or a compose error, fails that recipient without a send.
Each person once. A person listed twice gets one message: every send is billed, and a duplicate marketing message harms the merchant. The same person is the same phone number compared by its digits (+1 650-555-1234 and 16505551234), the same business-scoped user id, the same group, or a phone number and a user id that one Recipient::PhoneAndUser carried together. The repeat's line is SendOutcome::Duplicate, naming the first listing; BroadcastBuilder::dedupe(false) sends the list as given.
The pace. Meta's throughput (throughput) is per registered business number: 80 messages a second by default (Rate::DEFAULT), 1,000 once Meta upgrades the number (Rate::HIGHER_THROUGHPUT), 20 for a number also used in the WhatsApp Business app (Rate::BUSINESS_APP). The default RateLimiter, TokenBucket, gives each number its rate (TokenBucket::rate_for for the ones that differ) and spaces the sends evenly, so no one-second window holds more than the rate (TokenBucket::burst lets a few go at once after a quiet spell, and so up to burst - 1 more than the rate in some one-second window). Meta documents no mapping from a number's reported throughput level to a rate, so set the rate yourself, and change it while running with TokenBucket::set_rate on a bucket you keep behind an Arc (Pacer::shared): for a number Meta upgrades (a phone_number_quality_update webhook, ThroughputUpgrade) or a merchant's number onboarded meanwhile.
pub fn shared_pacer(upgraded: PhoneNumberId) -> Pacer {
Pacer::new(TokenBucket::new(Rate::DEFAULT).rate_for(upgraded, Rate::HIGHER_THROUGHPUT))
}Throughput upgrades, followed (opt-in, off by default). Meta upgrades an eligible number to 1,000 messages a second by itself, then sends a phone_number_quality_update webhook whose event is THROUGHPUT_UPGRADE (throughput, "Higher throughput"). A ThroughputUpgrades hears it and raises that number's rate in the limiter you give it to Rate::HIGHER_THROUGHPUT (RateLimiter::raise_rate: a TokenBucket in this process, a KvRateLimiter for every replica), so its next sends go 1 ms apart instead of 12.5 ms (the first of them still waits out the 12.5 ms the last send before the upgrade booked). Give it to the bot with BotBuilder::follow_throughput_upgrades (a listener of phone_number_quality_updated, after the middleware like any listener), or, in a process without a bot, deliver events to it: it is an EventSink<WebhookEvent>, to put next to your sink in a FanoutSink. Without either, no webhook changes a rate.
pub fn following_upgrades(
waba: WabaId,
display_phone_number: &str,
number: PhoneNumberId,
) -> meta_whatsapp_rs::Result<(Pacer, ThroughputUpgrades)> {
let bucket = Arc::new(TokenBucket::new(Rate::DEFAULT)); // kept, to raise a rate while running
let numbers = KnownNumbers::new().number(waba, display_phone_number, number)?; // digits compared
let upgrades = ThroughputUpgrades::new(bucket.clone(), numbers); // in memory: record upgrades yourself
Ok((Pacer::shared(bucket), upgrades)) // `BotBuilder::follow_throughput_upgrades(upgrades)`
}Which number. The webhook carries the WABA (the entry's
id) and the number'sdisplay_phone_number, never its phone number id, which is what the pacer keys on. ANumberDirectorysays which id it is. The default,KnownNumbers, holds the numbers you register by WABA and display number, and compares display numbers by their digits (+1 555-078-3881, as the phone numbers API may show it, is the webhook's15550783881); register each with its country code, as the webhook shows it (555-078-3881is not15550783881). An upgrade for a number it does not know changes nothing and is logged as a warning, naming no number. Add numbers while running withKnownNumbers::inserton one you share behind anArc(ThroughputUpgrades::shared), or implementNumberDirectoryover your own store.Only the upgrade.
ONBOARDING,FLAGGEDand any value Meta adds later change no rate (logged at debug level): Meta documents no downgrade event, and no mapping from a throughput level to a rate. An upgrade never lowers a rate: a number already at or above it keeps its own, and an upgrade heard twice changes nothing.Headroom.
ThroughputUpgrades::ratesets the rate an upgrade gives (Rate::per_second(800)leaves a fifth for inbound messages, see below).Where the rate is kept. With a
TokenBucketit lives in the bucket: a restart, or a replica the webhook did not reach, is back at the configured rate, and Meta sends the webhook once per upgrade. With aKvRateLimiter(below) it is in the number's record, for every replica on the store, and survives restarts until the record expires (KvRateLimiter::idle_expiry, 24 hours after the number's last slot by default). To keep it longer, store the number yourself (ThroughputUpgrades::observereturns it, called from a listener or sink of your own) and set it at startup withTokenBucket::rate_fororKvRateLimiter::rate_for. A limiter whose store fails raises nothing, and the error goes where a failed directory's goes.What else loses an upgrade, which Meta does not send again. As the bot's listener it runs after the middleware: a middleware that stops the event (one ignoring everything outside business hours, or everything but messages) stops the upgrade too, so let events without a message through, or deliver to the
ThroughputUpgradesas a sink next to the bot. ANumberDirectorythat fails changes no rate: as a sink that isSinkError::Delivery(the webhook answers500, Meta redelivers for up to 7 days), as the bot's listener it goes to theErrorHandler, whose default,LogErrors, logs and acknowledges it, losing the upgrade (PropagateErrorshas it redelivered; following an upgrade twice is harmless). A directory answersOk(None), not an error, for a number that is not its own.Concurrency. At 1,000 a second a broadcast's concurrency is what caps it (below): raise
BroadcastBuilder::concurrencyfor the upgraded number.Headroom for inbound messages. Meta's throughput is "inclusive of inbound and outbound messages and all message types", and a pacer counts only what this process sends. A campaign that draws replies shares the number's rate with them: pace below it by the inbound traffic you expect (at 80 a second and one reply for every four messages,
Rate::per_second(64)).One
Pacerper process, shared by every broadcast (BroadcastBuilder::pacer) and by the bot (BotBuilder::pacer, which puts its outbound in aPacedOutbound: replies, refusals, read receipts and typing indicators wait for a slot too; a handler reaches the pacer withCtx::pacer). Two pacers give one number two budgets. A reply waits behind at most a broadcast's concurrency in slots, and a cancelled broadcast hands its waiting slots back (RateLimiter::release).Group operations go through the same budget through
PacedGroups::new(client.groups(number), pacer)and itsgroup(id)(aPacedGroup): each of the client's group calls, after a slot of the number. Meta documents no rate for group operations; this is a choice, and the client's own calls are not paced by themselves.Pacer::acquire(&number)paces any other call. Every call costs one slot (SlotRequest::cost): Meta documents no lower cost for a read receipt or a typing indicator.Several replicas with a
TokenBucketeach count their own sends: two replicas at 80 a second send 160. Give each its share of the rate (Rate::per_second), or pace every replica with aKvRateLimiterover theKvStorethey share (Redis, Postgres; opt-in):Pacer::new(KvRateLimiter::new(kv, Rate::DEFAULT)). Each number's schedule is then a record in the store (namespacewa.bot.pacer,PACER_NAMESPACE, the key a SHA-256 of the phone number id), booked bycompare_and_swapon the version read, so the replicas book from one budget; a slow-down and a raised rate are shared too, and a raised rate is never lowered. What to know:- Configure every replica alike: the rates (
KvRateLimiter::rate_for,KvRateLimiter::set_rate) and settings are each replica's, the schedule is shared. A number's rate is the higher of its configured one and the one raised in the store. - Clocks. Each replica books by its own clock against the shared schedule, so their clocks must be synchronized (NTP). Two replicas whose clocks differ by a skew may start two sends up to that skew closer than the rate spaces them: a one-second window can hold up to
⌈skew × rate⌉more (at 80 a second, one more per 12.5 ms of skew), and a store round trip counts the same way. Pace below Meta's rate by that headroom. The schedule never moves back: a replica whose clock is behind waits longer, never sends early; one whose clock jumped ahead books slots everyone then waits for (delete the number's record,KvRateLimiter::store_key, to drop them). - Contention. A booking that loses the swap reads again after a backoff (1 ms doubling to 64 ms, jittered), 8 times at most (
KvRateLimiter::contention), then fails withStorageError::Busy. One key takes a read and a swap per booking: about one booking per two store round trips, so 1,000 a second needs a store answering well within half a millisecond. - Failures fail closed. A store that fails, contention past the bound, or a record of a version this release cannot read fails the booking: nothing is sent (a broadcast ends
BroadcastEnd::LimiterFailed, aPacedOutboundfails the call before sending). It never falls back to a budget of its own. - Expiry. A record expires
KvRateLimiter::idle_expiry(24 hours) after its last slot, and its raised rate with it, silently. Redis drops it; with the memory, Postgres or SQLite store, callpurge_expirednow and then. - Resetting a number: delete its record (
kv.delete(&KvRateLimiter::store_key(&number))). It is the remedy for a record this release cannot read (never overwritten: the number sends nothing until it expires or is deleted), for slots a clock stepped forward booked, and for a raised rate to lower (nothing else lowers it). It drops the slots booked too: those waiting still start when booked and new ones from now, so for the schedule's length the number can send up to twice its rate. - A slot given back (
RateLimiter::release) is reused only while no replica booked after it.
Another shared limiter (a Redis script, a row in a database) implements
RateLimiter(RateLimiter::reservebooks a slot and returns the wait,RateLimiter::releasetakes an unused one back,RateLimiter::slow_downhears Meta's throttling,RateLimiter::raise_ratetakes an upgrade).- Configure every replica alike: the rates (
Concurrency. Up to
BroadcastBuilder::concurrencysends (default 32) are in flight at once, so the rate is reached when each send takes a while: about the rate times a send's duration. On a number atRate::HIGHER_THROUGHPUTthe default caps it: 32 sends of 400 ms each make 80 a second, so raise it to about 400.Don't give a broadcast a
PacedOutbound: it paces its own sends, and each would take two slots.Zero where it means nothing is a
ConfigErrorwhen given:Rate::per_second(0),TokenBucket::burst(0),TokenBucket::recoveryof zero, aTokenBucket::slow_down_factorbelow two (and the same settings ofKvRateLimiter, with itsKvRateLimiter::idle_expiryof zero andKvRateLimiter::contentionwith zero attempts or a zero delay),Backoff::max_attempts(0), and a concurrency of zero atBroadcastBuilder::build.
Failed sends. A BroadcastPolicy decides, from the error's kind (its code, never its text), given a SendFailure (the error, how many sends to that recipient failed, and whether every recipient gets the same message); the default is Backoff:
| Meta answers | Backoff |
|---|---|
131056, the pair rate limit (one user messaged too often) | that recipient waits 1, 4, 16, 64 s (the 4^X schedule of about-the-platform); the others go on |
130429 throughput, or another RateLimited code | retried after 1, 2, 4, 8 s |
131057, the number in maintenance (Meta's throughput upgrade takes it off for up to a minute, throughput) | retried every 20 s (Backoff::MAINTENANCE_RETRY), so its five sends outlast the minute |
131049, the per-user marketing limit | reported for that recipient, not retried (Meta: wait at least 24 hours before resending) |
a kind of Backoff::STOPS, which holds for the number: the token, a permission, the account, the classification limit, payment, the spam limit (131048: Meta's advice is to check the number's quality status), registration, marketing turned off (131063) | the run stops: every recipient not sent yet is skipped |
a kind of Backoff::CONTENT_STOPS (the template not found, paused, disabled, or its parameters wrong), for a broadcast of one message (BroadcastBuilder::content) | the run stops, since every recipient would get it; with compose, reported for that recipient |
| anything else not retryable | reported for that recipient |
Every delay is at most Backoff::max_delay (64 s by default, the pair limit's fourth). Backoff::stops and Backoff::content_stops replace the two lists; Backoff::max_attempts (5), Backoff::base_delay (1 s) and Backoff::max_delay are settings.
The number's pacer slows down on the errors its SlowDownRule names (default ThrottlingErrors: the RateLimited codes, 131048 and 131057), for a broadcast's sends and a paced bot's replies alike: the TokenBucket halves the rate (TokenBucket::slow_down_factor), counts the slow-downs of one second as one (TokenBucket::slow_down_spacing, since every send in flight gets the same error), never goes below one message a second, and doubles back once per 30 quiet seconds (TokenBucket::recovery) until the rate is whole again. TokenBucket::adaptive(false) turns slow-downs off.
What is resent. Whatever the policy says, a recipient is sent again only when Error::may_resend holds: the error proves Meta refused the send before doing anything (a throttling code on any status, an HTTP 429, or 131057 on a 4xx). It is the library's one rule, the same the client's own retries follow; a policy can refuse more, never less. A timeout, a 5xx that is not a throttling refusal, a 131000 (Meta's "unknown error") or a connection refused is reported as SendOutcome::Failed and never resent, since a duplicate campaign message is worse than a missing one: when may_have_been_sent() is true, match it with the status webhooks (the callback data) before sending again. BroadcastBuilder::client turns the client's own replays off (Client::with_retry(RetryPolicy::NONE)), so a throttled send is retried by the broadcast, through the pacer. An outbound of your own (BroadcastBuilder::outbound) must not retry inside a call either, and must fail a send that may have reached Meta with an error whose may_have_been_sent() is true. BotBuilder::pacer does the same for a bot built with BotBuilder::client: its client stops retrying, and the PacedOutbound retries with the client's policy, each retry after its delay and a slot of its own (PacedOutbound::retry).
The report. run returns a BroadcastReport: one RecipientReport per recipient, in order (its index, attempts, a SendOutcome: Sent with Meta's response, Failed with the last error, Skipped, or Duplicate; and for a recipient skipped while it waited for a retry, its last_error), its counts (BroadcastReport::progress) and how the run ended (BroadcastEnd: completed, cancelled, stopped by the policy, or stopped because the rate limiter or the report sink failed). For a list too long to keep every line in memory, BroadcastBuilder::report_to(sink) hands each line to a ReportSink as it settles (a tokio::sync::mpsc::Sender is one), and the report keeps the counts only; the run waits for the sink, and a sink that fails stops it, so nothing is sent whose line would be lost. Sent means Meta accepted the message: a 131049 can also arrive later, as a failed status webhook (templates/marketing-templates/per-user-limits). Meta's daily messaging limit (unique users per 24 hours per business portfolio, messaging-limits) is Meta's to enforce; nothing here counts it.
Cancel. BroadcastHandle::cancel starts no send afterwards: a wait for a slot or a retry ends at once (the slot goes back to the pacer), sends in flight finish and are reported (abandoning one would leave unknown whether it went out), and the rest are Skipped. BroadcastHandle::progress counts sent, failed, skipped, duplicates and BroadcastProgress::remaining meanwhile. Dropping the run future instead (a select! it loses, an aborted task) abandons the sends in flight and the report: cancel, then let run return.
Not durable. A run lives in memory: a restart loses it, and the report is its only record. A broadcast that survives a restart, or a message sent at a time, is a job (section 10).
Time comes from the pacer's Timer, a Clock that can wait until a deadline (Timer::sleep_until): SystemClock sleeps on Tokio; a ManualClock moves forward to the deadline at once and never back, so a test runs a whole paced broadcast instantly and reads when each send started, exactly, however many senders share it (Pacer::with_timer). A send takes no time on it: to test slow sends overlapping, implement Timer over Tokio's paused clock (tokio::time::sleep under #[tokio::test(start_paused = true)]). A wall clock stepping back neither pauses the pacer nor puts off a retry.
10. Scheduled and durable jobs
A Job is one message or a broadcast from one number, due at a time: KvJobs keeps it in your KvStore (namespaces wa.bot.jobs*, no new port), and a JobRunner in each replica sends it when due. Nothing about the message is stored but your JSON payload: the runner's composer makes each recipient's message from the job when it sends, so give every replica, and every release after a restart, the same composer.
let jobs = KvJobs::new(kv, Arc::new(SystemClock));
let at = time::OffsetDateTime::now_utc() + Duration::from_secs(3600);
jobs.schedule(
Job::new(JobId::new("spring-sale")?, PhoneNumberId::new("106540352242922")?, at)
.to([Recipient::user("US.13491208655302741918")])
.payload(json!({"template": "spring_sale", "language": "en_US"})),
)
.await?;
// In every replica: the same composer, a runner id of its own.
let runner = JobRunner::builder(jobs.clone())
.client(client)
.pacer(Pacer::default())
.compose(|job, to| {
let name = job.payload["template"].as_str().unwrap_or_default();
let language = job.payload["language"].as_str().unwrap_or("en_US");
Ok(OutboundMessage::template(to.clone(), TemplateMessage::new(name, language)))
})
.runner_id("replica-1")
.build()?;
runner
.run_until(Duration::from_secs(10), std::future::pending())
.await;(The snippet is the jobs module's rustdoc example, which the gate compiles.) A job's id is yours (a campaign's name, an order's reminder): scheduling the same job again, after an error or a crash, is safe; a different job under a used id is refused. Its recipients are kept each person once, by the broadcast's rule.
How a job runs. JobRunner::tick reads the due index from the oldest minute still to read up to now, and for each job due:
- claims it by compare-and-swap, with a lease naming the runner (
JobRunnerBuilder::lease, 2 minutes by default, renewed before a send when half is gone): of two replicas that read the same job, one claim succeeds; a replica that crashes holds the job until its lease expires, then any replica resumes it; - skips every recipient already marked for this run (an unreadable marker counts as an outcome unknown), and every one Meta refused with
131049in the last 24 hours (below), renewing the lease as it reads them; - sends to the others through a paced
Broadcast(its pacer, itsBroadcastPolicy, its retries only whenError::may_resendholds; a pacer over aKvRateLimitershares the number's budget across replicas, as for any broadcast), each send after a check that the job is still the runner's and not cancelled, and after the recipient's marker is created (put_if_absent): another runner never sends to them too. With the marker in place the job is read once more, and a cancel or a re-run that landed meanwhile gets no request; - settles it: done when every recipient is marked or skipped; when some are provably unsent (a throttled send out of retries, a stop, the rate limiter or the store failing), the job is due again later, with backoff (
JobRunnerBuilder::retries,JobRunnerBuilder::retry_delay: 5 retries from 30 s, doubling to 30 min), and stopped after the last (JobState::Stopped,JobSummary::unsent).
At most once, not exactly once. The marker says "sending" before the request, then sent (with the message id), failed (Meta refused it), or unknown (a timeout, a 5xx: it may have gone out). A runner that crashes mid-send leaves "sending", which whoever resumes the job reads as unknown: it is never sent again (JobSummary::unknown; reconcile with the status webhooks), and neither is a timed-out send. A crash after the marker but before the request left is read the same way: that recipient is never sent. A store that fails fails closed: no send without a claim and a marker. Leases are read against each replica's clock: one whose clock runs ahead, or a replica paused past its lease, sees the job taken over, which costs a pass but never a duplicate (the marker decides who sends to whom).
Cancel and re-run. KvJobs::cancel moves a pending or running job to cancelled; a runner running it stops before its next send (a send already past its second check, its marker in place, goes out). KvJobs::rerun runs a settled job again at a time: a new run, everyone sent to again, except the recipients marked limited. KvJobs::get reads a job: its state, run, retries, recipients, payload and, once settled, its JobSummary.
The per-user marketing limit. Meta may accept a marketing message and fail it later with 131049, in a status webhook; resending within 24 hours only fails again (templates/marketing-templates/per-user-limits). The runner keeps each message's id for 7 days (KvJobs::message_ttl). Hand status webhooks to KvJobs::observe, or wire KvJobs::feedback in as a listener (.listen(Listen::event("status_updated"), jobs.feedback())) or as a sink next to the bot: a 131049 for a message a job sent marks that recipient for 24 hours from its arrival, and a re-run of that job skips them (JobSummary::limited) and sends everyone else. A 131049 the send itself returns marks them too. The mark is per job: another job may still send to them.
Retention. Settled jobs, markers and index pages expire after 30 days (KvJobs::retention); a pending or running job never does. Resume a job within that time, or its recipients are sent again. A job left unrun for 30 days after its due time (no runner all that time) is no longer found: its index pages have expired, and its record stays pending, never sent. A due time already past is due now: the job is indexed under the current minute (its 30 days count from then). With the memory, Postgres or SQLite store, call purge_expired now and then; on Redis, run maxmemory-policy noeviction (an evicted marker is a recipient sent again, an evicted index page a job never found).
Stable names. The five namespaces, their keys and their records are stable identifiers once released, pending the owner's confirmation (architecture.md).
11. Retention and auto-delete
The bot keeps no history of its own: the chats it answers are in the ConversationStore your inbox records them in (InboxSink, section 6). A RetentionPolicy deletes from it by age, by a cap per chat, or both, with the store's own operations, so the store's rules hold (and the store's own with_retention need not be set):
use std::time::Duration;
use meta_whatsapp_rs::bot::retention::{RetentionPolicy, RetentionSweeper};
let policy = RetentionPolicy::default()
.max_age(Duration::from_hours(24 * 30)) // 30 days
.max_messages_per_chat(500);
let sweeper = RetentionSweeper::new(store, policy) // Arc<dyn ConversationStore>
.number(phone_number_id) // this bot's number(s) only
.every(Duration::from_hours(1));
tokio::spawn(async move { sweeper.run(shutdown_signal).await });(Not from the skill's example: the rustdoc of meta_whatsapp_rs::bot::retention has a compiled one like it.)
- By age (
max_age):ConversationStore::purge_beforeatnow - max_age: messages older than the cutoff (one exactly at it stays), window events, ownership records and contact removals before it, and the summary of a chat whose latest message went, so the chat leaves the inbox list too. - By a cap per chat (
max_messages_per_chat):ConversationStore::trim_conversations: each chat keeps its newestnrows by(timestamp, id), the order the history pages in. Every row counts: messages of both directions, synced history, a revoke's tombstone, and a group message an erasure redacted. A chat whose newestnrows are all tombstones, or a cap of 0 (no history at all), loses its summary, and with it the customer service window it holds, open or not:Inbox::replyrefuses free-form content (templates still go) until the customer's next message (with a cap of 0, in every chat, after each sweep). Any other chat keeps its summary as it was (the unread count may count deleted messages untilmark_read). - Either way, synced contacts and the links between a person's identities stay (design D35: a later erasure follows them), and a deleted message's id is free again. A group is a chat like the others: its oldest rows go whoever sent them; what an erasure redacted there stays redacted until it is old enough to go. Retention is not erasure: to delete one person, use
Inbox::erase_all(cms-inbox.md). - Once or on a schedule.
RetentionPolicy::apply(store, number, now)sweeps once, at the time you give (from your own housekeeping);RetentionSweeper::sweeponce at its timer's time;runsweeps at once and then everyevery(an hour by default) until its stop future completes, logging a failed sweep (never a message) and trying again next time. The age is applied first, then the cap. - Scope. Give the sweeper your bot's numbers (
number, once each) on a store other tenants use too: without one it sweeps every number in the store with one policy. - Replicas. Several may each run a sweeper on one store: the sweeps delete the same rows and stay correct (on Postgres, trims and purges take turns under one lock), only redundant. An append at the same time is never lost to a trim, and keeps its summary; a message older than what a trim keeps, recorded while it runs (a late history chunk), may stay over the cap until the next sweep.
- Time comes from the sweeper's
Timer(SystemClockby default): on aManualClock,runmoves forward an interval at a time without waiting, so a test reads exactly which sweep deleted what.
Which retention you need is your privacy obligations' call (design D10): the default policy keeps everything, and nothing deletes until you apply one.
12. Testing
Build the bot on a client with a ScriptedTransport (feature testing) and deliver events parsed from Meta's documented payloads; assert the exact requests (see the skill's example and meta-whatsapp-rs-testing). Or implement Outbound to record what the bot sends. A bot or broadcast with a pacer on a ManualClock never waits for real. For jobs, give the MemoryKvStore (with_clock), the KvJobs and the pacer one ManualClock and call JobRunner::tick yourself; a restart is a new KvJobs and runner on the same store.
A handler is a function of its context, so it can be tested without a bot: Ctx::new(event, outbound, renderer), then .with_invocation(Invocation::new(name, trigger, args)) for a command (args from Args::parse, or Args::parse_with_flags for a command that reads flags; .with_subcommand(sub) for a subcommand), and .with_pacer(pacer) for one that uses Ctx::pacer, and call it.
Not here yet
Recurring schedules (a job runs once, then again only when re-run); a job store behind a trait of its own (swap the KvStore adapter instead). A rich reply's image goes by link only (an uploaded media id: build the message and use Ctx::send), and Markdown's own  stays text (alt (url)): images are their own part. Subcommands nest one level, and flags have no short or space-separated form (section 1). Conversation state and multi-step forms are yours: keep them in your store, keyed by BotSender::key.