Skip to main content

Overview

WhatsApp-Rust supports three authentication methods for linking companion devices:
  1. QR Code Pairing - Scan a QR code with your phone
  2. Pair Code (Phone Number Linking) - Enter an 8-character code on your phone
  3. Passkey Linking (SHORTCAKE_PASSKEY) - Gate the link behind a WebAuthn passkey already registered to the account
All three methods use the Noise Protocol for secure key exchange (passkey linking additionally requires a WebAuthn assertion) and can run concurrently - whichever completes first wins.

Authentication Flow

QR code pairing

How it works

Location: src/pair.rs, wacore/src/pair.rs
  1. Server sends pairing refs: After connection, server sends pair-device with multiple refs
  2. Generate QR codes: Each ref becomes a QR code containing device keys
  3. QR rotation: First code valid for 60s, subsequent codes for 20s each
  4. Phone scans: User scans QR with WhatsApp > Linked Devices
  5. Crypto handshake: Noise-based key exchange establishes trust
  6. Completion: Server sends pair-success, device signs identity

QR code contents

QR Format: ref,noise_pub,identity_pub,adv_secret,client_type
  • ref: Pairing reference from server
  • noise_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-byte CompanionWebClientType wire id (e.g. 1 for Chrome, 9 for OtherWebClient)
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.
Generated in: 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.

QR ref exhaustion

The server hands out six pair-device refs per connection (60s for the first, 20s for each of the other five — 160s total). When the rotation runs out of refs, it dispatches Event::PairingQrCodesExhausted rather than disconnecting unconditionally:
A pair code flow has an unrelated lifetime — a code sits on a phone screen for up to its ~180s validity window (and longer still while a companion_finish is pending), which outlasts the 160s the six QR refs buy. Disconnecting unconditionally would revoke a code the client had just told the user was still good, and any primary_hello for it would then arrive at a session the server had already dropped. So the client now disconnects only when no pair-code flow is outstanding — a QR-only consumer keeps the reconnect-for-fresh-refs behavior it relies on, while a phone-number flow in progress keeps its socket up.
disconnected: true means the client is about to tear down its own socket, not that it already has: as the snippet above shows, the event dispatches before disconnect().await is called. A handler registered as a plain EventHandler runs inline during dispatch, ahead of the disconnect; a Bot/on_event closure runs off a channel on its own task and can race it either way.Note that no Event::Disconnected follows this particular teardown: Client::disconnect() sets the expected_disconnect flag, and Disconnected is scoped to disconnects the client did not intend — so waiting on it here would hang forever. disconnect() also disables auto-reconnect and, as of PR #1258, is final for this Client instance: it fires the same sticky shutdown signal that connect() now checks on entry, so every later connect() call on this client returns ConnectError::Shutdown, not AlreadyConnected — retrying with backoff never succeeds. To resume, construct a fresh Client against the same persistence_manager (the not-yet-paired device state lives there) and call connect() on that new instance instead.
Breaking change: this is a new event. If you match on Event exhaustively with a wildcard already in place, no change is needed. Code that used to rely on the client always disconnecting when QR refs ran out — e.g. treating any disconnect during pairing as “start over” — should instead branch on PairingQrCodesExhausted.disconnected and reload only when it’s true.
By default the Event::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:
make_qr_data and Event::PairingQrCode never add this prefix — the emitted string is always the raw 5-field payload. If you want the deep-link behavior you must prepend the prefix yourself before rendering the QR. PairUtils::parse_qr_code transparently strips the prefix, so the raw and deep-link forms are interchangeable on the scanning side.
Render the raw 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
  1. Generate code: 8-character Crockford Base32 code
  2. Stage 1 - Hello: Send phone number + encrypted ephemeral key
  3. Server response: Returns pairing reference
  4. User enters code: On phone: WhatsApp > Linked Devices > Link with phone number
  5. Stage 2 - Finish: Phone confirms, companion sends key bundle
  6. Completion: Server sends pair-success

Pair code format

Alphabet: Crockford Base32 (excludes 0, I, O, U)
Length: Exactly 8 characters Example: ABCD1234, MYCODE12

Implementation

Random Code

Custom Code

One code at a time

A second code does not replace the first for the phone: the server routes primary_hello by phone number, never seeing the code itself, so whoever is still reading the older code reaches stage 2 and is handed a key bundle their code cannot open — the phone reports a failed link and the companion sees nothing. WA Web forbids the overlap outright (invariant(stage === Initialized) in Alt/DeviceLinkingApi.js). pair_with_code now enforces the same rule: it fails with PairCodeError::CodeAlreadyOutstanding { remaining } while the previous code is still outstanding, instead of silently overwriting it. “Outstanding” is either clock: the code’s own validity window, or — once primary_hello has been accepted — the pending pair-success that follows it, which can run up to a minute past the window (remaining reads as 0 in that case, since there’s no window left to report). Call cancel_pair_code first when the replacement is intentional:
Do not drive pair_with_code from QR-code rotation. The two flows have unrelated lifetimes — a pair code is read off a screen and typed into a phone minutes later, well past the point a QR ref would rotate. Re-requesting a code on every QR rotation trips CodeAlreadyOutstanding and does not match WA Web, which only regenerates on the server’s refresh_code, on force_manual_refresh, or on its own expiry timers — never on a QR ref rotating. See Pair code refresh events for the cases that do warrant a new request.
Client::cancel_pair_code() abandons the outstanding flow, if any — WA Web’s initializeAltDeviceLinking().
Cancellation is now reliable on both sides of primary_hello. Before a primary_hello has been accepted, cancelling is immediate and complete: a later primary_hello for the cancelled ref is dropped rather than answered with a bundle its holder cannot open. Deriving the key bundle and sending companion_finish runs under the same lock cancel_pair_code takes, so the two never interleave mid-derivation — cancel_pair_code either runs before stage 2 starts (and the notification is dropped) or after stage 2 has already sent companion_finish and released the lock. In that second case, cancelling re-mints the device’s adv_secret_key: the value stage 2 derived and persisted is keyed to a primary that has just been told to stop, so a pair-success that still arrives for it fails signature verification instead of silently completing the link. A flow already in PairCodeState::Completed is left untouched — that secret belongs to a device that did pair, and re-minting it would invalidate the account’s own ADV signatures.
An expired code never blocks a new request — CodeAlreadyOutstanding is only returned while the previous code (or a pending pair-success for it) is still live.

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:
The proto’s 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, WAIL, and the proto’s UNKNOWN collapse to OtherWebClient — the Android letters need attestation this crate cannot produce, and '0' is server-rejected.

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 to Chrome (<OS>), mirroring WA Web’s reported renderer name.
  • Explicit AndroidPhone/AndroidTablet/AndroidAmbiguous overrides emit Android (<OS>), e.g. Android (Android).
Unlike QR pairing — which never sends this field and so tolerates an arbitrary branding string in DeviceProps::os — the pair-code companion_hello server rejects a non-OS companion_platform_display with bad-request. The OS component is therefore canonicalized through wacore::companion_reg::CompanionOs into a small, server-safe set instead of using DeviceProps::os verbatim.
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)
Generated in: src/pair_code.rs The validity clock is stamped before the stage-1 companion_hello request is sent, matching WA Web’s startAltLinkingFlow, and held as a deadline — code_expires_at: wacore::time::Instant, the generation instant plus PairCodeUtils::code_validity() — rather than the generation instant itself (whatsapp-rust#1379). A monotonic clock has no history before the process started, so subtracting a validity window from “now” in a young process can saturate at the clock’s origin and land in the future; storing the deadline directly sidesteps that and matches what both readers (the event’s timeout and handle_primary_hello’s expiry check) actually want. Because of this, timeout on the dispatched event is the remaining window, 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: PairCodeState’s code_generation_ts: i64 (wall-clock seconds) field is replaced by code_expires_at: wacore::time::Instant (the deadline, not the generation instant), and is_outstanding / live_flow_remaining now take an Instant instead of a wall-clock reading. The boundary is unchanged — the deadline is still the last live instant, matching handle_primary_hello rejecting only strictly-past arrivals (WA Web OldCodeError) — but a caller comparing against wall-clock time no longer compiles.
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)
PairingCodeRefresh now covers two triggers, matching WA Web’s Alt/DeviceLinkingApi.js and Link/DevicePhoneNumberCodeScreen.react.js:
  1. Server-requested. A link_code_companion_reg notification arrives with stage="refresh_code" (WA Web refreshAltLinkingCode / forceManualRefresh) and its link_code_pairing_ref matches the outstanding request. force_manual reflects the notification’s force_manual_refresh attribute.
  2. Silent pair-success. The code was entered on the phone (primary_hello accepted), companion_finish was not refused — either the server accepted it, or the 30s wait for its own answer timed out unanswered — but no pair-success arrived within PairCodeUtils::primary_hello_pair_success_timeout() (WA Web’s one-minute primary_hello_expire timer). A primary that fails to open the key bundle just goes quiet at this stage — silence is the only signal there is — so the client times the wait out itself and dispatches PairingCodeRefresh with force_manual: false.
A refused companion_finish is a different case and no longer falls under this event: the server answers with an error immediately, so the client doesn’t need to wait out the silence timer to know the flow is dead. See Pair code failure events. In both cases the outstanding flow is cleared before the event fires, so a handler can call Client::pair_with_code immediately without hitting PairCodeError::CodeAlreadyOutstanding. The previous code is no longer valid either way. Register a handler with Bot::on_pair_code_refresh or match on the event directly via on_event.

Pair code failure events

Event: Event::PairingCodeError(PairingCodeError)
The counterpart to PairingCode on the failure path, and the only surface that reports a failed request when pairing is driven by BotBuilder::with_pair_code: that call runs Client::pair_with_code inside a detached task, so nothing returns its Err to a caller. pair_with_code dispatches this event in addition to returning Err, mirroring how the success path both returns the code and emits Event::PairingCode. Register a handler with BotBuilder::on_pair_code_error or match on the event directly via on_event. Fires for every stage-1 failure, including local validation — a phone number that’s too short never reaches the server. rejection carries the server’s refusal as a typed status in most cases where the server answered: Some(PairCodeRejection::Unknown(code)) even for a code outside WA Web’s own five — the code is still preserved, just not aliased to a named arm. None means one of two things instead: the failure never reached the server at all (local validation, no connection, timeout), or it did, but the server paired a named code with a text that contradicts it — see PairCodeRejection::from_server for why a contradiction is refused rather than trusted. In every None case the message from error is still the only description available. A claim the failed request itself held is released before this fires, so pair_with_code can be called again immediately. Also fires for a stage-2 refusal. Since the Stage 2: Finish companion_finish round trip was made to wait for its answer, a server refusal there dispatches this same event — immediately, not after the minute-long pair-success silence timer. There is no separate event type: both round trips fail the same way for a consumer, which is that this code is finished and another has to be requested. rejection is classified from the same PairCodeRejection set stage 1 uses — WA Web’s own companion_finish parser only ever answers with two of those codes (see PairCodeRejection below), but a server response outside that pair is still classified rather than discarded, exactly as an out-of-set stage-1 code is. An unanswered (timed-out) companion_finish does not dispatch this event; that silence is still owned by the one-minute timer and surfaces as PairingCodeRefresh instead. Two failures do not dispatch this event, because for them a code may still be on its way and the event would say the opposite:
  • PairCodeError::CodeAlreadyOutstanding — refused precisely because an earlier code is still live; the consumer already has it from the PairingCode event that minted it.
  • PairCodeError::Cancelled — the caller withdrew this request via cancel_pair_code, and a replacement may already own the slot by the time this one resolves; reporting the withdrawn request would be uncorrelated with the flow that’s actually running.
Both are consequences of something the caller did, so neither is news to them, and a direct caller still receives the Err either way — only the event is suppressed. PairError::lost_the_flow_to_another_request() is true for exactly these two variants.

PairCodeRejection

The five named variants are the complete set WA Web’s stage-1 response parser (WASmaxInMdIqMixinErrors.parseIqMixinErrors) accepts; anything else makes its own RPC throw “unknown error”, which is what Unknown(code) preserves here. Classified via PairCodeRejection::from_server(code, text) from both wire attributes together — WA Web asserts them as a literal pair (e.g. 429/rate-overlimit) and falls back to its generic error path when they disagree, so a contradicting text classifies as None rather than aliasing the code to the named arm. An absent text is not treated as a contradiction; the code alone decides in that case. Both pair-code round trips report through this same type, though only stage 1’s companion_hello accepts the full five. Stage 2’s companion_finish has its own, narrower server-side parser (WASmaxInMdCompanionFinishErrors) that admits only BadRequest (400) and InternalServerError (500) — WA Web shows its generic failure for anything else there. A stage-2 code outside that pair is still classified here rather than discarded: what a consumer does about a refusal follows from the code, which is one namespace across both requests. PairCodeRejection::is_throttled() is true for RateOverlimit and BadRequest — deliberately wider than the literal 429, because the server throttles pair-code requests per phone number under bad-request instead of rate-overlimit, and the two are indistinguishable on the wire. Treat a true here as “back off, then retry a bounded number of times,” not as proof the request would eventually succeed. FeatureNotAvailable is never throttled — retrying cannot fix it, and WA Web falls back to the QR code instead. PairError (the Err pair_with_code returns) exposes the same classification without depending on the event:

Two-Stage Flow

Stage 1: Hello

Purpose: Register phone number and encrypted ephemeral key
Response: Pairing reference

Stage 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:
  1. Extract primary’s wrapped ephemeral pub (80 bytes)
  2. Extract primary’s identity pub (32 bytes)
  3. Validate the notification before touching any crypto (checked in this order, so a rejected notification never consumes a retry slot):
    • link_code_pairing_ref must match the ref cached from companion_hello (WA Web InvalidRefError)
    • the code must still be within its ~180s validity window (WA Web OldCodeError)
    • at most PairCodeUtils::max_primary_hello_attempts() (3, matching WA Web’s T) genuine attempts are processed per code (WA Web MaxPrimaryHelloError) — a rejected attempt does not count against this cap
  4. Decrypt primary’s ephemeral key (expensive PBKDF2, run in spawn_blocking)
  5. Prepare encrypted key bundle
  6. Send companion_finish IQ and wait for its answer, up to PairCodeUtils::companion_finish_iq_timeout() (30s)
The whole of stage 2 runs under the pair_code_state lock, held from validation through the socket send — not through the wait for the answer. 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 through the send makes concurrent notifications for the same code process sequentially, matching WA Web’s single-threaded model; releasing it before the wait means cancel_pair_code is never blocked for the length of a round trip to an unresponsive server. 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. companion_finish used to be sent fire-and-forget: nothing read its answer, so a server refusal surfaced only as a generic “unhandled IQ” log line and the consumer learned the flow had died from a minute of silence (the timer below). It now goes out through the same IQ path as stage 1 and the response is handled explicitly:
  • Accepted. Nothing changes — the flow stays WaitingForPhoneConfirmation, waiting on pair-success as before.
  • Refused (the server answers with an <error>, e.g. bad-request or internal-server-error). The flow is retired immediately and reported through Event::PairingCodeError with a typed PairCodeRejection — the same surface stage 1 already had, so a consumer no longer has to wait out the silence timer to learn stage 2 failed.
  • Timeout (no answer within 30s). This is deliberately not reported and does not retire the flow: silence already belongs to the one-minute pair-success timer described below, over a longer window, and ending the flow on the shorter IQ timeout could cut a link the server is still completing.
Retiring a flow that reached stage 2 — via a refusal, the one-minute timer, or cancel_pair_code — also re-mints the device’s adv_secret_key (see the cancellation note in One code at a time). That secret was derived and persisted for a primary that will now never link, so leaving it in place would only let a later, unrelated flow inherit a stale value. 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)

Breaking change (unreleased): enable the opt-in passkey feature flag if you use passkey linking — it’s off by default. This lands after the published 0.7.0 release, so it needs a git dependency until a later version ships it:
Cargo.toml
On the published 0.7.0, nothing gates it yet — passkey linking works with no feature flag at all. On a build past this change, skip the passkey feature and whatsapp_rust::passkey doesn’t exist for you — set_passkey_authenticator, send_passkey_response, and send_passkey_confirmation are unavailable, and a passkey_prologue_request notification from the server reaches your app as Event::Notification instead of the Event::PairPasskey* events below, the same way any notification type this client doesn’t model does. See Feature flags.

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.
  1. Server requests a WebAuthn assertion: a passkey_prologue_request notification carries (or points to, via IQ) the PublicKeyCredentialRequestOptions JSON.
  2. 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.
  3. 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).
  4. Shared key + verification code: both nonces and public keys derive an AES-256-GCM key and an 8-character “XXXX-XXXX” verification code.
  5. 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>.
  6. Completion: as with QR/pair-code, the server sends pair-success and linking finishes through the same PairSuccess/PairError path.
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.
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
Two helper functions parse/build the wire shapes so a host authenticator doesn’t have to:
If you don’t need a custom integration, CallbackAuthenticator wraps any async closure as a PasskeyAuthenticator:

Driving modes

  • Automatic: with set_passkey_authenticator called, the client drives the assertion step — it calls get_assertion when the server asks and sends the response for you. It also auto-confirms re-links whose skip_handoff_ux is true, since continuity is already proven. A fresh link does not auto-confirm even in this mode: it still emits Event::PairPasskeyConfirmation, and you must show the code to the user and call send_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 calls send_passkey_response / send_passkey_confirmation itself.

Implementation

Passkey events

Linking completes through the ordinary 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:
XX flow (cold start):
  1. Initiator → Responder: ephemeral pub
  2. Responder → Initiator: ephemeral pub, static pub, encrypted payload (cert chain)
  3. Initiator → Responder: encrypted static pub, encrypted payload
The verified server_cert_chain is persisted at the end of XX so the next connect can use IK. IK flow (resumed):
  1. Initiator → Responder: ephemeral pub, encrypted static, encrypted 0-RTT payload (built against the cached server static)
  2. Responder → Initiator: ephemeral pub, encrypted payload — handshake is complete after this single round trip.
If the server’s static no longer matches the cached value, step 2 returns an 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 DevicePropsdevice_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():
Internally this dispatches DeviceCommand::SetClientProfile(profile) through the persistence manager (see State Management).

Key Derivation

For QR Code:
For Pair Code:
Parameters:
  • 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:
  1. Server sends signed device identity
  2. Companion verifies signature
  3. Identity keys exchanged
  4. 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 with ack 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:
  1. Pair-success <client-props>. The primary attaches an optional <client-props> child to pair-success carrying ClientPairingProps.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.
  2. lid_migration_mapping_sync_message. The primary can later push a ProtocolMessage.lid_migration_mapping_sync_message to its own companions (mirroring WA Web’s setLidMigrationMappings) carrying newly-assigned PN↔LID pairs. The client learns the mappings and, once the lid_one_on_one_migration_enabled ab prop allows it, persists the account as migrated. This message is honored only from is_from_me sources — 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.
Once set, the flag never reverts for the same account (like the WA Web pref) — it can only be reset to 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:
State Management:
Cancellation:
The 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

QR codes are handled internally and retried automatically. If all refs expire, the client dispatches Event::PairingQrCodesExhausted and disconnects only when no pair-code flow is outstanding — see QR ref exhaustion.

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):
CodeAlreadyOutstanding and Cancelled are new — see One code at a time. CodeAlreadyOutstanding means pair_with_code refused to supersede a code that is still outstanding — either within its validity window, or awaiting a pair-success after an accepted primary_hello; Cancelled means cancel_pair_code() withdrew the request while stage 1 (companion_hello) was in flight.
Prefer PairError::rejection() to matching on bad-request by string: the server reuses PairCodeRejection::BadRequest (400) for both malformed requests and its per-phone-number rate limit, and the two are indistinguishable on the wire. PairCodeRejection::is_throttled() covers this — it’s true for both BadRequest and RateOverlimit — so 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) and surfaced directly via PairError::backoff().
PairError::RequestFailed’s Display now renders exactly what it wraps ({0}) instead of the fixed string "pair-code IQ request failed" — so a log line that only prints the error ({e}) still shows the server’s code and text, e.g. 429 (rate-overlimit).
The previous catch-all 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
Next connection:

Logout

Best Practices

Phone number format

Event Handling

Concurrent Usage

Architecture

Understand the project structure

Events

Learn about all event types

Storage

Explore session persistence

Quick Start

Build your first bot