Skip to main content
The Bot provides a simplified, ergonomic API for building WhatsApp bots. It handles client setup, event routing, and background sync tasks automatically.
Don’t confuse this with client.bots(), which fetches WhatsApp’s server-side directory of first-party AI bots. Bot here is the client-side framework for building a program that answers messages — a different domain that happens to share the word.

Overview

Use the Bot builder pattern to:
  • Configure storage backend, transport, HTTP client, and async runtime
  • Register event handlers
  • Configure device properties and versions
  • Enable pair code authentication
  • Skip history sync for bot use cases
The builder uses a typestate pattern with four type parameters <B, T, H, R> (Backend, Transport, HttpClient, Runtime). The build() method is only callable when all four are Provided, making missing-component errors compile-time instead of runtime.
The Bot is the recommended way to use whatsapp-rust. It provides sensible defaults and handles boilerplate setup.

Basic Usage


Builder Methods

builder

Creates a new bot builder.

with_backend

Sets the storage backend (required).
Arc<dyn Backend>
required
Backend implementation providing storage operations
Example:
For multi-account scenarios, use SqliteStore::new_for_device(path, device_id) to create isolated storage per account.
A bot that pairs once and stays connected for weeks is the single-long-lived-session profile, and SqliteStore’s defaults are tuned for the opposite one (many small per-session stores in a process). See Memory and Thread Tuning for the cache size, reader count, and mmap setting that profile wants, and pass them with SqliteStore::with_config (whatsapp-rust#1411).

with_transport_factory

Sets the transport factory for creating WebSocket connections (required).
F: TransportFactory
required
Transport factory implementation
Example:

with_http_client

Sets the HTTP client for media operations and version fetching (required). The client is wrapped in its own Arc on each call, so that Arc isn’t shared with any other builder — though the client value itself still can be: cloning a Clone client like UreqHttpClient shares its underlying ureq::Agent and connection pool, so with_http_client(shared.clone()) already shares state today. Use with_http_client_arc to hand several bots the identical Arc<dyn HttpClient> instead of a clone apiece.
C: HttpClient
required
HTTP client implementation
Example:

with_http_client_arc

Like with_http_client, but for a client that’s already behind an Arc<dyn HttpClient>. Reach for this when a process runs several bots and you want them all pointing at one client — and one connection pool — instead of a clone apiece per builder. A single non-Clone client passed by value to one builder already works with with_http_client, since it carries no Clone bound. What with_http_client can’t do is hand that same non-Clone client to a second builder — passed by value, it moves into the first one and no further. This setter covers that case, and it’s also the only way in for a client that’s already type-erased to Arc<dyn HttpClient> because the host chooses it at runtime.
Arc<dyn HttpClient>
required
Already-shared HTTP client implementation
Example:
See sharing one client across many sessions for what a shared client costs and what it doesn’t.

with_runtime

Sets the async runtime for spawning tasks, sleeping, and blocking operations (required).
Rt: Runtime
required
Runtime implementation providing spawn, sleep, and spawn_blocking
Example:
TokioRuntime is only available when the tokio-runtime feature is enabled (it is by default). To use a different async runtime, implement the Runtime trait from wacore::runtime. See custom backends for details.

with_task_instrument

Instruments the client’s internal tasks with a TaskInstrument hook, called around every poll of a spawned task (and around blocking work). Runtime-agnostic — it wraps whichever Runtime the client uses (via with_runtime or the default), so every task spawned through the Runtime trait is covered. Bot::run also meters the main run loop itself (the read loop, including frame decryption), so that work is covered whether you launch via bot.run().await or Bot::spawn() — the two paths never double-wrap. Default: no hook, the runtime is used untouched and nothing is metered.
When the voip feature is enabled, 1:1 call media tasks spawn directly on Tokio instead of through the Runtime trait, so they are not covered by this hook. A CpuMeter attached here will undercount CPU for sessions with active calls.
Pass the built-in wacore::stats::CpuMeter for per-session CPU accounting (busy time + poll count), keeping a clone to read .snapshot() later. Or implement TaskInstrument yourself to scope allocator attribution, an ESP-IDF heap_caps sampler, or any other per-session platform hook — the library only calls on_poll_start/on_poll_end and never inspects what the hook does.
Arc<dyn wacore::stats::TaskInstrument>
required
Hook invoked around every poll of the client’s internal tasks
Example:
Opt-in and off by default — instrumenting every poll has measurable overhead, so treat this as a diagnostics tool rather than an always-on meter. See Client::stats() for always-on wire I/O counters (atomics incremented on every frame) and Client::memory_report() for the on-demand, zero-cost-when-unused memory breakdown.

with_alloc_meter

Installs a wacore::stats::AllocMeter as this client’s task instrument and keeps a typed handle so Client::resource_report() can fold in its allocation-churn snapshot. AllocMeter is a first-class TaskInstrument — the churn counterpart to CpuMeter’s busy-time tracking — that attributes heap bytes allocated and freed to this client. This is sugar over with_task_instrument: it occupies the same single instrument slot, so it’s mutually exclusive with CpuMeter or any other hook — last setter wins. Calling with_task_instrument after with_alloc_meter drops the typed alloc-meter handle, so resource_report()’s alloc field reverts to None even though the instrument itself is replaced. The library never touches an allocator directly. The host must install a #[global_allocator] that calls AllocMeter::on_alloc / AllocMeter::on_dealloc on every (de)allocation — examples/alloc_tracking.rs in the source repo is the ~20-line reference implementation.
Only allocations made inside an instrumented poll or blocking closure are counted — every task spawned through the Runtime trait, plus the main run loop (see the with_task_instrument voip caveat above, which applies here too). Deallocations are charged to whichever meter is active when the free happens, not the one that allocated the block, so freed_bytes (and net_bytes()) drift for buffers that outlive the poll that made them — allocated_bytes is the reliable cumulative signal.
Arc<wacore::stats::AllocMeter>
required
The allocation meter to install and drive via the poll hooks
Example:

Event Handling

on_event

Registers an async event handler. The handler receives an Arc<Event> — use &*event to pattern-match on the inner event type.
F
required
Async function that receives Arc<Event> and Arc<Client>
Example:
See Events Reference for all event types.

on_event_for

Like on_event, but registers the handler with a narrowed EventInterest so the event bus skips materializing kinds you don’t subscribe to. Useful when you only handle a couple of event types and want to avoid paying the Arc allocation for high-frequency events like presence or receipts.

with_event_delivery

Chooses how on_event/on_event_for (and the other closure-based registrars) receive events off the bus.
EventDelivery
required
Delivery strategy. Defaults to EventDelivery::Concurrent — existing consumers are unaffected unless they opt in.
Example:
capacity is clamped to at least 1. Only affects the closure-based callbacks (on_event, on_event_for, on_message, and the other typed registrars) — a raw handler registered via with_event_handler always runs inline on the dispatch path and is unaffected by this setting.
Under Ordered, a callback that panics is caught and logged; it does not kill the single drainer or drop later events. If you need at-least-once delivery instead of best-effort drops under load, pair Ordered with an inbound durability hook — the hook buffers and redelivers independently of the delivery mailbox. See Client::stats() for the events_dropped counter.

with_event_handler

Registers a struct-based EventHandler directly on the bus. Unlike the closure registrars, the handler holds its state as struct fields (no per-field clone dance); because handle_event takes &self, mutable state requires interior mutability (Mutex, RwLock, or atomics). handle_event runs inline on the dispatch path — spawn your own task for slow work. Not affected by with_event_delivery. Example:

Using ChannelEventHandler

For scenarios where you need to process events outside of a closure (e.g., testing, custom event loops, or runtime-agnostic code), use ChannelEventHandler with register_handler instead of on_event:
ChannelEventHandler uses async-channel (runtime-agnostic) with an unbounded buffer, so events fired before the receiver starts listening are not lost. You can combine it with on_event — both handlers will receive all events.

with_enc_handler

Registers a custom handler for specific encrypted message types.
String
required
Encrypted message type (e.g., “frskmsg”, “skmsg”)
H: EncHandler
required
Handler implementation
On wasm32 targets, EncHandler drops the Send + Sync supertrait — your handler can capture !Send JS handles. On native, Send + Sync is retained via MaybeSendSync so Arc<dyn EncHandler> remains thread-safe. This mirrors the convention used by EventHandler and SendContextResolver.

Configuration

with_version

Overrides the WhatsApp version used by the client.
(u32, u32, u32)
required
Tuple of (primary, secondary, tertiary) version numbers
Example:
By default, the client checks the cached WhatsApp Web version on each connect and only fetches a new one if that cache is missing or over 24 hours old. It reads that version from web.whatsapp.com/sw.js, except on wasm32 targets, which read the same revision from the Facebook JS SDK bundle instead (see HTTP Client — the version fetch does not pool a connection). Use with_version to pin a specific version when you need deterministic behavior — for example, in integration tests or CI environments where external HTTP requests are undesirable.
As of PR #1360: that wasm32 source, the Facebook JS SDK bundle, sits on common tracker blocklists. If a content blocker keeps a wasm client from reaching it, the client still connects: it settles for the version the device already holds and reports this on Event::Connected via app_version_fallback. Call with_version to skip the fetch (and this fallback) entirely, on either target.

with_device_props

Overrides DeviceProps fields sent to WhatsApp servers during pairing. Takes a DevicePropsOverride built via its chained setters — a field left unset keeps the library’s default for that field, it does not clear it.
DevicePropsOverride
required
Builder describing which DeviceProps fields to override
Example:
platform_type determines the device name shown on the phone’s Linked Devices list. Common values: CHROME, FIREFOX, SAFARI, DESKTOP. Only applied on the initial pairing — DeviceProps is not sent again after registration.

DevicePropsOverride

WA Web itself only ever sends one of two coherent DeviceProps shapes, chosen by whether the companion is a browser or the Windows-native (“win_hybrid”) client:The library’s sync fields (require_full_sync, history_sync_config) default to the browser row’s sync behavior. Pairing requests a recent history sync, not a full backfill.The identity fields (os, platform_type) deliberately follow neither row. They stay at "rust" / UNKNOWN by default, so the library doesn’t impersonate a specific client unless you configure one.To opt into a full backfill, set require_full_sync together with the matching history_sync_config fields. Setting require_full_sync alone produces a combination no real WhatsApp client sends:
Earlier versions requested a full history sync (require_full_sync: true) on every pairing by default, and advertised support_call_log_history: false. Both now match WA Web’s own browser default: require_full_sync is false (a recent-only sync) and support_call_log_history is true. If you relied on receiving a full backfill at pairing time, set .with_require_full_sync(true) plus a matching history_sync_config, as shown above (PR #1164).

with_push_name

Sets an initial push name on the device before connecting.
String
required
Display name to set on the device
Example:
The push name is included in the ClientPayload during registration. This is useful for testing scenarios where the server assigns phone numbers based on push name.

with_ab_props_fetch

Whether to fetch the server’s A/B props catalog on connect, as WA Web does. On by default.
bool
required
Whether fetch_props() runs on connect. Default: true.
Example:
The catalog is the largest frame of an ordinary login — a few thousand props, ~30 KB compressed — and the client keeps a couple of dozen of them. It’s consumed as a stream (see Client::execute_streaming), so on most hosts it costs nothing worth turning off. Turn it off only if your target’s heap can’t afford even the compressed frame plus the inflate state (~80 KB together) at the moment it arrives.Turned off, every flag reads as its registry default — the value WA Web itself uses before its first fetch. The server sees no abt request (whatsmeow never sends one) and accepts your client either way. The cost: an account the server has 1:1-LID-migrated is not recognized as such from the props (lid_one_on_one_migration_enabled defaults to off), and the privacy-token and trusted-contact-token gates run on their defaults.

Authentication

with_pair_code

Configures pair code authentication to run automatically after connecting.
PairCodeOptions
required
Configuration for pair code authentication
Example:
Pair code runs concurrently with QR code pairing — whichever completes first wins.
with_pair_code runs Client::pair_with_code in a detached task, so a failure never reaches a caller as an Err — it only reaches Event::PairingCodeError. Register on_pair_code_error if the consumer must distinguish “still waiting for the user” from “no code is coming” (a rate-limited request otherwise looks identical to the former).
The companion_platform_display shown on the phone is derived automatically from the resolved platform_id and a canonicalized OS derived from the device’s os string: web variants emit <Browser> (<OS>) (Android PlatformTypes map to Chrome, so they show as Chrome (Android) by default); explicit AndroidPhone/AndroidTablet/AndroidAmbiguous overrides emit Android (<OS>). The OS is coerced into a small server-safe set (Windows/Mac OS/Linux/Android/iOS) because the pair-code server rejects a non-OS display with bad-request — an arbitrary branding os string falls back to Linux. Set PairCodeOptions::display_os to send a real, non-canonical OS name verbatim instead. See Authentication — companion_platform_display for the full classification table.

on_pair_code_refresh

Registers a handler for Event::PairingCodeRefresh, fired when the in-progress phone-number pairing code should be replaced. The bool argument is force_manual.
F
required
Async function that receives force_manual: bool and Arc<Client>
Example:
This callback fires for two triggers, not just a server request: the server asking for a refresh (only while a pair-code flow is outstanding and the notification’s ref matches it — a refresh_code notification for a stale or unrelated flow is ignored), and a non-refused companion_finish — accepted, or its own 30s wait going unanswered — whose pair-success then went unanswered for a minute (force_manual is always false for this second trigger). A companion_finish the server actively refuses is reported through on_pair_code_error instead, immediately rather than after this timeout — register that handler too if you need to react to a refusal, since it no longer reaches this one. See Pair code refresh events for the full breakdown.

on_pair_code_error

Registers a handler for Event::PairingCodeError, fired when a pair-code flow fails — either a stage-1 request that never gets a code issued, or a stage-2 companion_finish the server refuses after a code was already entered on the phone. The counterpart to on_pair_code_refresh on the failure path, and a dedicated convenience over matching the event yourself in on_event — either works to observe it. A with_pair_code request runs in a detached task, so the Err it would otherwise return reaches no caller directly; this event is the only surface that reports its failure at all.
F
required
Async function that receives the PairingCodeError event and Arc<Client>
Example:
Branch on err.rejection rather than the message, which is not a stable surface — see Pair code failure events for the full field breakdown and the two failures (PairCodeError::CodeAlreadyOutstanding and Cancelled) that deliberately never reach this handler.

Cache Configuration

with_cache_config

Configures cache TTL and capacity settings for internal caches.
CacheConfig
required
Custom cache configuration
Example:
See Cache Configuration for available cache types.

History Sync

History sync transfers chat history from the phone to the linked device. The processing pipeline is optimized for minimal RAM usage through zero-copy streaming and lazy parsing.

How it works

When your bot receives history sync data, the pipeline:
  1. Stream-decrypts external blobs in 8KB chunks (or moves inline payloads without copying)
  2. Decompresses zlib data on a blocking thread with pre-allocated buffers capped at 8 MiB
  3. Walks protobuf fields manually instead of decoding the entire message tree — only internal data (pushname, NCT salt, TC tokens) is extracted at this stage
  4. Wraps the compressed payload in a LazyHistorySync with cheap metadata (sync type, chunk order, progress) available without decoding
  5. Dispatches Event::HistorySync(Box<LazyHistorySync>) — full protobuf decoding is deferred until you call .get(). Use .stream() for incremental memory-bounded access, .decompress() for one-shot inflation, or .compressed_bytes() for the raw compressed payload
If no event handlers are registered, the blob is not retained in memory.

skip_history_sync

Skips processing of history sync notifications from the phone. When enabled:
  • Sends a receipt so the phone stops retrying uploads
  • Does not download or process historical data
  • Emits debug log for each skipped notification
  • Useful for bot use cases where message history is not needed
Example:
For bots that only need to respond to new messages, enabling this can significantly reduce startup time and bandwidth usage.

with_wanted_pre_key_count

Sets the number of one-time pre-keys generated and uploaded per batch. Mirrors WhatsApp Web’s UPLOAD_KEYS_COUNT. Default: 812. The value is clamped at upload time to 5..=65_535. Values outside that range log a warn! and are clamped to the nearest bound.
usize
required
Pre-keys per upload batch. Clamped to 5..=65_535.
Example:
Leave this at the default unless you have a specific reason to change it. Embedded or memory-constrained consumers may prefer a smaller batch to shrink the working set during each upload; smaller batches also mean more frequent uploads as peers consume keys.
The floor of 5 prevents an empty-but-flagged pool and a re-upload loop (the count guard never clears below the trigger threshold). The ceiling of 65,535 is the wire-format limit — the upload IQ encodes the pre-key list length as a u16, so a larger batch would generate and store keys locally and then fail to encode.

Building and Running

build

Builds the bot with the configured options. Errors:
Missing required components (backend, transport, HTTP client, runtime) are caught at compile time via the typestate pattern — build() is only available when all four type parameters are Provided. You won’t see runtime errors for missing components.
BotBuilder is #[must_use] — building it does nothing until you call .build(), so a builder chain left unbound (or dropped before .build()) now triggers a compiler warning.

client

Returns the underlying Client Arc. Example:

run

Starts the bot’s connection loop and background workers. Returns a BotHandle that implements Future. You can also call .abort() on it to cancel the bot. Example:
BotHandle is #[must_use]: dropping it aborts the bot task instead of leaving it running in the background. Bind it and either .await it or call .shutdown()/.abort() explicitly — do not let it fall out of scope while you expect the bot to keep running.
If a with_task_instrument hook is configured, run() meters the client’s main run loop (the read loop, including frame decryption) itself, in addition to the tasks the instrumented runtime already covers. This closes the gap where bot.run().await polls that future on the caller’s task rather than through Runtime::spawn.

MessageContext

A convenience helper for message handling. You can construct it from an InboundMessage — the item type carried by Event::MessagesMessageBatch:
Since v0.6 message is Arc<wa::Message> (was Box<wa::Message>). This matches the InboundMessage payload and lets from_inbound / from_arc reuse the bus-dispatched Arc with zero deep clones.
ephemeral_expiration and comment_target used to live on the shared MessageInfo. Writing them there needed an Arc::make_mut copy of the whole struct on every message in an ephemeral chat. They moved to InboundMessage to avoid that copy, and MessageContext now carries them too.Use from_inbound when you need these fields — it’s the only constructor that sees the stanza-derived event. from_arc and from_parts build from message/info alone, so they always leave both fields None. A bot handler reached through Bot::on_message (which uses from_inbound internally) sees them populated whenever the underlying event carried them.

from_parts

Constructs a MessageContext from individual message components. Internally clones the wa::Message into a new Arc.

from_arc

Constructs a MessageContext from an existing Arc<wa::Message> without copying the body — pair this with the Arc you receive from an InboundMessage to keep dispatch zero-clone. ephemeral_expiration and comment_target are left None, since this constructor never sees the stanza they’re read from.

from_inbound

Extracts a MessageContext from a single InboundMessage (one item of a MessageBatch). Unlike the removed from_event, this is infallible — there’s no “wrong event kind” case once you’re iterating Event::Messages’ batch. Reuses the existing Arc<wa::Message> rather than cloning the body, and carries ephemeral_expiration and comment_target straight from the event. This is what Bot::on_message uses internally to fan a batch out to your per-message handler, invoked once per item in arrival order.

send_message

Sends a message to the same chat. Returns a SendResult containing the message_id and to JID.

build_quote_context

Builds a quote context for replying to this message. Handles:
  • Correct stanza_id/participant for groups and newsletters
  • Stripping nested mentions
  • Preserving bot quote chains
Example:

edit_message

Edits a message in the same chat. See Client::edit_message for what the returned SendResult describes.

revoke_message

Deletes a message in the same chat. See Client::revoke_message for what the returned SendResult describes.

react

Sends an emoji reaction to the incoming message. The chat JID, target message ID, and group/status participant are taken from the context — you only supply the emoji. Pass an empty string ("") to remove a previously sent reaction. Internally this calls Client::send_reaction with self.message_key() as the target. Example:
Newsletter (channel) messages don’t flow through MessageContext::react. Use client.newsletter().send_reaction() for newsletter reactions.

Complete Example


Cache configuration reference

The CacheConfig struct controls TTL and capacity for all internal caches. All fields have sensible defaults matching WhatsApp Web behavior.

CacheEntryConfig

Available Caches

Timed caches

The lid_pn_cache and sender_key_devices_cache use time-to-idle (TTI) semantics — entries expire after being idle for the timeout period. All other caches use time-to-live (TTL) semantics.
The recent_messages cache is disabled by default (capacity 0), meaning sent messages are stored only in the database for retry handling — matching WhatsApp Web’s behavior. Set capacity greater than 0 to enable a fast in-memory L1 cache in front of the database. See DB-backed sent message retry for details.
device_registry_cache’s default rose from 5,000 to 20,000 in whatsapp-rust#1400. At 5,000, if your account was active in a few dozen mid-sized groups, it could hold more distinct contacts than the cache, so every group-devices memo recompute paid a backend read per evicted member. The cache only ever holds what has been resolved, so you pay nothing for the higher ceiling if your account stays small.

Coordination caches (capacity-only, no TTL)

The lane’s live entry count plus cumulative capacity evictions and blocked evictions are exposed via Client::memory_report() (group_distribution_locks, group_distribution_lock_evictions, group_distribution_lock_eviction_blocks), so operators can derive eviction rates and tune this capacity without guessing.
group_devices_memo_capacity and dm_devices_memo_capacity replaced two private, fixed constants in whatsapp-rust#1400 and gained least-recently-used eviction in the same change (previously oldest-first). The prior fixed bound was 64 groups: if you rotate sends across more than 64 groups, oldest-first eviction guaranteed the entry for the group you were about to resolve had already been evicted by the intervening sends, for a hit rate of exactly zero. At the new default of 512, the client_group_scale bench’s warm-resolve pass over 256 groups of 64 members each (one send per group, cycling through all of them) goes from a 33.4 ms median — every group missing and re-resolving from scratch, at the old 64-group bound — to 33.0 µs — every group hitting, at the new 512 bound.

Sent message DB cleanup

The sent_message_ttl_secs default was raised from 300s to 7200s. Retry receipts can arrive well after a message is sent (e.g. after the recipient comes back online); a 5-minute TTL could expire the stored payload before its retry, silently dropping the retry. Two hours covers realistic offline gaps.

messageSecret retention

The client stores messageSecret values so it can later decrypt add-ons that reference an original message — poll votes, poll/event edits, message edits, and Meta AI / fbid bot replies. Retention is bounded by policy and a per-class event-time horizon (expires_at = parent_message_ts + horizon, not insertion time), so secrets survive offline gaps without growing unbounded.
The OriginalMessageResolver trait lets you supply secrets from your own store (required when the policy is Disabled):
It is consulted only after the in-core MsgSecretStore and the LID/PN alternate lookups miss.
MsgSecretPolicy, MsgSecretRetention, and OriginalMessageResolver are re-exported from the crate root (whatsapp_rust::{MsgSecretPolicy, MsgSecretRetention, OriginalMessageResolver}). The default Managed policy is bounded and needs no tuning for most apps.
Steady-state sizing. msg_secret_retention sizes what is, by row count, the largest table the store holds: one row per inbound message that carries a messageSecret, plus one per outbound message that mints one, each kept until its horizon passes. The steady state is therefore the horizon’s worth of traffic, and nothing else bounds it. For a busy bot — roughly 15k inbound and 1.5k outbound messages a day — the default 30-day text horizon settles at ~500k rows, or ~130 MB at roughly 270 bytes a row once the primary key and the expiry index are counted, and materially more where poll traffic (a 90-day horizon) is heavy. Shortening text trades add-on decryption of older messages for disk, and is the one lever that moves the figure. See whatsapp-rust#1411.
InMemoryBackend only: on top of the horizons above, the built-in InMemoryBackend caps the number of expiring secret rows it retains at 8,192, backend-wide across all chats — so wasm32 linear memory (whose allocator never returns freed pages) can’t grow without bound from that traffic. Permanent rows (expires_at = 0, written by Full policy or a direct put_msg_secret call) are never evicted and don’t count toward the cap, so they can still accumulate past it. Once the cap is hit, eviction drops the soonest-to-expire rows first, always by whole message — a message’s sender-alias rows are kept or dropped together. That’s not necessarily the oldest messages: horizons differ by class, so a fresh text secret (30-day horizon) can expire, and be evicted, before an older poll secret (90-day horizon). SqliteStore, the production backend, has no such cap and honors the full msg_secret_retention horizons unmodified. See whatsapp-rust#1297.

Custom cache store overrides

You can replace any of the pluggable caches with a custom CacheStore backend (e.g., Redis):
Fields left as None keep the default in-process PortableCache behaviour. See Custom backends — cache store for a full implementation guide.
Coordination caches (session_locks, chat_lanes, group_distribution_locks), the signal write-behind cache, and pdo_pending_requests always stay in-process — they hold live Rust objects that cannot be serialized to an external store.

Custom configuration example

DB-backed sent message retry

Sent messages are persisted to the database for retry handling, matching WhatsApp Web’s getMessageTable pattern. When a retry receipt arrives, the client looks up the original message payload from the database, re-encrypts it, and resends. How it works:
  1. Every send_message() call stores the serialized message payload in the sent_messages table
  2. On retry receipt, the client retrieves and consumes the payload (atomic take)
  3. Expired entries are periodically cleaned up based on sent_message_ttl_secs
Optional L1 cache: By default, the recent_messages cache capacity is 0 (DB-only mode). If you set capacity greater than 0, sent messages are also cached in memory for faster retrieval. In L1 mode, the DB write is backgrounded since the cache serves reads immediately. In DB-only mode, the write is awaited to guarantee persistence.

See Also