Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions api/bot.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -645,6 +645,10 @@ Bot::builder()
Pair code runs concurrently with QR code pairing — whichever completes first wins.
</Note>

<Note>
`with_pair_code` runs [`Client::pair_with_code`](/api/client#pair_with_code) in a detached task, so a failure never reaches a caller as an `Err` — it only reaches [`Event::PairingCodeError`](/concepts/events#pairingcodeerror). Register [`on_pair_code_error`](#on_pair_code_error) if the consumer must distinguish "still waiting for the user" from "no code is coming" (a rate-limited request otherwise looks identical to the former).
</Note>

<Tip>
The `companion_platform_display` shown on the phone is derived automatically from the resolved `platform_id` and a **canonicalized** OS derived from the device's `os` string: web variants emit `<Browser> (<OS>)` (Android `PlatformType`s map to `Chrome`, so they show as `Chrome (Android)` by default); explicit `AndroidPhone`/`AndroidTablet`/`AndroidAmbiguous` overrides emit `Android (<OS>)`. The OS is coerced into a small server-safe set (`Windows`/`Mac OS`/`Linux`/`Android`/`iOS`) because the pair-code server rejects a non-OS display with `bad-request` — an arbitrary branding `os` string falls back to `Linux`. Set `PairCodeOptions::display_os` to send a real, non-canonical OS name verbatim instead. See [Authentication — companion_platform_display](/concepts/authentication#companion-platform-display) for the full classification table.
</Tip>
Expand Down Expand Up @@ -689,6 +693,65 @@ Bot::builder()
This callback fires for **two** triggers, not just a server request: the server asking for a refresh (only while a pair-code flow is outstanding and the notification's ref matches it — a `refresh_code` notification for a stale or unrelated flow is ignored), and a `companion_finish` that went unanswered for a minute after the phone entered the code (`force_manual` is always `false` for this second trigger). See [Pair code refresh events](/concepts/authentication#pair-code-refresh-events) for the full breakdown.
</Note>

### on_pair_code_error

```rust
pub fn on_pair_code_error<F, Fut>(self, handler: F) -> Self
where
F: Fn(PairingCodeError, Arc<Client>) -> Fut + Send + Sync + 'static,
Fut: Future<Output = ()> + Send + 'static
```

Registers a handler for [`Event::PairingCodeError`](/concepts/events#pairingcodeerror), fired when a pair-code request fails and no code will be issued. The counterpart to [`on_pair_code_refresh`](#on_pair_code_refresh) on the failure path, and a dedicated convenience over matching the event yourself in `on_event` — either works, but this is the only way to observe a [`with_pair_code`](#with_pair_code) request failing at all: that request runs in a detached task, so its `Err` reaches no caller.

<ParamField path="handler" type="F" required>
Async function that receives the `PairingCodeError` event and `Arc<Client>`
</ParamField>

**Example:**
```rust
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::Arc;
use whatsapp_rust::pair_code::{PairCodeOptions, PairCodeRejection};

const MAX_THROTTLE_RETRIES: u32 = 3;
let retries = Arc::new(AtomicU32::new(0));

Bot::builder()
.with_pair_code(PairCodeOptions {
phone_number: "15551234567".to_string(),
..Default::default()
})
.on_pair_code_error(move |err, client| {
let retries = retries.clone();
async move {
eprintln!("Pair code request failed: {}", err.error);
match err.rejection {
// Cap attempts — a persistently throttled number should stop
// retrying rather than loop forever on repeated PairingCodeError.
Some(r) if r.is_throttled() && retries.fetch_add(1, Ordering::Relaxed) < MAX_THROTTLE_RETRIES => {
// Back off using the server's own hint when it gave one.
let delay = err.backoff.unwrap_or(std::time::Duration::from_secs(30));
tokio::time::sleep(delay).await;
let _ = client.pair_with_code(PairCodeOptions {
phone_number: "15551234567".to_string(),
..Default::default()
}).await;
}
Some(PairCodeRejection::FeatureNotAvailable) => {
// Retrying will not help — fall back to QR pairing instead.
}
_ => {}
}
}
})
// ...
```

<Note>
Branch on `err.rejection` rather than the message, which is not a stable surface — see [Pair code failure events](/concepts/authentication#pair-code-failure-events) for the full field breakdown and the two failures ([`PairCodeError::CodeAlreadyOutstanding`](/concepts/authentication#one-code-at-a-time) and `Cancelled`) that deliberately never reach this handler.
</Note>

---

## Cache Configuration
Expand Down
25 changes: 24 additions & 1 deletion api/client.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,10 @@ This can run concurrently with QR code pairing — whichever completes first win
**One code at a time.** Fails with `PairCodeError::CodeAlreadyOutstanding` while a previous code is still outstanding, instead of silently replacing it. "Outstanding" means either the previous code's validity window hasn't elapsed yet, *or* its `primary_hello` was already accepted and a `pair-success` for it is still pending — that second case can outlast the validity window by up to a minute, and `remaining` reads as `0` for it since there's no window left to report. A second code does not replace the first for the phone: the server routes `primary_hello` by number and never sees the code itself, so whoever is still reading the older one reaches stage 2 regardless. Call [`cancel_pair_code`](#cancel_pair_code) first when the replacement is intentional. Do not call this on a schedule driven by QR-code rotation — the two flows have unrelated lifetimes. See [One code at a time](/concepts/authentication#one-code-at-a-time).
</Note>

<Note>
On any failure other than `CodeAlreadyOutstanding` or `Cancelled`, this also dispatches [`Event::PairingCodeError`](/concepts/authentication#pair-code-failure-events) before returning the `Err` — the only surface [`BotBuilder::with_pair_code`](/api/bot#with_pair_code) can report through, since that path drives this call from a detached task. A direct caller sees the failure both ways: as the returned `Err` and, unless it's one of those two exclusions, on the event bus.
</Note>

<ParamField path="options" type="PairCodeOptions" required>
Configuration for pair code authentication:
- `phone_number` — Phone number in international format (e.g., `"15551234567"`)
Expand Down Expand Up @@ -306,7 +310,26 @@ This can run concurrently with QR code pairing — whichever completes first win
| `PairCode(AdvSecretKeyDerivation)` / `BundleKeyDerivation` | HKDF expand failed |
| `PairCode(BundleAead)` | AES-GCM encryption of the key bundle failed (typed `CryptoProviderError` source) |
| `PairCode(MissingPairingRef)` | Server response missing pairing ref |
| `RequestFailed` | Server IQ request failed (typed `IqError` source) |
| `RequestFailed` | Server IQ request failed (typed `IqError` source). `Display` renders exactly what it wraps (e.g. `429 (rate-overlimit)`) |

`PairError` also exposes the server's refusal as a typed status, so a consumer doesn't have to match the message:

```rust
impl PairError {
/// The server's refusal, classified from its `code`/`text` pair. `None`
/// when nothing was refused (local validation, no connection, timeout)
/// or the server sent a code/text pair WA Web itself wouldn't accept.
pub fn rejection(&self) -> Option<PairCodeRejection>;
/// The server's requested retry delay, when it named one.
pub fn backoff(&self) -> Option<std::time::Duration>;
/// `true` for `CodeAlreadyOutstanding` and `Cancelled` — failures that
/// don't mean "no code is coming", because another flow may still own
/// the slot. `Event::PairingCodeError` is not dispatched for these.
pub fn lost_the_flow_to_another_request(&self) -> bool;
}
```

See [Pair code failure events](/concepts/authentication#pair-code-failure-events) for `PairCodeRejection`'s five named variants (`BadRequest`, `Forbidden`, `RateOverlimit`, `FeatureNotAvailable`, `InternalServerError`) plus its `Unknown(i32)` fallback, and for `is_throttled()`.

**Example:**
```rust
Expand Down
60 changes: 59 additions & 1 deletion concepts/authentication.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -507,6 +507,60 @@ pub struct PairingCodeRefresh {

In both cases the outstanding flow is cleared **before** the event fires, so a handler can call [`Client::pair_with_code`](/api/client#pair_with_code) immediately without hitting [`PairCodeError::CodeAlreadyOutstanding`](#one-code-at-a-time). The previous code is no longer valid either way. Register a handler with [`Bot::on_pair_code_refresh`](/api/bot#on_pair_code_refresh) or match on the event directly via `on_event`.

### Pair code failure events

**Event:** `Event::PairingCodeError(PairingCodeError)`

```rust
// wacore/src/types/events.rs
#[derive(Debug, Clone, Serialize, bon::Builder)]
#[non_exhaustive]
pub struct PairingCodeError {
pub rejection: Option<PairCodeRejection>, // The server's refusal, when it answered with one
pub backoff: Option<std::time::Duration>, // The server's requested retry delay, when it named one
pub error: String, // The failure rendered for logs — do not branch on it
}
```

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`](/api/bot#with_pair_code): that call runs [`Client::pair_with_code`](/api/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`](/api/bot#on_pair_code_error) or match on the event directly via `on_event`.

Fires for **every** 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 when one is available: `Some(PairCodeRejection)` when the server answered with a `code`/`text` pair it classifies cleanly. `None` covers three different cases, not just "no answer": the failure never reached the server at all (local validation, no connection, timeout), *or* it did and the server sent a code/text pair that doesn't classify (an unrecognized code, or a code/text pair that disagrees with WA Web's own pairing — see [`PairCodeRejection::from_server`](#paircoderejection)). 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.
Comment thread
jlucaso1 marked this conversation as resolved.
Outdated

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

```rust
// wacore/src/pair_code.rs
pub enum PairCodeRejection {
BadRequest, // 400 — malformed, or throttled per phone number (server reuses this code)
Forbidden, // 403
RateOverlimit, // 429 — requesting codes too fast; the only correct response is to slow down
FeatureNotAvailable, // 452 — phone-number linking disabled for this account; retrying will not help
InternalServerError, // 500
Unknown(i32), // a code outside WA Web's own accepted set
}
```

The five named variants are the complete set WA Web's 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.

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

```rust
impl PairError {
pub fn rejection(&self) -> Option<PairCodeRejection>;
pub fn backoff(&self) -> Option<std::time::Duration>;
pub fn lost_the_flow_to_another_request(&self) -> bool;
}
```

### Two-Stage Flow

#### Stage 1: Hello
Expand Down Expand Up @@ -1095,7 +1149,11 @@ match client.pair_with_code(options).await {
</Note>

<Note>
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`](#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](/api/errors#iqerror-base-type)).
Prefer [`PairError::rejection()`](#pair-code-failure-events) 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`](#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](/api/errors#iqerror-base-type)) and surfaced directly via `PairError::backoff()`.
</Note>

<Note>
`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)`.
</Note>

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`.
Expand Down
41 changes: 41 additions & 0 deletions concepts/events.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ pub enum Event {
PairingQrCode(PairingQrCode),
PairingCode(PairingCode),
PairingCodeRefresh(PairingCodeRefresh),
PairingCodeError(PairingCodeError),
PairingQrCodesExhausted(PairingQrCodesExhausted),
PairSuccess(PairSuccess),
PairError(PairError),
Expand Down Expand Up @@ -576,6 +577,46 @@ Breaking change: `PairingCodeRefresh` moved from an inline `Event::PairingCodeRe
The unanswered-`companion_finish` timeout trigger is new. If your handler assumed `PairingCodeRefresh` only ever came from the server, no code changes are required — the reaction (call `pair_with_code` again) is the same either way, and the outstanding flow is already cleared by the time the event fires in both cases.
</Note>

### PairingCodeError

**Emitted:** When a phone-number pair-code request fails, so no code will be shown. `Client::pair_with_code` dispatches this in addition to returning `Err` — it's the *only* surface that reports the failure when pairing is driven by [`BotBuilder::with_pair_code`](/api/bot#with_pair_code), since that request runs in a detached task and its `Err` reaches no caller.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

```rust
/// A phone-number pair-code request failed, so no code will be shown.
///
/// Fires for every failure, including local validation (a too-short phone
/// number never reaches the server) — a consumer waiting on a code needs to
/// learn that it is not coming, whatever the reason.
#[derive(Debug, Clone, Serialize, bon::Builder)]
#[non_exhaustive]
pub struct PairingCodeError {
/// The server's refusal, when it answered with one. `None` when the
/// failure was local (validation, no connection) or the request went
/// unanswered (timeout) — nothing was refused, so there is no status to
/// report.
pub rejection: Option<PairCodeRejection>,
/// How long the server asked the client to wait, from the `backoff`
/// attribute. Usually absent.
pub backoff: Option<std::time::Duration>,
/// The failure rendered for logs. Do not branch on it; use `rejection`.
pub error: String,
}
```

**Example:**
```rust
Event::PairingCodeError(PairingCodeError { rejection, backoff, error, .. }) => {
eprintln!("Pair code request failed: {error}");
if rejection.is_some_and(PairCodeRejection::is_throttled) {
// Back off, then retry — using the server's own delay when it named one.
}
}
```

<Note>
Two failures never reach this event, because for them a code may still be on its way and the event would say the opposite: `PairCodeError::CodeAlreadyOutstanding` (an earlier code is still live — the consumer already has it from the `PairingCode` that minted it) and `PairCodeError::Cancelled` (the caller withdrew this request, and a replacement may already own the slot by the time it resolves). Both are consequences of something the caller did, so neither is news to them, and a direct caller still receives the `Err` either way. See [Pair code failure events](/concepts/authentication#pair-code-failure-events) for `PairCodeRejection`'s variants and the full breakdown, and [`BotBuilder::on_pair_code_error`](/api/bot#on_pair_code_error) to register a handler.
</Note>

### PairingQrCodesExhausted

**Emitted:** When the server's `<pair-device>` refs are used up — there is no QR left to render until the connection is re-established. WA Web's rotation timer (`Handle/PairDevice.js`) reports `UNPAIRED_IDLE` here and stops; it does not close the socket unconditionally, because a phone-number (pair-code) flow may still be riding the same connection.
Expand Down