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().

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.

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:
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