Skip to main content

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

The storage backend is split into four domain-grouped traits:
  1. SignalStore - Signal protocol cryptography (identities, sessions, keys)
  2. AppSyncStore - WhatsApp app state synchronization
  3. ProtocolStore - WhatsApp Web protocol alignment (SKDM, LID mapping, device registry)
  4. DeviceStore - Device persistence operations
See Store API reference for the complete trait definitions.

Backend Trait

Any type implementing all four traits automatically implements Backend:

Implementing a custom storage backend

Step 1: define your store

Step 2: implement SignalStore

Handle Signal protocol cryptographic operations:
See Store API reference for all SignalStore methods.

Step 3: implement AppSyncStore

Handle WhatsApp app state synchronization:
See Store API reference for all AppSyncStore methods.

Step 4: implement ProtocolStore

Handle WhatsApp Web protocol alignment:
See Store API reference for all ProtocolStore methods.

Pending Inbound Buffer

If you want to support the opt-in InboundDurabilityHook, your backend must implement four additional ProtocolStore methods. Without them, Bot::build() rejects any attempt to register a durability hook with a BotBuilderError::UnsupportedDurabilityBackend error.
Two further methods, store_pending_inbound_batch and delete_pending_inbound_batch, back the client’s inbound commit batching during the offline drain. Both default to looping the single-row methods below, so implementing just the four methods in this section is enough — your backend keeps working, just without batch atomicity. Override them if you want the whole batch committed in one transaction:
The bundled SqliteStore overrides both this way, so the offline-drain batcher pays one Diesel transaction per batch instead of one round-trip per message.
The default trait implementations fail closed (return an error) rather than silently degrading to at-most-once, so you only need to override them if your backend opts in.
store_pending_inbound/get_pending_inbound/delete_pending_inbound/delete_expired_pending_inbound and their batch counterparts do not take device_id as a parameter — (chat, sender, id) is the whole key on the trait. A store that serves a single account scopes rows by (chat, sender, id) alone. If your backend is a shared multi-account store (one table/connection serving several SqliteStore-like instances), scope rows by your own instance’s device id internally, the same way the bundled SqliteStore reads its own self.device_id field rather than accepting it as an argument.
The recommended schema (matching the bundled SQLite migration):
The device_id column exists so one shared table can serve multiple accounts/devices without cross-account collisions — the bundled SqliteStore has one such table per account and always filters by its own self.device_id. A backend with one table per account can drop the column and the device_id predicate entirely.
The message column stores the proto-serialized wa::Message bytes. The client writes them before the Signal ratchet advances so that a crash between the buffer write and the hook return can replay the exact same decrypted bytes on redelivery.

Step 5: implement DeviceStore

Handle device data persistence:
See Store API reference for all DeviceStore methods.

Using your custom backend

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 aborts the spawned task, ensuring 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 to refresh a stale media-auth token (401/403) or move to the next CDN host on other errors like 5xx; download additionally re-derives an expired URL (404/410) the same way. If you turn a non-2xx status into Err instead, you hide it from all of this, and every retry repeats the same stale token. See HTTP Client Trait for the full contract, including how the bundled UreqHttpClient works around ureq’s opposite default.
If your HTTP client doesn’t support streaming, you only need to implement execute. The supports_streaming method defaults to false, and download_to_writer will automatically use a buffered fallback.

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 bundled-sqlite feature on whatsapp-rust-sqlite-storage. No system SQLite installation is required.

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