New:Introducing Socket Scanning for VS Code Marketplace Extensions.Learn more →
Get Started

whalibmob

Package Overview
Dependencies
Maintainers
1
Versions
204
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

whalibmob

Node.js library for WhatsApp — register a number over SMS, or link as a companion by QR. Signal E2E encryption, media, groups, channels.

latest
Source
npmnpm
Version
5.33.31
Version published
Weekly downloads
15K
110.66%
Maintainers
1
Weekly downloads
 
Created
Source

whalibmob

A Node.js library for WhatsApp that can register a phone number of its own.

Every other JavaScript library links to an account that already exists on someone's phone. whalibmob does that too — but it can also take a bare phone number, request the SMS or voice code, and bring the account into being. Both transports, one API.

npm node license types

Leia em Português do Brasil

🇧🇷 Fala português? A documentação completa está traduzida em Brasil.md — tudo o que está aqui, na sua língua.

Start here

Register a number Link an account Use the CLI

Library API Send messages Handle events

Device attestation

Why registration can be blocked

[!TIP] Registration not working on some numbers? Here is the 100% success route.

On a few numbers the SMS or voice code simply never arrives. That is WhatsApp's anti-abuse / attestation refusing an unofficial client — not a bug in whalibmob. When that happens, you can still register any number, first try:

  • Register the number once in the official WhatsApp app (a phone or an emulator). The official app passes attestation natively, so the number becomes a clean, already-attested account.
  • Then run whalibmob with --method wa_old. The code is delivered through that existing WhatsApp account, whalibmob confirms it and takes the number over on the first attempt. This works on any number.
wa registration --request-code <number> --method wa_old
wa registration --register   <number> --code <code>

This is optional. Most numbers register fine with --method sms or --method voice — try those first. Use the wa_old route only if you want a guaranteed, 100% success rate on numbers where SMS/voice are blocked.

💬 Need help? Talk to me on Telegram

whalibmob channel

Contact me on Telegram

whalibmob support channel

💼 Need support for whalibmob or for any WhatsApp-related service? Enter here in the whalibmob support channel 👉 https://t.me/+V6aZiZ01u3YxM2U0 — I offer dedicated support to clients.

I'm waiting for you — message me on Telegram at @brtyu545 if you want help with whalibmob, or if you need anything related to features such as WhatsApp or Instagram. I'm at your disposal.

[!CAUTION] Use a dedicated phone number with this library. Connecting with a number that is already active on a real device will cause WhatsApp to log that device out.

[!NOTE] Actively developed. WhatsApp changed the mobile protocol recently, and whalibmob is being kept in step with it release by release. Contributions are welcome — pull requests are accepted.

[!IMPORTANT] This project is not affiliated, associated, authorized, endorsed by, or in any way officially connected with WhatsApp or any of its subsidiaries or affiliates. "WhatsApp" and related names are registered trademarks of their respective owners. Use at your own discretion.

  • It registers numbers. Give it a phone number that has never been on WhatsApp, request the code over SMS, voice call, flash call or an old WhatsApp account, confirm it, and the account exists — as an Android or iOS device, on the Mobile API. No phone, no scanning, no existing account to borrow. See Register a New Number.
  • It also speaks WhatsApp Web, over a WebSocket. When a number cannot receive an SMS, or is already live on a phone, whalibmob links itself to that account by QR code or an 8-character pairing code and runs as one of its linked devices — with the full message history and the account's address book. See Linking to an Existing Account.
  • The API is identical in both modes. Everything below — sending, media, groups, events — reads the same whichever way the session was created.
  • No browser, no Selenium, no external runtime. It talks to WhatsApp directly over a TCP socket with the Noise Protocol handshake.
  • Signal Protocol encryption is fully inlined in pure JavaScript — no native binaries, no node-gyp, runs anywhere Node.js runs.
  • It can prove it is a real handset. The on-device Frida scripts in frida/ mint the hardware attestation WhatsApp's registration server expects — Play Integrity and Keystore on Android, App Attest on iOS — and whalibmob folds the result into the registration requests. See Device Attestation.

Device Attestation — Play Integrity and App Attest

This is the part most projects stop at, so it is worth saying up front what is here and what it does.

When the real WhatsApp app registers a number, it does not just send the number and the code. It also proves to the server that it is running on a genuine handset, by minting a hardware-backed attestation token — Play Integrity plus a Keystore certificate chain on Android, DeviceCheck App Attest on iOS. Those tokens cannot be forged off-device: they are signed by a key that lives in the phone's secure hardware.

whalibmob ships the on-device scripts that obtain them. They run under Frida on a rooted Android phone or a jailbroken iPhone, start a small HTTP server on the device, and whalibmob calls it while registering:

PlatformEndpointWhat it feeds into the registration request
Android/infoAPK hashes, signature and secret key — the device fingerprint
Android/integritygpia and its _gg _gi _gp _ge _ga companions — the Play Integrity verdict
Android/certthe &H= body signature and the Authorization certificate chain
iOS/integritythe App Attest assertion and its Authorization header
# on your computer — build the bundle
cd frida/android                                  # or: cd frida/ios
npm install && npm run build                      # → server_with_dependencies.js

# attach it to WhatsApp on the device, with the Frida server running there
frida -U "WhatsApp" -l server_with_dependencies.js

# on the machine running whalibmob — it listens on 1119 (WhatsApp) / 1120 (Business)
export WA_FRIDA_HOST=192.168.1.50
wa registration --request-code 919634847671

The code, and the per-platform setup:

[!NOTE] None of this is required. Leave WA_FRIDA_HOST unset and whalibmob sends the same empty low-trust attestation fields the native client sends when its own integrity minting fails — which the server tolerates. Registration works without a rooted phone anywhere in sight. Attaching a device raises the trust score, which is what helps when a number keeps hitting no_routes or a block screen.

[!IMPORTANT] The Frida scripts themselves are reference material, not a maintained feature. They are published so that anyone with a rooted or jailbroken device can reproduce what the native app does, and so that the method is on the record. They also need the official app installed from the Play Store / App Store — a sideloaded APK will not attest, because the token is bound to the store-signed build. lib/Attestation.js, the client side inside whalibmob, is maintained as part of the library.

Full walkthrough, with the device prerequisites: Device Attestation with Frida.

Install

npm install whalibmob

Install the CLI globally:

npm install -g whalibmob

Index

CLI — Getting Started

Install the CLI

Install whalibmob globally to get the wa command available from anywhere on your system:

npm install -g whalibmob

Verify the installation:

wa version

First-Time Setup: Register a Number

Registration is a one-time process. You need a phone number that can receive an SMS or voice call. Use a dedicated number — do not use a number already active on a real WhatsApp device.

Step 1 — request a verification code

# via SMS (default)
wa registration --request-code 919634847671

# via voice call
wa registration --request-code 919634847671 --method voice

# via an old WhatsApp account
wa registration --request-code 919634847671 --method wa_old

# via flash call — WhatsApp rings the number and hangs up (Android only)
WA_OS=android wa registration --request-code 919634847671 --method flash

# via email — WhatsApp sends a 6-digit code to the email address linked to the number
wa registration --request-code 919634847671 --method email_otp

Flash call sends no code anywhere. WhatsApp rings the number from a one-time number and drops the call before it can be answered, and the verification code is that number — its last 6 digits. The official Android app reads them out of the call log; here the person holding the phone reads the missed call instead and types them:

# phone shows a missed call from +40 21 555 123456
wa registration --register 919634847671 --code 123456

Pasting the whole number works too — only its last six digits are sent. Do not answer the call; it hangs up on its own, and answering it does not verify anything.

Three things to know before choosing it:

  • Android only. iOS has no API for reading an incoming call's number, so WhatsApp never offers flash there. Asking for it as iOS is refused before any request goes out, so the number spends no attempt learning that.
  • The server decides. Flash is not offered for every number or country. When it declines, the request falls back to SMS automatically and the session records that, so the SMS code is confirmed as an SMS code rather than being trimmed like a caller ID.
  • The caller ID has to be visible. A carrier that withholds the calling number leaves nothing to read.

Set WA_FLASH_CODE_LEN if a number's server expects a different length than the six digits the app defaults to.

Email OTP sends a 6-digit verification code to the email address that the phone number's WhatsApp account has associated. This method only works when the number already has an email linked to its WhatsApp account — if there is no email on file, the server returns an error and you should fall back to sms or voice.

# request via email
wa registration --request-code 919634847671 --method email_otp

# then confirm with the 6-digit code WhatsApp emailed
wa registration --register 919634847671 --code 123456

Two things to know before choosing it:

  • Email must be linked. If the number has never had an email associated in WhatsApp Settings → Account → Email address, the server rejects the request. Use sms or voice instead.
  • Falls back to SMS. When email_otp fails (no email on file or email delivery issue), whalibmob automatically retries with sms so the attempt is not wasted.

Set the account's display name while registering with --name. This is the name people who have not saved your number see next to it — in group participant lists, in notifications, and beside your messages. Quote it if it contains spaces:

wa registration --request-code 919634847671 --name "Ricardo Trade"

The name is stored with the session and announced on every connection from then on. Registration itself carries no name — the server learns it when the session connects — so a number registered without --name has none until one is set. It can be given at either registration step, and changed later with /name.

Registering as Android is the same command with the platform named. Nothing else to prepare — see what it does behind that one command.

Linux, macOS, Termux:

WA_OS=android WA_DEVICE=samsung-s24-ultra wa registration --request-code 919634847671 --debug

Windows, Command Prompt — set on its own line, because VAR=value in front of a command is Unix syntax and Windows refuses it:

set WA_OS=android
set WA_DEVICE=samsung-s24-ultra
wa registration --request-code 919634847671 --debug

Windows, PowerShell:

$env:WA_OS = "android"
$env:WA_DEVICE = "samsung-s24-ultra"
wa registration --request-code 919634847671 --debug

A .env file in the directory you run from works the same everywhere and saves repeating it — see Device Quick Start. --debug prints every request and reply, which is worth having the first time: its first line names the platform that actually went out, so you can see whether the variables arrived.

The variables only matter for the command that creates the session. What a session registered as is written into it, so the confirmation step and every later connect follow the session, not whatever the shell carries at the time.

The CLI sends the code request, prints the result, and then stays open in the interactive shell. You will see:

requesting sms code for +919634847671...
  status  sent
  now run: wa registration --register 919634847671 --code <code>

staying in shell — use /reg confirm 919634847671 <code> to complete
wa>

Step 2 — confirm the code you received

Either run the one-shot command:

wa registration --register 919634847671 --code 123456

Or type it directly in the shell that stayed open:

wa> /reg confirm 919634847671 123456

On success you will see:

registered  session saved to /home/user/.waSession/919634847671/919634847671.json
now run: /connect 919634847671

Check if a number already has WhatsApp

wa registration --check 919634847671

Output:

checking +919634847671...
  status  registered

Possible statuses: registered · registered_blocked · not_registered · cooldown · unknown

CLI Connect

After registering, connect with:

wa connect 919634847671

The shell opens with a persistent prompt:

connecting to +919634847671...
connected as +919634847671
wa +919634847671>

[!TIP] The shell never exits on its own. It stays open until you type /quit or press Ctrl+C. This is true for every command — registration, connection, sending messages — everything.

Use a different authentication folder with --session:

wa connect 919634847671 --session /data/my-sessions

The CLI asks what to call it once, on a first run, and remembers the answer in ~/.whalibmob.json. Each number gets its own folder inside — see Saving & Restoring Sessions.

[!IMPORTANT] If this is refused with 405, do not re-register the number. The account is fine; the server declined the version the connect announced. Check for a WA_VERSION in your shell or in a .env file in the directory you ran the command from — it overrides the version the session registered with, and a stale one left there refuses every connect from that directory. See When the server answers 405 on connect.

CLI Pairing Code

If the number is already in use on a phone, or the verification SMS never arrives, link to the existing account instead.

wa connect works out which of the two a number is set up for by reading its session files, and says which one it chose:

connecting as companion (pairing code)...

A number can hold both — registered over SMS as its own device, and linked as a companion to another account. When it holds both, the one used most recently wins. A half-finished registration does not count as a session either way. When there is nothing to connect with, it says so and names the two commands that would create one, rather than picking a side.

Force either one:

wa connect 919634847671 --sms
wa connect 919634847671 --pair

Or go straight to linking:

wa pair 919634847671
linking +919634847671 to an existing WhatsApp account...
────────────────────────────────────────────────────────
  pairing code   K7M2-QX4B
────────────────────────────────────────────────────────
  on the phone that owns +919634847671:
    WhatsApp → Settings → Linked Devices → Link a device
    → Link with phone number instead → enter the code above

  the code is valid for a few minutes; waiting...

  linked as 919634847671:7@s.whatsapp.net  (112713111982325:7@lid)
  device slot 7  ·  primary is android
  finishing handshake...
connected as +919634847671  (web / companion)
  history  INITIAL_BOOTSTRAP  chats=214  contacts=486
wa +919634847671>

Once linked, plain wa connect reconnects without asking for a new code. To pick your own code, pass it as a second argument — exactly 8 characters:

wa pair 919634847671 MYCODE12

The debug prompt works the same in both modes. Answer y at startup, or pass --debug, to see every stanza of the pairing exchange:

wa pair 919634847671 --debug

From inside the shell:

wa> /pair 919634847671
wa> /connect 919634847671 pair
wa> /connect 919634847671 sms

Listen Mode

Connect and print all incoming events to the terminal. The process stays alive indefinitely until you press Ctrl+C:

wa listen 919634847671

Output as messages arrive:

connected  listening on +919634847671  (Ctrl+C to stop)
  ────────────────────────────────────────────────────────
  time                    2025-03-13 10:00:05
  from                    919634847671@s.whatsapp.net
  id                      3EB0ABCDEF123456
  text                    Hello there!

CLI — Interactive Shell Commands

After running wa connect <phone>, every feature of the library is available as a /command. Type /help at any time to see all commands.

[!NOTE] JIDs can be written as plain phone numbers (e.g. 919634847671) — the shell automatically appends @s.whatsapp.net. For groups, use the full @g.us JID.

Messaging Commands

Send Text

wa> /send 919634847671 Hello, how are you?
sent  3EB0ABCDEF123456

# to a group
wa> /send 120363000000000000@g.us Hello everyone!

Send Image

wa> /image 919634847671 ./photo.jpg
wa> /image 919634847671 ./photo.jpg Look at this!

The second argument is the file path. The optional third argument is the caption.

Send Video

wa> /video 919634847671 ./clip.mp4
wa> /video 919634847671 ./clip.mp4 Watch this

Send Audio

Sends the file as a regular audio attachment:

wa> /audio 919634847671 ./song.mp3

Send Voice Note

Sends the file as a push-to-talk voice note with waveform:

wa> /ptt 919634847671 ./voice.ogg

Send Document

wa> /doc 919634847671 ./report.pdf
wa> /doc 919634847671 ./report.pdf "Q1 Report.pdf"

The optional third argument overrides the displayed filename.

Send Sticker

The file must be in WebP format:

wa> /sticker 919634847671 ./sticker.webp

Send Poll (CLI)

Separate the question from the options using |. At least two options are required. Optionally append selectable=N to limit how many options a voter may choose (0 = any):

# single-choice poll (selectable=1)
wa> /poll 919634847671 Best language? | JavaScript | Python | Rust | selectable=1

# unlimited-choice poll (default)
wa> /poll 120363000000000000@g.us Pick your favourites | Red | Green | Blue

React to a Message

wa> /react 919634847671 3EB0ABCDEF123456 👍

# remove a reaction — pass a space or empty string
wa> /react 919634847671 3EB0ABCDEF123456 " "

The message ID is shown in the incoming message display as id.

Edit a Message

[!NOTE] Editing is only possible within 15 minutes of the original send.

wa> /edit 919634847671 3EB0ABCDEF123456 Corrected text here

Delete a Message

# delete for yourself only
wa> /delete 919634847671 3EB0ABCDEF123456

# delete for everyone (revoke)
wa> /delete 919634847671 3EB0ABCDEF123456 all

Post a Status / Story

Posts a text Status visible to your contacts:

wa> /status Good morning everyone!

Forward a Message

Sends a message with the forwarded flag set:

wa> /forward 919634847671 This message was forwarded

Reply to a Message (CLI)

Quote and reply to a specific message. You need the message ID (shown as id: in the receive log) and the sender's JID.

# DM — senderJid is the same as the chat JID
wa> /reply 919634847671 3EB0XXXXXXXX 919634847671 Got it, thanks!

# Group — senderJid is the member who sent the original message
wa> /reply 120363000000000000@g.us 3EB0XXXXXXXX 919634847671 Agreed!

The message ID is printed when a message arrives:

  id                    3EB0C5BA7XXXXXXXX

Send Location (CLI)

Send a GPS location pin. Latitude and longitude are required; name and address (separated by |) are optional:

# lat/lon only
wa> /location 919634847671 48.8566 2.3522

# with name
wa> /location 919634847671 48.8566 2.3522 Eiffel Tower

# with name and address (separate with |)
wa> /location 919634847671 48.8566 2.3522 Eiffel Tower | Champ de Mars, Paris

# to a group
wa> /location 120363000000000000@g.us 51.5074 -0.1278 London

Send Contact / vCard (CLI)

Send a contact card. The vCard string must follow the vCard v3 format. Wrap it in quotes in the shell:

wa> /vcard 919634847671 "Alice Smith" "BEGIN:VCARD\nVERSION:3.0\nFN:Alice Smith\nTEL;TYPE=CELL:+919634847671\nEND:VCARD"

For multi-line vCards it is easiest to store the string in a shell variable:

VCARD="BEGIN:VCARD
VERSION:3.0
FN:Alice Smith
TEL;TYPE=CELL:+919634847671
EMAIL:alice@example.com
END:VCARD"

wa> /vcard 919634847671 "Alice Smith" "$VCARD"

Presence Commands

Set Online / Offline

wa> /online
wa> /offline

Typing and Recording Indicators

# show "typing…" in a chat
wa> /typing 919634847671

# show "recording audio…" in a chat
wa> /recording 919634847671

# stop the indicator
wa> /stop 919634847671

Subscribe to a Contact's Presence

Subscribes to online/offline events for a contact. The shell will print presence updates as they arrive:

wa> /subscribe 919634847671
subscribed to 919634847671@s.whatsapp.net

# when they come online:
  presence  919634847671@s.whatsapp.net  online

Profile Commands

CLI Change Display Name

wa> /name My Bot Name
name updated

CLI Change About Text

wa> /about Available 24/7 for support
about updated

about updated now means the about was read back from the server and matches what you typed. When the server accepts the stanza but still reports something else, it says so instead — and if the about is stored but hidden, it names the privacy setting that is hiding it.

CLI Change Profile Picture

Reads the image from disk, crops it square, scales it to 640×640 and uploads it as your profile picture. JPEG, PNG, GIF and BMP need nothing installed; WebP and HEIC need ffmpeg or jimp.

wa> /photo ./avatar.jpg
profile picture updated  id=1753912045

wa> /photo remove
profile picture removed

CLI Change Privacy Settings

wa> /privacy last_seen contacts
wa> /privacy profile_picture contacts
wa> /privacy status contacts
wa> /privacy online match_last_seen
wa> /privacy read_receipts none
wa> /privacy groups_add contacts

Available types: last_seen · profile_picture · status · online · read_receipts · groups_add

Available values: all · contacts · contact_blacklist · none · match_last_seen

CLI Account Email (recovery address)

An account can carry an email address, used to get back in if the number is lost or the two-step PIN is forgotten.

[!NOTE] This is not a way to receive the registration code. There is no email delivery at the code-request step — register the number first (--method sms, voice, wa_old or flash), connect, and only then bind an email. It works on a primary account; a companion (QR/pairing) link cannot use it.

Bind the address, ask for a code, read it from your inbox, submit it, confirm:

wa> /email set name@example.com
  email bound: name@example.com
  next:  /email request    then  /email verify <code>

wa> /email request
  code sent — check the inbox, then:  /email verify <code>

wa> /email verify 123456
  code accepted for name@example.com
  finishing:  /email confirm

wa> /email confirm
  email confirmed

wa> /email status
  email      name@example.com
  verified   yes
  confirmed  yes

/email request takes an optional language and country (e.g. /email request pt BR) that only pick which language the email is written in — the code is the same.

Contact Commands

Check Who Has WhatsApp

Checks multiple phone numbers (plain digits, no +) and lists which ones are registered on WhatsApp:

wa> /whatsapp 919634847671 12345678901
  has whatsapp (1)
    919634847671@s.whatsapp.net
  not found (1)
    12345678901

Get Profile Picture URL

Returns the CDN URL for a contact's or group's profile picture:

wa> /picture 919634847671
  https://mmg.whatsapp.net/v/...

wa> /picture 120363000000000000@g.us
  https://mmg.whatsapp.net/v/...

Get Contact About Text

Fetches the bio / about text for a contact:

wa> /contact about 919634847671
  Available 24/7

Chat Management Commands

On a linked (pairing-code) session these write to app state, so a change here reaches your phone and every other linked device.

On an SMS session this device is the primary and there is no app state key unless you have linked a companion to it. /mute, /unmute and /read still send the request a primary makes for itself; /pin, /archive and /star update this session only. The command says which happened:

wa> /pin 919634847671
pinned  (this session only — no app state key)

Mark Read / Unread

wa> /read   919634847671
wa> /unread 919634847671

Mute / Unmute

# mute for 60 minutes
wa> /mute 919634847671 60

# mute indefinitely
wa> /mute 919634847671

# unmute
wa> /unmute 919634847671

Pin / Unpin

wa> /pin   919634847671
wa> /unpin 919634847671

Archive / Unarchive

wa> /archive   919634847671
wa> /unarchive 919634847671

Star / Unstar a Message (CLI)

Add me when the message is one you sent — it is part of how the star is filed, so leaving it off on your own message stars the wrong thing.

wa> /star   919634847671 3EB0ABCDEF123456 me
wa> /unstar 919634847671 3EB0ABCDEF123456

Sync App State

Pulls in pins, archives, mutes, stars and contact names changed on your phone or another linked device. This happens on its own whenever the server says something moved; the command is for pulling on demand.

wa> /appstate
syncing app state...
  ──────────────────────────────────────────────────
  critical_block        v3   0 change(s)
  critical_unblock_low  v18  2 change(s)
  regular_high          v7   0 change(s)
  regular_low           v41  3 change(s)
  regular               v2   0 change(s)
  total                 5 change(s) applied
  ──────────────────────────────────────────────────

# just one part of it
wa> /appstate regular_low

# throw away what we hold and re-read everything
wa> /appstate --snapshot

Changes that arrive on their own are printed as they land:

  pinned  919634847671@s.whatsapp.net
  muted  120363000000000000@g.us  until 2026-08-01T09:00:00.000Z
  contact  12345678901@s.whatsapp.net  → Ion

If your phone has not yet shared a sync key with this session, the command says so — leave WhatsApp open on the phone for a moment and try again.

Account Restriction Countdown

When WhatsApp restricts an account, new chats are refused with error 463 until it expires — usually about five hours the first time. /restriction asks the server how long is left and then counts it down, once a second, in place:

wa> /restriction
checking account restriction...
  ──────────────────────────────────────────────────
  status                RESTRICTED
  reason                too many people you messaged blocked or reported you
  type                  BIZ_QUALITY
  ends at               2026-07-28 19:41:12 UTC
  remaining             04:59:22
  ──────────────────────────────────────────────────
  New chats with people you have never messaged are refused with
  error 463 until this expires. Existing conversations keep working,
  and sending more only makes the restriction longer.

  press any key to stop watching
  restricted — 04:59:20 remaining

The last line rewrites itself every second — 04:59:20, 04:59:19, … — and stops on its own the moment the restriction lifts. Any key ends the watch; the restriction is unaffected either way. /limit is the same command.

For the numbers without the countdown:

wa> /restriction --once

To check the display without waiting to be restricted, --demo counts down a made-up restriction. Nothing is sent, nothing is asked of the server, and nothing is left behind when it ends:

wa> /restriction --demo
  ──────────────────────────────────────────────────
  status                RESTRICTED  (demo — not real)
  reason                too many people you messaged blocked or reported you
  remaining             05:00:00
  ──────────────────────────────────────────────────
  press any key to stop watching
  restricted — 04:59:57 remaining

--demo 90 uses ninety seconds instead of the default five hours, which is short enough to watch it reach zero and stop on its own.

An account that is fine says so and returns immediately:

wa> /restriction
checking account restriction...
  ──────────────────────────────────────────────────
  status                not restricted — you can start new chats
  ──────────────────────────────────────────────────

You do not have to run it to find out. A refused send checks by itself, and the shell prints the change as it happens:

  ACCOUNT RESTRICTED — too many people you messaged blocked or reported you, 04:59:58 remaining
  run /restriction to watch the countdown

CLI Disappearing Messages

DurationSeconds
Off0
24 hours86400
7 days604800
90 days7776000
# set 1-day timer on a DM
wa> /ephemeral 919634847671 86400

# set 1-week timer on a group
wa> /ephemeral 120363000000000000@g.us 604800

# turn off
wa> /ephemeral 919634847671 0

Default Disappearing Timer

Sets the global default ephemeral timer applied to all new chats:

wa> /ephemeral-default 86400
default ephemeral set  86400

# turn off
wa> /ephemeral-default 0
default ephemeral set  0

Accepts the same values as /ephemeral: 0, 86400, 604800, 7776000.

Block / Unblock

wa> /block   919634847671
blocked  919634847671@s.whatsapp.net

wa> /unblock 919634847671
unblocked  919634847671@s.whatsapp.net

Show Block List

wa> /blocklist
  blocked (2)
    919634847671@s.whatsapp.net
    12345678901@s.whatsapp.net

Group Commands

CLI Create a Group

wa> /group create MyGroup 919634847671 12345678901
creating group...
created  120363000000000000@g.us
  subject  MyGroup
  members  919634847671@s.whatsapp.net, 12345678901@s.whatsapp.net

CLI Leave a Group

wa> /group leave 120363000000000000@g.us
left  120363000000000000@g.us

Add / Remove Participants

# add participants
wa> /group add 120363000000000000@g.us 919634847671 12345678901
  added  919634847671@s.whatsapp.net
  failed  12345678901@s.whatsapp.net  — their privacy settings do not allow it (403)  · can be invited instead

# remove participants
wa> /group remove 120363000000000000@g.us 919634847671
  removed  919634847671@s.whatsapp.net

Multiple participants can be listed, separated by spaces. Every participant is reported on its own line, because the server decides each one separately.

Promote / Demote Admins

# promote to admin
wa> /group promote 120363000000000000@g.us 919634847671

# demote from admin
wa> /group demote 120363000000000000@g.us 919634847671

Change Group Name

wa> /group subject 120363000000000000@g.us New Group Name
subject updated

Change Group Description

wa> /group desc 120363000000000000@g.us This is the group for project updates
description updated

Change Group Picture

Reads the image from disk and sets it as the group's profile picture. You must be an admin.

wa> /group photo 120363000000000000@g.us ./group-logo.jpg
group picture updated  id=1705315800
wa> /group invite 120363000000000000@g.us
  https://chat.whatsapp.com/AbCdEfGhIjKlMnOpQrStUv

Invalidates the current invite link and generates a new one:

wa> /group revoke 120363000000000000@g.us
invite link revoked

Join a Group by Invite Code

Pass only the code part — do not include https://chat.whatsapp.com/:

wa> /group join AbCdEfGhIjKlMnOpQrStUv
joined  120363000000000000@g.us

Query Group Invite Info

Preview a group's metadata from an invite link before joining. Accepts the bare code or the full URL:

wa> /group invite-info AbCdEfGhIjKlMnOpQrStUv
  ────────────────────────────────────────────────────────
  jid                   120363000000000000@g.us
  subject               My Group
  creator               919634847671@s.whatsapp.net
  created               2024-01-15 10:30:00
  description           Group description here
  participants          (3)
    919634847671@s.whatsapp.net  [admin]
    12345678901@s.whatsapp.net
    98765432109@s.whatsapp.net
  ────────────────────────────────────────────────────────

You can also pass the full link:

wa> /group invite-info "https://chat.whatsapp.com/AbCdEfGhIjKlMnOpQrStUv"

Query Group Metadata

wa> /group meta 120363000000000000@g.us
  jid                   120363000000000000@g.us
  subject               My Group
  creator               919634847671@s.whatsapp.net
  created               2024-01-15T10:30:00.000Z
  description           Group description here
  ephemeral             off
  only admins send      no
  only admins edit      yes
  join approval         required
  who can add           admins only
  size                  3
  participants          (3)
    112713111982325@lid  (919634847671@s.whatsapp.net)  [admin]
    229063524376784@lid  (12345678901@s.whatsapp.net)
    98765432109@s.whatsapp.net

A participant addressed by LID is shown with the phone number behind it when the server sends one. Two more lines appear only when they apply:

  suspended             yes — this group has been taken down
  incognito             yes — phone numbers are hidden

A suspended group answers every send with a refusal and nothing else, so it is worth checking here before hunting for the cause elsewhere.

List All Groups

Fetches all groups you are a member of and prints a numbered list:

wa> /groups
  groups (3)
    1  120363000000000000@g.us  My Project Group  (5 members)
    2  120363111111111111@g.us  Family Chat        (12 members)
    3  120363222222222222@g.us  Friends            (8 members)

List Group Participants

Lists all participants of a group with their roles:

wa> /group participants 120363000000000000@g.us
  My Group  (3 participants)
    112713111982325@lid  (919634847671@s.whatsapp.net)  [admin]
    229063524376784@lid  (12345678901@s.whatsapp.net)
    98765432109@s.whatsapp.net

Pending Join Requests

Lists users who have requested to join a group (only visible when approve_participants is enabled):

wa> /group pending 120363000000000000@g.us
  pending (2)
    919634847671@s.whatsapp.net   2026-07-20T09:12:00.000Z
    12345678901@s.whatsapp.net    2026-07-21T14:03:20.000Z

Approve / Reject Join Requests

# approve one or more pending members
wa> /group approve 120363000000000000@g.us 919634847671
  approved  919634847671@s.whatsapp.net

# reject one or more pending members
wa> /group reject 120363000000000000@g.us 919634847671
  rejected  919634847671@s.whatsapp.net

Multiple JIDs can be listed, separated by spaces. Anyone the server would not let through is listed separately with the reason.

Personal Invitations

For someone whose privacy settings do not let them be added to a group directly, add-invite adds whoever it can and sends the rest a personal invitation:

wa> /group add-invite 120363000000000000@g.us 919634847671 12345678901
  added  919634847671@s.whatsapp.net
  failed  12345678901@s.whatsapp.net  — their privacy settings do not allow it (403)  · can be invited instead  · invitation sent

An invitation that arrives for you shows the command that accepts it:

  message from      919634847671@s.whatsapp.net
  id                3EB0A1B2C3D4
  type              group invitation  My Group
  accept with       /group accept-invite 120363000000000000@g.us 919634847671@s.whatsapp.net AbCdEfGh 1790000000
# look at the group without joining it
wa> /group preview-invite 120363000000000000@g.us 919634847671 AbCdEfGh 1790000000

# join
wa> /group accept-invite 120363000000000000@g.us 919634847671 AbCdEfGh 1790000000
joined 120363000000000000@g.us

# send one by hand
wa> /group send-invite 120363000000000000@g.us 12345678901 AbCdEfGh 1790000000

# take one back before it is used
wa> /group revoke-invite 120363000000000000@g.us 12345678901

Group Settings

Controls who can send messages, edit group info, add participants, or requires approval to join:

# only admins can send messages
wa> /group settings 120363000000000000@g.us send_messages admins

# everyone can send messages
wa> /group settings 120363000000000000@g.us send_messages all

# only admins can edit group info
wa> /group settings 120363000000000000@g.us edit_group_info admins

# only admins can add participants
wa> /group settings 120363000000000000@g.us add_participants admins

# require admin approval for join requests
wa> /group settings 120363000000000000@g.us approve_participants admins

Community Commands (CLI)

Communities group multiple linked groups under one umbrella. Only the community creator can link / unlink groups or deactivate the community.

# create a community (description is optional)
wa> /community create "Dev Squad" "Our developer community"

# link an existing group into the community
wa> /community link  120363000000000001@g.us 120363000000000002@g.us

# unlink a group from the community
wa> /community unlink 120363000000000001@g.us 120363000000000002@g.us

# permanently deactivate (delete) a community
wa> /community deactivate 120363000000000001@g.us

Newsletter / Channel Commands

Newsletters are one-to-many broadcast channels. Only the owner can post; anyone can subscribe.

# create a new channel
wa> /newsletter create Tech News Daily tips about technology

# subscribe to a channel
wa> /newsletter join 120363000000000004@newsletter

# unsubscribe from a channel
wa> /newsletter leave 120363000000000004@newsletter

# query channel metadata (name, description, subscriber count)
wa> /newsletter info 120363000000000004@newsletter
  ────────────────────────────────────────────────────────
  jid           120363000000000004@newsletter
  name          Tech News
  description   Daily tips about technology
  subscribers   1234
  ────────────────────────────────────────────────────────

# update the channel description (you must be the owner)
wa> /newsletter desc 120363000000000004@newsletter New description here

# post a text update to your channel (you must be the owner)
wa> /newsletter post 120363000000000004@newsletter Breaking: WhatsApp adds polls!

Business Profile Command (CLI)

Query the public business profile of any WhatsApp Business account:

wa> /biz 919634847671
  ────────────────────────────────────────────────────────
  jid           919634847671@s.whatsapp.net
  category      Software & IT Services
  email         contact@example.com
  website       https://example.com
  address       123 Main St
  description   We build software
  ────────────────────────────────────────────────────────

Returns a message if the number is not a WhatsApp Business account.

Registration Commands (in-shell)

These commands work from within the shell — useful when you need to register a second number without closing the current session:

# check if a phone number has WhatsApp
wa> /reg check 919634847671

# request a verification code
wa> /reg code 919634847671
wa> /reg code 919634847671 voice
wa> /reg code 919634847671 wa_old

# register with a display name — what people who have not saved
# your number see. Quote it if it has spaces.
wa> /reg code 919634847671 --name "Ricardo Trade"

# confirm the code received
wa> /reg confirm 919634847671 123456
registered  session saved to /home/user/.waSession/919634847671/919634847671.json
now run: /connect 919634847671

Registering as Android from the shell works the same way, but WA_OS has to be set when the shell starts — the wa> prompt is already inside a running process, and nothing typed at it can change the environment that process was launched with:

WA_OS=android WA_DEVICE=samsung-s24-ultra wa
wa> /reg code 919634847671
no Android token material yet — fetching the WhatsApp APK from Google Play
  ...
  status  sent
wa> /reg confirm 919634847671 123456

A .env file with WA_OS=android in the directory you start from does the same and saves the typing. Either way it only matters for the command that creates the session: what a session registered as is written into it, and every later step — the confirmation, a reconnect from a different shell tomorrow — follows the session rather than the environment.

Connection Commands (in-shell)

# connect to a number (while already in the shell)
# picks sms or pairing from the session files on disk
wa> /connect 919634847671

# force one or the other
wa> /connect 919634847671 sms
wa> /connect 919634847671 pair

# link to an existing account by 8-digit pairing code
wa> /pair 919634847671

# or link by scanning a QR drawn in the terminal
wa> /qrcode 919634847671

# with a code you chose yourself (exactly 8 characters)
wa> /pair 919634847671 MYCODE12

# disconnect
wa> /disconnect

# force a reconnection
wa> /reconnect

# show current session info
wa> /session
  phone                   919634847671
  name                    My Bot
  session                 /home/user/.waSession

# show all available commands
wa> /help

# disconnect and exit the shell
wa> /quit

Full Command Reference Table

CommandDescription
Messaging
/send <jid> <text>Send a text message
/image <jid> <file> [caption]Send an image
/video <jid> <file> [caption]Send a video
/audio <jid> <file>Send an audio file
/ptt <jid> <file>Send a voice note (push-to-talk)
/doc <jid> <file> [name]Send a document
/sticker <jid> <file>Send a sticker (.webp)
/poll <jid> <question> | <opt1> | <opt2> [selectable=N]Send a poll
/react <jid> <msgId> <emoji>React to a message
/edit <jid> <msgId> <text>Edit a sent message
/delete <jid> <msgId> [all]Delete a message (add all for everyone)
/status <text>Post a Status / Story
/forward <jid> <text>Send with forwarded flag
/reply <jid> <msgId> <senderJid> <text>Reply quoting a specific message
`/location [name] [address]`
/vcard <jid> <displayName> <vcard>Send a contact card (vCard v3)
Presence
/onlineSet yourself as online
/offlineSet yourself as offline
/typing <jid>Show typing indicator in a chat
/recording <jid>Show recording audio indicator
/stop <jid>Stop typing / recording
/subscribe <jid>Subscribe to a contact's presence
Profile
/name <text>Change your display name
/about <text>Change your bio / about text
/photo <file>Change your profile picture
/privacy <type> <value>Change a privacy setting
Contacts
/whatsapp <phone...>Check which numbers have WhatsApp
/picture <jid>Get profile picture CDN URL
/contact about <jid>Get bio / about text of a contact
Chat Management
/read <jid>Mark chat as read
/unread <jid>Mark chat as unread
/mute <jid> [minutes]Mute a chat (indefinitely if no minutes given)
/unmute <jid>Unmute a chat
/pin <jid>Pin a chat
/unpin <jid>Unpin a chat
/archive <jid>Archive a chat
/unarchive <jid>Unarchive a chat
/star <jid> <msgId> [me]Star a message (me if you sent it)
/unstar <jid> <msgId> [me]Unstar a message
/appstate [collection...]Pull pins/archives/mutes/stars from your phone
/appstate --snapshotRe-read all app state from scratch
/restrictionAccount restriction status with a live countdown
/restriction --onceRestriction status without the countdown
/restriction --demo [seconds]Fake countdown, to check the display
/limitAlias for /restriction
/ephemeral <jid> <seconds>Set disappearing messages timer for a chat
/ephemeral-default <seconds>Set global default ephemeral timer for new chats
/block <jid>Block a contact
/unblock <jid>Unblock a contact
/blocklistShow all blocked contacts
Groups
/group create <name> <jid...>Create a group
/group leave <jid>Leave a group
/group add <jid> <member...>Add participants
/group remove <jid> <member...>Remove participants
/group promote <jid> <member...>Promote to admin
/group demote <jid> <member...>Demote from admin
/group subject <jid> <name>Rename group
/group desc <jid> <text>Change group description
/group photo <jid> <file>Change group picture
/group invite <jid>Get invite link
/group revoke <jid>Revoke invite link
/group join <code>Join group by invite code
/group invite-info <code|url>Preview group metadata from an invite link (without joining)
/groupsList all groups you are a member of
/group meta <jid>Query group metadata
/group participants <jid>List group participants with roles
/group pending <jid>List pending join requests
/group approve <jid> <member...>Approve pending join requests
/group reject <jid> <member...>Reject pending join requests
/group settings <jid> <setting> <policy>Change group setting
Community
/community create <subject> [description]Create a community
/community deactivate <communityJid>Permanently delete a community
/community link <communityJid> <groupJid>Link a group into a community
/community unlink <communityJid> <groupJid>Unlink a group from a community
Newsletter / Channel
/newsletter create <name> [description]Create a newsletter channel
/newsletter join <jid>Subscribe to a channel
/newsletter leave <jid>Unsubscribe from a channel
/newsletter info <jid>Query channel metadata
/newsletter desc <jid> <text>Update channel description
/newsletter post <jid> <text>Post a text update to your channel
Business
/biz <phone|jid>Query business profile of a WhatsApp Business account
Registration
/reg check <phone>Check if number has WhatsApp
/reg code <phone> [method] [--name "Name"]Request verification code, optionally naming the account
/reg confirm <phone> <code> [--name "Name"]Complete registration
Connection
/connect <phone> [sms|pair]Connect to WhatsApp — picks the method from the session files when unset
/pair <phone> [code]Link to an existing account by 8-digit pairing code
/qrcode <phone>Link to an existing account by scanning a QR drawn in the terminal
/disconnectDisconnect current session
/reconnectForce reconnection
/sessionShow session info
/helpShow all commands
/quit / /exitDisconnect and exit

Library API

Everything the CLI does is available as a Node.js library. The sections below cover connecting an account, sending and receiving every message type, groups, communities, channels, presence, privacy, history sync, and device emulation.

What the package exports

Two layers. The first is the flat one the rest of this document uses — the eighty-two names the common paths are built from:

const { WhalibmobClient, createNewStore, requestSmsCode } = require('whalibmob')

The second is everything else. The library is ninety-five modules and close to five hundred exported names, and the flat layer picks out under a fifth of them. The rest are reachable as namespaces, each one a module of lib/ under a name of its own:

const wa = require('whalibmob')

wa.MediaService.getMediaKeyName('ptt')        // → 'WhatsApp Audio Keys'
wa.MessageProto.decodeMessageContainer(buf)   // raw bytes → a decoded message
wa.Messages.MediaRetry.mediaRetryKey(key)     // the retry-receipt key
wa.Signal.libsignal.SessionCipher             // the vendored libsignal
wa.AppState.SyncdProto                        // app-state mutation records
wa.Image.Jpeg                                 // the JPEG header reader

A namespace is named after the module it comes from, and a directory in lib/ becomes one namespace rather than several — so Signal is all of lib/signal/, AppState all of lib/appstate/, Messages all of lib/messages/. Two names could not be had: Devices is lib/DeviceManager.js, because the flat DeviceManager is the class rather than the module, and Proto is lib/proto.js while the message protobufs in lib/proto/ are MessageProto.

Deep requires work as well, and always have — the package declares no exports map, so nothing is sealed off:

const MediaService = require('whalibmob/lib/MediaService')
// the same object, by a longer name
require('whalibmob').MediaService === MediaService   // true

Adding the namespaces renamed and removed nothing: every name the library exported before is still exported, still holding what it held.

[!NOTE] The namespaces are typed as far as the rest of this document goes — media, the message protobufs, media retry. Past that they are any, which is what the internals have always been in index.d.ts. They are reachable, not described.

ES modules

The package is CommonJS, and every one of its names can be imported from an ES module — a bot written with "type": "module" needs no interop dance:

import { WhalibmobClient, createNewStore, requestSmsCode } from 'whalibmob'
import { MediaService, MessageProto } from 'whalibmob'

// the default import is the whole export object, if you prefer it
import wa from 'whalibmob'

// deep imports work too — ESM wants the extension, CommonJS does not
import { downloadMedia } from 'whalibmob/lib/MediaService.js'

[!NOTE] import { … } used to fail for all but five names. Node reads a CommonJS module's names statically, and the reader gives up at the first property of module.exports whose value is not a plain identifier — an inline arrow function five entries in cost the other 116. require() and the default import were unaffected, which is why it went unnoticed. Everything is now bound to a name before it is exported, and a test fails if that stops being true.

Connecting Account

Register a New Number

Registration is a one-time process. You need a phone number that can receive an SMS or voice call.

Step 1 — request a verification code

const {
  createNewStore, saveStore, requestSmsCode
} = require('whalibmob')
const path = require('path')
const fs   = require('fs')

const phone    = '919634847671'           // country code + number, no '+'
const sessDir  = path.join(process.env.HOME, '.waSession')
const sessFile = path.join(sessDir, phone + '.json')

fs.mkdirSync(sessDir, { recursive: true })

// `name` is the display name the account registers with — what people who have
// not saved the number see. Omit it and the account has no name until one is
// set later with client.changeName().
const store = createNewStore(phone, { name: 'Ricardo Trade' })
saveStore(store, sessFile)

await requestSmsCode(store, 'sms')   // 'sms' | 'voice' | 'wa_old'

The name is written to the session, not sent to the registration endpoint — the server learns it from the first connection, and from every one after. It can also be passed as an option at either step, which is the way to name a session that already exists:

await requestSmsCode(store, 'sms', { name: 'Ricardo Trade' })
await verifyCode(store, '123456',  { name: 'Ricardo Trade' })

Names are trimmed, collapsed to single spaces and capped at 25 characters.

Step 2 — verify the code

const { loadStore, saveStore, verifyCode } = require('whalibmob')

const store  = loadStore(sessFile)
const result = await verifyCode(store, '123456')

if (result.status === 'ok') {
  saveStore(result.store, sessFile)
  console.log('registered')
}

Optional — let the code arrive by itself. The two steps above are the whole flow, and nothing about them changes if you do nothing else. But because a registration sends a push token (see The Push Token), WhatsApp may also deliver the six-digit code as a silent push. Open a listener for it before requesting the code, and the code can come back with nothing typed — on an Android profile over Firebase, on an iOS one over APNs:

const { receivePushCode } = require('whalibmob')

// open the listener FIRST, so the push has somewhere to land
const codePromise = receivePushCode(store, store.device, { timeoutMs: 180000 })

await requestSmsCode(store, 'sms')        // any method; the push is a copy of the code
const code = await codePromise            // the six digits, or null if no push came

if (code) {
  const result = await verifyCode(store, code)
  if (result.status === 'ok') saveStore(result.store, sessFile)
} else {
  // no push this time — read the code the ordinary way and call verifyCode(store, code)
}

This is purely additive: receivePushCode resolves null on a timeout or any failure, so the plain requestSmsCode / verifyCode path always stands behind it. Whether WhatsApp sends the silent push is the server's decision — see Receiving the code over push for what governs it. From the CLI the same thing is one command, /reg push <phone>.

When the code is not the end of it. The server can answer a submitted code with a CAPTCHA, or with a demand for the account's two-step verification PIN. Neither is something the library can work out on its own, so both come back to you through optional handlers:

const result = await verifyCode(store, '123456', {
  // image and audio are Buffers, or null when that variant was not sent.
  // Return the answer, or null to give up.
  async solveCaptcha({ image, audio }) {
    fs.writeFileSync('captcha.png', image)
    return await askTheUser()
  },

  // The six-digit PIN set on the phone under
  // Settings → Account → Two-step verification.
  async twoFactorPin() {
    return await askTheUser()
  }
})

Both are optional and both keep working when omitted — you get an error naming what was asked for instead of a silent failure, with the CAPTCHA blobs attached as err.captcha. A wrong CAPTCHA answer is replied to with another one, so solveCaptcha may be called several times. The CLI prompts for both, writing the image to a temp file first.

[!NOTE] Registration reports the screens it passes through to WhatsApp's /client_log, the way the phone clients do — a client that registers in total silence does something no real installation does. It is fire-and-forget and every failure is swallowed, so it can never take a registration down. Set WA_FUNNEL_LOG=0 to send none of it.

[!NOTE] Those events carry timestamps, and registration waits between them the way a person would: a few seconds to type the number in, a moment on the confirmation sheet, longer before asking again after a refusal. Without the waits the whole funnel leaves inside one millisecond, which no handset does. It adds a handful of seconds to a registration. Set WA_REG_PACING=0 to remove them.

Registering as Android

There is nothing to do first. Name the platform and register:

WA_OS=android WA_DEVICE=samsung-s24-ultra wa registration --request-code 919634847671 --debug

The first Android registration finds no token material, fetches the WhatsApp APK from Google Play, reads what it needs out of it, checks that WhatsApp really signed it, and carries on to the code request — one command, no APK to find:

no Android token material yet — fetching the WhatsApp APK from Google Play
    version 2.26.30.3 (code 263000302)
    downloading base.apk and 1 density split (of 24 the server offered): config.xxxhdpi
    token material written to /home/you/.waSession/android-apk-material.json
requesting sms code for +919634847671...
  status  sent

It happens once. Every later registration reads the file — from the session directory you are using, wherever that is. WA_NO_APK_DOWNLOAD=1 turns the fetch off if you would rather it never pulled a hundred megabytes unasked, and wa apk-material below does the same job by hand.

[!NOTE] The download goes through the Aurora OSS token dispenser, a free third-party service that mints an anonymous Play account. It refuses with HTTP 403 when it is rate limiting and 5xx when it is having a bad minute, so the request is retried a few times with a growing wait before giving up. WA_PLAY_RETRY_DELAY_MS sets that wait (default 2000, doubling each time). If it still refuses, nothing is wrong with your setup — pass an APK path to wa apk-material instead, which is the same thing without the middleman.

The rest of this section is what happens behind that one command, and how to drive each part yourself.

Registering a WhatsApp Business account

Set WA_BUSINESS, or pass --business, and the whole registration switches to the Business build:

WA_OS=android WA_BUSINESS=1 wa registration --request-code 919634847671
wa registration --register 919634847671 --code 123456
wa connect 919634847671

Everything that names the app follows from that one variable:

consumerBusiness
announced platformANDROID / IOSANDROID_BUSINESS / IOS_BUSINESS
User-AgentAndroid/… · iOS/…SMBA/… · SMB iOS/…
Android token materialcom.whatsappcom.whatsapp.w4b
iOS token constantconsumerBusiness
version lookupconsumer listingBusiness listing
vname fieldnot senta self-signed verified-name certificate
Frida attestation port11191120

The Android token material lives in its own file (android-apk-material-business.json), so the two builds never overwrite each other and wa apk-material --download --business can sit beside the consumer one.

vname is a VerifiedNameCertificate the client signs itself, carrying an empty name, the issuer smb:wa and a random serial. The name is empty because nothing is verified yet — WhatsApp issues the real one after it reviews the business. What the server checks is that the signature over those details was made with the identity key the same request registers.

[!IMPORTANT] Decide before the code goes out. An account is created as Business or as consumer, and the token, the User-Agent and the certificate all have to keep saying which. A session that is already part-way through registering keeps what it started as and says so rather than flipping halfway; delete the session file to start it over as the other one.

Why an APK is involved at all

The registration token is computed differently on each platform, and only iOS derives it from a constant. The Android client signs it with material out of its own APK:

key   = PBKDF2-HMAC-SHA1(password = "com.whatsapp" + about_logo.png,
                         salt = fixed, iterations = 128, length = 64)
token = urlencode(base64(HMAC-SHA1(key, signing certificates
                                      + MD5(classes.dex)
                                      + national number)))

None of that can be derived, so it has to be read out of a real APK once. Sending the iOS-shaped token as Android is answered with {"reason":"bad_token"}, and no value in WA_STATIC_TOKEN changes that — the constant is not what is wrong, the formula is. WA_STATIC_TOKEN applies to iOS only.

Fetching the APK on its own

This runs by itself on a registration that finds no material. To do it separately — to refresh after a WhatsApp release, or just to see what it picks up:

wa apk-material --download

The route is an anonymous Play Store token from the Aurora OSS dispenser, then Google's own /fdfe catalogue and delivery endpoints, then the signed CDN URLs. No Google account of yours is involved. Only the density splits are downloaded — the token's key comes from about_logo.png, which lives in one of those, and the architecture and language splits would be a hundred megabytes nothing here reads.

Two caveats. The dispenser is a free third-party service: when it is down or rate limiting, this fails and an APK from a phone still works. And downloading from Play this way is against Play's terms of service, which is your call to make.

Play serves the splits that suit the device profile it is asked as, not the full set a bundle holds, so the density this yields can differ from the one in a complete bundle — and a different density is a different key. The about_logo line in the output names which one was used.

Reading it out of an APK you already have

Pull one off any phone or emulator that has WhatsApp installed:

adb shell pm path com.whatsapp
# package:/data/app/~~xyz==/com.whatsapp-abc==/base.apk
# package:/data/app/~~xyz==/com.whatsapp-abc==/split_config.xxhdpi.apk
adb pull /data/app/~~xyz==/com.whatsapp-abc==/base.apk
adb pull /data/app/~~xyz==/com.whatsapp-abc==/split_config.xxhdpi.apk

Recent releases ship as an App Bundle, so about_logo.png often lives in a density split rather than in base.apk. Pull the split_config.*dpi.apk files too and pass them along — only splits whose name ends in dpi are searched.

Read the material out of it:

wa apk-material base.apk split_config.xxhdpi.apk
reading base.apk...
  package               com.whatsapp
  version               2.26.25.80  (code 260908001)
  certificates          1
  signed by             C=US, ST=California, L=Santa Clara, O=WhatsApp Inc., …
  sha256                AD:AD:64:31:29:8F:64:2B:…
  classes.dex md5       5c71f02aaad331e16e436bfa83ea3c5b
  written to            /home/you/.waSession/android-apk-material.json

Watch the signed by line. The token is an HMAC over the signing certificates, so it is only the token the server expects when those certificates are WhatsApp's own. Mirrors re-sign the APKs they host, commonly with the AOSP test key whose private half ships in the Android sources — a re-signed APK yields a token that is well-formed and belongs to nobody. Anything other than WhatsApp in that subject is called out here as a warning, and registration refuses to start on it rather than spending code requests against your number on something that cannot succeed. WA_ALLOW_FOREIGN_APK=1 sends it anyway.

Only the derived pieces are kept — the APK is never needed again. Registration picks the file up on its own from then on. --out <file> writes it elsewhere, and WA_ANDROID_APK_MATERIAL points at it if you keep it somewhere else.

The version comes from the APK too. It is read out of the binary AndroidManifest.xml and announced from then on, instead of the version the Play Store currently lists. The token is signed over this build's classes.dex, so announcing any other version describes a build the token does not belong to. WA_VERSION still overrides everything if you need it to; on a manifest with no versionName, pass --version <x.y.z.w>.

Refresh it on a new WhatsApp release. classes.dex changes every release and its MD5 is signed into the token, so material from an older build stops matching the version being announced. wa apk-material --download re-reads the current one; deleting the file and registering again does the same thing on its own.

Programmatically:

const { extractMaterial, computeToken, materialToJson } = require('whalibmob/lib/AndroidApk')

const material = extractMaterial(
  fs.readFileSync('base.apk'),
  [{ name: 'split_config.xxhdpi.apk', data: fs.readFileSync('split_config.xxhdpi.apk') }]
)
fs.writeFileSync('android-apk-material.json', JSON.stringify(materialToJson(material)))

Device Attestation with Frida (optional)

WhatsApp's registration servers score every /code and /register request on how much it looks like a genuine phone. A real device proves itself with a hardware attestation token — Play Integrity plus a Keystore-signed request on Android, App Attest on iOS. whalibmob sends these fields on every registration request, but it can only fill them with real values if it can talk to an actual device.

This repository ships a frida/ folder containing the on-device scripts that produce those tokens. It is entirely optional: with no device attached, whalibmob sends the same empty attestation fields a real phone sends when its integrity check fails, and registration still works. Attaching a device raises the trust score, which helps when you keep hitting no_routes or block screens.

Requires a rooted Android phone or a jailbroken iPhone with the official WhatsApp app installed from the Play Store / App Store. Sideloaded APKs do not work — the attestation is bound to the store-signed build.

What's in the folder

frida/
  android/     Play Integrity + Keystore attestation server  (/integrity, /cert, /info)
  ios/         App Attest attestation server                 (/integrity)

Each platform folder has its own README.md with the full device prerequisites.

1. Build the script

Install Frida on your computer, then build the bundle for your platform:

cd frida/android      # or: cd frida/ios
npm install
npm run build         # produces server_with_dependencies.js

2. Run it on the device

Make sure the Frida server is running on the phone, then attach to WhatsApp:

# Android — open WhatsApp and reach the "register a number" screen FIRST,
# otherwise the integrity components are not loaded yet
frida -U "WhatsApp" -l server_with_dependencies.js

# iOS
frida -U -l server_with_dependencies.js -f "net.whatsapp.WhatsApp"

The script starts a small HTTP server on the phone, listening on port 1119 (or 1120 if you attached to WhatsApp Business). Wait for:

[*] Server ready on port 1119

3. Make the port reachable

whalibmob talks to that server over plain HTTP, so the port has to be reachable from the machine running your bot. Over USB, forward it with adb:

adb forward tcp:1119 tcp:1119     # Android
iproxy 1119 1119                  # iOS (libimobiledevice)

Alternatively, if the phone is on the same Wi-Fi, use its LAN IP directly.

4. Point whalibmob at it

Set the host — and the port, if it isn't the default 1119:

WA_FRIDA_HOST=127.0.0.1
WA_FRIDA_PORT=1119        # optional; use 1120 for WhatsApp Business

Or in .env:

WA_FRIDA_HOST=127.0.0.1
WA_FRIDA_PORT=1119

That is the whole integration. On the next requestSmsCode() / verifyCode() call, whalibmob queries the device automatically and attaches the attestation to the request:

PlatformFields it fills
Androidgpia (Play Integrity) in the body, plus a Keystore signature and certificate chain on the request
iOSAn App Attest assertion and attestation on the request

Keep the Frida session open while you register. When you unset WA_FRIDA_HOST, whalibmob silently goes back to the empty-attestation path — no code changes, and nothing else in your bot is affected.

Troubleshooting. If the fields come back empty, check in this order: the Frida session is still attached, the port is forwarded, and — on Android — that you opened WhatsApp's registration screen at least once before attaching, since the integrity components are lazy-loaded. whalibmob never fails a registration because attestation is unavailable; it just falls back to empty values.

Connect

const { WhalibmobClient } = require('whalibmob')
const path = require('path')

const client = new WhalibmobClient({
  sessionDir: path.join(process.env.HOME, '.waSession')
})

client.on('connected', () => {
  console.log('connected')
})

await client.init('919634847671')

Client Options

Every option is optional; sessionDir is the only one most senders ever set.

OptionDefaultWhat it does
sessionDir~/.waSessionThe authentication folder. Each number gets its own subfolder inside it — see Saving & Restoring Sessions.
autoFixNumbertrueRe-file the session automatically when the server reports the account under a different number. Set false to be told instead of fixed — see The Number WhatsApp Files Your Account Under.
autoReadtrueSend read receipts for incoming messages. false leaves them unread.
refreshVersiontrueCheck the platform's store for the current build on every connect and reconnect, and write it into the session file. Both iOS and Android. Set false to keep announcing whatever the session registered with — see Keeping the Announced Version Current.
pinooffDebug logging. true turns it on at debug level; an object is passed to pino as-is.
sentCacheSize2000How many sent messages keep their plaintext so a retry receipt naming them can be answered.
maxRetryResends5How many times one message may be re-sent in answer to retry receipts before the client gives up.
tcTokenPresendTimeoutMs5000How long the first message to a new contact waits for its trusted-contact token before going out without one — see tcToken — Error 463 Defense. 0 sends immediately and lets the token arrive for the next message.
const client = new WhalibmobClient({
  sessionDir:      path.join(process.env.HOME, '.waSession'),
  sentCacheSize:   10000,
  maxRetryResends: 8
})

When to raise the last two. A recipient's device that cannot decrypt a message asks for it again, and whalibmob answers by rebuilding the Signal session and re-sending. Answering requires the original text, which is why sent messages are held: a receipt naming a message no longer in the cache cannot be answered at all, and that message — already acked by the server — silently never arrives.

The defaults cover an ordinary sender. Raise sentCacheSize if you push messages faster than replies come back, which is easy to do when several recipients are being worked through at once: your own replies age out of the cache while the recipient is still asking for them. The symptom is this line in the debug log:

[DBG] RETRY_RECV msgId=... — no cached plaintext, skipping resend

If you see it, the cache is smaller than your in-flight window. An entry holds the encoded message, not the media it points at, so entries are small and raising the bound costs little memory.

Linking to an Existing Account (Pairing Code or QR)

Registering a number over SMS makes whalibmob that number's own device. Sometimes that is not what you want — the number is already in use on a phone, or the verification SMS never arrives. For those cases whalibmob can instead connect over a WebSocket and link itself to an account that already exists, exactly the way the WhatsApp Web and desktop clients do.

There are two ways to link, and they reach the same place:

  • Pairing code — whalibmob gives you an 8-character code, the owner types it into their phone.
  • QR code — whalibmob draws a QR, the owner scans it with the phone's camera.

Both link the number as a companion device, both leave the whole library working the same afterwards — same client, same methods, same events. The only difference is what the owner does: type a code, or scan a square.

[!IMPORTANT] The two modes are independent. SMS registration is unchanged and still the default; nothing about it is affected by linking. A single number can even have both a registered session and a linked session — they are stored in separate files and never share state.

Requesting a Pairing Code

Connect first, then ask for the code: the request travels over the encrypted channel, so the channel has to exist before there can be a code.

const { WhalibmobClient } = require('whalibmob')
const path = require('path')

const client = new WhalibmobClient({
  sessionDir: path.join(process.env.HOME, '.waSession')
})

client.on('paired', (p) => {
  console.log('linked as', p.jid)          // 919634847671:7@s.whatsapp.net
  console.log('lid      ', p.lid)          // 112713111982325:7@lid
  console.log('slot     ', p.deviceIndex)  // 7
})

client.on('connected', () => {
  console.log('ready')
})

// open the WebSocket connection as a companion
await client.connectWeb('919634847671', { syncFullHistory: true })

// ask for the code — returns immediately, the link completes later
const code = await client.requestPairingCode('919634847671')
console.log('enter this on the phone:', code)   // e.g. "K7M2QX4B"

On the phone that owns the number:

WhatsApp → Settings → Linked Devices → Link a device → Link with phone number instead, then type the code.

Linking by QR Code

The QR path is the scan-to-connect alternative. You do not request a pairing code — you just connect and listen for the qr event. Because no code is asked for, the server volunteers a QR instead, and whalibmob turns it into a ready-to-render string:

const { WhalibmobClient } = require('whalibmob')
const qrcode = require('qrcode-terminal')          // npm install qrcode-terminal
const path = require('path')

const client = new WhalibmobClient({
  sessionDir: path.join(process.env.HOME, '.waSession')
})

// a fresh QR string arrives here, and again each time the previous one expires
client.on('qr', ({ qr, remaining }) => {
  qrcode.generate(qr, { small: true })             // draw it in the terminal
  console.log('scan it — refreshes on its own,', remaining, 'left before it expires')
})

client.on('qr_timeout', () => console.log('QR set expired — reconnect for a fresh one'))

client.on('paired',    (p)  => console.log('scanned — linked as', p.jid))
client.on('connected', ()   => console.log('ready'))

// connect as a companion — and DO NOT request a pairing code
await client.connectWeb('919634847671', { syncFullHistory: true })

On the phone that owns the number:

WhatsApp → Settings → Linked Devices → Link a device, then point the camera at the QR.

The qr string is what a WhatsApp camera reads: ref,noise,identity,advSecret,platformId — a one-time ref plus this client's public keys, comma-joined, the same fields WhatsApp Web itself renders. It rotates on its own (about a minute for the first, then shorter) until the account is scanned or the refs run out. qrcode-terminal is optional — without it you get the raw string on qr and can render it however you like (a PNG, a web page, an image in a chat). To emit the QR as a wa.me/settings/linked_devices#… link instead of the bare fields, pass { qrWrapUrl: true } to connectWeb.

Everything after the scan is identical to the pairing-code path: paired, a stream restart, then connected.

A few seconds after the code is accepted (or the QR is scanned) you will see paired, the server restarts the stream, and connected fires on the new connection. From that point on everything else in this document applies unchanged:

await client.sendText('919876543210', 'sent from a linked device')

Reconnecting a Linked Session

The link is persisted. On every later run, connectWeb() alone is enough — do not request a new code:

const client = new WhalibmobClient({ sessionDir })

client.on('connected', () => console.log('reconnected'))

await client.connectWeb('919634847671')

requestPairingCode() throws if the session is already linked, so it is safe to guard on that.

Choosing Your Own Code

If you would rather show the user a code you picked, pass it as the second argument. It must be exactly 8 characters:

const code = await client.requestPairingCode('919634847671', 'MYCODE12')

Options

await client.connectWeb(phone, {
  syncFullHistory: true,                       // ask the phone for full history (default true)
  browser: ['Ubuntu', 'Chrome', '120.0.0.0']   // what the owner sees in Linked Devices
})

browser is [os, client, version]. The second element decides the device icon shown on the phone — Chrome, Firefox, Safari, Edge, Opera, Desktop are all recognised.

Events Specific to Linking

EventFires when
pairing_codea code has been requested — { code, phoneNumber }
qra QR is ready to render — { qr, ref, ttlMs, remaining }; fires again on each rotation
qr_timeoutthe QR refs ran out — reconnect for a fresh set
pairedthe owner accepted the code or scanned the QR — { jid, lid, deviceIndex, platform }
restart_requiredthe server is restarting the stream after pairing (normal; the reconnect is automatic)
pair_devicethe raw QR reference strings, before they are turned into qr — { refs }
history_synca chunk of history arrived from the phone

Getting the History and the Address Book

This is the part a registered number can never do. A device registered over SMS is the account's primary, and a primary has nobody to receive history from — it starts with an empty contact list and an empty chat list, and only learns about people who message it.

A linked device is different: the account's phone ships its chats, its contacts and its push names over as soon as the link is established. whalibmob decrypts and stores those automatically, and emits them as they arrive:

client.on('history_sync', (r) => {
  console.log(r.syncTypeName)          // INITIAL_BOOTSTRAP, RECENT, FULL, PUSH_NAME
  console.log('chats   ', r.chats.length)
  console.log('contacts', r.contacts.length)

  for (const c of r.contacts.slice(0, 5)) {
    console.log(c.jid, c.name || c.notify)
  }
})

await client.connectWeb(phone, { syncFullHistory: true })

History arrives in chunks over the first minute or so after linking, largest first. Everything is merged into <phone>.web.history.json in your session directory, so it survives restarts and you can read it directly.

[!NOTE] syncFullHistory: false asks only for recent messages, which links noticeably faster on accounts with years of history.

Session Files

FileHolds
<phone>.web.jsonlink state — keys, advSecretKey, the device slot you were given
<phone>.web.signal.jsonSignal sessions, signed pre-key, identities, LID map, pre-key counters
<phone>.web.pre-key-<id>.jsonone one-time pre-key — 812 of them
<phone>.web.sk.jsongroup SenderKeys
<phone>.web.tctoken.jsonprivacy tokens
<phone>.web.appState.jsonapp-state version and hash per collection
<phone>.appStateKeys.jsonapp-state sync keys shared by the phone
<phone>.web.history.jsonsynced chats, contacts, push names, LID↔PN mappings
<phone>.web.messages.jsonflat map of message id → message metadata

The .web. prefix keeps a linked session entirely separate from an SMS-registered one for the same number.

Media in Companion Mode

Uploads and downloads go to the CDN endpoints the web client uses, not the mobile ones. This is handled for you — sendImage, sendVideo, sendSticker and the rest take the same arguments in both modes — but it is worth knowing why the distinction exists.

The web client has no endpoint per media type. A sticker is uploaded to the image endpoint and a GIF to the video one; what makes them a sticker or a GIF lives in the message itself, not in the URL. Uploading a sticker to /mms/sticker as a companion gets a 404. The CDN also expects a browser Origin on both the upload and the download, and a mobile WhatsApp user agent against a web-issued auth token is a mismatch it can reject.

MediaPrimary endpointCompanion endpoint
image/mms/image/mms/image
video/mms/video/mms/video
audio/mms/audio/mms/audio
document/mms/document/mms/document
sticker/mms/sticker/mms/image
gif/mms/gif/mms/video
voice note/mms/ptt/mms/audio

The same applies to history sync blobs and profile pictures.

Device Identity

Every message a companion sends that opens a new Signal session carries a device-identity node: the signed record the account's primary device issued during pairing, including the account signature key. That is how the recipient's client knows a message from device 7 of an account genuinely belongs to that account rather than to someone who merely knows the number.

whalibmob attaches it automatically whenever a pkmsg is in the stanza, in direct messages and in groups alike. A device registered over SMS has no such record — nothing issued one to it — and correctly sends nothing.

The pairing code is a password, not an identifier. It is never sent to the server in the clear. Both sides run it through PBKDF2-SHA256 (131,072 iterations) to derive a key that wraps the ephemeral public keys they exchange, then combine two Diffie-Hellman results into adv_secret — the key every later proof of account membership is authenticated under.

whalibmob verifies three things before accepting a link, and refuses it outright if any fails: the HMAC over the signed device identity, the account signature over its own identity key, and the device slot it was issued. A wrong code cannot produce a working link, and a tampered response cannot either.

Companion Mode — Node.js API

Everything below is the full surface for running whalibmob as a linked device. If you have used the SMS primary API, none of it will surprise you: the client is the same class, the methods take the same arguments, and the events carry the same shapes. Only the way in differs.

Complete Working Example

A bot that links itself on first run, reconnects silently on every run after that, and replies to messages.

const { WhalibmobClient } = require('whalibmob')
const path = require('path')
const fs   = require('fs')

const PHONE    = '919634847671'                              // no '+', no spaces
const SESS_DIR = path.join(process.env.HOME, '.waSession')

// A session is already linked when this file exists and carries a device JID.
function isLinked() {
  const f = path.join(SESS_DIR, PHONE + '.web.json')
  if (!fs.existsSync(f)) return false
  try {
    const j = JSON.parse(fs.readFileSync(f, 'utf8'))
    return !!(j.registered && j.me && j.me.id)
  } catch { return false }
}

async function start() {
  const client = new WhalibmobClient({ sessionDir: SESS_DIR })

  client.on('pairing_code', ({ code }) => {
    console.log('\n  pairing code:', code.slice(0, 4) + '-' + code.slice(4))
    console.log('  phone → Settings → Linked Devices → Link a device')
    console.log('        → Link with phone number instead\n')
  })

  client.on('paired', ({ jid, lid, deviceIndex, platform }) => {
    console.log('linked as', jid, '· slot', deviceIndex, '· primary is', platform)
  })

  // The server restarts the stream right after pairing. This is normal and the
  // reconnect is automatic — there is nothing to do but log it.
  client.on('restart_required', () => console.log('restarting stream...'))

  client.on('connected', () => console.log('connected'))

  client.on('history_sync', (r) => {
    console.log(`history ${r.syncTypeName}: ${r.chats.length} chats, ${r.contacts.length} contacts`)
  })

  client.on('message', async (msg) => {
    const d = msg.decoded
    if (!d) return
    console.log(msg.from, d.type, d.text || '')

    if (d.type === 'text' && d.text === 'ping') {
      await client.sendText(msg.from, 'pong')
    }
  })

  client.on('error', (e) => console.error('error:', e.message))

  await client.connectWeb(PHONE, { syncFullHistory: true })

  if (!isLinked()) {
    await client.requestPairingCode(PHONE)
  }
}

start()

Run it once, type the code into the phone, and it is linked. Run it again and it connects straight away.

Methods

MethodDescription
client.connectWeb(phone, opts?)Open the companion connection. Resolves as soon as the encrypted channel is up when unlinked, or after <success> when already linked.
client.requestPairingCode(phone?, customCode?)Ask for an 8-character code. Returns it immediately; the link completes later. Throws if the session is already linked.
client.disconnect()Close the connection. The link survives — reconnect with connectWeb().
client.logout()Unlink this device from the account, the way the Linked Devices screen does, then disconnect. The link does not survive — the next connectWeb() pairs afresh. Nothing on disk is deleted; remove the session directory yourself if you want the keys gone.

connectWeb(phone, opts) options:

OptionDefaultDescription
syncFullHistorytrueAsk the phone for the full archive. false requests recent messages only and links noticeably faster.
browser['Ubuntu', 'Chrome', '120.0.0.0'][os, client, version]. The second element picks the icon shown under Linked Devices: Chrome, Firefox, Safari, Edge, Opera, Desktop.
versionfetched liveWeb client revision to announce, e.g. [2, 3000, 1035194821]. Set it to pin one.
fetchVersiontrueSet false to skip the live lookup and use the pinned fallback.

The Announced Version

The web endpoint checks the client revision during the handshake and refuses an unrecognised one with <failure reason="405"> — before any stanza is exchanged, and with nothing in the failure to say the version was the problem. The mobile endpoint is far more forgiving; this one is not.

whalibmob therefore reads the live revision from WhatsApp Web's own service worker on each connectWeb(), and falls back to a pinned value if that lookup fails. You should not have to think about it, but you can:

const { fetchWaWebVersion } = require('whalibmob')

const { version, isLatest } = await fetchWaWebVersion()
console.log(version, isLatest)   // [2, 3000, 1035194821] true

// pin it yourself
await client.connectWeb(phone, { version: [2, 3000, 1035194821] })

// or skip the lookup entirely
await client.connectWeb(phone, { fetchVersion: false })

If you ever see 405, this is what it means. It is not a revoked session and there is nothing to re-pair — reconnect to pick up the current revision.

requestPairingCode(phone, customCode):

  • phone — optional; defaults to the number given to connectWeb.
  • customCode — optional; must be exactly 8 characters or it throws. Use it to show a code you generated yourself.

Events

Every event from the SMS primary API fires here too — message, receipt, presence, group_update, call, blocklist, privacy_settings, and the rest. These are the ones only companion mode produces:

EventPayloadFires when
pairing_code{ code, phoneNumber }a code was requested
paired{ jid, lid, deviceIndex, platform }the owner accepted the code
restart_required{ reason }the server is restarting the stream after pairing — the reconnect is automatic
pair_device{ refs }the QR path produced reference strings
history_sync{ syncTypeName, chats, contacts, pushNames, merged }a chunk of history arrived
history_sync_error{ err, notification }a chunk could not be fetched or decrypted
media_retry{ messageId, chatJid, fromMe, participant, ciphertext, iv, error }the sender's phone answered a requestMediaRetry(). Read it with decryptMediaRetry() and the original media key; an error instead of a payload means the phone no longer has the file either
client_rejected{ reason, location, message }the server refused the client itself, not the session — 405 means the announced version is not accepted. Fires whether the refusal arrives during the handshake or once the stream is open, and the client stops retrying either way. Distinct from auth_failure, and there is nothing to re-pair.
version_update{ from, to, source }the announced version was refreshed from the platform's store before a handshake. Fires on reconnects as well as the first connect
apk_material_stale{ materialVersion, liveVersion, hint }Android only: the Play Store listing has moved past the APK the cached token material came from. Nothing is broken — registration is what reads that material — but the next number registered from this install would go out under an older build

Reading What the Phone Sent

client.on('history_sync', (r) => {
  // r.syncTypeName — INITIAL_BOOTSTRAP | RECENT | FULL | PUSH_NAME | ...
  for (const chat of r.chats) {
    // { id, name, unreadCount, lastMsgTimestamp, messageCount }
    console.log(chat.id, chat.name, chat.unreadCount, chat.messageCount)
  }
  for (const contact of r.contacts) {
    // { id, name, username, pnJid, lidJid }
    console.log(contact.id, contact.name, contact.pnJid, contact.lidJid)
  }
  for (const p of r.pushNames || []) {
    console.log(p.id, p.pushname)
  }
})

Chunks arrive over the first minute or so after linking, largest first. Everything is merged into <phone>.web.history.json, so you can also read it straight off disk once and skip the event:

const hist = JSON.parse(
  fs.readFileSync(path.join(SESS_DIR, PHONE + '.web.history.json'), 'utf8')
)
console.log(Object.keys(hist.chats || {}).length, 'chats on disk')

Sending

Identical to primary mode — same methods, same arguments, same return shapes:

await client.sendText(jid, 'hello')
await client.sendImage(jid, './photo.jpg', 'a caption')
await client.sendVideo(jid, './clip.mp4', 'watch this')
await client.sendAudio(jid, './voice.ogg', { ptt: true })
await client.sendDocument(jid, './report.pdf', 'report.pdf')
await client.sendSticker(jid, './sticker.webp')
await client.sendLocation(jid, 44.4268, 26.1025, 'Bucharest')
await client.sendContact(jid, 'Ana', vcard)
await client.sendPoll(jid, 'Lunch?', ['Pizza', 'Sushi'], 1)
await client.sendReaction(jid, msgId, '👍')
await client.sendStatus({ image: './photo.jpg', caption: 'hi' })

Full signatures for each of these are in Sending Messages and Media Messages; nothing about them changes in companion mode.

Groups, blocking, privacy settings and profile changes work the same way too.

[!NOTE] Media uploads go to the endpoints the web client uses, which are not the same as the mobile ones for stickers, GIFs and voice notes. whalibmob switches automatically — see Media in Companion Mode.

Handling Reconnects

The library reconnects on its own with backoff. What you should handle is the difference between a transient drop and a revoked link:

client.on('disconnected',  ()      => console.log('dropped'))
client.on('reconnecting',  ({ delay }) => console.log('retry in', delay / 1000, 's'))
client.on('reconnected',   ()      => console.log('back'))

// The owner removed this device under Linked Devices. The session is dead —
// delete it and pair again.
client.on('auth_failure', ({ reason }) => {
  console.error('link revoked:', reason)
  fs.rmSync(path.join(SESS_DIR, PHONE + '.web.json'), { force: true })
  process.exit(1)
})

Knowing Which Mode You Are In

console.log(client._mode)              // 'web' or 'mobile'
console.log(client.store.me.id)        // 919634847671:7@s.whatsapp.net
console.log(client.store.me.lid)       // 112713111982325:7@lid
console.log(client.store.deviceIndex)  // 7 — which linked-device slot
console.log(client.store.platform)     // 'android' — what the primary runs

deviceIndex is what makes a companion a companion. A primary is device 0 and its JID is the bare number; a companion occupies a numbered slot, and the account's own phone becomes a peer it encrypts to like any other device.

Two Sessions on One Number

A number may be SMS-registered and separately linked as a companion. They never share state — separate files, separate Signal sessions, separate history. Which one you get is decided by the method you call:

await client.init(PHONE)        // mobile / primary, over TCP
await client.connectWeb(PHONE)  // web / companion, over WebSocket

Use two WhalibmobClient instances if you want both at once.

The Number WhatsApp Files Your Account Under

WhatsApp does not always keep an account under the number you type. Brazilian mobiles are the standing example: they gained a ninth digit, and WhatsApp keeps older accounts under the eight-digit form. 5596976042705 and 559676042705 are the same account, but only the second is the one the server recognises.

This matters because the number in your session is sent as the username on every connection. Get the form wrong and the server has no registration matching it, so the login is refused with 401 — a code that says "logged out" and gives no hint that the number was the problem.

whalibmob handles this on its own, in both directions:

When you register, the canonical form is read from the server's reply and the session is saved under it. Numbers registered through whalibmob cannot end up in this state.

When you connect an older session that is in this state, the first login is refused, whalibmob asks the server which form it uses, re-files the session, and connects. You see one line:

connecting to +5596976042705...
  this account is registered as +559676042705 (not +5596976042705) — session updated
connected as +559676042705

The rename keeps the registration and the Signal keys — it moves the session files, it does not re-register anything.

client.on('number_corrected', ({ from, to }) => {
  console.log('session re-filed:', from, '→', to)
})

To be told rather than fixed:

const client = new WhalibmobClient({ sessionDir, autoFixNumber: false })

Then the 401 is thrown as-is, and checkSessionAlive() tells you what the server calls the account:

const probe = await client.checkSessionAlive()
// { alive: true, status: 'ok', current: '5596976042705',
//   canonical: '559676042705', mismatch: true }

if (probe.mismatch) {
  await client.adoptCanonicalNumber(probe.canonical)
}

[!NOTE] The check costs nothing when things work — it runs only after a login has already been refused.

Some numbers come back from /register like this:

{ "login": "557176034186", "pending": "app_store_age", "reason": "consent", "status": "fail" }

The code was not refused and the account was found — WhatsApp is asking for an age signal that only a real app-store install carries, and will not finish without it. Brazilian numbers are where this turns up in practice.

The first thing to try is the Android device profile. The iOS registration request carries six fields and none of them says anything about consent, terms or age; the Android one carries tos_version, education_screen_displayed and clicked_education_link.

WA_OS=android wa registration --code 5571976034186

If that is refused too, the number has to go through the real app once, on a phone, before it can be registered here.

[!NOTE] The login field in that reply is worth reading. Brazilian mobiles gained a ninth digit that WhatsApp never adopted, so +5571976034186 is filed as +557176034186. whalibmob adopts the server's form automatically on a successful registration and saves the session under it — the digit difference is not itself the failure.

Why Registration Can Be Blocked — The Anti-Abuse Landscape

If registration works on some numbers and fails on others with the same code, the failure is almost never a bug in whalibmob. It is one of two separate defences WhatsApp runs, built at different times, for different reasons. Telling them apart is the whole of debugging a block, so this section explains what each one is, where it came from, and what actually moves the needle.

Two different walls

Wall 1 — "are you the real app?" (unofficial-client detection). This is the wall built against modified WhatsApp clients — GB WhatsApp, FM WhatsApp, YoWhatsApp, WhatsApp Plus and the rest of that family, mods that repackage the official APK to add themes, dual accounts and privacy toggles. WhatsApp fought them for years and, from around 2024, banned tens of millions of the accounts using them in a single wave. The detection that came out of that fight checks three things on every connection: the APK signature (is it signed by WhatsApp Inc. or by a mod author?), the integrity attestation (does Play Integrity / App Attest vouch that this is the genuine app on a real device?), and behaviour.

whalibmob is not a mod — but from the server's point of view a whalibmob request with empty attestation produces the same answer to those questions as a mod does: "I can't prove I'm the official app." WhatsApp does not read your project's name; it reads signals, and on that signal whalibmob and a mod look alike. That is why the same screens a mod triggers can appear here:

  • "reason":"blocked" with a custom_block_screen — "For security reasons, we can't connect you right now." The request is refused before the code is even routed.
  • "We could not confirm you are using the official WhatsApp app," often with a link to download it.
  • On a linked/companion session, "Use the official WhatsApp Web to continue" — the same idea, one layer over, aimed at web-class linked devices.

These tend to appear on numbers that have been flagged before (previously banned, or previously seen on an unofficial client). A clean number is usually given the benefit of the doubt and passes with empty attestation; a flagged number has spent that benefit and is asked to prove it is the official app — which empty attestation cannot.

Wall 2 — "are you spamming?" (anti-abuse / IP reputation). This wall is older than the mods and unrelated to them. WhatsApp scores the IP address the request comes from, and if it belongs to a datacenter, a known VPN range or an address already on a spam list, it is refused regardless of how genuine the client looks. This is what fails many numbers at once from the same server: the common factor is not the numbers, it is the IP. It is also what a no_routes reply (with every *_wait at 3600) usually means — a routing/rate refusal; cool down for an hour and slow down.

The distinction matters because the fixes are different, and because Wall 2 would exist even if the mods never had. Even in a world with no GB WhatsApp, WhatsApp would still stop mass account creation — that is a spam problem it has fought since before any mod existed. There is no configuration that turns a headless server into "unlimited numbers": that is precisely the outcome the anti-abuse system is designed to prevent.

Reading the reply

What the server sends backWhich wallWhat it means
"reason":"no_routes", every *_wait: 3600Anti-abuseCan't route the code now; usually the IP or rate. Wait out the cooldown, use a cleaner IP.
"reason":"blocked" + custom_block_screenUnofficial-client / reputationRefused for "security"; the IP and/or the number are distrusted.
"not using the official app" / download linkUnofficial-clientThe number is flagged and is being asked to prove it is the genuine app.
"reason":"consent", "pending":"app_store_age"Consent (separate)See When Registration Is Refused for Consent.

What actually helps — and what does not

Honest, in order of effect:

  • A clean IP does the most. A residential or mobile IP, one that is not on a blocklist, clears Wall 2 more than anything else. Datacenter/VPS IPs are the single most common cause of a blocked reply across many numbers. Route through a good residential/mobile proxy (see Routing Traffic Through a Proxy), and do not push dozens of numbers through one address.
  • Clean, unused numbers. A number that has never been flagged passes with empty attestation. A number that already carries the "unofficial app" screen has been marked, and is usually not worth fighting — a fresh clean number is the better use of time.
  • The Android profile (WA_OS=android) carries more of the fields the server wants than the iOS one, and is the first thing to try when a number is refused.
  • Real attestation answers Wall 1 directly. The Frida scripts in frida/ mint a genuine Play Integrity / App Attest token from a real handset with the Play-Store app and fold it in — this is the honest way to answer "prove you're the official app," because it is a real device proving it. It needs a rooted Android or jailbroken iOS phone (see Device Attestation with Frida), so it is not always practical; clean numbers do not need it, and no software-only trick substitutes for it.

The honest bottom line

This is a moving target, not a solved problem. WhatsApp changes these checks continually — the mods went from surviving months to surviving hours under the same pressure — and whalibmob is kept in step release by release, but nothing here is permanent and there is no magic bypass. Registration on the mobile protocol is a real capability that works, most reliably on clean numbers from clean IPs; the further you get from that, the harder the anti-abuse system pushes back, by design.

The Push Token

Every WhatsApp on a real phone holds a push token. It is the address the push network uses to wake the app, and no install exists without one — so a registration that ships no push_token describes a WhatsApp that cannot be notified, which is a device that does not exist.

Which token you get follows from the profile. Which push network an install uses follows from its platform: Android holds a Firebase token and keeps a stream open to Google, iOS holds an APNs token and keeps one open to Apple. They are not interchangeable, and the registration server sees both the token and the User-Agent naming the platform that sent it — an iPhone presenting a Firebase token describes a device nobody ships. Both sides are implemented, and the right one is chosen from WA_OS without you asking for it. A profile that is neither sends no push_token at all, which is the correct thing for a client with no push line.

The token does two distinct jobs, and it is easy to conflate them:

  • It makes the registration look real — always. Every /code request now carries the token, whichever delivery method you ask for. The server sees an install that can be reached, not a headless client. This is the reason the token matters, and it applies to sms, voice, wa_old — all of them.
  • It can carry the code itself — sometimes. Because you handed WhatsApp a direct line, WhatsApp may also push the six-digit code silently down it, so the app fills the code in on its own. This is why the code auto-completes on a real phone before you have read the SMS. It happens alongside the method you chose, not instead of it.

The delivery method and the push are not alternatives. You still choose how a human receives the code (sms, voice, wa_old); the push, when it comes, is a second silent copy of that same code sent straight to the app.

request a code ─┬─ method you chose  →  reaches a human   (SMS, a call, the existing WhatsApp)
                └─ push_token line   →  reaches the app    (silent, auto-filled — if WhatsApp sends it)

On Android: Firebase

Registration fetches a real token and sends it, in three plain HTTPS calls to Google:

StepEndpointYields
1android.clients.google.com/checkinan android id and security token — the device's identity with Google
2firebaseinstallations.googleapis.coma Firebase installation for WhatsApp's project
3android.clients.google.com/c2dm/register3the FCM token itself

No root, no Frida, no phone. This is unrelated to Play Integrity attestation — the two travel in the same request but come from different places, and one works without the other.

It runs once per number. The Firebase identity is cached on the session, because Google issues an android id once and expects it back; fetching a new one per registration step would mint a fresh phantom device each time.

Turn it off with WA_FCM_PUSH=0.

On iOS: APNs

Apple's flow is not Google's, but it lands in the same place:

StepEndpointYields
1albert.apple.coma device certificate Apple signs for a keypair generated here — the activation every Apple device runs on first boot
2init-p01st.push.apple.com/bagwhich courier hosts to dial
3<n>-courier.push.apple.com:443the device token, then GET_TOKEN for net.whatsapp.WhatsApp — the per-topic token that is push_token

Step 1 runs once per number and its result is cached on the session: the certificate Apple issues is good for a year, and the identity behind it is what makes the same push token come back on the next run. A token WhatsApp has already recorded stops being deliverable if that identity is thrown away, which is the same reason the Firebase android id is kept.

The courier connection is a TLS stream that offers ALPN apns-security-v3 — the device proves itself with a signature over a fresh nonce in the first frame rather than with a TLS client certificate. Apple accepts the offer without echoing it back, so a missing echo is normal and is not treated as a failure. A network that terminates TLS in the middle cannot carry the stream; what gives it away is the certificate, since a middlebox has to present one its own CA signed rather than Apple's. The failure names that issuer, instead of looking like a dropped connection.

Turn it off with WA_APNS_PUSH=0.

Failure is silent by design, on both platforms. A blocked network, a refusal from Google or Apple, a malformed answer — all end with the field omitted and registration proceeding exactly as it did before push tokens were wired up. A push token helps; it is never a prerequisite.

Receiving the code over push, without typing it

This is the receiving end of job 2 above. When WhatsApp sends the code as a silent push, something has to be listening on the push line to catch it — the same long-lived connection every phone keeps open to its push network. receivePushCode(store, device) opens the one that matches the profile:

ProfileConnectionWhere the code is
WA_OS=androidmtalk.google.com:5228, the MCS protocol, logged in with the Firebase identityan appData entry keyed registration_code
WA_OS=ios<n>-courier.push.apple.com:443, the APNs courier, authenticated with the activation certificatethe regcode field of the notification's JSON payload

Either way the call resolves with the code the moment a push carrying it arrives. A profile that is neither resolves null immediately rather than holding a listener open on a line no push can reach, and /reg push says so and stops instead of waiting out its timeout. Check with supportsPush(store.device) if you want to branch on it.

The order matters. Open the listener first, so the line is live before the code is requested; then request the code by whatever method; then await it.

const { receivePushCode, requestSmsCode, verifyCode } = require('whalibmob')

// 1. open the listener first, so the push has somewhere to land
const codePromise = receivePushCode(store, store.device, { timeoutMs: 180000 })

// 2. request the code — any method. The push, if it comes, is a copy of it.
await requestSmsCode(store, 'sms')          // or 'voice', 'wa_old', …

// 3. if the push arrives, the code is here with nothing typed
const code = await codePromise
if (code) await verifyCode(store, code)
else      { /* no push — read the code from SMS / the existing WhatsApp, then verifyCode */ }

From the CLI the whole sequence is one command:

/reg push <phone> [sms|voice]

It opens the listener, waits until it is logged in, requests the code, and confirms automatically if the push arrives — falling back to /reg confirm <phone> <code> when it does not.

Either connection carries a heartbeat and acknowledges what it has read, so a reconnect does not re-read a delivered code, the way the native client does. It resolves null on timeout, a refused login, or any failure — at which point you simply read the code the ordinary way and verify it. Like the token, it routes through the configured SOCKS proxy.

[!IMPORTANT] Receiving the push is not the same as making WhatsApp send it. Whether WhatsApp pushes the code for a given request is the server's decision, and on a client shipping empty attestation it will often send the code only by the method you asked for (SMS, wa_old, a call) and no silent push. This listener catches the push correctly when one is sent; it cannot force that channel, and it never replaces the chosen method — it runs beside it. With a valid Play Integrity attestation in the request (WA_FRIDA_HOST), the server is more likely to include the silent push.

Routing Traffic Through a Proxy

Two reasons to want this. Registration is the part of the protocol most likely to be refused from a datacenter IP, so a residential exit helps with security blocks and undeterminable number statuses. And on a network that cannot reach WhatsApp at all, nothing works without one.

Install the optional dependency and set one environment variable:

npm install socks
# Tor
export TOR_PROXY=socks5://127.0.0.1:9050

# a residential proxy that needs credentials
export SOCKS_PROXY=socks5://user:pass@proxy.example.com:1080

A bare host:port is assumed to be SOCKS5, so TOR_PROXY=127.0.0.1:9050 works too. socks5, socks5h, socks4 and socks4a are all accepted, and TOR_PROXY wins if both variables are set.

If the password contains an @ or a :, percent-encode it — p@ss:word becomes p%40ss%3Aword. whalibmob decodes it before handing it to the proxy.

// or from code, before you call any registration function
process.env.SOCKS_PROXY = 'socks5://user:pass@proxy.example.com:1080'

await requestSmsCode(phone, store)

What goes through it

Everything the library sends out:

ConnectionDestination
Message socket, mobileraw TCP to WhatsApp
Message socket, webwss://web.whatsapp.com/ws/chat
Registrationv.whatsapp.net
Announced version, iOSApp Store lookup
Announced version, AndroidPlay Store listing
Announced version, webweb.whatsapp.com/sw.js
APK material for registrationPlay Store download
Media uploadWhatsApp's media hosts

Node's http/https modules ignore proxy environment variables entirely — a proxy has to be handed in as an agent, per call site. Only the registration POST used to do that, which produced a confusing failure: curl -x socks5://… reached WhatsApp while the library timed out on the same machine, falling back to a pinned version with only a debug line to show for it:

[DBG] WEB_VERSION 2.3000.1035194821 (pinned fallback: sw.js fetch timed out)

All eight paths now share one agent.

If socks is not installed, or a proxy is unreachable, you get a message saying so rather than a silent failure. A proxy URL that cannot be parsed leaves the connection direct rather than throwing — a version lookup with a pinned fallback should not take a process down over its proxy. In the rare case the package lives somewhere require() cannot find it, WA_SOCKS_LIB takes an absolute path to it.

Saving & Restoring Sessions

Sessions are persisted to disk under the sessionDir you provide — the authentication folder. Pass any path you like; nothing is hardcoded.

const client = new WhalibmobClient({
  sessionDir: path.join(process.env.HOME, 'whalibmob_auth')
})

// no need to register again — just connect
await client.init('919634847671')

Each number gets a folder of its own inside it, holding every file that number owns:

whalibmob_auth/
├── android-apk-material.json            ← shared by every Android registration
├── android-apk-material-business.json
├── 919634847671/
│   ├── 919634847671.json                ← the store: keys, device, version
│   ├── 919634847671.signal.json         ← Signal sessions, signed pre-key, identities
│   ├── 919634847671.pre-key-1.json      ← one-time pre-keys, one file each
│   ├── 919634847671.pre-key-2.json
│   ├── …                                  812 of them
│   ├── 919634847671.pre-key-812.json
│   ├── 919634847671.sk.json             ← sender keys
│   ├── 919634847671.tctoken.json
│   ├── 919634847671.device-cache.json
│   ├── 919634847671.lid-mapping.json
│   ├── 919634847671.lid-reverse-mapping.json
│   ├── 919634847671.appState.json
│   ├── 919634847671.appStateKeys.json
│   ├── 919634847671.history.json
│   └── 919634847671.messages.json
└── 5568936182750/
    └── …

A companion link for the same number lives beside it as <phone>.web.json and friends, in the same folder.

One account is then one directory: copy it to move a number to another machine, delete it to be rid of one, archive it to keep it. Nothing has to be picked out of a pile by prefix.

[!NOTE] Sessions written by earlier versions keep working where they are. The layout is decided per number: a number whose files sit loose in the base directory is left exactly as it is, and only new numbers get a folder. Nothing is moved unless you ask — wa migrate-sessions does that, one number or all of them, and re-running it is safe.

Keeping the Announced Version Current

Every connection announces the WhatsApp build it claims to be. The server checks it, and when it stops recognising the number it refuses the handshake with 405 — a failure that says nothing about the version and has nothing to do with the account.

A session records the version it registered with. Left alone it announces that same number forever, so a number registered in spring is still claiming a spring build in autumn, and one day the connect simply stops working.

Sessions refresh themselves, on both platforms. Before every handshake the client asks the platform's store what the current build is and writes it into <phone>.json. iOS reads the App Store listing, Android the Play Store one. Nothing to run, nothing to remember:

client.on('version_update', ({ from, to, source }) => {
  console.log('announcing', to, 'instead of', from, '—', source)
})

await client.init('919634847671')

Reconnects are covered too. The check runs from the socket-connect step rather than from init(), so the library's own reconnect loop passes through it as well. A bot that stays up for weeks does not go on announcing whatever the listing said the morning it started. The handshake reads the version out of the store at the moment it runs, so a version written here is the one that goes out.

Four rules keep this from being the thing that breaks a working session:

  • It never goes backwards. Both lookups answer with a pinned fallback rather than failing when they cannot reach the network, and that fallback can easily be older than what the session holds. Moving a session to an older version is the one outcome that makes a 405 more likely.
  • WA_VERSION still wins. A version pinned on the way out of a 405 is a decision, and is never quietly replaced.
  • A failed lookup changes nothing. The session keeps the version it has and the connect carries on.
  • The network is not asked twice for the same answer. Each lookup is memoised for six hours, so a connection that flaps every few seconds reaches the store once rather than once per attempt — and a session up for a month still picks up a release the day it lands.

Set { refreshVersion: false } on the client to turn it off.

[!NOTE] Registration still reads the APK. A number being registered announces the versionName of the APK its token material came from, because the Android registration token is signed over that build. Only the announced version on an already-registered session follows the Play Store listing — no token travels in the handshake, so there is nothing there for it to fall out of step with. When the listing moves past the cached material the client emits apk_material_stale, which is the cue to run wa apk-material --download before registering the next number.

The manual tool still works on both platforms:

wa refresh-version 919634847671                    # current build for the platform
wa refresh-version 919634847671 --version 2.25.1.2 # or one you name

Companion sessions have never had this problem: connectWeb() reads the live web revision on every connect already.

One-time Pre-keys

A pre-key is a one-shot Diffie-Hellman key the account leaves with the server so somebody who wants to message it can open a Signal session without it being online. Each one is handed out once and then gone. Run out and nobody can start a conversation with the number at all — the messages are not delayed, they are never sent, and nothing anywhere reports it.

whalibmob keeps a pool of 812, generated the moment a session is created and written one key per file, in whatever authentication folder you gave it:

~/.waSession/919634847671/
├── 919634847671.pre-key-1.json
├── 919634847671.pre-key-2.json
└── … 919634847671.pre-key-812.json

A companion link for the same number keeps its own pool beside it as 919634847671.web.pre-key-<id>.json. The two are never shared: they are separate devices with separate identity keys, and a key offered under the wrong one cannot be answered.

The file is the same shape the reference client writes — the raw 32-byte public key and the private key, both BufferJSON:

{"private":{"type":"Buffer","data":"…"},"public":{"type":"Buffer","data":"…"}}

Keeping the server stocked. Three things drive it, and all of them ask the server what it is actually holding rather than guessing from the local pool — the two drift apart, because the server spends a key on every bundle it hands out and none of that reaches this side.

WhenWhat it does
on loginasks the server for its count; 0 there means a full 812 goes up, anything low means a top-up of 5
when the server says it is running lowsame check, same batch
every 30 minutessame check — for the session that stays up long enough to be drained without a word

A batch is the next ids the server has not been sent, never the whole pool again, and only counts as sent once the server has acknowledged it — a rejected upload is retried four times with backoff and then left for the next check, with the same keys still queued. Ids are never reused, even after the key under one has been spent.

After the login upload the account also asks for the server's key bundle digest and compares the identity key and signed pre-key it is being served against the ones this session holds. A mismatch there is the failure that used to need the number registering again to clear.

None of this needs calling: init() and connectWeb() both do it, identically.

[!NOTE] Sessions written before 5.14.19 keep every key they had. Pre-keys used to live inside <phone>.signal.json; on the first start they are moved out into files of their own and the aggregate copy is dropped. The keys themselves do not change, so the bundles the server has already handed out stay answerable.

Where the folder comes from

ordersource
1sessionDir passed to WhalibmobClient (library), or --session <dir> (CLI)
2WA_SESSION_DIR in the environment
3the answer remembered in ~/.whalibmob.json, which the CLI asks for once on a first run
4~/.waSession

The CLI asks only when none of the above has decided it and the default folder is empty, so an existing installation is never asked to rename anything, and a non-interactive run — a script, a cron — never blocks on the question.

  where should sessions be kept? each number gets its own folder inside.
  a bare name goes under your home directory; enter for /home/you/.waSession
  authentication folder:  whalibmob_auth
  sessions will be kept in /home/you/whalibmob_auth

A bare name is created under your home directory; an absolute path or one starting with ~ is taken as given.

Working out the paths yourself

lib/SessionPaths is the same resolver the library and the CLI use, so a caller that wants to read or move a session's files does not have to guess the layout:

// exported from the package itself, or from lib/SessionPaths directly
const {
  defaultBaseDir, sessionDirFor, storeFileFor, webStoreFileFor,
  listSessions, migrateSession, SessionPaths
} = require('whalibmob')

const { isLegacyLayout, SESSION_SUFFIXES } = SessionPaths

const base = path.join(process.env.HOME, 'whalibmob_auth')

sessionDirFor(base, '919634847671')                  // …/whalibmob_auth/919634847671
sessionDirFor(base, '919634847671', { create: true }) // and makes it
storeFileFor(base, '919634847671')                   // …/919634847671/919634847671.json
webStoreFileFor(base, '919634847671')                // …/919634847671/919634847671.web.json

listSessions(base)
// [ { phone, dir, storeFile, webStoreFile, legacy, hasMobile, hasWeb }, … ]
//   covers both layouts, so it is what to iterate over

migrateSession(base, '919634847671')
// { phone, from, to, moved: [...], skipped: [...] }   moves one number into its folder

SESSION_SUFFIXES is every per-number file the library writes — the list to copy or delete against if you are moving an account by hand.

Where a Session Is Kept

Everything above describes files, because files are what whalibmob writes and what it will go on writing unless you say otherwise. Nothing in this section is something you have to do. Leave it alone and sessions stay exactly where they have always been, in the JSON files named above.

What is new is that the place is now a choice. A backend is four synchronous operations over a flat key space:

read(key)          // the stored text, or null when the key was never written
write(key, value)  // put it there, replacing whatever was there before
remove(key)        // take it away; a key that is not there is not an error
list(prefix)       // every key present that starts with prefix

The keys are logical names rather than file names — auth, signal, sender-key, tc-token, device-cache, lid-mapping, lid-reverse-mapping, history, messages, app-state, app-state-keys, and `pre-key/${id}` for each of the 812 one-time pre-keys.

Three implementations ship with the package:

BackendWhere the state goesNeeds
FileBackendJSON files — what the library has always writtennothing; this is the default
SqliteBackendone database fileNode 22.5+, or better-sqlite3
MemoryBackendnowhere; gone when the process endsnothing
const { FileBackend } = require('whalibmob')

const backend = new FileBackend({
  dir:   '/home/you/.waSession/919634847671',
  phone: '919634847671'
})

backend.write('auth', JSON.stringify(creds))
backend.read('auth')          // the text, or null
backend.list()                // ['auth', 'pre-key/1', 'signal', …]
backend.list('pre-key/')      // just the pre-keys
backend.remove('pre-key/42')
backend.fileFor('signal')     // …/919634847671.signal.json

FileBackend writes the same names in the same places as every release before it, so a session written by whalibmob 5.30 opens through it untouched and one written through it opens in 5.30. The only change is that writes now go to a temporary file and are renamed into place, so a process that dies mid-write leaves the previous state intact instead of half a file.

[!IMPORTANT] A number has two halves, and they are separate sessions. The one registered over the Mobile API and the companion linked over the Web API never share state — in particular they have separate pre-key id spaces, and mixing them hands out two different keys under one id and breaks decryption. A backend covers one half, chosen by web:

const mobile = new FileBackend({ dir, phone, web: false })  // default
const web    = new FileBackend({ dir, phone, web: true })

Both take the same keys and keep entirely separate values.

[!NOTE] The client does not accept a backend yet. new WhalibmobClient({ … }) takes sessionDir and writes files, as it always has; the modules that hold session state still do their own file I/O. The backends are usable on their own — for reading, inspecting, copying or moving a session — and wiring them through the client is the next step. Nothing here changes how a session is created or connected today.

One Database Instead of 834 Files

A number's state is 22 named files plus a file for each of its 812 one-time pre-keys. On a laptop nobody notices. On a phone under Termux, on a container with a small inode budget, or with fifty numbers in one folder, it is 40 000 files whose directory has to be read every time the pre-key pool is counted.

SqliteBackend is the same state as a handful of rows:

const { SqliteBackend } = require('whalibmob')

const db = new SqliteBackend({
  path:  '/home/you/.waSession/sessions.sqlite',
  phone: '919634847671'
})

db.write('auth', JSON.stringify(creds))
db.read('auth')
db.list('pre-key/')
db.driver          // 'node:sqlite' or 'better-sqlite3'
db.close()         // let go of the file

What it needs. Node ships SQLite of its own from 22.5.0 as node:sqlite, and that is what this uses when it is there — nothing to install, nothing for node-gyp to fail at, and Termux stays a place whalibmob runs. On older Node it falls back to better-sqlite3 if that is installed:

npm install better-sqlite3      # only on Node older than 22.5

Neither is a dependency of this package. On a runtime with neither, the constructor throws and says which of the two to reach for — and FileBackend goes on needing nothing at all.

One file holds as many numbers and halves as you like, while each backend sees only its own slice:

const mobile = new SqliteBackend({ path: file, phone: '919634847671' })
const web    = new SqliteBackend({ path: file, phone: '919634847671', web: true })
const other  = new SqliteBackend({ path: file, phone: '40712345678' })

SqliteBackend.sessionsIn(file)
// [ { phone: '40712345678',  half: 'mobile', web: false },
//   { phone: '919634847671', half: 'mobile', web: false },
//   { phone: '919634847671', half: 'web',    web: true  } ]

The file handle is shared between the backends opened on it and closed once the last of them calls close(), so closing one does not pull the file out from under the others. Calling close() twice is harmless.

The database runs in WAL mode, so a client flushing its Signal store and another reading the pre-key pool do not block each other. Values are stored as blobs rather than text: a credential blob carrying a NUL byte is truncated by a text binding and the session then loads with keys that are wrong from that byte on, which is the worst way for a bug to present.

Moving a Session Between Backends

Nothing is removed and the source is never modified, so if the result is not what you wanted the old files are still there to go back to:

const { FileBackend, SqliteBackend, copySession, compareSessions } = require('whalibmob')

const files = new FileBackend({ dir: '/home/you/.waSession/919634847671',
                                phone: '919634847671' })
const db    = new SqliteBackend({ path: '/home/you/.waSession/sessions.sqlite',
                                  phone: '919634847671' })

const moved = copySession(files, db)
// { copied: ['auth', 'pre-key/1', …], skipped: [], bytes: 1284 }

const check = compareSessions(files, db)
// { ok: true, missing: [], differing: [], extra: [] }

db.close()

compareSessions reads both sides back rather than trusting that the copy said so — run it before deleting anything. A key the destination already holds is left alone, so an interrupted copy is safe to run again; pass { overwrite: true } when you do mean to replace what is there.

Do both halves of a number separately — web: false and web: true are two sessions and a copy of one is not a copy of the other.

Signal Store Utilities

auth-utils is a collection of optional helpers for power users who manage their own SignalStore instances directly (e.g. custom storage backends, multi-account servers).

const {
  makeCacheableSignalKeyStore,
  addTransactionCapability,
  assertMeId,
  initAuthCreds
} = require('whalibmob')

makeCacheableSignalKeyStore

Wraps a SignalStore with an in-memory cache (5-minute TTL). Reads for sessions, pre-keys, signed pre-keys and identity keys are served from cache on subsequent accesses; store* calls write through to both, and remove* / delete* calls drop the entry.

A lookup that finds nothing is not cached. Absence is the state most likely to change from underneath — a session about to be built, a pre-key about to be uploaded — so a remembered miss is the one that would hurt.

useClones is set to false so that SessionRecord objects — which carry internal state and methods — are returned by reference and never deep-cloned.

Every method the underlying store has is forwarded, including transaction() and isInTransaction() when present, so the wrapper is a drop-in replacement and safe to stack with addTransactionCapability.

const { SignalStore, makeCacheableSignalKeyStore } = require('whalibmob')

const store  = new SignalStore()
const cached = makeCacheableSignalKeyStore(store)

// reads hit cache after first access
const session = await cached.loadSession('919634847671.0')

Call await cached.flushCache() to drop everything — after a key rotation, or when another process may have written to the same session file.

When to use: whenever your SignalStore is backed by a remote or disk-based store (database, Redis, file system) and you want to reduce repeated lookups for sessions that haven't changed between sends.

addTransactionCapability

Wraps a SignalStore with batched-write (transaction) semantics. During a transaction all writes are buffered in memory and flushed to the underlying store in one shot when the callback returns — there is no commit() to call. Reads check the buffer first, so a transaction sees its own writes. If the callback throws, nothing is written at all and the error reaches the caller.

Uses AsyncLocalStorage to propagate transaction context across async call chains, and a Mutex with reference-counting per transaction key.

const { SignalStore, addTransactionCapability, makeCacheableSignalKeyStore } = require('whalibmob')

// recommended: cache first, then transactions on top
const base     = new SignalStore()
const cached   = makeCacheableSignalKeyStore(base)
const txnStore = addTransactionCapability(cached)

// inside a send flow
await txnStore.transaction(async () => {
  // all writes are buffered until this callback returns
  await txnStore.storeSession('919634847671.0', sessionRecord)
  await txnStore.storePreKey(1, preKeyPair)
}, 'send')

The second argument is a scope: two transactions with different keys run concurrently, two with the same key run one after the other. It defaults to 'default'. Any key is safe — the transaction locks are kept separate from the locks reads take, so no choice of key can make a transaction wait on itself.

Nested transaction() calls reuse the enclosing context instead of opening a second one, so an inner transaction does not commit on its own.

Stacking order matters: put makeCacheableSignalKeyStore below addTransactionCapability, so that a transaction which rolls back never reaches the cache. The other order works too — a failed transaction flushes the cache rather than leave it holding writes the store never took — but it throws away good entries to do it.

When to use: for high-throughput servers that send to many recipients concurrently and need to batch Signal key writes into a single atomic flush per message.

assertMeId

Returns the account's JID, or throws an Error describing what is missing.

Works with both kinds of store — the SMS store from initAuthCreds / createNewStore, and the companion store from createNewWebStore. Only the wording of the error differs, since the way out of "not registered yet" is SMS verification in one case and pairing in the other.

Once registered, the JID the server assigned is preferred (store.me.id) — on a companion it carries the device suffix, and rebuilding it from the phone number drops that silently. Before registration it throws, including during the pairing window: requesting a pairing code writes a placeholder me with no suffix, and that is not treated as being linked.

const { assertMeId } = require('whalibmob')

const store = loadStore(sessFile)

try {
  const jid = assertMeId(store)
  // '919634847671:12@s.whatsapp.net' once connected,
  // '919634847671@s.whatsapp.net' before that
  console.log('account JID:', jid)
} catch (err) {
  console.error('store is not registered:', err.message)
}

When to use: as a guard before calling client.init() to give a clear error message when a corrupted or unregistered session file is accidentally loaded.

initAuthCreds

Creates a fresh credential store for the given phone number — the key pairs, registration ID and device identifiers a number needs before it can even ask for an SMS code. This is what /reg code calls.

It returns everything createNewStore does, plus a few fields kept for application code that expects them: nextPreKeyId, firstUnuploadedPreKeyId, accountSyncCounter, accountSettings, processedHistoryMessages and advSecretKey. Those extras live on the object only — saveStore does not write them, so they are not there again after a reload. Nothing in the library reads them; treat them as a convenience, not as state.

[!NOTE] The two pre-key counters the library actually runs on are the ones in the SignalStore — nextPreKeyId() and firstUnuploadedPreKeyId(), persisted in <phone>.signal.json. They live there rather than in the store because a number's mobile half and its companion half have a SignalStore each and must never share a pre-key id space. The fields above are the same names on a different object and do not drive anything.

const { initAuthCreds, saveStore } = require('whalibmob')
const path = require('path')
const fs   = require('fs')

const phone    = '919634847671'
const sessDir  = path.join(process.env.HOME, '.waSession')
const sessFile = path.join(sessDir, phone + '.json')

fs.mkdirSync(sessDir, { recursive: true })

const store = initAuthCreds(phone)
saveStore(store, sessFile)

This is the function the CLI uses for every new SMS session. Prefer it over createNewStore for forward compatibility.

[!NOTE] initAuthCreds and createNewStore produce equivalent stores for every current library operation, and identical files on disk. Use createNewWebStore instead when the device will be linked as a companion rather than registered by SMS — that one adds the pairing fields, and its own serialiser persists them.

The file it writes has exactly these 16 keys:

{
  "phoneNumber": "40712345678",
  "noiseKeyPair":    { "private": "…", "public": "…" },
  "identityKeyPair": { "private": "…", "public": "…" },
  "signedPreKey":    { "id": 3649616, "private": "…", "public": "…", "signature": "…" },
  "registrationId": 2183,
  "fdid": "2f701f8b-d693-4728-…",
  "deviceId": "CBIyyJRAokOdYoTYEjTbng==",
  "identityId": "SE8Q785oful7dGdBgNpwjg==",
  "advertisingId": "5c97eea9-783f-4fcc-…",
  "backupToken": "…",
  "registered": true,
  "codePending": false,
  "name": "Boss",
  "version": "2.26.9.75",
  "device": { "os": "ios", "platform": 1, "model": "iPhone 15 Pro", "…": "…" },
  "advIdentity": "…"
}

registered and codePending are the two that move: both false when the store is created, codePending flips to true once a code has been requested, and verifyCode sets registered to true and codePending back to false. advIdentity stays null until the server sends the signed device identity in <success>.

For a production multi-account server:

const {
  SignalStore,
  makeCacheableSignalKeyStore,
  addTransactionCapability,
  initAuthCreds,
  saveStore,
  loadStore
} = require('whalibmob')

// 1. load or create the credential store
let store = loadStore(sessFile) || initAuthCreds(phone)

// 2. build the layered Signal key store
const signalStore = new SignalStore()
signalStore.attachFile(sessFile)
const cachedStore = makeCacheableSignalKeyStore(signalStore)
const txnStore    = addTransactionCapability(cachedStore)

// 3. pass to the client (advanced usage — most users should use WhalibmobClient directly)

For standard usage, WhalibmobClient handles all of this internally. These helpers are for advanced scenarios where you need direct control over Signal key storage.

Handling Events

whalibmob uses the EventEmitter syntax for events.

Example to Start

const { WhalibmobClient } = require('whalibmob')
const path = require('path')

async function connect() {
  const client = new WhalibmobClient({
    sessionDir: path.join(process.env.HOME, '.waSession')
  })

  client.on('connected', async () => {
    console.log('connected')
    await client.sendText('919634847671', 'Hello!')
  })

  client.on('disconnected', () => {
    console.log('disconnected — reconnecting...')
    setTimeout(() => connect(), 3000)
  })

  client.on('message', msg => {
    const d = msg.decoded
    if (d && d.type === 'text') console.log('message from', msg.from, d.text)
  })

  client.on('auth_failure', ({ reason }) => {
    console.error('session revoked:', reason)
    // re-register the number
  })

  await client.init('919634847671')
}

connect()

All Events

EventPayloadDescription
connected—Session authenticated and ready
disconnected—Connection closed
reconnecting{ attempt, delay }Lost connection, will retry
reconnected—Connection restored
auth_failure{ reason }Session revoked or banned
messagemessage objectIncoming message received
receipt{ type, id, from }Delivery / read / played receipt
presence{ from, available }Contact came online or went offline
group_update{ type, groupJid, actor, participants, subject, timestamp }Member added / removed / promoted / demoted, subject or settings changed
notificationnode objectGroup or contact update notification
call{ from, id, status, tag, creator, creatorAlt, groupJid, platform, version, reason, ts, node }A call stanza. id is what rejectCall() needs, creator is who placed the call (it differs from from in a group), and status is the stage: offer, offer_notice, ringing, accept, preaccept, transport, terminate, reject or unknown
chat_read{ jid, read, remote?, synced? }Chat marked read (read: true) or unread (read: false)
chat_muted{ jid, muted, until, remote?, synced? }Chat muted or unmuted; until is epoch ms (−1 = indefinite)
chat_pinned{ jid, pinned, remote?, synced? }Chat pinned or unpinned
blocklist{ action, dhash, prevDhash, changes }Block list changed on another device; changes is [{ jid, action }]
privacy_settings{ changes, settings }Privacy settings changed on another device
chat_archived{ jid, archived, remote?, synced? }Chat archived or unarchived
message_starred{ msgId, chatJid, starred, fromMe?, remote?, synced? }Message starred or unstarred
chat_removed{ jid, kind, remote }A chat was cleared or deleted on another device
contact_update{ jid, name, firstName, lid, username, removed, remote }A contact was renamed or removed elsewhere
push_name_update{ name, remote }Your own display name changed on another device
app_state_sync{ collections, applied }An app-state sync finished; see Reading Changes Made Elsewhere
app_state_mutation{ collection, index, action, removed }An app-state change this library does not model
app_state_key_missing{ collection, keyId }App state cannot be read until your phone shares this key
app_state_keys{ keys }Your phone shared app-state sync keys; a sync starts automatically
account_restriction{ active, remaining, remainingMs, endsAtDate, enforcementType, reason, source }The account was restricted or the restriction was lifted — see When 463 Means the Account Is Restricted
mex_notification{ opName, data }A server push over w:mex this library does not model

remote: true on a chat event means the change was made on your phone or another linked device rather than by this session. Your own calls carry synced instead, saying whether the change reached app state — see Modifying Chats. | stream_error | { reason } | Server sent a fatal stream error | | decrypt_error | { id, from, participant, err } | Failed to decrypt an incoming message | | session_refresh | { node } | Late re-authentication success; Signal session refreshed | | close | — | Underlying TCP socket closed | | error | Error | Unhandled transport error |

The message object contains:

{
  id:          string,   // unique message ID
  from:        string,   // sender JID — may be a LID (e.g. '112345678901234@s.whatsapp.net')
  participant: string,   // group member JID (groups only; equals from for DMs)
  ts:          number,   // Unix timestamp (seconds)
  node:        object,   // raw XML node — node.attrs.sender_pn holds the real phone JID
  decoded:     object,   // structured payload — shape depends on message type (see below)
  fromMe:      boolean,  // you sent this from another of your own devices
  chat:        string,   // the conversation — equals from unless fromMe is true
}

[!NOTE] When you send a message from your phone, WhatsApp echoes a copy of it to every other device on the account, wrapped in a DeviceSentMessage. On that copy from is your own JID — it names the sender, not the chat. Use chat, which is the conversation whichever direction the message went, and fromMe to tell the two apart. The envelope's own fields are on decoded.deviceSentMeta (destinationJid, phash) if you need them raw. These messages are not answered with a read receipt: nothing was read by receiving your own message back.

[!NOTE] WhatsApp Multi-Device uses LID JIDs internally. The from field may be a LID like 112345678901234@s.whatsapp.net rather than the real phone number. To get the actual phone number JID always read msg.node.attrs.sender_pn:

const spn = msg.node.attrs.sender_pn         // { user: '919634847671', server: 's.whatsapp.net' }
const phoneJid = spn.user + '@s.whatsapp.net' // '919634847671@s.whatsapp.net'

The decoded object shape per message type:

// Text
{ type: 'text', text: string }

// Image
{ type: 'image', caption: string, url: string, mimetype: string, mediaKey: Buffer, directPath: string }

// Video
{ type: 'video', caption: string, url: string, mimetype: string, mediaKey: Buffer, directPath: string }

// Audio (music file)
{ type: 'audio', url: string, mimetype: string, mediaKey: Buffer, directPath: string }

// Voice note (push-to-talk)
{ type: 'voice', url: string, mimetype: string, mediaKey: Buffer, directPath: string }

// Document
{ type: 'document', fileName: string, url: string, mimetype: string, mediaKey: Buffer, directPath: string }

// Sticker
{ type: 'sticker', url: string, mimetype: string, mediaKey: Buffer, directPath: string }

// Reaction
{ type: 'reaction', emoji: string }

// Location
{ type: 'location', latitude: number, longitude: number, name: string, address: string, url: string }

// Contact (vCard)
{ type: 'contact', displayName: string, vcard: string }

// Personal invitation into a group — see Personal Invitations
{ type: 'groupInvite', groupJid: string, inviteCode: string, inviteExpiration: number,
  groupName: string, jpegThumbnail: Buffer|null, caption: string, isCommunity: boolean }

// Protocol (revoke, ephemeral, etc.)
{ type: 'protocol', subtype: string }

History Sync

How History Sync Works

When whalibmob connects, WhatsApp automatically sends the account's chat history to the client. The library handles the entire pipeline without any code from you — you only need to listen to the events if you want to use the data.

The full internal flow:

  • WhatsApp server sends an encrypted ProtocolMessage (type 6) containing a HistorySyncNotification.
  • The library decrypts it via Signal Protocol.
  • HistorySyncHandler downloads the encrypted blob from WhatsApp's CDN (mmg.whatsapp.net), or reads the inline payload if the server embedded it directly.
  • The blob is decrypted with AES-256-CBC using an HKDF key derived from "WhatsApp History Keys".
  • The result is decompressed with zlib and decoded from protobuf (WAProto v2.3000.x — field numbers verified against the official proto definition).
  • Chats, contacts, push names, LID↔PN mappings, and tcTokens are merged into the disk store.
  • tcTokens from history are seeded into TcTokenStore in memory so the first outbound DM after reconnect already carries a valid <tctoken> node (prevents error 463 on cold start).
  • The history_sync event fires with a summary of what was received.

History arrives in multiple chunks. Each chunk fires one history_sync event. The first chunk (sync type INITIAL_BOOTSTRAP) is usually the largest and carries the most recent conversations.

Listening to History Sync Events

const { WhalibmobClient } = require('whalibmob')
const path = require('path')

const client = new WhalibmobClient({
  sessionDir: path.join(process.env.HOME, '.waSession')
})

// history_sync fires once per history chunk — may fire multiple times on first connect
client.on('history_sync', result => {
  console.log('History sync chunk received:')
  console.log('  type    :', result.syncTypeName)   // e.g. 'INITIAL_BOOTSTRAP', 'RECENT', 'FULL'
  console.log('  progress:', result.progress)        // 0-100 server-reported
  console.log('  chunk   :', result.chunkOrder)
  console.log('  chats   :', result.chats.length)
  console.log('  contacts:', result.contacts.length)
  console.log('  pushNames:', (result.pushNames || []).length)
})

// history_sync_error fires if a chunk fails to download or decrypt
client.on('history_sync_error', ({ err, notification }) => {
  console.error('History sync failed:', err.message)
  console.error('  syncType:', notification.syncType)
})

await client.init('919634847671')

The result object shape emitted by history_sync:

{
  syncType:      number,   // HistorySyncType enum value
  syncTypeName:  string,   // 'INITIAL_BOOTSTRAP' | 'RECENT' | 'FULL' | 'PUSH_NAME' | 'NON_BLOCKING_DATA' | 'ON_DEMAND'
  progress:      number,   // 0-100, server-reported progress
  chunkOrder:    number,   // chunk sequence number
  chats: [                 // one entry per conversation in this chunk
    {
      id:               string,   // chat JID e.g. '919634847671@s.whatsapp.net' or '120363...@g.us'
      name:             string,   // display name (may be undefined for unknown contacts)
      unreadCount:      number,
      lastMsgTimestamp: number,   // Unix seconds
      messageCount:     number    // number of messages in this chunk for this chat
    }
  ],
  contacts: [              // one entry per contact discovered in this chunk
    {
      id:       string,
      name:     string,
      username: string,    // WhatsApp username if set
      pnJid:    string,    // phone-number JID e.g. '919634847671@s.whatsapp.net'
      lidJid:   string     // LID JID e.g. '112345678901234@lid'
    }
  ],
  pushNames:     Array,    // push-name entries { id, pushname }
  lidPnMappings: Array,    // LID<->PN mapping entries { lidJid, pnJid }
  merged:        object    // raw merged history store (see Reading the History Store below)
}

Sync type values:

syncTypeNameWhen it fires
INITIAL_BOOTSTRAPFirst connect — most recent conversations
RECENTReconnect after a short offline period
FULLFull historical sync (older messages)
PUSH_NAMEContact name updates only
NON_BLOCKING_DATABackground low-priority data
ON_DEMANDExplicitly requested by the client

Persistent Files Written to Disk

The library automatically writes these files to sessionDir per account. You do not need to create or manage them.

FileContents
<phone>.history.jsonChats, contacts, push names, LID↔PN mappings, tcTokens
<phone>.messages.jsonFlat map of msgId → message metadata
<phone>.appStateKeys.jsonApp-state sync keys, shared by your primary device
<phone>.appState.jsonPer-collection app-state version, hash and index map
<phone>.tctoken.jsonTrusted-contact token store (tcToken per contact JID)

Reading the History Store

After history sync completes you can read the on-disk files directly:

const fs   = require('fs')
const path = require('path')

const sessDir = path.join(process.env.HOME, '.waSession')
const phone   = '919634847671'

// ── Read chats ────────────────────────────────────────────────────────────────
const histPath = path.join(sessDir, phone + '.history.json')
const hist     = JSON.parse(fs.readFileSync(histPath, 'utf8'))

// List all chats sorted by last message time
const chats = Object.values(hist.chats)
  .sort((a, b) => (b.lastMsgTimestamp || 0) - (a.lastMsgTimestamp || 0))

for (const chat of chats.slice(0, 10)) {
  console.log(chat.id, '|', chat.name || '(unknown)', '|', chat.unreadCount, 'unread')
}

// ── LID ↔ PN lookup ───────────────────────────────────────────────────────────
// Look up LID JID from phone number JID
const myLid = hist.pnLidMap['919634847671@s.whatsapp.net']
console.log('LID:', myLid)   // e.g. '112345678901234@lid'

// Reverse: phone number JID from LID
const myPn = hist.lidPnMap[myLid]
console.log('PN:', myPn)

// ── Read message metadata ─────────────────────────────────────────────────────
const msgPath = path.join(sessDir, phone + '.messages.json')
const msgs    = JSON.parse(fs.readFileSync(msgPath, 'utf8'))
const msgList = Object.values(msgs).sort((a, b) => (b.timestamp || 0) - (a.timestamp || 0))
console.log('Total messages indexed:', msgList.length)
console.log('Latest:', msgList[0])

hist.chats schema per chat entry:

{
  id:                     string,   // JID
  name:                   string,
  displayName:            string,
  unreadCount:            number,
  lastMsgTimestamp:       number,   // Unix seconds
  messageCount:           number,   // total messages indexed from history
  ephemeralExpiry:        number,   // disappearing messages timer in seconds, if set
  archived:               boolean,
  pinned:                 number,   // pin sort order (0 = not pinned)
  tcToken:                string,   // base64 — trusted-contact token (used internally by the library)
  tcTokenTimestamp:       number,   // Unix seconds — when the token was issued by the server
  tcTokenSenderTimestamp: number    // Unix seconds — sender-side issuance timestamp for 7-day bucket dedup
}

msgs schema per message entry:

{
  id:        string,   // WhatsApp message ID
  chatId:    string,   // JID of the conversation
  fromMe:    boolean,
  fromJid:   string,   // sender JID
  timestamp: number,   // Unix seconds
  pushName:  string,   // display name of sender at send time
  status:    number    // 0=error 1=pending 2=server 3=delivered 4=read 5=played
}

tcToken — Error 463 Defense

[!IMPORTANT] This section is informational. The entire tcToken lifecycle is fully automatic. You do not need to write any code for it.

WhatsApp counts every outbound DM sent without a <tctoken> node as an anonymous "reach-out" event. Once enough such events accumulate the server enforces a time-based Reach-out Time-lock and returns error 463 (NackCallerReachoutTimelocked), blocking all outbound messages and calls for a period.

whalibmob implements the full lifecycle to prevent this:

StepWhat the library does automatically
History seedOn every history sync chunk, tcToken bytes are extracted from each conversation in the protobuf and loaded into TcTokenStore in memory. The first send after reconnect already has a valid token ready — no 463 risk on cold start.
First contactThe very first DM to a contact has no token on file yet. Before the stanza is built, the library issues one and waits for it, so that message carries a <tctoken> like every later one instead of counting as an anonymous reach-out. The wait is bounded by tcTokenPresendTimeoutMs (default 5 s) — past it the message goes out regardless and the token lands in time for the next one.
Attach on sendBefore dispatching any DM, MessageSender looks up the token for the recipient JID, checks it has not expired (28-day rolling window), and pushes a <tctoken> child node into the message stanza.
RenewalAfter a successful DM send, the library fires a <iq type='set' xmlns='privacy'> requesting a fresh token for that JID from the server — once per 7-day bucket, deduplicated in-flight. This one is fire-and-forget: the token being attached right now is still valid, so nothing waits for it.
Incoming notificationWhen a contact starts a new conversation, WhatsApp pushes a <notification type='privacy_token'>. The library catches it in _handlePrivacyTokenNotification and stores the token immediately.
Identity change re-issueWhen decrypting a pkmsg (new Signal session from peer), the library calls _reissueTcTokenAfterIdentityChange to re-issue the token for the new session.
Error 463 recoveryIf a send fails with error 463, the library issues a fresh token, waits for the server response, and automatically retries the same message with the new token attached.
ExpiryTokens use a 4-bucket rolling window (4 × 7 days = 28-day TTL). Expired tokens are cleared before the send and a fresh one is requested proactively.

Token storage uses the LID JID of the contact (e.g. 112345678901234@lid) as the key — never the phone-number JID — matching WhatsApp's internal convention.

cstoken — the companion fallback. When there is no <tctoken> for a recipient, the genuine client attaches a <cstoken> instead. Unlike a tctoken it is not granted per contact; it is computed locally:

cstoken = HMAC-SHA256(nctSalt, recipient-LID)

The nctSalt is an account-wide secret the server distributes over app state, so only a companion (QR / pairing-code) session ever holds one — a primary (SMS) session has none and sends no cstoken, exactly as the real primary does until it has minted its own. When a companion session receives the salt it fires a nct_salt event and persists it; from then on, any first DM without a tctoken carries a <cstoken> derived for that recipient's LID. Like the rest of this section it is fully automatic — there is nothing to call.

When 463 Means the Account Is Restricted

A 463 has two causes that look identical on the wire, and telling them apart matters because only one of them is worth retrying.

  • No privacy token for that one contact. The library issues one and re-sends. This is the common case and it resolves itself.
  • The account is restricted. WhatsApp has decided you are starting too many chats with people who never reply, and refuses all new conversations until the restriction expires. Existing chats keep working, which is why the account otherwise looks healthy. No retry helps — and sending more counts as further reach-outs, which makes it longer.

The expiry is never pushed to a client that has not asked. fetchReachoutTimelock() is the only way to learn it:

const r = await client.fetchReachoutTimelock()

if (r.active) {
  console.log('restricted:', r.reason)
  console.log('ends at:   ', r.endsAtDate.toISOString())
  console.log('remaining: ', r.remaining)      // '04:59:20'
} else {
  console.log('not restricted')
}
{
  active:          true,
  remaining:       '04:59:20',   // HH:MM:SS, hours not wrapped at 24
  remainingMs:     17960000,
  endsAt:          1800018000000,
  endsAtDate:      Date,
  enforcementType: 'BIZ_QUALITY',
  reason:          'too many people you messaged blocked or reported you',
  expiryUnknown:   false,        // true when the server withheld the end time
  checkedAt:       1800000040000
}

getReachoutTimelock() returns the same shape from what was last learned, without asking again — the countdown is recomputed on every call, so it is what a once-a-second display reads.

You rarely need to call either. A send refused with 463 triggers a check on its own (rate-limited to once a minute), and the server pushes an update both when a restriction starts and when it is lifted:

client.on('account_restriction', (r) => {
  if (r.active) console.log('restricted for', r.remaining, '—', r.reason)
  else          console.log('restriction lifted')
})

r.source is 'notification' when the server announced it and 'query' when we asked.

[!NOTE] A first restriction is usually around five hours. Repeats get longer. Nothing client-side shortens it; the only thing that helps is not sending more unanswered first messages while it is on.

[!NOTE] This transport answers in one of two encodings, and the reply says which: JSON, or Argo — Meta's compact binary encoding for GraphQL. Both are read. WhatsApp sends Argo with its self-describing flag set, so it carries its own field names and decodes without the GraphQL schema the query was written against.

Do not add a format attribute to the query in the hope of steering this — it was tried against a live server, which responded by dropping the stanza entirely and answering nothing.

Some servers answer tersely — a bare false or true rather than the object with the expiry in it. false is taken as "no restriction", because it is a definite answer rather than a missing one. true sets active with expiryUnknown: true, since it says there is a restriction but not when it ends; the countdown then comes from the announcement.

An answer that is neither — null, or anything that says nothing either way — throws with the decoded value on err.mexDecoded, rather than being reported as "not restricted". An all-clear invented from an answer that never gave one would have you send more messages and lengthen the very restriction you were asking about.

The account_restriction event does not depend on any of this. The server announces a restriction starting and lifting, and those announcements carry the countdown. Keep a listener attached and you are told either way.

What Is Automatic vs What You Need to Do

Everything in the "Automatic" column requires zero code from you.

FeatureAutomaticNotes
Download history blob from CDN✅
Decrypt history blob (AES-256-CBC + HKDF)✅
Decompress zlib✅
Decode protobuf (WAProto v2.3000.x)✅Field numbers verified against official proto
Persist chats / contacts / push names✅Written to <phone>.history.json
Persist message metadata✅Written to <phone>.messages.json
Persist app-state sync keys✅Written to <phone>.appStateKeys.json
Sync app state when the server says it changed✅Pins, archives, mutes, stars, contact names
Verify app-state MACs and LT hash✅A collection that drifts is re-read from a snapshot
Persist app-state versions across restarts✅Written to <phone>.appState.json
Seed tcTokens into memory on connect✅Prevents error 463 on first send after reconnect
Attach tcToken to every outbound DM✅
Issue fresh tcTokens after each send✅Once per 7-day bucket per contact
Handle incoming privacy_token notifications✅
Re-issue tcToken after peer identity change✅
Recover from error 463 with automatic retry✅
Check for an account restriction after a 463✅Rate-limited to once a minute
Track restriction start / lift pushed by the server✅Raised as account_restriction
Populate in-memory LID↔PN maps✅
Listen to history_sync event🔵 OptionalOnly if your app needs to react to history data
Read <phone>.history.json🔵 OptionalOnly if your app needs chat/contact data at rest
Read <phone>.messages.json🔵 OptionalOnly if your app indexes messages

Minimum working integration — history sync, tcTokens, and error-463 defense all active with zero extra code:

const { WhalibmobClient } = require('whalibmob')
const path = require('path')

const client = new WhalibmobClient({
  sessionDir: path.join(process.env.HOME, '.waSession')
})

// History sync, tcToken seeding, error-463 defense, and all disk persistence
// happen automatically. Add the listeners below only if your app needs the data.

client.on('history_sync', result => {
  // Optional — fires once per chunk (multiple times on first connect)
  console.log('[', result.syncTypeName, ']',
    result.chats.length, 'chats,',
    result.contacts.length, 'contacts')
})

client.on('history_sync_error', ({ err }) => {
  // Optional — log failures (library continues working even if a chunk fails)
  console.error('History chunk failed:', err.message)
})

await client.init('919634847671')

Receiving Media

When a media message arrives, msg.decoded carries the CDN location and the mediaKey the file is encrypted under. client.downloadMedia() fetches it and hands back the plaintext bytes:

const fs = require('fs')

const EXT = { image: '.jpg', video: '.mp4', audio: '.ogg', voice: '.ogg',
              sticker: '.webp', document: '' }

client.on('message', async (msg) => {
  const d = msg.decoded
  if (!d || !d.mediaKey) return

  try {
    const bytes = await client.downloadMedia(d)
    const name  = d.fileName || (msg.id + (EXT[d.type] || ''))
    fs.writeFileSync(name, bytes)
    console.log('saved', d.type, 'to', name)
  } catch (e) {
    console.error('media download failed:', e.message)
  }
})

It works the same in both modes, and that is the point: the CDN applies a browser check on the way down as well as on the way up, so a companion has to identify itself as one here too. Doing this by hand means knowing that, and knowing which HKDF key name each media type derives from — a voice note derives from the PTT keys and a GIF from the video ones, not from their own. Get either wrong and the download is refused or the decryption yields garbage.

The whole message object is accepted as well as its decoded half, so client.downloadMedia(msg) does the same thing.

Verifying the file

Three things are checked, and all three by default — the message says what the file should be, so there is no reason to take the CDN's word for it:

checkwhat it covers
fileEncSha256the blob as it arrived, before any work is spent decrypting it
the MACthe ciphertext, under a key derived from mediaKey — this is the one that authenticates
fileSha256the decrypted file
const bytes = await client.downloadMedia(d)                    // all three
const raw   = await client.downloadMedia(d, { verify: false }) // MAC only

verify: false skips the two digests for a caller that wants the bytes whatever they are. The MAC is checked either way and is never optional.

Link preview thumbnails

A text message that quoted a link carries the preview the sender's client built. The small inline image is already on the message as jpegThumbnail and needs no download. The full-size one is a media file of its own — its own directPath, its own mediaKey, encrypted under a key name of its own — so downloadMedia cannot reach it:

if (d.thumbnail) {
  const preview = await client.downloadThumbnail(d)
  // d.title, d.description and d.matchedText are the rest of the preview
}

What happens underneath

  • The encrypted blob is fetched from url, or from directPath when the message carries no absolute URL.
  • HKDF-SHA256 expands mediaKey into an IV, a cipher key and a MAC key, using the info string for that media type.
  • The trailing 10-byte HMAC-SHA256 is verified, then AES-256-CBC decrypts the rest.

downloadMedia throws with the reason rather than returning empty: a message with no media, no CDN location, an unsupported type, or a file that does not match the message all say so.

View-once and disappearing messages

Some messages arrive inside an envelope. A photo sent as view-once is an ordinary ImageMessage wrapped in a ViewOnceMessage; the same photo in a chat with disappearing messages turned on is one wrapped in an EphemeralMessage. Nothing about the photo changes — the envelope only records how it was sent.

The decoder opens them, so nothing special is needed on your side. msg.decoded is the photo, and downloadMedia() works on it exactly as it does on any other:

client.on('message', async (msg) => {
  const d = msg.decoded
  if (!d || !d.mediaKey) return

  if (d.viewOnce)  console.log('sent as view-once')
  if (d.ephemeral) console.log('from a disappearing chat')

  const bytes = await client.downloadMedia(d)   // same call, wrapped or not
})

Three flags say how the message was sent, and are absent otherwise:

flagmeaning
d.viewOncesent as view-once — ViewOnceMessage, ViewOnceMessageV2 or the V2 extension a voice note uses
d.ephemeralfrom a chat with disappearing messages on
d.editedthe new text of an edited message — the payload is the protocol message carrying it

They stack. A view-once photo in a disappearing chat carries both:

if (d.viewOnce && d.ephemeral) { /* … */ }

Documents sent with a caption (DocumentWithCaptionMessage) are unwrapped the same way and decode as a plain document, with no flag of their own.

[!NOTE] Opening the envelope is what makes this media reachable at all. Before it, a view-once photo decoded as { type: 'unknown' } — no mediaKey, no directPath — and there was nothing for downloadMedia() to fetch.

Nothing here changes when the message came through history sync, or when it is one of your own echoed back to this device from another: those envelopes nest, and are unwrapped down to the message inside.

When the file is gone from the CDN

Media is not carried inside the message — the message carries a URL, a hash and the key, and the bytes sit on WhatsApp's CDN for a limited time. A download that answers 404 or 410 has nothing left to fetch. This is most common for messages that arrive through history sync long after they were sent.

The sender's phone still has the original, and can be asked to upload it again:

client.on('media_retry', (notification) => {
  const result = client.decryptMediaRetry(notification, mediaKey)
  if (result.ok) {
    // fresh location — download it the usual way
    console.log('re-uploaded at', result.directPath)
  } else {
    // the phone no longer has it either; nothing further to try
  }
})

try {
  await client.downloadMedia(msg)
} catch (err) {
  if (/404|410/.test(err.message)) {
    await client.requestMediaRetry({
      id:          msg.key.id,
      chatJid:     msg.key.remoteJid,
      fromMe:      msg.key.fromMe,
      // groups only — the member who sent it
      participant: msg.key.participant
    }, mediaKey)
  }
}

requestMediaRetry resolves as soon as the request is on the wire; the answer arrives later on the media_retry event, which is why the two halves are written separately. Keep the mediaKey — the reply is encrypted under a key derived from it, and without it the fresh location cannot be read.

That derivation is also what makes the request safe to send: it proves the asker was a recipient of the message rather than someone who merely knows a message id, so no phone can be made to re-upload a file on request.

Sending Messages

Text Message

await client.sendText('919634847671', 'Hello!')

Quote Message

For the simplest quoted reply use sendReply. If you need low-level control (e.g. quoting a non-text message), pass a contextInfo object directly into sendText:

// low-level: pass contextInfo manually inside sendText options
await client.sendText(
  '919634847671@s.whatsapp.net',
  'This is a reply',
  {
    contextInfo: {
      quotedMessageId: 'ABCDEF123456',          // ID of the quoted message
      participant:     '919634847671@s.whatsapp.net',  // sender of the quoted message
      remoteJid:       '919634847671@s.whatsapp.net',  // chat JID
    }
  }
)

Mention User

await client.sendText(
  '120363000000000000@g.us',
  '@919634847671 hello!',
  { mentions: ['919634847671@s.whatsapp.net'] }
)

Reaction Message

// react to a message
await client.sendReaction('919634847671', 'MSGID123', '👍')

// remove a reaction — pass empty string
await client.sendReaction('919634847671', 'MSGID123', '')

Edit Message

[!NOTE] Editing is only possible within 15 minutes of the original send.

await client.editMessage(
  'MSGID123',                           // original message ID
  '919634847671@s.whatsapp.net',
  'Corrected text here'
)

Delete Message

// delete for yourself only
await client.deleteMessage('MSGID123', '919634847671', true, false)

// delete for everyone (revoke)
await client.deleteMessage('MSGID123', '919634847671', true, true)

Forward Message

Forward text or a full media message (image, video, audio, document, sticker) without re-uploading. Pass a decoded message object from the message event to forward any media type.

// Forward text
await client.forwardMessage('919634847671', 'text to forward')

// Forward any received message (full media, no re-upload)
client.on('message', async (msg) => {
  if (msg.decoded && msg.decoded.type !== 'text') {
    await client.forwardMessage('919634847671', msg)
  }
})

Poll

Send a WhatsApp poll. selectableCount is how many options a voter may choose (0 = any).

const { id, encKey } = await client.sendPoll(
  '919634847671@s.whatsapp.net',
  'Best language?',
  ['JavaScript', 'Python', 'Rust'],
  1            // voters may pick 1 option (0 = unlimited)
)
// encKey (32-byte Buffer) is what reads the votes — keep it
// (also returned as `messageSecret`, which is the name the protocol uses)

Reading the votes

A vote arrives as its own message and the ballot inside it is encrypted, so nothing about who chose what is visible in the stanza. decryptPollVote() opens it with the poll's encKey:

const options = ['JavaScript', 'Python', 'Rust']

client.on('message', (msg) => {
  if (msg.decoded?.type !== 'pollVote') return
  if (msg.decoded.pollKey.id !== id) return      // a vote on some other poll

  const { selected, votedAt } = client.decryptPollVote(msg, { encKey, options })
  console.log(msg.participant, 'voted for', selected)   // [ 'Rust' ]
})

Pass options and the choices come back as text. Without them you get selectedHashes — the SHA-256 of each chosen option, which is how the ballot names them on the wire — and match them yourself.

The key is derived from the poll id, the poll's sender and the voter, so a vote on someone else's poll needs their encKey, which only arrives if they sent the poll to you: read it off msg.decoded.encKey on the poll message itself.

Quoted Reply

Send a text message that quotes (replies to) a specific earlier message. The recipient sees the original message highlighted above your reply.

// DM: senderJid is the same as the chat JID
await client.sendReply(
  '919634847671@s.whatsapp.net',  // chat JID
  '3EB0XXXXXXXX',                 // ID of the quoted message
  '919634847671@s.whatsapp.net',  // sender of the quoted message (same as chat for DMs)
  'Got it, thanks!'               // your reply text
)

// Group: senderJid is the group member who sent the quoted message
await client.sendReply(
  '120363000000000000@g.us',      // group JID
  '3EB0XXXXXXXX',                 // ID of the quoted message
  '919634847671@s.whatsapp.net',  // who sent the original message
  'Agreed!'
)

You can get the id of a received message from msg.id inside the message event.

Location Message

Send a GPS location pin. name and address are optional labels shown below the map preview.

// minimal — lat/lon only
await client.sendLocation('919634847671', 48.8566, 2.3522)

// with name and address
await client.sendLocation('919634847671', 48.8566, 2.3522, {
  name:    'Eiffel Tower',
  address: 'Champ de Mars, 5 Av. Anatole France, Paris'
})

// to a group
await client.sendLocation('120363000000000000@g.us', 51.5074, -0.1278, {
  name: 'London'
})

// with a map image for the bubble — WhatsApp draws a blank card without one
await client.sendLocation('919634847671', 48.8566, 2.3522, {
  name: 'Eiffel Tower',
  thumbnail: jpegBuffer
})

Contact Message (vCard)

Send a contact card using the standard vCard v3 format. The recipient can save the contact directly from WhatsApp.

const vcard = [
  'BEGIN:VCARD',
  'VERSION:3.0',
  'FN:Alice Smith',
  'TEL;TYPE=CELL:+919634847671',
  'EMAIL:alice@example.com',
  'END:VCARD'
].join('\n')

await client.sendContact('919634847671', 'Alice Smith', vcard)

Ask WhatsApp for a link that lets someone join a call by tapping it, instead of being rung. Returns the token, the shareable link and the media type.

const { link } = await client.createCallLink('video')
// https://call.whatsapp.com/video/XXXXXXXX

// audio is the default
const audio = await client.createCallLink()

// schedule it — startTime is seconds since the epoch
await client.createCallLink('video', { startTime: 1800000000 })

// create it and send it in one step
await client.sendCallLink('919634847671', 'video', {
  text: 'Join me here:'
})

Media Messages

Photos and videos are sent with an inline preview so they show up in the chat directly, rather than as a placeholder the recipient has to tap. The dimensions are read out of the file header with no dependency at all. The preview itself uses jimp (installed automatically as an optional dependency) or sharp if you have it; failing both, a photo that carries an EXIF thumbnail gets one for free, and anything else is scaled in process — JPEG, PNG, GIF and BMP all work with nothing installed. Video previews need ffmpeg on PATH — on Termux, pkg install ffmpeg. Without any of these the media still sends, just without the preview.

Image Message

// from file path
await client.sendImage('919634847671', './photo.jpg', { caption: 'Look at this' })

// from Buffer
await client.sendImage('919634847671', buffer, {
  caption: 'Photo',
  mimetype: 'image/jpeg'
})

Video Message

await client.sendVideo('919634847671', './clip.mp4', { caption: 'Watch this' })

Audio Message

await client.sendAudio('919634847671', './song.mp3')

Voice Note

// ptt: true renders the audio as a push-to-talk voice note with waveform
await client.sendAudio('919634847671', './voice.ogg', { ptt: true })

Document Message

await client.sendDocument('919634847671', './report.pdf', {
  fileName: 'Q1 Report.pdf'
})

Sticker Message

await client.sendSticker('919634847671', './sticker.webp')

Status / Stories

A Status is posted to status@broadcast and travels the SenderKey path a group message does — one encrypted body plus a key distribution to every recipient. Who those recipients are comes from your status privacy settings, resolved against the contacts in the synced history store.

// text
await client.sendStatus('Good morning!')

// photo, video, voice — same arguments as the matching send* methods
await client.sendStatus({ image: './photo.jpg', caption: 'Look at this' })
await client.sendStatus({ video: './clip.mp4' })
await client.sendStatus({ audio: './voice.ogg' })

// styled text status
await client.sendStatus('Colorful', { backgroundArgb: 0xFF25D366, font: 3 })

// post to an explicit list instead of the privacy-derived one
await client.sendStatus('Hi', { recipients: ['919634847671'] })

Status Privacy

const [def, ...rest] = await client.queryStatusPrivacy()
// def = { type: 'contacts' | 'blacklist' | 'whitelist', isDefault, list }

contacts sends to everyone in your address book, blacklist to everyone except list, whitelist to list only. Your own JID is always included so the post reaches your other devices.

Recipients are taken from the named contacts history sync brought; when there are none, the people you have chats with stand in. If neither is known, sendStatus says so rather than posting a message no one holds the key for — pass recipients in that case.

Send States in Chat

Reading Messages

// mark all messages in a chat as read (sends IQ to server)
await client.markChatRead('919634847671')

Mark Voice Message Played

Send a played receipt for a received voice note (push-to-talk audio). This tells the sender that you have listened to the message.

// msgId: ID of the audio message, from: JID of the sender
client.markMessagePlayed('3EB0ABCDEF123456', '919634847671')

Update Presence

// set yourself as online / offline globally
client.setOnline(true)
client.setOnline(false)

// show typing or recording in a specific chat
client.setChatPresence('919634847671', 'composing')   // typing
client.setChatPresence('919634847671', 'recording')   // recording audio
client.setChatPresence('919634847671', 'paused')      // stopped

Modifying Chats

Pinning, archiving, muting, marking read and starring are app state. That is WhatsApp's own synchronised settings store — the same one your phone writes to — so a change made here shows up on the phone and on every other linked device, and survives reinstalling.

Each of these is async, sends a patch, and waits for the server to accept it. The local view only moves once the change is actually stored, and they throw if the server refuses.

They return a boolean: whether the change reached app state, and so whether other devices will see it.

const synced = await client.pinChat('919634847671')
if (!synced) console.log('pinned here, but your phone will not know')

[!IMPORTANT] App state needs a key, and where that key comes from depends on how you connected.

A linked session (pairing code) is a companion. Your phone shares an app state key with it automatically, shortly after linking — so everything on this page works, in both directions.

An SMS session is the primary device. Nobody shares a key with it, because it is the device that would create one. Unless you have linked a companion to it, there is no app state to read or write.

Check with client.canSyncAppState().

When there is no key, these calls do not throw. muteChat, unmuteChat and markChatRead fall back to the request a primary device sends for itself, which is what this library did before app state existed. pinChat, archiveChat and starMessage have no such request, so they update this session only. Either way the return value is false, which is how you tell.

Archive / Unarchive a Chat

await client.archiveChat('919634847671')
await client.unarchiveChat('919634847671')

Mute / Unmute a Chat

await client.muteChat('919634847671', 8 * 60 * 60 * 1000)  // 8 hours
await client.muteChat('919634847671', 0)                   // until unmuted
await client.unmuteChat('919634847671')

Mark a Chat Read / Unread

This is the chat's own unread badge. To send read receipts (blue ticks) for particular messages, use markRead() instead.

await client.markChatRead('919634847671')
await client.markChatUnread('919634847671')

Pin / Unpin a Chat

await client.pinChat('919634847671')
await client.unpinChat('919634847671')

Star / Unstar a Message

The third argument says whether the message being starred is one you sent. It is part of how the star is filed, so getting it wrong stars a different message.

await client.starMessage('MSGID123', '919634847671', true)   // yours
await client.unstarMessage('MSGID123', '919634847671', false) // theirs

Reading Changes Made Elsewhere

The traffic runs both ways. When you pin a chat on your phone, mute a group from another linked device, or rename a contact, that change is waiting in app state for this session to pick up.

syncAppState() fetches it. It is called for you whenever the server says something has moved — so with a listener attached you generally never need to call it by hand. On an SMS session with no companions linked there is nothing to fetch, and it reports waitingForKeys instead.

client.on('chat_pinned',     (u) => u.remote && console.log('pinned elsewhere:', u.jid))
client.on('chat_archived',   (u) => u.remote && console.log('archived elsewhere:', u.jid))
client.on('chat_muted',      (u) => u.remote && console.log('muted elsewhere:', u.jid, u.until))
client.on('chat_read',       (u) => u.remote && console.log('read elsewhere:', u.jid))
client.on('message_starred', (u) => u.remote && console.log('starred elsewhere:', u.msgId))
client.on('contact_update',  (u) => console.log('contact renamed:', u.jid, u.name))
client.on('push_name_update',(u) => console.log('your display name is now', u.name))

remote: true marks a change as somebody else's doing. Your own calls emit the same events without it — they carry synced instead — so a listener can tell the two apart and avoid echoing a change back where it came from.

To pull on demand:

// everything
const r = await client.syncAppState()
console.log(r.applied, 'change(s)')

// or just one part of it
await client.syncAppState(['regular_low'])

// re-read everything from scratch, discarding what we hold
await client.syncAppState(null, { snapshot: true })

The result reports each collection separately:

{
  applied: 3,
  collections: {
    regular_low: { version: 41, applied: 3, skipped: 0, snapshot: false, macOk: true }
  }
}

The five collections are critical_block, critical_unblock_low, regular_high, regular_low and regular. Which one a setting lives in is WhatsApp's choice, not yours — the methods above already file each change where it belongs.

When it repairs itself. Each collection carries a running hash that has to keep agreeing with the server's. If it stops — a patch went missing, or one could not be decrypted — the incremental history is no longer trustworthy, so that collection is thrown away and re-read whole. This happens on its own, once per sync, and shows up as snapshot: true in the result.

Events for anything not modelled here. WhatsApp tracks more in app state than this library turns into methods. Rather than dropping those, they are emitted raw, so it is at least visible that something happened:

client.on('app_state_mutation', ({ collection, index, action, removed }) => {
  console.log('unhandled app state change', index)
})

Disappearing Messages

DurationSeconds
Off0
24 hours86 400
7 days604 800
90 days7 776 000
// set disappearing timer for a specific chat (DM or group)
await client.changeEphemeralTimer('919634847671', 86400)
await client.changeEphemeralTimer('120363000000000000@g.us', 604800)

// remove disappearing messages
await client.changeEphemeralTimer('919634847671', 0)

User Queries

Check If a Number Has WhatsApp

const { checkNumberStatus } = require('whalibmob')

const result = await checkNumberStatus('919634847671')
// result.status: 'registered' | 'registered_blocked' | 'not_registered' | 'cooldown' | 'unknown'
console.log(result.status)

Check multiple numbers at once while connected:

const results = await client.hasWhatsapp(['919634847671', '12345678901'])
// returns array of JIDs that have WhatsApp

Fetch Profile About

const about = await client.queryAbout('919634847671')
console.log(about)

// your own, asked the same way
console.log(await client.queryOwnAbout())

Fetch Profile Picture

const url = await client.queryPicture('919634847671')
// also works for groups
const groupUrl = await client.queryPicture('120363000000000000@g.us')

Subscribe to Presence

// triggers 'presence' events when the contact comes online or goes offline
client.subscribeToPresence('919634847671')

client.on('presence', ({ from, available }) => {
  console.log(from, available ? 'online' : 'offline')
})

Change Profile

Change Display Name

client.changeName('My Bot')

Change About Text

await client.changeAbout('Available 24/7')

// what the server actually has now — the set IQ is answered before the new
// text has to be visible anywhere, so this is the part worth reading
const stored = await client.queryOwnAbout()

changeAbout throws when the server refuses the change or never answers; it used to hand back the reply unread, so a refusal looked exactly like a success. An about is limited to 139 characters — a longer one is dropped rather than refused, and that too is now a thrown error instead of silence.

An about the server stores and nobody can see is a privacy setting rather than a failed write. The category that governs who may read it is status:

await client.changePrivacySetting('status', 'all')

Change Profile Picture

Both methods accept a Buffer (use fs.readFileSync to load a file).

const fs = require('fs')

// change your own profile picture — any image, any shape, any size
// returns the new picture id, or 'remove' when it was taken down
const picId = await client.changeProfilePicture(fs.readFileSync('./avatar.jpg'))

// pass null to remove it
await client.changeProfilePicture(null)

// change a group's picture (you must be admin)
// returns the new picture id, or 'remove' when the picture was taken down
const picId = await client.changeGroupPicture('120363000000000000@g.us',
  fs.readFileSync('./group.jpg'))

// pass null to remove the current picture
await client.changeGroupPicture('120363000000000000@g.us', null)

changeGroupPicture throws when the server refuses — 406 for an image it will not take, 403 when you are not an admin of that group.

[!NOTE] The two are addressed differently, and it matters. A group picture names the group in the IQ's target; your own picture names nobody — the server takes that from the session. Your own JID in target is not an error the server reports, it is a stanza it drops without answering.

Both calls re-encode the image before sending it. WhatsApp wants a square 640×640 JPEG, and a picture that is not one is dropped without an answer rather than refused — so an unprepared file fails as a timeout that looks like a network fault. The image is cropped square, scaled and written out as a fresh JPEG, which also strips EXIF and any progressive encoding the file was carrying.

No image library is required. JPEG, PNG, GIF and BMP are decoded, cropped and re-encoded in process, so a bare npm install on a phone or a container built without a compiler converts a picture just as well as a full desktop. Whatever is installed is preferred, and the fallbacks run in this order:

HandlesNeeds
sharp or jimpeverything, best qualityeither installed
built inJPEG, PNG, GIF, BMPnothing
ffmpegWebP, HEIC, progressive JPEGffmpeg on PATH
metadata stripan already-square JPEGnothing

The built-in converter also reads the EXIF orientation tag, so a photo taken in portrait is turned upright instead of arriving on its side, and flattens transparency onto white, since a JPEG has no alpha channel.

Only an exotic format with nothing installed — a WebP or a HEIC on a machine with no ffmpeg — still fails, and the error says so rather than reporting a bare 406.

// skip the re-encode if you have prepared the image yourself
await client.changeProfilePicture(buf, { raw: true })

// or choose the size
await client.changeProfilePicture(buf, { size: 640, quality: 50 })

Account Email (recovery address)

An account can carry an email address, used to recover access when the number is lost or the two-step PIN is forgotten.

[!NOTE] This is not a registration-code channel — there is no email delivery at the /code step. Register the number first (SMS, voice, wa_old or flash) and connect; the email is bound on the live account and verified with a code the server sends to the inbox. Primary (mobile) sessions only — a companion link is refused.

// 1. bind the address (not verified yet)
await client.setEmail('name@example.com')

// 2. ask the server to email a six-digit code
//    lg/lc only choose the language the email is written in
await client.requestEmailCode({ lg: 'en', lc: 'US' })

// 3. submit the code the user read from their inbox
const r = await client.verifyEmailCode('123456')
// r → { verified: true, autoVerifyFailed: false, email: 'name@example.com' }

// 4. confirm ownership
await client.confirmEmail()

// read the current binding any time
const status = await client.getEmailStatus()
// status → { email: 'name@example.com', verified: true, confirmed: true }

Each call throws with the server's reason when it is refused — 535 the code expired, 536 the code is wrong, 537 too many wrong tries, 534 locked out, 403 not allowed for this account.

Privacy

Block / Unblock User

// both return the updated block list, and throw if the server refuses
const blocked = await client.blockContact('919634847671')
await client.unblockContact('919634847671')

// the block list is addressed by LID; a phone number is resolved to one first,
// looking it up if it is not already known

// blocking done from the phone arrives as an event
client.on('blocklist', ({ changes }) => console.log(changes))

Get Block List

const list = await client.queryBlockList()
console.log(list)   // [ '919634847671@s.whatsapp.net', ... ]

Update Privacy Settings

// type:  'last_seen' | 'profile_picture' | 'status' | 'online' | 'read_receipts'
//        'groups_add' | 'call_add' | 'messages' | 'defense' | 'stickers'
// value: 'all' | 'contacts' | 'contact_blacklist' | 'contact_allowlist' | 'none'
//        'match_last_seen' | 'known' | 'on_standard' | 'off'

// returns the settings as they now stand; throws if the server refuses
await client.changePrivacySetting('last_seen',        'contacts')
await client.changePrivacySetting('profile_picture',  'contacts')
await client.changePrivacySetting('status',           'contacts')
await client.changePrivacySetting('online',           'match_last_seen')
await client.changePrivacySetting('read_receipts',    'none')
await client.changePrivacySetting('groups_add',       'contacts')
await client.changePrivacySetting('call_add',         'known')

Each setting takes its own values, and they are not interchangeable:

settingaccepts
last_seen, profile_picture, status, groups_addall · contacts · contact_blacklist · none
read_receiptsall · none
onlineall · match_last_seen
call_addall · known
messagesall · contacts
defenseon_standard · off
stickerscontacts · contact_allowlist · none

Friendlier words are translated: on, off, everyone, nobody, my_contacts, contacts_except, contact_whitelist. on becomes all — or on_standard for defense, which spells its on-state differently.

await client.changePrivacySetting('read_receipts', 'on')    // sent as 'all'
await client.changePrivacySetting('read_receipts', 'off')   // sent as 'none'

A value the setting does not take throws before anything is sent. This matters more than it looks: the server does not refuse an unknown value, it drops the stanza and never answers, so the mistake would otherwise surface as a timeout that looks like a network problem.

Read Privacy Settings

const s = await client.queryPrivacySettings()
// { lastSeen, profile, status, online, readReceipts,
//   groupAdd, callAdd, messages, defense, stickers }
// anything the server did not report is null

await client.queryPrivacySettings({ force: true })   // skip the cache

// a change made on the phone arrives as an event
client.on('privacy_settings', ({ changes, settings }) => console.log(changes))

Turning read_receipts off changes what read receipts go out: they become read-self, which syncs the read state across your own devices without telling the sender. The settings are fetched once after connect so this holds from the first message.

Update Default Disappearing Mode

// sets the default ephemeral timer for all new chats
await client.changeNewChatsEphemeralTimer(86400)   // 1 day
await client.changeNewChatsEphemeralTimer(0)       // off

Account State

Three things the app speaks on every account and whalibmob did not: the server's own feature flags, the address book, and the legal notices an account is asked to accept. Each is one IQ family; together they are part of what makes a session look like an app rather than a script.

AB Props

Every WhatsApp client asks the server which experiments and configuration values apply to this account, then gates behaviour on the answer — how many bytes an identity hash is truncated to, whether the LID migration has started here, what sampling weight each telemetry event carries. A client that never asks runs on guessed defaults wherever the server has an opinion.

This is synced automatically on every connect. You do not have to call it.

const client = new WhalibmobClient({ sessionDir: './auth' })
await client.connect()

// Already synced by the time 'connected' fires. Read a value:
client.abProp(4405)              // '16'   — always a string on the wire
client.abPropInt(4405, 8)        // 16     — parsed, or 8 if unset/unparseable
client.abPropBool(5010, false)   // true   — understands "1"/"0" and "true"/"false"

Reading a prop before the first sync gives the fallback rather than throwing, so gating on a flag never needs a guard:

// Safe even on a client that has not connected yet.
const hashLen = client.abPropInt(4405, 8)

To re-sync by hand, or to look at the whole set:

const props = await client.queryAbProps({ force: true })

console.log(props.hash)      // 'A1B2…' — rides on the next sync, asking for a delta
console.log(props.refresh)   // 86400 — seconds the server suggests waiting
console.log(props.delta)     // false — this reply was the whole table
console.log(props.props)     // { '4405': '16', '5010': 'true', … }
console.log(props.sampling)  // { '1200': 100, … }  telemetry weights

The hash is what turns the next request into a delta. A delta is merged onto what is already held rather than replacing it — otherwise every prop the server saw no reason to repeat would be dropped. The hash lives for the session only: a fresh process asks for the full set again, which is what a fresh install does.

Turn the automatic sync off if you do not want the extra IQ on a short session:

new WhalibmobClient({ sessionDir: './auth', syncAbPropsOnConnect: false })

Being straight about the scope: nothing inside the library consumes these values yet. What you get today is the ability to see what the server thinks about an account — useful when one number behaves differently from another and you would otherwise be guessing — plus one more request a real client makes.

Contact Sync

The address-book upload a phone does on first launch, and the delta syncs after it. It answers the question worth asking before a first message: is this number on WhatsApp at all?

const out = await client.syncContacts([
  '40712345678',
  '+55 11 91234-5678',
  '40799999999'
])

out.registered    // ['40712345678', '+55 11 91234-5678']  — on WhatsApp
out.unregistered  // ['40799999999']                       — not on WhatsApp
out.jids          // { '40712345678': '40712345678@s.whatsapp.net', … }

Numbers come back in the spelling you passed in, however they were written — spaces, dashes and a leading + are all normalised before sending and restored in the answer.

A number the server did not answer about appears in neither list. That is deliberate. Reporting a timeout as "not on WhatsApp" is the one wrong answer available here, because a caller acts on it — skipping the number, or deleting it. Silence is not a verdict:

const asked = phones.length
const known = out.registered.length + out.unregistered.length
if (known < asked) {
  console.log(`${asked - known} numbers went unanswered — try them again later`)
}

Large books are chunked automatically, and one unanswered chunk does not cost the others their answers:

await client.syncContacts(bigList, { chunkSize: 200 })   // default is 500

The mode says what the sync claims about your address book. full is the whole book — what the app sends on first launch — and carries the registration context by default. delta is what changed since. query is a plain lookup that claims nothing:

await client.syncContacts(phones)                      // mode 'full',  context 'registration'
await client.syncContacts(phones, { mode: 'delta' })   // mode 'delta', context 'interactive'
await client.syncContacts(phones, { mode: 'query' })   // a lookup, nothing claimed

This is not called automatically on connect. Uploading an address book is your decision, not a side effect of connecting.

Where it earns its place is in front of a batch of first messages, together with the token path described under tcToken:

// 1. Which of these actually exist?
const { registered } = await client.syncContacts(numbers)

// 2. Warm a trusted-contact token for each, so the first message carries one.
for (const n of registered) {
  const jid = `${n.replace(/\D/g, '')}@s.whatsapp.net`
  await client.ensureTcTokenBeforeSend(jid, jid)
  await new Promise(r => setTimeout(r, 300))
}

// 3. Send. No wasted sends to dead numbers, and no anonymous reach-outs.
for (const n of registered) {
  await client.sendText(`${n.replace(/\D/g, '')}@s.whatsapp.net`, 'Hello')
  await new Promise(r => setTimeout(r, 1000))
}

Terms-of-Service Notices

WhatsApp gates some surfaces on the account having accepted a given legal notice — a Terms update, a privacy-policy change, a regional disclosure. The app pulls the acceptance state, shows what is outstanding, and posts back what the user accepted.

const out = await client.queryTosNotices(['20230324', '20240101'])

out.refresh   // 86400 — seconds the server suggests waiting before asking again
out.notices   // [ { id: '20230324', accepted: true },
              //   { id: '20240101', accepted: false } ]

// Anything still outstanding:
const pending = out.notices.filter(n => !n.accepted).map(n => n.id)
if (pending.length) await client.acceptTosNotices(pending)

Reading one back without asking again:

client.isTosAccepted('20230324')   // true
client.isTosAccepted('20240101')   // false
client.isTosAccepted('unknown-id') // null — this session has not asked

null matters: it means unknown, not not accepted. Code deciding whether to accept something needs to be able to tell those apart.

Clearing an acceptance, so the server asks for it again:

await client.clearTosNotice('20230324')

An acceptance made on another device arrives on its own, inside the same account_sync notification that carries device and privacy changes:

client.on('tos_notices', ({ notices }) => {
  for (const n of notices) {
    console.log(n.id, n.accepted ? 'accepted' : 'outstanding')
  }
})

A note on the wire, because it reads backwards from how it looks: the state attribute is present and "false" for a notice that has not been accepted, and absent for one that has. whalibmob reads it that way. If you parse these stanzas yourself, treating a missing attribute as "unknown" would report every accepted notice as outstanding.

Scope, honestly: no specific feature has been shown to be blocked by an unaccepted notice on a headless account. What this gives you is something to check rather than guess at — if a freshly registered number will not do something, you can now ask whether it has a notice pending.

Groups

Create a Group

Returns the same metadata object as getGroupMetadata (jid, subject, participants, etc.).

const group = await client.createGroup('My Group', [
  '919634847671@s.whatsapp.net',
  '12345678901@s.whatsapp.net'
])
console.log('created', group.jid)       // '120363000000000000@g.us'
console.log('subject', group.subject)   // 'My Group'
console.log('members', group.participants.map(p => p.jid))

Add / Remove or Demote / Promote

Each of these returns one result per participant — the ones that went through and the ones that did not. The server decides every participant separately, so a call that half worked tells you which half and why.

const groupJid = '120363000000000000@g.us'

const results = await client.addGroupParticipants(groupJid, [
  '919634847671@s.whatsapp.net',
  '12345678901@s.whatsapp.net'
])

for (const r of results) {
  if (r.ok) console.log('added', r.jid)
  else      console.log('failed', r.jid, r.status, r.needsInvite ? '(invite instead)' : '')
}

await client.removeGroupParticipants(groupJid,  ['919634847671'])
await client.promoteGroupParticipants(groupJid, ['919634847671'])
await client.demoteGroupParticipants(groupJid,  ['919634847671'])

Each result looks like this:

{
  jid:         '919634847671@s.whatsapp.net',
  status:      '403',        // '200' when the action went through
  error:       403,          // null on success
  ok:          false,        // getter: error == null
  admin:       null,         // 'admin' | 'superadmin' | null
  phoneNumber: '919634847671@s.whatsapp.net',
  lid:         '112713111982325@lid',   // when the server told us one
  displayName: null,
  // Only on a refused add: the code a personal invitation is built from.
  addRequest:  { code: 'AbCdEfGh', expiration: 1790000000 },
  needsInvite: true          // getter: true when addRequest holds a code
}

The common error codes are 403 (their privacy settings do not allow it), 404 (not on WhatsApp), 408 (not a member), 409 (already a member) and 401 (you are not allowed to do this).

[!TIP] A result object stringifies to its JID, so results.join(', ') and String(results[0]) read exactly as they did when these methods returned a plain list of JID strings.

Change Subject

await client.changeGroupSubject('120363000000000000@g.us', 'New Group Name')

Change Description

await client.changeGroupDescription('120363000000000000@g.us', 'This is the group description')

Change Settings

// setting: 'edit_group_info' | 'send_messages' | 'add_participants' | 'approve_participants'
// policy:  'admins' | 'all'

await client.changeGroupSetting('120363000000000000@g.us', 'send_messages',   'admins')
await client.changeGroupSetting('120363000000000000@g.us', 'edit_group_info', 'admins')
await client.changeGroupSetting('120363000000000000@g.us', 'add_participants', 'all')

Leave a Group

await client.leaveGroup('120363000000000000@g.us')

Get Invite Code

const link = await client.queryGroupInviteLink('120363000000000000@g.us')
// e.g. 'https://chat.whatsapp.com/AbCdEfGhIjK'
console.log(link)

Revoke Invite Code

await client.revokeGroupInvite('120363000000000000@g.us')

Join Using Invitation Code

[!NOTE] Pass only the code portion — do not include https://chat.whatsapp.com/

const jid = await client.acceptGroupInvite('AbCdEfGhIjK')
console.log('joined', jid)

Fetch a group's metadata from an invite code or full URL without joining the group. Useful for displaying a preview to the user before they confirm.

// bare code
const info = await client.queryGroupInviteInfo('AbCdEfGhIjKlMnOpQrStUv')

// or pass the full URL — the code is extracted automatically
const info = await client.queryGroupInviteInfo('https://chat.whatsapp.com/AbCdEfGhIjKlMnOpQrStUv')

console.log(info)
// {
//   jid:          '120363000000000000@g.us',
//   subject:      'My Group',
//   creator:      '919634847671@s.whatsapp.net',
//   creation:     1705315800,   // Unix timestamp
//   description:  'Group description here',
//   participants: [
//     { jid: '919634847671@s.whatsapp.net', role: 'admin' },
//     { jid: '12345678901@s.whatsapp.net',  role: 'member' }
//   ]
// }

Fetch All Groups

Returns an array of metadata objects for every group you are a member of. Each object has the same shape as getGroupMetadata.

const groups = await client.fetchAllGroups()
for (const g of groups) {
  console.log(g.jid, g.subject, g.participants.length + ' members')
}

Query Metadata

const meta = await client.getGroupMetadata('120363000000000000@g.us')
console.log(meta.subject, meta.participants.length + ' members')
{
  jid:             '120363000000000000@g.us',
  subject:         'My Group',
  size:            57,            // the server's own count
  creation:        1705315800,
  creator:         '919634847671@s.whatsapp.net',
  subjectTime:     1705315900,
  subjectBy:       '919634847671@s.whatsapp.net',
  description:     'Group description here',
  descriptionId:   'DESC1',       // echoed back as `prev` on the next edit
  descriptionBy:   '919634847671@s.whatsapp.net',
  descriptionByPn: '919634847671@s.whatsapp.net',
  descriptionTime: 1705315950,
  ephemeral:       86400,         // 0 when disappearing messages are off
  onlyAdminsSend:  false,
  onlyAdminsEdit:  false,
  joinApprovalMode: true,         // new members need an admin's approval
  memberAddMode:   'admin_add',   // 'admin_add' | 'all_member_add' | null
  isCommunity:         false,
  isCommunityAnnounce: false,
  defaultMembershipApprovalMode: null,   // communities only
  linkedParent:    null,          // the community this group belongs to
  isIncognito:     false,         // members' phone numbers hidden from each other
  isSuspended:     false,         // the group has been taken down
  notify:          'My Group',
  creatorPn:       '919634847671@s.whatsapp.net',
  creatorUsername: null,
  creatorCountry:  'IN',
  subjectByPn:     '919634847671@s.whatsapp.net',
  subjectByUsername: null,
  participantVersion: 'PV1',      // bumped when the member list changes
  announceVersion:    'AV1',      // bumped when the announce flag changes
  addressingMode:  'lid',         // 'lid' | 'pn'
  participants: [
    {
      jid:          '112713111982325@lid',
      role:         'admin',      // 'admin' | 'superadmin' | 'member'
      isAdmin:      true,
      isSuperAdmin: false,
      phoneNumber:  '919634847671@s.whatsapp.net',
      lid:          '112713111982325@lid',
      displayName:  null,
      username:     null
    }
  ]
}

Both addresses are filled in on every participant whichever way round the server named them, so you never have to resolve a LID by hand to know who somebody is.

Get Request Join List

const pending = await client.queryGroupPendingParticipants('120363000000000000@g.us')

for (const r of pending) {
  console.log(r.jid, 'asked at', new Date(r.requestedAt * 1000).toISOString())
}

Each entry is { jid, requestedAt } — requestedAt is unix seconds, or 0 when the server did not say. Like the participant results, an entry stringifies to its JID.

Approve / Reject Request Join

The second parameter is a boolean: true to approve, false to reject. The return value is the same list of per-participant results the add/remove calls give you.

// approve join requests
const done = await client.approveGroupParticipants('120363000000000000@g.us', true, [
  '919634847671@s.whatsapp.net'
])
console.log(done.filter(r => !r.ok))   // whoever could not be let in, and why

// reject join requests
await client.approveGroupParticipants('120363000000000000@g.us', false, [
  '919634847671@s.whatsapp.net'
])

Personal Invitations

An invite link is public — anyone holding it can join. A personal invitation is the other kind: minted for one named person, and the only way into a group for somebody whose privacy settings stop them from being added outright.

The whole flow starts with a refused add. When the server turns a participant away for that reason it hands back a code, which travels to them as a message they can tap.

const groupJid = '120363000000000000@g.us'

// add whoever can be added, and invite whoever cannot — in one call
const results = await client.addGroupParticipantsOrInvite(groupJid, [
  '919634847671@s.whatsapp.net',
  '12345678901@s.whatsapp.net'
])

for (const r of results) {
  if (r.ok)              console.log('added', r.jid)
  else if (r.invited)    console.log('invited', r.jid)
  else                   console.log('failed', r.jid, r.status, r.inviteError || '')
}

Or drive it yourself, if you want to decide who gets an invitation:

const results = await client.addGroupParticipants(groupJid, [
  '919634847671@s.whatsapp.net'
])

for (const r of results.filter(x => x.needsInvite)) {
  await client.sendGroupInvite(r.jid, groupJid,
    r.addRequest.code, r.addRequest.expiration,
    { caption: 'Come join us' })
}

sendGroupInvite(to, groupJid, code, expiration, opts) accepts { groupName, caption, jpegThumbnail, isCommunity, id, contextInfo }. The group name is filled in from the group's own metadata when you do not supply one.

On the receiving side, an invitation arrives as an ordinary message event whose decoded.type is 'groupInvite':

client.on('message', async (msg) => {
  const d = msg.decoded
  if (!d || d.type !== 'groupInvite') return

  // look before you leap — this does not join anything
  const info = await client.queryGroupInviteMessageInfo(
    d.groupJid, msg.participant || msg.from, d.inviteCode, d.inviteExpiration)
  console.log(info.subject, info.size + ' members')

  // and accept it
  const jid = await client.acceptGroupInviteMessage(
    d.groupJid, msg.participant || msg.from, d.inviteCode, d.inviteExpiration)
  console.log('joined', jid)
})

A decoded invitation carries { groupJid, inviteCode, inviteExpiration, groupName, jpegThumbnail, caption, isCommunity }.

To withdraw an invitation you sent before it is used:

await client.revokeGroupInviteForParticipant(groupJid, '919634847671')

An expired or already-spent invitation throws rather than resolving to nothing, so the two cases are easy to tell apart.

Toggle Ephemeral in Group

await client.changeEphemeralTimer('120363000000000000@g.us', 86400)  // 1 day
await client.changeEphemeralTimer('120363000000000000@g.us', 0)      // off

Communities

WhatsApp Communities are a superset of groups — a parent container that can hold multiple linked sub-groups plus an automatic general-chat group.

Create a Community

const community = await client.createCommunity('My Community', 'A place for discussion')
// community.jid  — e.g. 120363000000000001@g.us

Deactivate / Delete a Community

await client.deactivateCommunity('120363000000000001@g.us')
const linked = await client.linkGroupsToCommunity(
  '120363000000000001@g.us',          // community JID
  ['120363000000000002@g.us',         // group JIDs to link
   '120363000000000003@g.us']
)
await client.unlinkGroupFromCommunity(
  '120363000000000001@g.us',   // community JID
  '120363000000000002@g.us'    // group JID
)

Newsletters (Channels)

Newsletters are one-to-many broadcast channels. Only the owner can post; anyone can subscribe.

Create a Newsletter

const nl = await client.createNewsletter('Tech News', 'Daily updates on tech')
// nl.jid — e.g. 120363000000000004@newsletter

Join / Leave a Newsletter

await client.joinNewsletter('120363000000000004@newsletter')
await client.leaveNewsletter('120363000000000004@newsletter')

Query Newsletter Metadata

const meta = await client.queryNewsletterMetadata('120363000000000004@newsletter')
// { jid, name, description, subscriberCount }

Update Newsletter Description

await client.changeNewsletterDescription('120363000000000004@newsletter', 'New description here')

Post a Text Update to Your Newsletter

await client.sendNewsletterText('120363000000000004@newsletter', 'Breaking: WhatsApp adds polls!')

Business Profile

Query the public business profile of any WhatsApp Business account:

const bp = await client.queryBusinessProfile('919634847671')
if (bp) {
  console.log(bp.category)     // e.g. "Software & IT Services"
  console.log(bp.email)        // business email (if set)
  console.log(bp.website)      // business website (if set)
  console.log(bp.address)      // physical address (if set)
  console.log(bp.description)  // business description (if set)
}
// Returns null if the number is not a WhatsApp Business account

WhatsApp IDs

  • Individual contacts: [countrycode][number]@s.whatsapp.net
    • Example: 919634847671@s.whatsapp.net
  • Groups: [groupid]@g.us
    • Example: 120363000000000000@g.us
  • Status broadcast: status@broadcast
  • Broadcast lists: [timestamp]@broadcast

[!NOTE] Phone numbers must include the country code without the + prefix.

Transport

whalibmob uses Noise_XX_25519_AESGCM_SHA256 over TCP to g.whatsapp.net:443:

  • Client sends ClientHello with an ephemeral X25519 public key.
  • Server replies ServerHello (ephemeral + encrypted static + payload).
  • Three DH steps (EE, SE, SS) derive the final session keys.
  • Client sends ClientFinish (encrypted static + encrypted payload).
  • The library waits for a <success> stanza before emitting connected, or <failure> for auth_failure.

Automatic reconnection uses exponential backoff:

AttemptDelay
11 s
22 s
34 s
48 s
515 s
6+30 s

Media Encryption

Sending (upload flow)

  • A random 32-byte media key is generated.
  • HKDF-SHA256 expands it into IV (16 bytes), cipher key (32 bytes), and MAC key (32 bytes).
  • The file is encrypted with AES-256-CBC.
  • A 10-byte HMAC-SHA256 MAC is appended to the ciphertext.
  • The encrypted blob is uploaded to the WhatsApp CDN.
  • The media key and CDN URL are embedded in the Signal-encrypted message envelope sent to the recipient.

Receiving (download + decrypt flow)

When you receive a media message, msg.decoded contains:

  • url — the HTTPS CDN link to download the encrypted blob
  • mediaKey — the 32-byte key (as a Buffer) needed to decrypt it
  • directPath — CDN path (fallback if full URL is unavailable)

The decryption process mirrors the upload:

StepOperation
1Download encrypted blob from CDN URL
2HKDF-SHA256(mediaKey, "", "WhatsApp <Type> Keys", 112) → expanded key material
3Split: [0:16] = IV · [16:48] = AES cipher key · [48:80] = HMAC key
4Verify: HMAC-SHA256(macKey, IV ∥ ciphertext)[:10] must equal last 10 bytes of blob
5Decrypt: AES-256-CBC(cipherKey, IV, ciphertext) — strip last 10 bytes first

HKDF info strings:

Media typeInfo string
image / stickerWhatsApp Image Keys
videoWhatsApp Video Keys
audio / voiceWhatsApp Audio Keys
documentWhatsApp Document Keys

See the Receiving Media section for a complete working code example.

Device Emulation

whalibmob can emulate any iOS or Android device when communicating with WhatsApp servers. The device profile controls the User-Agent header, the Noise Protocol platform field, and the static token used in registration token computation.

Configuration is done entirely through environment variables — no code changes required. Copy .env.example to .env in your project root and set the variables you need.

Device Quick Start

Emulate an Android Pixel 8 Pro. The syntax for setting a variable differs per shell, and getting it wrong is the most common reason a device profile appears to be ignored:

Linux, macOS, Termux — set them for the one command:

WA_OS=android WA_DEVICE=pixel_8_pro node your-app.js
WA_OS=android WA_DEVICE=pixel_8_pro wa registration --request-code 919634847671

Windows, Command Prompt — set first, one per line. VAR=value in front of a command is Unix syntax and Windows answers it with 'WA_OS' is not recognized as an internal or external command:

set WA_OS=android
set WA_DEVICE=pixel_8_pro
wa registration --request-code 919634847671

Keep them on separate lines. Chaining with && puts the space before the && inside the value.

Windows, PowerShell:

$env:WA_OS = "android"
$env:WA_DEVICE = "pixel_8_pro"
wa registration --request-code 919634847671

Anywhere, and the one worth preferring — a .env file in the directory you run from, which behaves identically on every platform:

WA_OS=android
WA_DEVICE=pixel_8_pro
wa registration --request-code 919634847671

The CLI reads .env from the current directory, not from where whalibmob is installed, so cd to the directory holding it before running. Whichever way you choose, the first line of --debug output tells you whether it took: User-Agent: WhatsApp/… Android/14 Device/Google-Pixel 8 Pro. An iOS/… there means the variables never arrived.

Or put the variables in a .env file. When using the CLI (wa command) the file is loaded automatically. When using the library directly, load it before require('whalibmob'):

require('dotenv').config()          // must be first
const { WhalibmobClient } = require('whalibmob')
WA_OS=android
WA_DEVICE=pixel_8_pro

Emulate a custom Samsung device:

WA_OS=android
WA_DEVICE_MODEL=SM-S928B
WA_DEVICE_MANUFACTURER=samsung
WA_DEVICE_OS_VERSION=14
WA_DEVICE_BUILD=UP1A.231005.007
WA_DEVICE_MODEL_ID=samsung-sm-s928b

iOS Profiles

Available values for WA_DEVICE when WA_OS=ios (default):

Profile keyDeviceiOS version
iphone_15_proiPhone 15 Pro17.4.1
iphone_15iPhone 1517.4.1
iphone_14_proiPhone 14 Pro16.7.5
iphone_14iPhone 1416.7.5
iphone_13_proiPhone 13 Pro16.7.5
iphone_13iPhone 1316.7.5
iphone_12_proiPhone 12 Pro15.8.2
iphone_12iPhone 1215.8.2
iphone_11_proiPhone 11 Pro15.8.2
iphone_11iPhone 1115.8.2
iphone_se3iPhone SE (3rd gen)16.7.5
iphone_xsiPhone Xs15.8.2

iOS User-Agent format: WhatsApp/<version> iOS/<osVersion> Device/<model>

Android Profiles

Available values for WA_DEVICE when WA_OS=android:

Profile keyDeviceAndroid version
pixel_8_proPixel 8 Pro14
pixel_8Pixel 814
pixel_7Pixel 714
pixel_7aPixel 7a14
samsung_s24_ultraSamsung Galaxy S24 Ultra14
samsung_s24Samsung Galaxy S2414
samsung_s23_ultraSamsung Galaxy S23 Ultra14
samsung_s23Samsung Galaxy S2314
samsung_a55Samsung Galaxy A5514
oneplus_12OnePlus 1214
oneplus_11OnePlus 1113
xiaomi_14Xiaomi 1414
xiaomi_13Xiaomi 1313
oppo_find_x7OPPO Find X714
realme_gt5realme GT 5 Pro14

Android User-Agent format: WhatsApp/<version> A

The Android version is fetched automatically from the Google Play Store on first use and cached in memory. If the fetch fails, ANDROID_VERSION_FALLBACK is used.

Custom Device Fields

These variables override individual fields on top of the selected profile:

VariableDescription
WA_DEVICE_MODELDevice model string (e.g. SM-S928B)
WA_DEVICE_MANUFACTURERManufacturer name (e.g. samsung)
WA_DEVICE_OS_VERSIONOS version string (e.g. 14)
WA_DEVICE_BUILDBuild fingerprint (e.g. UP1A.231005.007)
WA_DEVICE_MODEL_IDModel ID slug (e.g. samsung-sm-s928b)
WA_DEVICE_RAMUsable RAM in GiB, as Android reports it (e.g. 7.53). Each named profile carries its own; set this only for a different memory variant of the same model.

Declaring your SIM

Registration sends sim_mcc / sim_mnc to say which network the SIM belongs to. The built-in table can only name one operator per country — Romania is always Orange, Brazil always Vivo — and it cannot do better, because number portability broke the prefix→operator link years ago. Guessing from the number would be wrong more often than the table is.

So say which SIM is in the phone:

const store = createNewStore('40712345678', { simMcc: '226', simMnc: '01' })
VariableDescription
WA_SIM_MCCMobile country code of the SIM (e.g. 226)
WA_SIM_MNCMobile network code. Width matters — 10 and 010 are different networks to the server, so write it exactly as your operator does.

Either half can be given on its own; the other keeps the table's value, and the country's language and locale are unaffected either way. Leave both unset and nothing changes from before.

Version & Token Overrides

VariableDescription
WA_VERSIONPin the WhatsApp version (e.g. 2.24.13.80). Skips the live store fetch, and is announced on connect in place of the version stored in the session. The CLI also reads it from a .env file in the working directory, so one left there is announced by every connect from that directory — which is how a working session starts being refused with 405. Pin it deliberately, unset it when done.
WA_STATIC_TOKENOverride the static token used in registration token computation. iOS only — Android has no static token. Overrides both the consumer and the Business constant.
WA_BUSINESSRegister and connect as WhatsApp Business (1/true/yes/on). Decides the announced platform, the User-Agent, which APK the token material comes from, and the vname certificate. See Registering a WhatsApp Business account.

When the server answers 405 on connect

WhatsApp auth failure 405 — client outdated. The server refused the version
this connect announced, which was 2.24.10.75. That value came from WA_VERSION
in the environment — the CLI also reads it out of a .env file in the directory
it runs from — while the session itself holds 2.26.29.73.

The session is fine and the number is still registered. 405 is the server declining the client, not the account: the version being announced is not one it accepts. Registering the number again is the one move that cannot help — the same version would go out and be refused identically, at the cost of a real phone number and a code request.

Connecting announces exactly one version, and there are only two places it can come from:

orderwhere the announced version comes from
1WA_VERSION, from the shell or from a .env file in the directory the command runs in
2the version stored in the session file — refreshed from the platform's store before every handshake, unless { refreshVersion: false }

Almost every 405 is the first line winning when nobody meant it to.

Check WA_VERSION before anything else

The CLI loads .env from the working directory before it does anything else, so a WA_VERSION left in that file is announced by every connect started from that directory — in place of the version the session registered with, which the server would have accepted. Nothing about the session changes, so the failure looks like a dead account and is not one.

grep -i wa_version .env ~/.env
env | grep WA_VERSION

Remove or comment the line, then connect again. Since 5.12.17 you do not have to go looking: the CLI says so before it connects,

warning: WA_VERSION=2.24.10.75 is pinned (shell or .env in /home/you) —
connecting announces it instead of the version stored in the session.

and a 405 names the version that went out and which of the two places it came from, because that decides the remedy — unset the override, or pin a newer one.

Pin WA_VERSION deliberately and temporarily, to force one specific build:

WA_VERSION=2.26.30.3 wa connect 919634847671

Leaving it in .env means every session on that machine announces it until the day it goes stale, wherever those sessions came from.

Keeping the session's own version current

With no override, the session announces the version it was registered with, and each platform learns that differently:

how the version is foundwhat happens when that fails
iOSlooked up on the App Storefalls back to a version compiled into the library, which goes stale
Androidread from the APK the registration token material came fromwa apk-material --download fetches the current one

That version is written once, at registration, and nothing else ever touches it. A number registered today announces today's version next year too, and one day the server stops accepting it — a 405 with nothing wrong with the account. Refreshing the APK material does not reach the sessions already on disk.

wa refresh-version is the part that does:

wa apk-material --download        # Android: pick up the current APK first
wa refresh-version 5568936182750  # then write its version into the session
+5568936182750  android       2.24.10.75  →  2.26.30.5

  1 session(s) updated
  read from the APK the token material came from.
  reconnect for it to be announced.

It touches version and nothing else — the keys, the device profile and the registration are left exactly as they were — and it never moves a session backwards. The store it reads from can be behind what a session already holds, since Play serves the build matching the device profile it was asked with rather than the newest on the listing, and announcing an older version is the one outcome that makes a 405 more likely:

+40756218532  android       2.26.30.3  (kept — newer than the 2.26.29.73 available)
``` `--all` does every session in the
session directory, which is what belongs in a monthly cron for a bot that is
meant to stay up:

```sh
wa apk-material --download && wa refresh-version --all

--version 2.26.30.5 writes one you name instead of looking one up. And if WA_VERSION is set, the command says so — the override would mask whatever it writes.

From Node, the same thing:

const { refreshSessionVersion, currentVersionFor, storeFileFor } = require('whalibmob')

const base = path.join(process.env.HOME, '.waSession')
await refreshSessionVersion(storeFileFor(base, '5568936182750'))
await currentVersionFor({ os: 'android' })   // { version, source }

If every Android session is refused, whatever the version

Then it is the library, not the version — update it. Until 5.12.15 the Android device profiles announced platform 3, which is BlackBerry, a client WhatsApp stopped building in 2017. The server validates the announced app version against the platform it was announced with, so a current Android build arrived looking like an impossible BlackBerry one, and nothing in the failure named the platform. iOS announced 1 and was never affected.

Sessions written before the fix repair themselves the next time they are loaded — the platform is derived from the profile's os rather than trusted from the file — so nothing has to be registered again.

One other field in the same payload was wrong rather than merely unusual: connectType sent 3 on every reconnect, and the enum has no 3 — the legal values are 0 (cellular, unknown radio), 1 (wifi) and 100–112 for the named cellular radios. It now sends 1.

Nothing else in the payload changed. Other clients announce no carrier (mcc/mnc as 000) and en/US regardless of the number; this library announces the carrier and locale the number actually belongs to, and a live session was tried against the server both ways — neither is refused. 000/000 is what a handset with no SIM reports, so a number with a carrier behind it saying so is the more ordinary thing to be.

Finding out what a 405 objects to

When the version is right and the connect is still refused, stop guessing: tools/diagnose-405.js runs the login once per payload variation and prints what the server answered each time.

node tools/diagnose-405.js 5568936182750

# installed globally:
node $(npm root -g)/whalibmob/tools/diagnose-405.js 5568936182750
what this library sends           ok — LOGIN ACCEPTED
  without the carrier (000/000)   ok — LOGIN ACCEPTED
  without the locale (en/US)      ok — LOGIN ACCEPTED

405 means that row was refused, 401 means the client was accepted and only the credentials failed, ok means the login went through. The first row is what a real connect puts on the wire, and each row after it changes exactly one field, so a row that behaves differently from the first names the field the server objected to. Every row uses the session already on disk: nothing is registered, no code is requested, and the session is never written to. --dry-run prints the payload sizes without opening a socket.

If the first row is accepted while wa connect is refused, the payload is not the problem. The difference is then in what the CLI reads and this tool does not — WA_VERSION, from .env. Go back to the top of this section.

Support this project

If whalibmob saves you time, you can support its development.

USDT — TRC-20 (Tron network only)

TNxxWvAc5m5YS89uz5uSrbXC9EKPuS27aP

[!WARNING] This address is TRC-20 on the Tron network. Send only USDT on TRC-20 to it. USDT sent on any other network — ERC-20 (Ethereum), BEP-20 (BSC), Polygon, or anything else — will be lost and cannot be recovered. Always double-check the network in your wallet before sending, and copy the address in full.

The Sponsor button at the top of the repository leads back here.

License

MIT
.

Keywords

whatsapp

FAQs

Package last updated on 29 Oct 2026

Related posts