Skip to main content

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 an InboundDurabilityHook converts the consumer to at-least-once delivery:
  1. The decrypted message(s) are buffered durably in the pending_inbound_messages table before the Signal ratchet is flushed.
  2. Your hook is awaited with the whole batch. On Ok every message in the batch is acked and its buffer row cleared.
  3. 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.
Default behavior is unchanged — with no hook registered nothing is buffered and the ack path is identical to before.
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’s MessageProcessorCache 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 createSnapshotsendAggregateOfflineReceipts 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:
Return 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.
In SQL, a 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:
  1. The client detects the duplicate stanza.
  2. 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.
  3. On Ok the buffer row is cleared and the message is acked.
  4. On Err the buffer is kept; the hook runs again on the next redelivery.
  5. If no buffered copy exists (genuine duplicate already committed), the message is acked directly without invoking the hook.
A 7-day retention sweep removes rows that a permanently-failing hook would otherwise leak. Rows that reach their TTL without being cleared are deleted, and if the server redelivers after that point the message degrades to at-most-once.

Backend Requirement

Durable cross-crash replay requires a backend that implements the pending-inbound methods on ProtocolStore:
  • store_pending_inbound — write the decrypted message bytes before the hook runs
  • get_pending_inbound — read the buffer on redelivery
  • delete_pending_inbound — clear the buffer after the hook commits
  • delete_expired_pending_inbound — retention sweep (called unconditionally from keepalive)
Two additional batch-oriented methods, 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

At-least-once, not exactly-once. A crash after your consumer commits but before the ack lands replays the message (or its whole batch). Your hook must be idempotent (deduplicate by (chat, sender, id)).
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 returns Ok. 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 ships examples/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.
Key patterns from that example:
Always use tokio::task::spawn_blocking for disk I/O inside the hook. Blocking calls on the async thread stall the entire receive pipeline, including the batch that is waiting to commit.

See also

  • Retry Admission Hook — the sibling opt-in hook idiom (OnceLock, zero overhead unset) for gating inbound group/status retry receipts.