Skip to main content
Examples on this page target source snapshot 35a69a14, not a crates.io release. Use the current API guide to select the matching dependency.Custom backends must explicitly implement atomic patch commits and token merges. The sections below describe their required semantics.

Overview

This guide covers implementing custom backends for storage, network transport, and HTTP operations in whatsapp-rust. The library uses trait-based abstractions to allow full customization.

Storage backend architecture

A Backend implements five traits: SignalStore, AppSyncStore, ProtocolStore, MsgSecretStore and DeviceStore. The blanket implementation supplies Backend once all five are implemented. A four-trait adapter is incomplete. Use the pinned trait source for exact required methods and its Rustdoc for default behavior. Your database schema and transaction implementation remain adapter-specific; copying a partial SQL sketch is not a complete backend implementation.

Implement the secret read boundary

MsgSecretStore::get_stored_msg_secret(chat, sender, id) is required. Return a validated secret plus the parent’s available timestamp. Do not return an arbitrary byte vector and assume the read is valid.
Return None when there is no row. Unknown parent time is explicit None; a malformed secret returns InvalidMessageSecret. Implement put_msg_secrets and delete_expired_msg_secrets too. Preserve the expiry and timestamp conflict merge rules documented in MsgSecretStore.

Implement atomic storage operations

Implement these methods explicitly when adopting the merged #1681 contract in a custom Rust backend:
  • ProtocolStore::touch_tc_token_sender_timestamp
  • ProtocolStore::store_received_tc_token
  • AppSyncStore::commit_patch
Their signatures are unchanged, but the sequential default implementations were removed. This is a source compatibility break for adapters that inherited them. Update forwarding wrappers too; delegate the whole operation to the backing store’s atomic implementation. For trusted-contact tokens, make the comparison and update atomic in the account’s storage scope. Advancing the sender timestamp preserves the received bytes and timestamp and keeps the maximum sender timestamp. Storing a received token preserves the sender timestamp. Replace a real token only when the incoming timestamp is greater than or equal to the stored timestamp; an empty placeholder must accept the first real token even if that token’s timestamp is older. Use a transaction, a conditional update with CAS retry, or a lock shared by every writer of the same data. A lock on one adapter instance does not coordinate another process or connection. put_tc_token remains a full-row replacement; use the field-specific methods for concurrent issuance and reception. For an app-state patch, atomically store the collection version/hash, remove the listed MACs, then add the supplied MACs. If an index appears in both lists, its added value wins. Scope the entire operation to the account. Errors and cancellation must never leave a new cursor paired with only part of its MAC changes. A sequence of KV callbacks, a batch without transaction semantics, and a later flush do not satisfy this contract. Do not replace a missing atomic implementation with a no-op or the old read/modify/write default. In-memory storage uses one state lock; SQLite uses a transaction. Neither requires a schema or key-format migration. Atomicity and durability are separate. SQLite can return a typed CommitBarrierError after the entire SQL transaction has committed. Preserve the error and its cause; do not treat it as success or assume it rolled back. The app-state processor publishes a patch’s mutations only after commit_patch returns Ok. Atomicity alone does not recover mutation delivery after a post-commit barrier failure, and retrying the processor does not guarantee replay of that cursor.

Preserve durability

Signal sessions and sender keys must become durable before publishing messages that advance their ratchets. Backend batching must preserve required ordering; an in-memory cache alone does not provide crash safety. For crash replay, implement the pending-inbound methods on ProtocolStore. Store the pending payload before acknowledgement, and clear it only at the documented commit boundary. The consumer hook still needs idempotent commits by (chat, sender, id) because a crash can occur after the application’s commit and before the server receives its ACK. See inbound durability. Device-scoped operations must isolate one account from another. The scope of DeviceStore::resource_report depends on the adapter. SQLite reports the shared database pool through this legacy trait method; count it once per database. Use SqliteStore::scoped_resource_report() for an account and SqliteDatabase::resource_report() for the database-wide report. Register an owned adapter with with_backend or an Arc<dyn Backend> with with_backend_arc. Storage trait reference · SQLite reference implementation

Custom Runtime

The Runtime trait is the foundation of the runtime-agnostic architecture. It abstracts async task spawning, sleeping, blocking operations, and cooperative yielding. Implement it to use a different async runtime (e.g., async-std, smol, or a WASM executor).

The Runtime trait

AbortHandle

AbortHandle is a type-erased cancellation handle marked #[must_use]. Dropping an AbortHandle requests cancellation of the spawned task without waiting for destruction or cleanup. Call .detach() to prevent automatic cancellation for fire-and-forget tasks where the task should run to completion even if the parent scope is dropped.
Because AbortHandle is #[must_use], the compiler warns if you discard the return value of Runtime::spawn(). You must either store the handle (to abort later), call .detach() (to let the task run freely), or explicitly call .abort().
As of #1124, Runtime also has spawn_detached for fire-and-forget spawns:
The trait-level default is exactly self.spawn(future).detach() — the same cost as writing both calls out by hand, kept only so existing Runtime implementations keep compiling and behaving unchanged. It is not itself the optimization: spawn still boxes a dyn FnOnce capturing the executor’s own handle to build the AbortHandle, and the default still builds and immediately drops it. The saving requires overriding spawn_detached to bypass spawn entirely. The bundled TokioRuntime does exactly that in src/runtime_impl.rs:
This never constructs an AbortHandle at all. Implement spawn_detached this way for your own runtime if it can spawn a task with no cancellation bookkeeping; otherwise the inherited default is a correct (if not faster) fallback.

Using your runtime

To target WASM, disable all default features and implement Runtime without Send bounds. On target_arch = "wasm32", the trait automatically removes Send requirements.
Spawnable trait (v0.6). The future-bound that generic spawn helpers use is now the Spawnable marker trait instead of a hardcoded Send + 'static. On native targets Spawnable = Send + 'static; on wasm32 it’s just 'static (WASM runtimes are single-threaded and can’t require Send). Runtime::spawn’s signature is unchanged — this only matters if you write your own generic helpers over spawnable futures, in which case bound them on wacore::runtime::Spawnable rather than Send + 'static so they compile on both targets.
If you are implementing a single-threaded runtime, override yield_frequency() to return 1 and have yield_now() return Some(future). This ensures the event loop is not starved during tight processing loops (e.g., decoding incoming frames).

Proxy and custom TLS

whatsapp-rust has two separate network layers, each with its own extension point for proxy and TLS customization: To route all traffic through a proxy, you need to configure both layers.

Custom TLS for the WebSocket transport

Use TokioWebSocketTransportFactory::with_connector to supply a custom TLS Connector — for example, adding custom CA certificates or client certificates:
Use default_tls_connector() to inspect or replicate the default TLS configuration as a starting point.

Custom Origin for a relay

TokioWebSocketTransportFactory sends Origin: https://web.whatsapp.com on every upgrade by default, matching what whatsmeow and Baileys send unconditionally. If with_url points at a relay or gateway that enforces its own Origin check, override it with with_origin:
Keep the default for a relay that simply forwards traffic to WhatsApp — it’s still emulating a real WA Web browser, so Origin should stay https://web.whatsapp.com regardless of which host you dial. Only call with_origin when the relay itself checks the header against its own domain. without_origin() drops the header entirely, for a peer that rejects the upgrade because of it. See Transport API reference for details.

Full proxy support for the WebSocket transport

For full proxy support (SOCKS5, HTTP CONNECT, etc.), implement the TransportFactory trait directly. Establish the WebSocket connection through your proxy, then wrap it with from_websocket:

Proxy support for the HTTP client

Use UreqHttpClient::with_agent to supply a pre-configured ureq::Agent with proxy settings:
Alternatively, implement the HttpClient trait directly for full control over HTTP behavior.

Combining both layers

To route all traffic through a proxy, configure both the transport factory and the HTTP client:
The WebSocket transport handles the persistent WhatsApp protocol connection, while the HTTP client handles media operations (upload/download) and version fetching. Both must be configured separately for full proxy coverage.

Custom transport backend

Transport Trait

Implement the Transport trait for custom network transports:
See Transport API reference for full trait details.

Transport Factory

Implement TransportFactory to create transport instances:
See Transport API reference for factory details.

Transport Events

Custom HTTP Client

Implement the HttpClient trait for custom HTTP operations:
Make self.send_request(&request) below return Ok for a completed exchange, even a 4xx/5xx status. Reserve Err for two cases: the exchange never starts at all (DNS failure, connection refused, a TLS error, a timeout), or a body read fails after a 2xx status (for example, the body exceeds a size cap you enforce).Media download and upload read status_code off the Ok response. Session-derived downloads can refresh CDN hosts and re-derive the URL once after HTTP 401. HTTP 403/404/410 are terminal reference rejections. Download routes carry no auth token. Static URLs and standalone MediaDownloader cannot refresh a session. Uploads refresh authentication tokens on 401/403. Both paths can move to the next CDN host for other failures such as 5xx. Turning a non-2xx response into Err hides its status from this handling. See HTTP Client Trait, including how UreqHttpClient preserves non-2xx responses.
If your HTTP client doesn’t support streaming, you only need to implement execute. The supports_streaming method defaults to false, and unbounded download_to_writer calls use a buffered fallback. Bounded calls require streaming and fail before HTTP when it is unavailable.

SQLite reference implementation

The library includes a full SQLite implementation you can use as reference: See Store API reference for the SQLite implementation details.

Key Features

  • Diesel ORM with migrations
  • Connection pooling (r2d2)
  • WAL mode for concurrency
  • Prepared statements for performance
  • Transaction support
  • Database snapshotting for debugging

Using SQLite store

SQLite is bundled by default via the sqlite-storage-bundled feature on whatsapp-rust (which activates bundled-sqlite on whatsapp-rust-sqlite-storage). No system SQLite installation is required. If your binary already links another SQLite engine, keep sqlite-storage on and drop sqlite-storage-bundled — see Unbundling SQLite.

Custom cache store

The pluggable cache store adapter lets you replace the default in-process caches with an external backend like Redis or Memcached. This is useful for sharing cache state across multiple client instances or for deployments where in-process memory is limited.

The CacheStore trait

Implement the CacheStore trait from wacore::store::cache:

Plugging in your cache store

Use CacheStores to assign your custom backend to specific caches:
Or route all pluggable caches to the same backend:

Available namespaces

The following namespaces are used internally by the client:

Design considerations

  • Error handling is best-effort. Cache misses and failures are logged as warnings but don’t break the client — it falls back to fetching from the authoritative source.
  • Serialization uses serde_json. Values are serialized to JSON bytes on the custom-store path. The in-process path has zero serialization overhead.
  • TTL is forwarded from CacheEntryConfig. Your implementation receives the same TTL configured in CacheConfig.
  • Coordination caches cannot be externalized. Session locks, message queues, and enqueue locks hold live Rust objects (mutexes, channels) and always stay in-process.
  • invalidate_all() requires tokio-runtime. The synchronous invalidate_all() method on TypedCache spawns a fire-and-forget task via Tokio for custom backends. Without the tokio-runtime feature, the clear is skipped with a warning. Use the async clear() method instead if you disable tokio-runtime.

Best Practices

Next Steps

Bounded pending-message storage

Message durability on main snapshot 35a69a14 requires more than the legacy pending-row getters and batch writes shown above. Implement these ProtocolStore operations explicitly: There is no fallback from bounded reads to an unbounded getter. Retain the supplied PendingInboundLease through detached workers and retries, including after cancellation. The replacement payload owns its bytes and lease. Revision comparison alone does not prevent an absent-row ABA race after a successor creates and deletes a row; worker-terminal ownership is part of the contract. Construction probes these operations and rejects unsupported durability backends. Passing the probe does not certify crash durability, batch atomicity, retention or cancellation safety. Exercise concurrent replacement, stale cleanup, alias conflicts and restart against your actual backend. A successful batch flush alone does not satisfy the conditional mutation contract.

Session locking and Signal adapter failures

Custom SendContextResolver implementations must implement lock_device_sessions. Use the same canonical addresses and locks as direct sends, deduplicate targets and acquire locks in a stable order. Return a SessionLockGuard owning the locks until drop, including cancellation. SessionLockGuard::none() is valid only when the caller serializes every overlapping pairwise session operation. A sender-key lock alone does not protect pairwise ratchets. The Device adapter returns an io::ErrorKind::Unsupported cause for signed-prekey enumeration, insertion and removal, sub-device session enumeration and bulk session deletion. These operations no longer report empty results or successful no-ops. SignalProtocolStoreAdapter likewise rejects save_signed_pre_key; PersistenceManager owns rotation. Successful session, identity and sender-key writes admit state to cache. Flush and propagate errors before relying on durability. Default SignalStore batch methods run individual writes in order and stop at the first error. Earlier writes can already have committed. Backends used by SignalStoreCache must accept replay of the same snapshot after a failed flush. This does not authorize replay of an externally visible send.