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
10 changes: 5 additions & 5 deletions api/bot.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -658,7 +658,7 @@ where
Fut: Future<Output = ()> + Send + 'static
```

Registers a handler for [`Event::PairingCodeRefresh`](/concepts/events#pairingcoderefresh), fired when the server asks the companion to refresh an in-progress pair code. The `bool` argument is `force_manual`.
Registers a handler for [`Event::PairingCodeRefresh`](/concepts/events#pairingcoderefresh), fired when the in-progress phone-number pairing code should be replaced. The `bool` argument is `force_manual`.

<ParamField path="handler" type="F" required>
Async function that receives `force_manual: bool` and `Arc<Client>`
Expand All @@ -674,9 +674,9 @@ Bot::builder()
..Default::default()
})
.on_pair_code_refresh(|force_manual, client| async move {
println!("Server requested a pair-code refresh (force_manual={force_manual})");
// The previous code is no longer guaranteed valid — request a fresh
// one with the same phone number.
println!("Pair code needs a refresh (force_manual={force_manual})");
// The previous code is no longer valid, and the flow is already
// clear — request a fresh one with the same phone number.
let _ = client.pair_with_code(PairCodeOptions {
phone_number: "15551234567".to_string(),
..Default::default()
Expand All @@ -686,7 +686,7 @@ Bot::builder()
```

<Note>
Only fires 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.
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>

---
Expand Down
26 changes: 26 additions & 0 deletions api/client.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,10 @@ Initiates pair code authentication as an alternative to QR code pairing. The ret

This can run concurrently with QR code pairing — whichever completes first wins.

<Note>
**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>

<ParamField path="options" type="PairCodeOptions" required>
Configuration for pair code authentication:
- `phone_number` — Phone number in international format (e.g., `"15551234567"`)
Expand All @@ -291,6 +295,8 @@ This can run concurrently with QR code pairing — whichever completes first win
| `PairCode(PhoneNumberTooShort)` | Fewer than 7 digits |
| `PairCode(PhoneNumberNotInternational)` | Starts with `0` (not international format) |
| `PairCode(InvalidCustomCode)` | Custom code is not 8 valid Crockford Base32 characters |
| `PairCode(CodeAlreadyOutstanding { remaining })` | A previous code is still outstanding (within its validity window, or its `primary_hello` was accepted and `pair-success` is still pending) — call `cancel_pair_code` first |
| `PairCode(Cancelled)` | `cancel_pair_code` was called while `companion_hello` (stage 1) was still in flight |
| `PairCode(InvalidPrimaryEphemeralKey)` / `InvalidPrimaryIdentityKey` | Peer key parsing failed (typed `CurveError` source) |
| `PairCode(EphemeralKeyAgreement)` / `IdentityKeyAgreement` | Diffie–Hellman failed (typed `CurveError` source) |
| `PairCode(AdvSecretKeyDerivation)` / `BundleKeyDerivation` | HKDF expand failed |
Expand All @@ -315,6 +321,26 @@ println!("Enter this code on your phone: {}", code);

---

### cancel_pair_code

```rust
pub async fn cancel_pair_code(self: &Arc<Self>)
```

Abandons the outstanding pair-code flow, if any — the explicit reset [`pair_with_code`](#pair_with_code) requires before it will mint a replacement (WA Web's `initializeAltDeviceLinking()`). A no-op when no flow is outstanding.

<Note>
**Boundary: a `primary_hello` already being processed may still finish.** If no `primary_hello` has been accepted yet, cancellation is immediate and complete — a later `primary_hello` for the cancelled ref is dropped rather than answered. But once the phone's `primary_hello` has been accepted, stage 2 (deriving the key bundle and sending `companion_finish`) runs under the same lock `cancel_pair_code` takes, so the two race: if `cancel_pair_code` wins, stage 2 finds the flow gone and stops before sending anything; if stage 2 already holds the lock, `companion_finish` is sent before `cancel_pair_code` gets a turn. The library never revokes credentials the server has already issued, so an authenticated `pair-success` that follows still completes the link even though `cancel_pair_code` ran. Treat cancellation as reliable only before the phone has entered the code.
</Note>

**Example:**
```rust
client.cancel_pair_code().await;
let code = client.pair_with_code(new_options).await?;
```

---

### set_passkey_authenticator

```rust
Expand Down
93 changes: 86 additions & 7 deletions concepts/authentication.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,38 @@ for code in codes_clone {
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.
</Note>

### 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`](/concepts/events#pairingqrcodesexhausted) rather than disconnecting unconditionally:

```rust
// src/pair.rs
let pair_code_outstanding = client_clone
.pair_code_state
.lock()
.await
.is_outstanding(wacore::time::now_secs());

client_clone.core.event_bus.dispatch(Event::PairingQrCodesExhausted(
PairingQrCodesExhausted::builder()
.disconnected(!pair_code_outstanding)
.build(),
));
if !pair_code_outstanding {
client_clone.disconnect().await;
}
```

A [pair code](#pair-code-phone-number-linking) 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.

<Note>
`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. Don't reconnect from inside the handler — wait for the [`Event::Disconnected`](/concepts/events#disconnected) that follows before treating the socket as closed.
</Note>

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

### Native-camera deep link (open WhatsApp directly)

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.
Expand Down Expand Up @@ -285,6 +317,43 @@ let code = client.pair_with_code(options).await?;
assert_eq!(code, "MYCODE12");
```

### 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 }`](#pair-code-errors) 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:

```rust
use wacore::pair_code::PairCodeError;
use whatsapp_rust::pair_code::PairError;

match client.pair_with_code(options).await {
Ok(code) => println!("Enter this code on your phone: {code}"),
Err(PairError::PairCode(PairCodeError::CodeAlreadyOutstanding { remaining })) => {
eprintln!("A code is already displayed, {remaining:?} left — cancel it first");
client.cancel_pair_code().await;
}
Err(e) => eprintln!("Pair code request failed: {e}"),
}
```

<Note>
**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](#pair-code-refresh-events) for the cases that *do* warrant a new request.
</Note>

`Client::cancel_pair_code()` abandons the outstanding flow, if any — WA Web's `initializeAltDeviceLinking()`.

```rust
// src/pair_code.rs
pub async fn cancel_pair_code(self: &Arc<Self>);
```

<Note>
**Cancellation is reliable only before the phone enters the code.** 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. Once `primary_hello` has been accepted, deriving the key bundle and sending `companion_finish` runs under the same lock `cancel_pair_code` takes, so the two race — if stage 2 already holds the lock, `companion_finish` goes out before `cancel_pair_code` gets a turn, and the phone's subsequent `pair-success` still completes the link. The library never revokes credentials the server has already issued.
</Note>

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

```rust
Expand Down Expand Up @@ -426,7 +495,12 @@ pub struct PairingCodeRefresh {
}
```

Fired when 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. The typical reaction is to call [`Client::pair_with_code`](/api/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`](/api/bot#on_pair_code_refresh) or match on the event directly via `on_event`.
`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. **Unanswered `companion_finish`.** The code was entered on the phone (`primary_hello` accepted) 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 — silence is the only signal there is — so the client times the wait out itself and dispatches `PairingCodeRefresh` with `force_manual: false`.

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

### Two-Stage Flow

Expand Down Expand Up @@ -938,12 +1012,7 @@ Breaking change: `PairSuccess`, `PairError`, and `LoggedOut` (below) are now `#[

### QR code errors

```rust
// Handled internally, retries with new QR codes
// If all QR codes expire, disconnects:
info!("All QR codes for this session have expired.");
client.disconnect().await;
```
QR codes are handled internally and retried automatically. If all refs expire, the client dispatches [`Event::PairingQrCodesExhausted`](/concepts/events#pairingqrcodesexhausted) and disconnects **only when no pair-code flow is outstanding** — see [QR ref exhaustion](#qr-ref-exhaustion).

### Pair code errors

Expand All @@ -967,6 +1036,12 @@ match client.pair_with_code(options).await {
Err(PairError::PairCode(PairCodeError::InvalidCustomCode)) => {
eprintln!("Custom code must be 8 valid Crockford Base32 characters");
}
Err(PairError::PairCode(PairCodeError::CodeAlreadyOutstanding { remaining })) => {
eprintln!("A code is already displayed, {remaining:?} left in its validity window");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Do not label pending confirmation as validity time

When CodeAlreadyOutstanding is returned because an accepted primary_hello is awaiting pair-success after the code expired, remaining is deliberately zero, so this copied example prints that zero time remains in the validity window even though the live pending confirmation is what blocks the request. That misdiagnosis can prompt consumers to cancel or retry while pairing is about to complete; make the message distinguish a nonzero validity window from the zero-duration pending-confirmation case.

Useful? React with 👍 / 👎.

}
Err(PairError::PairCode(PairCodeError::Cancelled)) => {
eprintln!("cancel_pair_code() was called while companion_hello was in flight");
}
Err(PairError::PairCode(PairCodeError::MissingPairingRef)) => {
eprintln!("Server did not return a pairing reference");
}
Expand Down Expand Up @@ -1008,6 +1083,10 @@ match client.pair_with_code(options).await {
}
```

<Note>
`CodeAlreadyOutstanding` and `Cancelled` are new — see [One code at a time](#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.
</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)).
</Note>
Expand Down
Loading