Skip to main content
Besides the standard companion mode (linking via QR / pairing code, like WhatsApp Web), zapo can connect as a primary mobile client — speaking the Android app’s protocol over a raw TCP socket.
Mobile support is stable and functional. The one thing zapo does not provide is a registration API — requesting an SMS/voice code, submitting an OTP, or approving a takeover. Registering a number is complex and requires a physical phone, so it’s intentionally out of scope. You connect with an already-registered credential set, and that path is solid.

How it differs from companion mode

Enabling mobile mode

Mobile mode is triggered by the mobileTransport option (a WaMobileTransportOptions). Its presence — or persisted deviceInfo in the loaded credentials — switches the client from the WebSocket transport to the TCP transport.

WaMobileTransportOptions

WaMobileTransportDeviceInfo

manufacturer, device, osVersion, osBuildNumber, appVersion are required; mcc, mnc, localeLanguageIso6391, localeCountryIso31661Alpha2, phoneId, deviceBoard, deviceModelType are optional. A stable fingerprint across runs matters — persist it and reuse the same values.

Credentials

Mobile mode needs an already-registered credential set: a WaAuthCredentials with meJid populated, platform: 'android', and deviceInfo attached. You seed these into the auth store before connecting (e.g. imported from a device bundle).
Once credentials with deviceInfo are persisted, later reconnects automatically run in full mobile-primary mode — TCP transport, mobile-style IQ / message id formats, app-state primary gating, and placeholder-resend withholding (see below) all derive from the loaded deviceInfo. You don’t need to re-pass mobileTransport on every construction.

Placeholder-resend withholding

When a companion device fails to decrypt an incoming message, it normally asks a paired peer for the original plaintext via a placeholder-resend request. A primary phone has no peer device holding the plaintext, so a mobile-primary session skips the placeholder request entirely and falls back to a plain retry receipt — the standard re-encrypt path the sender already supports. This avoids the request silently timing out and the message being dropped.

Registration events

While your mobile session is connected, you’re notified when someone tries to register your number on another device — a security-relevant signal, surfaced as these events:
These events are informationalzapo surfaces them but intentionally does not expose methods to submit a code or respond to a takeover. Provisioning a number is done on a real phone; bring the resulting credentials to zapo and connect.

Email binding

Mobile-only client.email (WaEmailCoordinator) binds and verifies an email address on the account — a recovery/login factor. It is mobile-only: every method throws on a Web/companion connection.

Hosting companion devices

Mobile-primary only A companion session lives on the other side of a QR / pairing code, linked by a real phone. When zapo is the phone, the roles invert: this primary session can host companions — link a WhatsApp Web tab, revoke it, list what’s connected, and reconcile against the server’s device list. The coordinator sits at client.mobile (WaMobileCoordinator).
client.mobile requires a mobile-primary session. Reading the getter on a Web/companion connection returns the coordinator, but the link/revoke/publish methods throw with client.mobile requires a mobile-primary session (…) before touching the network. reconcileCompanions() becomes a no-op on non-primary sessions.

Methods

Events

Companion-host activity surfaces on the client:

Bootstrap gates handled for you

Every companion a WhatsApp phone links today expects three signals during pair-time. zapo supplies them so the companion doesn’t self-remove after the QR scan:
  • LID migration client-props — a LID-native primary declares isChatDbLidMigrated and isSyncdPureLidSession on the <client-props> element so the companion runs setIsLidMigrated at pair time and doesn’t self-remove on its LID-addressed blocklist.
  • INITIAL_BOOTSTRAP history-sync — the primary pushes the initial history-sync notification as a peer message; without it the companion self-removes with HistorySyncTimeout.
  • Seeded setting_pushName — the primary’s own display name is seeded into the critical_block app-state collection, once per session, so the companion’s critical bootstrap can complete.
All three are automatic; no method calls required.

Persistence

The ADV epoch state (rawId, currentKeyIndex, tracked companions) must persist across restarts — reusing an already-issued companion key index breaks previously linked devices. Wire a CompanionHostPersistence into WaClientOptions.companionHost.persistence and zapo loads/saves it transparently. A file-backed implementation ships with the library:
Without a persistence hook the epoch is in-memory only — fine for a smoke test, unsafe for production relinking. On restart the primary would re-issue key index 1 to a new companion while the previously linked companion still holds it, breaking session decryption on the older device.

Custom backend

The contract is two methods over CompanionHostEpochState:
Point it at whatever store you already run. The state is tiny (an epoch header plus one row per linked companion), so there’s no need for a dedicated core-store domain. Example against better-sqlite3:
The save runs on every linkCompanion / revokeCompanion / reconcileCompanions state change, so wrap the multi-statement write in a transaction to keep it atomic. load is called once during coordinator wire-up.

Worked example

Standard features still apply

Once connected in mobile mode, the rest of the API is unchanged — client.message, client.group, events, stores, etc. all work the same way. The only difference is the transport and the auth/identity model.
Last modified on July 12, 2026