Examples on this page target source snapshot
35a69a14, not a crates.io release. Use the current API guide to select the matching dependency.Overview
Without a hook, acknowledgment does not wait for your application’s durable commit. A process failure between acknowledgment and your write can lose the application copy. RegisterInboundDurabilityHook to commit encrypted direct and group messages before their transport acknowledgments. The SDK first writes a pending copy, flushes Signal state and awaits your hook. Return Ok(()) only after your commit is durable. A hook error suppresses acknowledgment; a later redelivery can use a retained pending copy.
This guarantee depends on successful pending-buffer storage and retention. It does not cover every Event::Messages origin, guarantee server redelivery, or provide exactly-once processing. Read the failure and origin limits below before choosing this hook for your application’s inbox.
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.
The normal batch path writes the durable buffer, flushes Signal state, calls your hook, publishes Event::Messages, conditionally clears pending rows, then permits acknowledgments and offline delivery receipts. Do not treat that sequence as one transaction across your backend and the server. An error during acknowledgment can leave some acknowledgments already sent. Make the consumer commit idempotent and keep application recovery independent of event callback delivery.
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.Ok, the dispatched Event::Messages carries hook_committed: true on its MessageBatch — a marker for another consumer of the same event stream, not an instruction to skip anything: a hook that persists somewhere other than that consumer’s own store still needs it to materialize every batch. An application-level store that materializes this same event stream can use the flag to avoid double-processing a hook-fed batch.
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
If a pending copy remains and the server redelivers after a crash, the hook can run again for a message your consumer already committed. Make the hook idempotent. A partially acknowledged batch may replay only in part, so repeated items and partially applied consumer commits must be safe to retry independently. 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, which does not make an arbitrary custom backend transactional. 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.
With an inbound hook configured, construction probes pending-inbound store, read and delete operations. A failed probe returns UnsupportedDurabilityBackend from the builder. Passing that probe does not certify batch atomicity, crash durability or retention behavior. Implement the bounded reads and conditional mutations as well as the legacy methods, and test those properties in your backend.
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. The same Client retains encoded plaintext across cancellation and reconnect so redelivery can retry persistence without advancing Signal again. A process exit loses resident-only copies. See retained plaintext for admission bounds and cleanup ordering.
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 callbacks can receive duplicates. The same Client suppresses repeated publication while its dispatch claim survives. Process restart, expiry or eviction can allow another event if a retained pending copy is replayed. Keep event handlers idempotent independently of the hook.
Resent messages get their own, separate gate. The redelivery handling above covers a byte-identical stanza the server replays. A sender-side outbox retry instead re-encrypts the same message under a new sender-key iteration, so it isn’t byte-identical and reaches the pipeline as an apparently-fresh decrypt. Since #1352, an in-memory dispatch-once gate (dispatched_messages in CacheConfig, default 5-minute TTL) collapses that case to one Event::Messages too — see Resend collapse. Unlike the pending-inbound buffer, this gate has no durable backing and does not survive a restart of your process, so it narrows the window for a duplicate dispatch rather than closing it: your hook and event handlers still need their own idempotency for the reasons above.
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 configures inbound repair and outbound decrypt-failure policies before client assembly.
- History Sync Admission Hook — the sibling opt-in policy for rejecting inbound history-sync chunks before the major-sync queue.
Current references and replay
UseInboundMessage::message_ref() for chat actions and newsletter_ref() for channels. Construct non-exhaustive inbound DTOs with builders in host fixtures. Preserve wildcard arms when matching extensible enums. See current contracts.
PDO placeholder recovery remains event-only. A later encrypted retry still must complete its durability hook even when the equivalent user event was already dispatched. The retry can promote that recovery to committed state without publishing the same event again. Distinct user payloads in an SKDM-bearing retry retain separate dispatch claims; adding an SKDM envelope does not make their user content identical. Hooks and event consumers still need idempotent (chat, sender, id) handling.
PDO retry and durability regression scenarios
Retained plaintext on current main
On main snapshot35a69a14, plaintext retention provides recovery within the same Client after a buffer-write failure. Admission reserves encoded backing before Signal decryption under a 4 MiB budget and 400 reservations. The budget includes carrier, consumer projection and metadata, so the maximum accepted ciphertext is smaller than 4 MiB. Capacity refusal leaves Signal untouched and sends no positive ACK. Oversized stanzas remain refused under this bound.
If persistence fails, the same Client owns an encoded plaintext copy across cancellation and reconnect. Redelivery can retry storage without advancing Signal again. This is resident retention, not disk persistence: a process exit loses copies that never reached durable pending storage. Decoded graphs held by active processing, callbacks and consumers are outside this backing budget.
The pending envelope retains the consumer projection, including a resolved secret-message payload. Bounded exact/group-alias reads and revision-conditional replacement/deletion are now required by the backend probe. See custom backend requirements.
After the hook commits, event publication precedes conditional pending-row cleanup. ACK waits for cleanup to complete. An error or cancellation can therefore leave a pending row after the event already fired. The same Client’s dispatch cache survives reconnect and suppresses repeated events within its retention window; a process restart still needs your durable deduplication by (chat, sender, id). Neither that cache nor the hook provides exactly-once delivery.
Message durability, history capture and group notification capture require separate registration. Enabling one does not enable the others.