Overview
WhatsApp-Rust supports three 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
- Passkey Linking (SHORTCAKE_PASSKEY) - Gate the link behind a WebAuthn passkey already registered to the account
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,client_type
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)client_type: Single-byteCompanionWebClientTypewire id (e.g.1for Chrome,9forOtherWebClient)
Current WhatsApp Web emits the 5-field form (with the trailing
client_type). The parser (PairUtils::parse_qr_code) still accepts the legacy 4-field string for backwards compatibility, but make_qr_data always produces 5 fields.Implementation
QR code events
Event:Event::PairingQrCode(PairingQrCode)
Breaking change:
PairingQrCode moved from inline fields on Event::PairingQrCode { code, timeout } to a dedicated #[non_exhaustive] struct sealed with a bon builder — Event::PairingQrCode(PairingQrCode). Update match/if let patterns to destructure through the newtype (with a .. rest), and construct via PairingQrCode::builder().code(code).timeout(timeout).build() instead of a struct literal.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.Native-camera deep link (open WhatsApp directly)
By default theEvent::PairingQrCode code is the raw comma-separated string above. It is meant to be scanned from inside WhatsApp (Linked Devices → Link a Device) — it is not a URL and tapping it does nothing.
WhatsApp Web (WAWebLinkDeviceQrcode) also supports a deep-link shape for iOS native-camera linking: prefixing the same payload with a wa.me URL turns the QR into a link. On iOS, scanning it with the native Camera app (not the in-app scanner) opens WhatsApp straight to the Linked Devices screen and hands off the pairing payload via the URL fragment (#...).
The prefix is exported as a constant:
Mini example — render a scannable deep-link QR
code instead of deep_link if you want the classic in-app scanner flow; both produce a valid, scannable code.
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 a canonicalized OS derived from DeviceProps::os:
- 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).
CompanionOs::from_hint classifies a free-form OS hint (case-insensitive, with whole-word guards so branding like "KaiOS"/"March"/"across" doesn’t false-match a substring) into one of:
An OS that doesn’t classify (empty, or a branding string such as
"Veloz") coerces to Linux — the same fallback QR pairing already used for an empty OS, now also covering non-OS branding strings. The client logs a one-time warn! when this coercion actually changes a non-empty os, so a consumer sees why their custom branding didn’t ride through.
Escape hatch: set PairCodeOptions::display_os to send an OS verbatim, bypassing canonicalization entirely — useful to keep a real, server-accepted name the canonical set collapses (e.g. "Ubuntu" → Linux). This is at the caller’s risk: a non-OS string here is rejected with bad-request. An all-whitespace override is ignored and falls back to the safe coercion.
The server also validates that the display string is 1..=100 bytes.
Pair code events
Event:Event::PairingCode(PairingCode)
src/pair_code.rs
The validity clock (code_generation_ts) is stamped before the stage-1 companion_hello request is sent, matching WA Web’s startAltLinkingFlow. Because of this, timeout on the dispatched event is the remaining window (code_validity() - elapsed), not always the full ~180 seconds — otherwise a UI countdown built from the event would outlast the server’s (and this crate’s own handle_primary_hello) actual expiry check by however long stage 1 took.
Breaking change:
PairingCode and PairingCodeRefresh moved from inline enum-variant fields (Event::PairingCode { code, timeout }, Event::PairingCodeRefresh { force_manual }) to dedicated #[non_exhaustive] structs sealed with a bon builder — Event::PairingCode(PairingCode) / Event::PairingCodeRefresh(PairingCodeRefresh). Destructuring patterns need a .. rest; construction goes through PairingCode::builder()…build().Pair code refresh events
Event:Event::PairingCodeRefresh(PairingCodeRefresh)
link_code_companion_reg notification arrives with stage="refresh_code" (WA Web refreshAltLinkingCode / forceManualRefresh) and its link_code_pairing_ref matches the outstanding request. The typical reaction is to call Client::pair_with_code again with the same phone number — the previous code is no longer guaranteed valid. Register a handler with Bot::on_pair_code_refresh or match on the event directly via on_event.
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
handle_pair_code_notification dispatches on the notification’s stage attribute, mirroring WA Web’s handleAltDeviceLinkingNotification. An unrecognized stage is ignored without touching the in-progress flow:
primary_hello — the user entered the code on their phone:
- Extract primary’s wrapped ephemeral pub (80 bytes)
- Extract primary’s identity pub (32 bytes)
- Validate the notification before touching any crypto (checked in this order, so a rejected notification never consumes a retry slot):
link_code_pairing_refmust match the ref cached fromcompanion_hello(WA WebInvalidRefError)- the code must still be within its ~180s validity window (WA Web
OldCodeError) - at most
PairCodeUtils::max_primary_hello_attempts()(3, matching WA Web’sT) genuine attempts are processed per code (WA WebMaxPrimaryHelloError) — a rejected attempt does not count against this cap
- Decrypt primary’s ephemeral key (expensive PBKDF2, run in
spawn_blocking) - Prepare encrypted key bundle
- Send
companion_finishIQ
pair_code_state lock, held from validation through the socket send. The transport dispatches <notification> stanzas on concurrent detached tasks, so two primary_hello notifications for the same code could otherwise each derive a different random adv_secret and race SetAdvSecretKey (last-write-wins) — desyncing the persisted secret from the companion_finish the server acts on. Holding the lock across the full stage makes concurrent notifications for the same code process sequentially, matching WA Web’s single-threaded model. State stays WaitingForPhoneConfirmation after a successful companion_finish (rather than moving to Completed) so a genuine retry can still reuse it — only pair-success (see crate::pair) completes the flow.
refresh_code — the server asks the companion to regenerate the code it is displaying. Dispatches Event::PairingCodeRefresh with force_manual taken from the notification’s force_manual_refresh attribute, but only when the notification’s ref matches the outstanding flow (WA Web’s getCurrentRef() guard) — otherwise it is ignored.
Passkey linking (SHORTCAKE_PASSKEY)
How it works
Location:wacore/src/shortcake.rs (pure crypto/protobuf core), src/passkey/mod.rs (the PasskeyAuthenticator seam), src/passkey/flow.rs (the client driver)
This gate requires a WebAuthn passkey already registered to the WhatsApp account (e.g. in Google Password Manager or iCloud Keychain) — it is not a standalone pairing method you can bootstrap from scratch like QR or pair code. The server asks the companion to prove possession of that passkey before it will hand over the ADV secret.
- Server requests a WebAuthn assertion: a
passkey_prologue_requestnotification carries (or points to, via IQ) thePublicKeyCredentialRequestOptionsJSON. - Companion obtains an assertion: delegated to a registered
PasskeyAuthenticator— e.g. Android Credential Manager — since the passkey’s private key is non-extractable and never touches this crate. - Ephemeral-identity commit/reveal: the companion generates a fresh X25519 keypair and nonce, commits to them (
<passkey_prologue>), and the primary reveals its own ephemeral identity in return (crsc_continuation). - Shared key + verification code: both nonces and public keys derive an AES-256-GCM key and an 8-character “XXXX-XXXX” verification code.
- Encrypted pairing request: the companion encrypts its static Noise/identity public keys plus a freshly rotated ADV secret under that key and sends
<encrypted_pairing_request>. - Completion: as with QR/pair-code, the server sends
pair-successand linking finishes through the samePairSuccess/PairErrorpath.
The rotated ADV secret is held only in memory until
send_passkey_confirmation succeeds — it is committed to the device store (DeviceCommand::SetAdvSecretKey) only after the primary has it. An abandoned or failed attempt never leaves the device on a secret the primary never received.Re-links skip the verification code
On a re-link — the device already has a prior linked identity (account, phone number, or LID persisted from an earlier pairing) — the client derives an HMAC “handoff proof” from the stored adv_secret_key and includes it in <passkey_prologue>. If the server accepts it as proof of continuity, Event::PairPasskeyConfirmation.skip_handoff_ux is true and the link can complete without showing the user a code.
A fresh link (no prior account/pn/lid) never derives a handoff proof, even though adv_secret_key itself is always present — it’s randomly generated at device creation, so it can’t by itself signal continuity with a real prior link. A brand-new link therefore always shows the verification code.
PasskeyAuthenticator trait
Location: src/passkey/mod.rs
CallbackAuthenticator wraps any async closure as a PasskeyAuthenticator:
Driving modes
- Automatic: with
set_passkey_authenticatorcalled, the client drives the assertion step — it callsget_assertionwhen the server asks and sends the response for you. It also auto-confirms re-links whoseskip_handoff_uxistrue, since continuity is already proven. A fresh link does not auto-confirm even in this mode: it still emitsEvent::PairPasskeyConfirmation, and you must show the code to the user and callsend_passkey_confirmation()yourself once they approve it — otherwise the link stalls before<encrypted_pairing_request>is ever sent. - Manual: with no authenticator registered, the host drives every step from the three
Event::PairPasskey*events (see below) and callssend_passkey_response/send_passkey_confirmationitself.
Implementation
Passkey events
PairSuccess/PairError events — there is no separate “passkey success” event.
PairPasskeyRequest, PairPasskeyConfirmation, and PairPasskeyError are #[non_exhaustive], sealed with a bon builder (e.g. PairPasskeyRequest::builder().request_options_json(json).build()). Field access by name (req.request_options_json) is unaffected; only an exhaustive struct-pattern destructure would need a .. rest.Client methods
See Client API — Connection Management for full signatures and error variants.
Cryptography
Noise protocol handshake
whatsapp-rust supports three Noise patterns to mirror WhatsApp Web:- 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.Built-in profiles
The
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.
One-to-one LID migration state
Fixes #941: some accounts are not yet 1:1-LID-migrated on WhatsApp’s servers, and those accounts get every LID-addressed DM rejected withack error="400". The client tracks this account-level state in a persisted Device.lid_migrated flag so it can keep DM wire addressing on PN until the account has actually migrated — see Signal Protocol — DM wire namespace vs. Signal session addressing.
The flag is set from two sources, both mirroring WA Web’s WAIsAccountLidFieldMigrated pref:
- Pair-success
<client-props>. The primary attaches an optional<client-props>child topair-successcarryingClientPairingProps.isChatDbLidMigrated.PairUtils::extract_pairing_props()decodes it; a malformed or absent payload is treated the same as “not reported” and never fails the pairing itself. lid_migration_mapping_sync_message. The primary can later push aProtocolMessage.lid_migration_mapping_sync_messageto its own companions (mirroring WA Web’ssetLidMigrationMappings) carrying newly-assigned PN↔LID pairs. The client learns the mappings and, once thelid_one_on_one_migration_enabledab prop allows it, persists the account as migrated. This message is honored only fromis_from_mesources — a peer-sent copy is dropped, since accepting it would let a peer poison the LID-PN cache and flip your own account’s wire addressing.
false when a different account is paired onto the same store. Mutations go through DeviceCommand::SetLidMigrated(bool) (see State Management).
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
Breaking change:
PairSuccess, PairError, and LoggedOut (below) are now #[non_exhaustive] and sealed with a bon builder. A struct literal from outside wacore/whatsapp-rust no longer compiles — construct via Type::builder()…build(), and add a .. rest to any destructuring pattern.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):
A
PairError::RequestFailed carrying bad-request (400) is not necessarily a permanent/invalid-input failure — the server reuses the same error for rate-limiting (it throttles pair-code requests per phone number), and the two are indistinguishable in the response. Back off and retry rather than treating every 400 as fatal. By default, the library canonicalizes companion_platform_display’s OS (see companion_platform_display), so display-shaped rejections are generally ruled out unless you explicitly bypass canonicalization via PairCodeOptions::display_os. Any server backoff hint is preserved on the wrapped IqError::ServerError (see Error Types).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