Overview
By default the client acknowledges a message to the WhatsApp server as soon as it is decrypted (at-most-once delivery). The ack tells the server to drop the message from its offline queue and never resend it. If your process crashes, or your storage write fails, after the ack but before you persist the message, the message is lost — the server will not redeliver it. Registering anInboundDurabilityHook converts the consumer to at-least-once delivery:
- The decrypted message(s) are buffered durably in the
pending_inbound_messagestable before the Signal ratchet is flushed. - Your hook is awaited with the whole batch. On
Okevery message in the batch is acked and its buffer row cleared. - On
Err(or a crash), all their acks are suppressed. The server redelivers the batch on the next connect, where the hook runs again from the buffered copies.
This is the same gap whatsmeow closes with
SynchronousAck + EnableDecryptedEventBuffer. The design follows whatsmeow’s decrypt-buffer approach rather than a global gate.Batching
Live traffic is delivered to the hook one message at a time — a batch of one, committed immediately, so latency is unchanged from the previous per-message behavior. During the offline drain (the backlog replayed on reconnect), the client accumulates decrypted messages and commits them as a batch, mirroring WhatsApp Web’sMessageProcessorCache granularity. A batch flushes on whichever trigger fires first:
These triggers are internal constants, not currently exposed as configuration. The message-count trigger matches WhatsApp Web’s
web_message_processing_cache_size (the snapshot flush granularity — distinct from the 200-stanza <offline_batch> server pull size). The drain→live transition is raceless: the tail batch of the drain always commits before any live-mode message is processed, so a consumer never observes drain and live messages out of order across the boundary.
Within a batch, the commit order is: durable buffer write (one transaction) → Signal-cache flush → your hook → buffer clear → acks → buffered offline delivery receipts flushed → Event::Messages dispatch. A failure at any step up to and including acks leaves the entire batch unacked, and the server redelivers all of it — so your hook must commit a batch all-or-nothing. The two trailing steps (the delivery-receipt flush and the Event::Messages dispatch) run after the acks have been sent and do not affect redelivery.
Offline delivery receipts are flushed once per durable drain batch, not only at the end of the full offline drain, matching WA Web’s
createSnapshot → sendAggregateOfflineReceipts per snapshot. This bounds the receipt buffer over a large backlog and caps redelivery on a mid-drain disconnect to a single snapshot instead of the whole backlog.Opting In
The InboundDurabilityHook Trait
Each
InboundMessage carries:
Ok(()) once the whole batch is durably committed. Return Err to suppress every ack in the batch.
InboundMessage, MessageBatch, and BatchOrigin are re-exported from the crate prelude.
Idempotency Requirement
At-least-once means the hook will be called more than once for the same message when a crash occurs after the consumer commits but before the ack lands. Your hook must be idempotent, and since a failed batch is redelivered whole, a partially-applied batch commit must also be safe to re-run. Deduplicate by the full triplet(info.source.chat, info.source.sender, info.id) — not info.id alone. Stanza IDs are only unique within a (chat, sender) pair, so two different chats can reuse the same ID string.
UNIQUE constraint or INSERT OR IGNORE on (chat, sender, id) is the most robust approach.
How Redelivery Works
When the server redelivers a message the client previously did not ack:- The client detects the duplicate stanza.
- If a hook is registered and a buffered copy exists in
pending_inbound_messages, the message re-enters the commit pipeline (it can be grouped into the same batch as other stanzas being processed at the time) and the hook runs from the buffered copy. - On
Okthe buffer row is cleared and the message is acked. - On
Errthe buffer is kept; the hook runs again on the next redelivery. - If no buffered copy exists (genuine duplicate already committed), the message is acked directly without invoking the hook.
Backend Requirement
Durable cross-crash replay requires a backend that implements the pending-inbound methods onProtocolStore:
store_pending_inbound— write the decrypted message bytes before the hook runsget_pending_inbound— read the buffer on redeliverydelete_pending_inbound— clear the buffer after the hook commitsdelete_expired_pending_inbound— retention sweep (called unconditionally from keepalive)
store_pending_inbound_batch and delete_pending_inbound_batch, default to looping the single-row methods above, so existing custom backends keep working unchanged. The bundled SqliteStore overrides both to commit a whole batch in one transaction — see Custom Backends if you want the same atomicity in your own backend.
Backends that implement none of these return an error from the defaults, which causes Bot::build() to fail with BotBuilderError::UnsupportedDurabilityBackend — a clear error rather than a silent runtime degradation.
Caveats
Backpressure. The hook is awaited inside the receive pipeline. A slow hook backpressures inbound processing for the duration of the commit — the same trade-off as whatsmeow’s synchronous ack. Persist and return; spawn any reply logic after the hook returnsOk.
No synchronous sends to a sender in the batch. During 1:1 message processing the per-sender Signal lock is held. Performing a synchronous client operation to a sender present in the batch (e.g. a blocking reply) will deadlock. Use tokio::spawn if you need to reply from within the hook.
Scope. The hook covers end-to-end encrypted messages (1:1 and group). Newsletter and broadcast channel messages use a separate ack path and are never gated by the hook — they dispatch Event::Messages directly. PDO placeholder recoveries (info.unavailable_request_id set) bypass the hook the same way.
Buffer-write failure. If the durable buffer write itself fails (e.g. disk full), the acks for that batch are suppressed, but if the process does not crash the Signal ratchet still advances. Those messages degrade to at-most-once on their next redelivery (they can no longer be decrypted and there is no buffered copy to replay). The guarantee holds whenever the buffer write succeeds.
Redelivery info fields. On a redelivery replay, info is re-parsed from the stanza. A few fields derived during the first dispatch (the ephemeral timer, encrypted comment threading) may be absent. The message body is always the original.
Event::Messages is at-least-once too, when a hook is registered. A redelivery whose buffered copy survived (e.g. the post-commit cleanup failed and the ack was lost) replays through the same commit and dispatches the event again — event handlers need the same idempotency discipline as the hook if they perform side effects.
Full Example
The repository shipsexamples/durability_hook.rs — a file-backed archiver that appends each message in a batch to disk with a single fsync before returning Ok, and seeds the deduplication set from the archive on startup so dedupe survives a restart.
See also
- Retry Admission Hook — the sibling opt-in hook idiom (
OnceLock, zero overhead unset) for gating inbound group/status retry receipts.