Overview
WhatsApp-Rust supports two authentication methods for linking companion devices:- QR Code Pairing - Scan a QR code with your phone
- Pair Code (Phone Number Linking) - Enter an 8-character code on your phone
Authentication Flow
QR code pairing
How it works
Location:src/pair.rs, wacore/src/pair.rs
- Server sends pairing refs: After connection, server sends
pair-devicewith multiple refs - Generate QR codes: Each ref becomes a QR code containing device keys
- QR rotation: First code valid for 60s, subsequent codes for 20s each
- Phone scans: User scans QR with WhatsApp > Linked Devices
- Crypto handshake: Noise-based key exchange establishes trust
- Completion: Server sends
pair-success, device signs identity
QR code contents
ref,noise_pub,identity_pub,adv_secret
ref: Pairing reference from servernoise_pub: Static Noise public key (32 bytes, base64)identity_pub: Signal identity public key (32 bytes, base64)adv_secret: Advertisement secret key (32 bytes, base64)
Implementation
QR code events
Event:Event::PairingQrCode
src/pair.rs:63-116
The rotation loop includes a safety guard that checks is_logged_in() before emitting each QR code. This prevents stale QR events from firing after pairing completes — important for single-threaded runtimes, fast auto-pair scenarios, and mock servers where the spawned task may not be polled until after pairing succeeds.
The rotation uses
futures::future::select with an async_channel stop signal rather than Tokio-specific primitives. This keeps the QR rotation compatible with any async runtime, since the Client uses the pluggable Runtime trait for sleep and spawn operations.Pair code (phone number linking)
How it works
Location:src/pair_code.rs, wacore/src/pair_code.rs
- Generate code: 8-character Crockford Base32 code
- Stage 1 - Hello: Send phone number + encrypted ephemeral key
- Server response: Returns pairing reference
- User enters code: On phone: WhatsApp > Linked Devices > Link with phone number
- Stage 2 - Finish: Phone confirms, companion sends key bundle
- Completion: Server sends
pair-success
Pair code format
Alphabet: Crockford Base32 (excludes 0, I, O, U)ABCD1234, MYCODE12
Implementation
Random Code
Custom Code
Pair code options
CompanionWebClientType
CompanionWebClientType is the wire-level enum emitted in the <companion_platform_id> child of the pair-code IQ. Each variant has a fixed single-byte ASCII identifier returned by wire_byte:
UNKNOWN (wire '0') is intentionally absent — WA Web never emits it from a real browser and the server rejects it. The default is OtherWebClient ('9'). The server accepts 23 single-byte ids (0..9 and a..m); only the 12 with a confirmed platform meaning are exposed.
Mapping from PlatformType
companion_web_client_type_for_platform maps each wa::device_props::PlatformType to a wire variant. Web platforms map to their browser variant (Chrome, Firefox, Edge, etc.). Desktop maps to Electron. The Android PlatformType variants (AndroidPhone, AndroidTablet, AndroidAmbiguous) map to Chrome — that’s what real WA Web on Chrome-Android emits and what the server accepts without attestation. To request the Android letter codes ('d'/'e'/'f') explicitly, set PairCodeOptions::platform_id. iOS, AR/VR, Wear OS, and the proto’s UNKNOWN collapse to OtherWebClient.
companion_platform_display
The display string sent in <companion_platform_display> is built from the resolved wire variant and the OS reported in DeviceProps:
- Web variants emit
<Browser> (<OS>), e.g.Chrome (Linux),Firefox (Windows). Non-browser web variants (Electron, UWP, OtherWebClient) and Android-mapped-to-Chrome fall back toChrome (<OS>), mirroring WA Web’s reported renderer name. - Explicit
AndroidPhone/AndroidTablet/AndroidAmbiguousoverrides emitAndroid (<OS>), e.g.Android (Android). - Empty OS substitutes
Linux.
Pair code events
Event:Event::PairingCode
src/pair_code.rs:215-219
Two-Stage Flow
Stage 1: Hello
Purpose: Register phone number and encrypted ephemeral keyStage 2: Finish
Trigger:link_code_companion_reg notification from server
Handling: src/pair_code.rs:229-376
Cryptography
Noise protocol handshake
whatsapp-rust supports three Noise patterns to mirror WhatsApp Web:| Pattern | When it runs | Round trips |
|---|---|---|
| Noise XX | Cold start, after pairing, or any reconnect with no cached server cert chain | 1.5 (3 messages) |
| Noise IK | Reconnect with a valid cached server_cert_chain | 0.5 (2 messages) |
| Noise XXfallback | Server-driven recovery when an IK attempt’s cached server static is stale | 0.5 (continues from IK ClientHello) |
- Initiator → Responder: ephemeral pub
- Responder → Initiator: ephemeral pub, static pub, encrypted payload (cert chain)
- Initiator → Responder: encrypted static pub, encrypted payload
server_cert_chain is persisted at the end of XX so the next connect can use IK.
IK flow (resumed):
- Initiator → Responder: ephemeral pub, encrypted static, encrypted 0-RTT payload (built against the cached server static)
- Responder → Initiator: ephemeral pub, encrypted payload — handshake is complete after this single round trip.
IkServerHelloOutcome::Fallback(...) and the client pivots to XXfallback in-place — without dropping the connection — finishing as if it had been XX from the start.
After a single crypto-fatal IK failure the client clears the cached cert chain (DeviceCommand::ClearServerCertChain), increments a process-local failure counter, and forces XX on the next connect. See WebSocket & Noise Protocol — Noise Protocol Handshake for the full state machine.
ClientProfile
Location:wacore/src/client_profile.rs
ClientProfile is the identity that gets baked into ClientPayload.UserAgent during the Noise handshake. It controls the platform, device, os_version, os_build_number, manufacturer fields, and whether web_info is attached to the payload.
It is independent of DeviceProps — device_props describes the companion entry on the phone, while ClientProfile describes the client identity to WhatsApp’s server during the handshake itself. The two can be set independently.
Since v0.6 the locale and
phone_id come from the active ClientProfile instead of being hard-coded. The locale is split into two ISO fields — locale_language (ISO-639-1, e.g. "en") and locale_country (ISO-3166-1 alpha-2, e.g. "US") — both written to the matching UserAgent proto attributes. When phone_id is None the client builds a fresh UUID-v4 on every ClientPayload build; it is not persisted on Device, so if you need a stable WA Web–style WAWebClientPayload.phoneId you must supply it yourself (e.g. generate once at install time and pass it in via your own ClientProfile constructor). Login counter (ClientPayload.lc) lives on Device, not here — see the Login counter section below.passive_login mirrors WA Web’s ClientPayload.passive: false (the default) tells the server to deliver queued offline messages on connect, true keeps the connection passive until you pull explicitly (whatsmeow’s convention).Built-in profiles
| Constructor | Platform | Device | Manufacturer | web_info |
|---|---|---|---|---|
ClientProfile::web() (default) | Web | Desktop | "" | included |
ClientProfile::android(os_version) | Android | Smartphone | "" | omitted |
ClientProfile::smb_android(os_version) | SmbAndroid | Smartphone | "" | omitted |
ClientProfile::ios(os_version) | Ios | iPhone | Apple | omitted |
ClientProfile::macos(os_version) | Macos | Desktop | Apple | omitted |
ClientProfile::windows(os_version) | Windows | Desktop | "" | omitted |
web() profile reproduces the legacy desktop-web payload (os_version and os_build_number are both "0.1.0"). Native profiles propagate the supplied os_version to both fields and drop web_info.
Setting a profile
Device.client_profile is #[serde(skip)], so it is never persisted. Set it on every fresh process before calling connect():
DeviceCommand::SetClientProfile(profile) through the persistence manager (see State Management).
Key Derivation
For QR Code:- Algorithm: AES-256-CBC
- KDF: PBKDF2-HMAC-SHA256
- Iterations: 2^16 (65,536)
- Salt: 16 random bytes
- IV: 16 random bytes
Signal protocol setup
After pairing:- Server sends signed device identity
- Companion verifies signature
- Identity keys exchanged
- Pre-keys registered
Login counter (ClientPayload.lc)
Since v0.6 the client persists a login_counter on Device. Every successful connect increments it and the new value is sent as ClientPayload.lc during the Noise handshake. This mirrors WA Web’s anti-abuse signal — the server uses the counter to spot replayed or cloned ClientPayloads. The counter resets when you call logout() or wipe device state.
Concurrent Pairing
Both methods can run simultaneously:is_logged_in() safety guard in the rotation loop acts as a fallback — even if the cancellation channel hasn’t been stored yet (race condition on fast pairing), the rotation task will exit cleanly on its next iteration.
Success Events
PairSuccess
PairError
Error Handling
QR code errors
Pair code errors
pair_with_code returns whatsapp_rust::pair_code::PairError, which wraps the wacore-side validation/crypto errors (PairCodeError) and the IQ transport layer (IqError):
CryptoError(String) and RequestFailed(String) variants have been split into typed variants that preserve their underlying source. Match on std::error::Error::source() (or downcast it) to inspect the inner CurveError, CryptoProviderError, or IqError.
Session Persistence
After successful pairing
State saved to storage:- Device JID (Phone Number)
- LID (Long-term Identifier)
- Identity keys
- Noise keys
- Registration ID
- Push name
Logout
Best Practices
Phone number format
Event Handling
Concurrent Usage
Related Sections
Architecture
Understand the project structure
Events
Learn about all event types
Storage
Explore session persistence
Quick Start
Build your first bot