[!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



💼 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:
| Android | /info | APK hashes, signature and secret key — the device fingerprint |
| Android | /integrity | gpia and its _gg _gi _gp _ge _ga companions — the Play Integrity verdict |
| Android | /cert | the &H= body signature and the Authorization certificate chain |
| iOS | /integrity | the App Attest assertion and its Authorization header |
cd frida/android
npm install && npm run build
frida -U "WhatsApp" -l server_with_dependencies.js
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
wa registration --request-code 919634847671
wa registration --request-code 919634847671 --method voice
wa registration --request-code 919634847671 --method wa_old
WA_OS=android wa registration --request-code 919634847671 --method flash
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:
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.
wa registration --request-code 919634847671 --method email_otp
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
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):
wa> /poll 919634847671 Best language? | JavaScript | Python | Rust | selectable=1
wa> /poll 120363000000000000@g.us Pick your favourites | Red | Green | Blue
React to a Message
wa> /react 919634847671 3EB0ABCDEF123456 👍
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
wa> /delete 919634847671 3EB0ABCDEF123456
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.
wa> /reply 919634847671 3EB0XXXXXXXX 919634847671 Got it, thanks!
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:
wa> /location 919634847671 48.8566 2.3522
wa> /location 919634847671 48.8566 2.3522 Eiffel Tower
wa> /location 919634847671 48.8566 2.3522 Eiffel Tower | Champ de Mars, Paris
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
wa> /typing 919634847671
wa> /recording 919634847671
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
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
wa> /mute 919634847671 60
wa> /mute 919634847671
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
──────────────────────────────────────────────────
wa> /appstate regular_low
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
| Off | 0 |
| 24 hours | 86400 |
| 7 days | 604800 |
| 90 days | 7776000 |
wa> /ephemeral 919634847671 86400
wa> /ephemeral 120363000000000000@g.us 604800
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
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
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
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
wa> /group promote 120363000000000000@g.us 919634847671
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
Get Invite Link
wa> /group invite 120363000000000000@g.us
https://chat.whatsapp.com/AbCdEfGhIjKlMnOpQrStUv
Revoke Invite Link
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
wa> /group approve 120363000000000000@g.us 919634847671
approved 919634847671@s.whatsapp.net
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
wa> /group preview-invite 120363000000000000@g.us 919634847671 AbCdEfGh 1790000000
wa> /group accept-invite 120363000000000000@g.us 919634847671 AbCdEfGh 1790000000
joined 120363000000000000@g.us
wa> /group send-invite 120363000000000000@g.us 12345678901 AbCdEfGh 1790000000
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:
wa> /group settings 120363000000000000@g.us send_messages admins
wa> /group settings 120363000000000000@g.us send_messages all
wa> /group settings 120363000000000000@g.us edit_group_info admins
wa> /group settings 120363000000000000@g.us add_participants admins
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.
wa> /community create "Dev Squad" "Our developer community"
wa> /community link 120363000000000001@g.us 120363000000000002@g.us
wa> /community unlink 120363000000000001@g.us 120363000000000002@g.us
wa> /community deactivate 120363000000000001@g.us
Newsletter / Channel Commands
Newsletters are one-to-many broadcast channels. Only the owner can post; anyone can subscribe.
wa> /newsletter create Tech News Daily tips about technology
wa> /newsletter join 120363000000000004@newsletter
wa> /newsletter leave 120363000000000004@newsletter
wa> /newsletter info 120363000000000004@newsletter
────────────────────────────────────────────────────────
jid 120363000000000004@newsletter
name Tech News
description Daily tips about technology
subscribers 1234
────────────────────────────────────────────────────────
wa> /newsletter desc 120363000000000004@newsletter New description here
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:
wa> /reg check 919634847671
wa> /reg code 919634847671
wa> /reg code 919634847671 voice
wa> /reg code 919634847671 wa_old
wa> /reg code 919634847671 --name "Ricardo Trade"
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)
wa> /connect 919634847671
wa> /connect 919634847671 sms
wa> /connect 919634847671 pair
wa> /pair 919634847671
wa> /qrcode 919634847671
wa> /pair 919634847671 MYCODE12
wa> /disconnect
wa> /reconnect
wa> /session
phone 919634847671
name My Bot
session /home/user/.waSession
wa> /help
wa> /quit
Full Command Reference Table
| 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 | |
/online | Set yourself as online |
/offline | Set 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 --snapshot | Re-read all app state from scratch |
/restriction | Account restriction status with a live countdown |
/restriction --once | Restriction status without the countdown |
/restriction --demo [seconds] | Fake countdown, to check the display |
/limit | Alias 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 |
/blocklist | Show 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) |
/groups | List 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 |
/disconnect | Disconnect current session |
/reconnect | Force reconnection |
/session | Show session info |
/help | Show all commands |
/quit / /exit | Disconnect 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')
wa.MessageProto.decodeMessageContainer(buf)
wa.Messages.MediaRetry.mediaRetryKey(key)
wa.Signal.libsignal.SessionCipher
wa.AppState.SyncdProto
wa.Image.Jpeg
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')
require('whalibmob').MediaService === MediaService
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'
import wa from 'whalibmob'
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'
const sessDir = path.join(process.env.HOME, '.waSession')
const sessFile = path.join(sessDir, phone + '.json')
fs.mkdirSync(sessDir, { recursive: true })
const store = createNewStore(phone, { name: 'Ricardo Trade' })
saveStore(store, sessFile)
await requestSmsCode(store, 'sms')
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')
const codePromise = receivePushCode(store, store.device, { timeoutMs: 180000 })
await requestSmsCode(store, 'sms')
const code = await codePromise
if (code) {
const result = await verifyCode(store, code)
if (result.status === 'ok') saveStore(result.store, sessFile)
} else {
}
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', {
async solveCaptcha({ image, audio }) {
fs.writeFileSync('captcha.png', image)
return await askTheUser()
},
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:
| announced platform | ANDROID / IOS | ANDROID_BUSINESS / IOS_BUSINESS |
| User-Agent | Android/… · iOS/… | SMBA/… · SMB iOS/… |
| Android token material | com.whatsapp | com.whatsapp.w4b |
| iOS token constant | consumer | Business |
| version lookup | consumer listing | Business listing |
vname field | not sent | a self-signed verified-name certificate |
| Frida attestation port | 1119 | 1120 |
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
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
npm install
npm run build
2. Run it on the device
Make sure the Frida server is running on the phone, then attach to WhatsApp:
frida -U "WhatsApp" -l server_with_dependencies.js
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
iproxy 1119 1119
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
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:
| Android | gpia (Play Integrity) in the body, plus a Keystore signature and certificate chain on the request |
| iOS | An 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.
sessionDir | ~/.waSession | The authentication folder. Each number gets its own subfolder inside it — see Saving & Restoring Sessions. |
autoFixNumber | true | Re-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. |
autoRead | true | Send read receipts for incoming messages. false leaves them unread. |
refreshVersion | true | Check 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. |
pino | off | Debug logging. true turns it on at debug level; an object is passed to pino as-is. |
sentCacheSize | 2000 | How many sent messages keep their plaintext so a retry receipt naming them can be answered. |
maxRetryResends | 5 | How many times one message may be re-sent in answer to retry receipts before the client gives up. |
tcTokenPresendTimeoutMs | 5000 | How 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)
console.log('lid ', p.lid)
console.log('slot ', p.deviceIndex)
})
client.on('connected', () => {
console.log('ready')
})
await client.connectWeb('919634847671', { syncFullHistory: true })
const code = await client.requestPairingCode('919634847671')
console.log('enter this on the phone:', code)
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')
const path = require('path')
const client = new WhalibmobClient({
sessionDir: path.join(process.env.HOME, '.waSession')
})
client.on('qr', ({ qr, remaining }) => {
qrcode.generate(qr, { small: true })
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'))
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.
After the link — same for both paths
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,
browser: ['Ubuntu', 'Chrome', '120.0.0.0']
})
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
pairing_code | a code has been requested — { code, phoneNumber } |
qr | a QR is ready to render — { qr, ref, ttlMs, remaining }; fires again on each rotation |
qr_timeout | the QR refs ran out — reconnect for a fresh set |
paired | the owner accepted the code or scanned the QR — { jid, lid, deviceIndex, platform } |
restart_required | the server is restarting the stream after pairing (normal; the reconnect is automatic) |
pair_device | the raw QR reference strings, before they are turned into qr — { refs } |
history_sync | a 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)
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
<phone>.web.json | link state — keys, advSecretKey, the device slot you were given |
<phone>.web.signal.json | Signal sessions, signed pre-key, identities, LID map, pre-key counters |
<phone>.web.pre-key-<id>.json | one one-time pre-key — 812 of them |
<phone>.web.sk.json | group SenderKeys |
<phone>.web.tctoken.json | privacy tokens |
<phone>.web.appState.json | app-state version and hash per collection |
<phone>.appStateKeys.json | app-state sync keys shared by the phone |
<phone>.web.history.json | synced chats, contacts, push names, LID↔PN mappings |
<phone>.web.messages.json | flat 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.
| 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.
How the Code Protects the Link
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'
const SESS_DIR = path.join(process.env.HOME, '.waSession')
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)
})
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
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:
syncFullHistory | true | Ask 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. |
version | fetched live | Web client revision to announce, e.g. [2, 3000, 1035194821]. Set it to pin one. |
fetchVersion | true | Set 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)
await client.connectWeb(phone, { version: [2, 3000, 1035194821] })
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:
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) => {
for (const chat of r.chats) {
console.log(chat.id, chat.name, chat.unreadCount, chat.messageCount)
}
for (const contact of r.contacts) {
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'))
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)
console.log(client.store.me.id)
console.log(client.store.me.lid)
console.log(client.store.deviceIndex)
console.log(client.store.platform)
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)
await client.connectWeb(PHONE)
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()
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.
When Registration Is Refused for Consent
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
"reason":"no_routes", every *_wait: 3600 | Anti-abuse | Can't route the code now; usually the IP or rate. Wait out the cooldown, use a cleaner IP. |
"reason":"blocked" + custom_block_screen | Unofficial-client / reputation | Refused for "security"; the IP and/or the number are distrusted. |
| "not using the official app" / download link | Unofficial-client | The 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:
| 1 | android.clients.google.com/checkin | an android id and security token — the device's identity with Google |
| 2 | firebaseinstallations.googleapis.com | a Firebase installation for WhatsApp's project |
| 3 | android.clients.google.com/c2dm/register3 | the 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:
| 1 | albert.apple.com | a device certificate Apple signs for a keypair generated here — the activation every Apple device runs on first boot |
| 2 | init-p01st.push.apple.com/bag | which courier hosts to dial |
| 3 | <n>-courier.push.apple.com:443 | the 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:
WA_OS=android | mtalk.google.com:5228, the MCS protocol, logged in with the Firebase identity | an appData entry keyed registration_code |
WA_OS=ios | <n>-courier.push.apple.com:443, the APNs courier, authenticated with the activation certificate | the 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')
const codePromise = receivePushCode(store, store.device, { timeoutMs: 180000 })
await requestSmsCode(store, 'sms')
const code = await codePromise
if (code) await verifyCode(store, code)
else { }
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
export TOR_PROXY=socks5://127.0.0.1:9050
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.
process.env.SOCKS_PROXY = 'socks5://user:pass@proxy.example.com:1080'
await requestSmsCode(phone, store)
What goes through it
Everything the library sends out:
| Message socket, mobile | raw TCP to WhatsApp |
| Message socket, web | wss://web.whatsapp.com/ws/chat |
| Registration | v.whatsapp.net |
| Announced version, iOS | App Store lookup |
| Announced version, Android | Play Store listing |
| Announced version, web | web.whatsapp.com/sw.js |
| APK material for registration | Play Store download |
| Media upload | WhatsApp'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')
})
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
wa refresh-version 919634847671 --version 2.25.1.2
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.
| on login | asks 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 low | same check, same batch |
| every 30 minutes | same 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
| 1 | sessionDir passed to WhalibmobClient (library), or --session <dir> (CLI) |
| 2 | WA_SESSION_DIR in the environment |
| 3 | the 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:
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')
sessionDirFor(base, '919634847671', { create: true })
storeFileFor(base, '919634847671')
webStoreFileFor(base, '919634847671')
listSessions(base)
migrateSession(base, '919634847671')
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)
write(key, value)
remove(key)
list(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:
FileBackend | JSON files — what the library has always written | nothing; this is the default |
SqliteBackend | one database file | Node 22.5+, or better-sqlite3 |
MemoryBackend | nowhere; gone when the process ends | nothing |
const { FileBackend } = require('whalibmob')
const backend = new FileBackend({
dir: '/home/you/.waSession/919634847671',
phone: '919634847671'
})
backend.write('auth', JSON.stringify(creds))
backend.read('auth')
backend.list()
backend.list('pre-key/')
backend.remove('pre-key/42')
backend.fileFor('signal')
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 })
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
db.close()
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
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)
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)
const check = compareSessions(files, db)
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)
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')
const base = new SignalStore()
const cached = makeCacheableSignalKeyStore(base)
const txnStore = addTransactionCapability(cached)
await txnStore.transaction(async () => {
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)
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>.
Recommended Stacking Pattern
For a production multi-account server:
const {
SignalStore,
makeCacheableSignalKeyStore,
addTransactionCapability,
initAuthCreds,
saveStore,
loadStore
} = require('whalibmob')
let store = loadStore(sessFile) || initAuthCreds(phone)
const signalStore = new SignalStore()
signalStore.attachFile(sessFile)
const cachedStore = makeCacheableSignalKeyStore(signalStore)
const txnStore = addTransactionCapability(cachedStore)
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)
})
await client.init('919634847671')
}
connect()
All Events
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 |
message | message object | Incoming 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 |
notification | node object | Group 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,
from: string,
participant: string,
ts: number,
node: object,
decoded: object,
fromMe: boolean,
chat: string,
}
[!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
const phoneJid = spn.user + '@s.whatsapp.net'
The decoded object shape per message type:
{ type: 'text', text: string }
{ type: 'image', caption: string, url: string, mimetype: string, mediaKey: Buffer, directPath: string }
{ type: 'video', caption: string, url: string, mimetype: string, mediaKey: Buffer, directPath: string }
{ type: 'audio', url: string, mimetype: string, mediaKey: Buffer, directPath: string }
{ type: 'voice', url: string, mimetype: string, mediaKey: Buffer, directPath: string }
{ type: 'document', fileName: string, url: string, mimetype: string, mediaKey: Buffer, directPath: string }
{ type: 'sticker', url: string, mimetype: string, mediaKey: Buffer, directPath: string }
{ type: 'reaction', emoji: string }
{ type: 'location', latitude: number, longitude: number, name: string, address: string, url: string }
{ type: 'contact', displayName: string, vcard: string }
{ type: 'groupInvite', groupJid: string, inviteCode: string, inviteExpiration: number,
groupName: string, jpegThumbnail: Buffer|null, caption: string, isCommunity: boolean }
{ 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')
})
client.on('history_sync', result => {
console.log('History sync chunk received:')
console.log(' type :', result.syncTypeName)
console.log(' progress:', result.progress)
console.log(' chunk :', result.chunkOrder)
console.log(' chats :', result.chats.length)
console.log(' contacts:', result.contacts.length)
console.log(' pushNames:', (result.pushNames || []).length)
})
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,
syncTypeName: string,
progress: number,
chunkOrder: number,
chats: [
{
id: string,
name: string,
unreadCount: number,
lastMsgTimestamp: number,
messageCount: number
}
],
contacts: [
{
id: string,
name: string,
username: string,
pnJid: string,
lidJid: string
}
],
pushNames: Array,
lidPnMappings: Array,
merged: object
}
Sync type values:
INITIAL_BOOTSTRAP | First connect — most recent conversations |
RECENT | Reconnect after a short offline period |
FULL | Full historical sync (older messages) |
PUSH_NAME | Contact name updates only |
NON_BLOCKING_DATA | Background low-priority data |
ON_DEMAND | Explicitly 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.
<phone>.history.json | Chats, contacts, push names, LID↔PN mappings, tcTokens |
<phone>.messages.json | Flat map of msgId → message metadata |
<phone>.appStateKeys.json | App-state sync keys, shared by your primary device |
<phone>.appState.json | Per-collection app-state version, hash and index map |
<phone>.tctoken.json | Trusted-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'
const histPath = path.join(sessDir, phone + '.history.json')
const hist = JSON.parse(fs.readFileSync(histPath, 'utf8'))
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')
}
const myLid = hist.pnLidMap['919634847671@s.whatsapp.net']
console.log('LID:', myLid)
const myPn = hist.lidPnMap[myLid]
console.log('PN:', myPn)
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,
name: string,
displayName: string,
unreadCount: number,
lastMsgTimestamp: number,
messageCount: number,
ephemeralExpiry: number,
archived: boolean,
pinned: number,
tcToken: string,
tcTokenTimestamp: number,
tcTokenSenderTimestamp: number
}
msgs schema per message entry:
{
id: string,
chatId: string,
fromMe: boolean,
fromJid: string,
timestamp: number,
pushName: string,
status: number
}
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:
| History seed | On 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 contact | The 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 send | Before 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. |
| Renewal | After 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 notification | When 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-issue | When decrypting a pkmsg (new Signal session from peer), the library calls _reissueTcTokenAfterIdentityChange to re-issue the token for the new session. |
| Error 463 recovery | If 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. |
| Expiry | Tokens 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)
} else {
console.log('not restricted')
}
{
active: true,
remaining: '04:59:20',
remainingMs: 17960000,
endsAt: 1800018000000,
endsAtDate: Date,
enforcementType: 'BIZ_QUALITY',
reason: 'too many people you messaged blocked or reported you',
expiryUnknown: false,
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.
| 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 | 🔵 Optional | Only if your app needs to react to history data |
Read <phone>.history.json | 🔵 Optional | Only if your app needs chat/contact data at rest |
Read <phone>.messages.json | 🔵 Optional | Only 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')
})
client.on('history_sync', result => {
console.log('[', result.syncTypeName, ']',
result.chats.length, 'chats,',
result.contacts.length, 'contacts')
})
client.on('history_sync_error', ({ err }) => {
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:
fileEncSha256 | the blob as it arrived, before any work is spent decrypting it |
| the MAC | the ciphertext, under a key derived from mediaKey — this is the one that authenticates |
fileSha256 | the decrypted file |
const bytes = await client.downloadMedia(d)
const raw = await client.downloadMedia(d, { verify: false })
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)
}
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)
})
Three flags say how the message was sent, and are absent otherwise:
d.viewOnce | sent as view-once — ViewOnceMessage, ViewOnceMessageV2 or the V2 extension a voice note uses |
d.ephemeral | from a chat with disappearing messages on |
d.edited | the 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) {
console.log('re-uploaded at', result.directPath)
} else {
}
})
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,
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:
await client.sendText(
'919634847671@s.whatsapp.net',
'This is a reply',
{
contextInfo: {
quotedMessageId: 'ABCDEF123456',
participant: '919634847671@s.whatsapp.net',
remoteJid: '919634847671@s.whatsapp.net',
}
}
)
Mention User
await client.sendText(
'120363000000000000@g.us',
'@919634847671 hello!',
{ mentions: ['919634847671@s.whatsapp.net'] }
)
Reaction Message
await client.sendReaction('919634847671', 'MSGID123', '👍')
await client.sendReaction('919634847671', 'MSGID123', '')
Edit Message
[!NOTE]
Editing is only possible within 15 minutes of the original send.
await client.editMessage(
'MSGID123',
'919634847671@s.whatsapp.net',
'Corrected text here'
)
Delete Message
await client.deleteMessage('MSGID123', '919634847671', true, false)
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.
await client.forwardMessage('919634847671', 'text to forward')
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
)
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
const { selected, votedAt } = client.decryptPollVote(msg, { encKey, options })
console.log(msg.participant, 'voted for', selected)
})
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.
await client.sendReply(
'919634847671@s.whatsapp.net',
'3EB0XXXXXXXX',
'919634847671@s.whatsapp.net',
'Got it, thanks!'
)
await client.sendReply(
'120363000000000000@g.us',
'3EB0XXXXXXXX',
'919634847671@s.whatsapp.net',
'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.
await client.sendLocation('919634847671', 48.8566, 2.3522)
await client.sendLocation('919634847671', 48.8566, 2.3522, {
name: 'Eiffel Tower',
address: 'Champ de Mars, 5 Av. Anatole France, Paris'
})
await client.sendLocation('120363000000000000@g.us', 51.5074, -0.1278, {
name: 'London'
})
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)
Call Link
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')
const audio = await client.createCallLink()
await client.createCallLink('video', { startTime: 1800000000 })
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
await client.sendImage('919634847671', './photo.jpg', { caption: 'Look at this' })
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
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.
await client.sendStatus('Good morning!')
await client.sendStatus({ image: './photo.jpg', caption: 'Look at this' })
await client.sendStatus({ video: './clip.mp4' })
await client.sendStatus({ audio: './voice.ogg' })
await client.sendStatus('Colorful', { backgroundArgb: 0xFF25D366, font: 3 })
await client.sendStatus('Hi', { recipients: ['919634847671'] })
Status Privacy
const [def, ...rest] = await client.queryStatusPrivacy()
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
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.
client.markMessagePlayed('3EB0ABCDEF123456', '919634847671')
Update Presence
client.setOnline(true)
client.setOnline(false)
client.setChatPresence('919634847671', 'composing')
client.setChatPresence('919634847671', 'recording')
client.setChatPresence('919634847671', 'paused')
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)
await client.muteChat('919634847671', 0)
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)
await client.unstarMessage('MSGID123', '919634847671', false)
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:
const r = await client.syncAppState()
console.log(r.applied, 'change(s)')
await client.syncAppState(['regular_low'])
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
| Off | 0 |
| 24 hours | 86 400 |
| 7 days | 604 800 |
| 90 days | 7 776 000 |
await client.changeEphemeralTimer('919634847671', 86400)
await client.changeEphemeralTimer('120363000000000000@g.us', 604800)
await client.changeEphemeralTimer('919634847671', 0)
User Queries
Check If a Number Has WhatsApp
const { checkNumberStatus } = require('whalibmob')
const result = await checkNumberStatus('919634847671')
console.log(result.status)
Check multiple numbers at once while connected:
const results = await client.hasWhatsapp(['919634847671', '12345678901'])
Fetch Profile About
const about = await client.queryAbout('919634847671')
console.log(about)
console.log(await client.queryOwnAbout())
Fetch Profile Picture
const url = await client.queryPicture('919634847671')
const groupUrl = await client.queryPicture('120363000000000000@g.us')
Subscribe to Presence
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')
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')
const picId = await client.changeProfilePicture(fs.readFileSync('./avatar.jpg'))
await client.changeProfilePicture(null)
const picId = await client.changeGroupPicture('120363000000000000@g.us',
fs.readFileSync('./group.jpg'))
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:
sharp or jimp | everything, best quality | either installed |
| built in | JPEG, PNG, GIF, BMP | nothing |
ffmpeg | WebP, HEIC, progressive JPEG | ffmpeg on PATH |
| metadata strip | an already-square JPEG | nothing |
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.
await client.changeProfilePicture(buf, { raw: true })
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.
await client.setEmail('name@example.com')
await client.requestEmailCode({ lg: 'en', lc: 'US' })
const r = await client.verifyEmailCode('123456')
await client.confirmEmail()
const status = await client.getEmailStatus()
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
const blocked = await client.blockContact('919634847671')
await client.unblockContact('919634847671')
client.on('blocklist', ({ changes }) => console.log(changes))
Get Block List
const list = await client.queryBlockList()
console.log(list)
Update Privacy Settings
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:
last_seen, profile_picture, status, groups_add | all · contacts · contact_blacklist · none |
read_receipts | all · none |
online | all · match_last_seen |
call_add | all · known |
messages | all · contacts |
defense | on_standard · off |
stickers | contacts · 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')
await client.changePrivacySetting('read_receipts', 'off')
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()
await client.queryPrivacySettings({ force: true })
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
await client.changeNewChatsEphemeralTimer(86400)
await client.changeNewChatsEphemeralTimer(0)
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()
client.abProp(4405)
client.abPropInt(4405, 8)
client.abPropBool(5010, false)
Reading a prop before the first sync gives the fallback rather than throwing,
so gating on a flag never needs a guard:
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)
console.log(props.refresh)
console.log(props.delta)
console.log(props.props)
console.log(props.sampling)
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
out.unregistered
out.jids
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 })
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)
await client.syncContacts(phones, { mode: 'delta' })
await client.syncContacts(phones, { mode: 'query' })
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:
const { registered } = await client.syncContacts(numbers)
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))
}
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
out.notices
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')
client.isTosAccepted('20240101')
client.isTosAccepted('unknown-id')
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)
console.log('subject', group.subject)
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',
error: 403,
ok: false,
admin: null,
phoneNumber: '919634847671@s.whatsapp.net',
lid: '112713111982325@lid',
displayName: null,
addRequest: { code: 'AbCdEfGh', expiration: 1790000000 },
needsInvite: true
}
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
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')
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)
Query Invite Info from Link
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.
const info = await client.queryGroupInviteInfo('AbCdEfGhIjKlMnOpQrStUv')
const info = await client.queryGroupInviteInfo('https://chat.whatsapp.com/AbCdEfGhIjKlMnOpQrStUv')
console.log(info)
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,
creation: 1705315800,
creator: '919634847671@s.whatsapp.net',
subjectTime: 1705315900,
subjectBy: '919634847671@s.whatsapp.net',
description: 'Group description here',
descriptionId: 'DESC1',
descriptionBy: '919634847671@s.whatsapp.net',
descriptionByPn: '919634847671@s.whatsapp.net',
descriptionTime: 1705315950,
ephemeral: 86400,
onlyAdminsSend: false,
onlyAdminsEdit: false,
joinApprovalMode: true,
memberAddMode: 'admin_add',
isCommunity: false,
isCommunityAnnounce: false,
defaultMembershipApprovalMode: null,
linkedParent: null,
isIncognito: false,
isSuspended: false,
notify: 'My Group',
creatorPn: '919634847671@s.whatsapp.net',
creatorUsername: null,
creatorCountry: 'IN',
subjectByPn: '919634847671@s.whatsapp.net',
subjectByUsername: null,
participantVersion: 'PV1',
announceVersion: 'AV1',
addressingMode: 'lid',
participants: [
{
jid: '112713111982325@lid',
role: 'admin',
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.
const done = await client.approveGroupParticipants('120363000000000000@g.us', true, [
'919634847671@s.whatsapp.net'
])
console.log(done.filter(r => !r.ok))
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'
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
const info = await client.queryGroupInviteMessageInfo(
d.groupJid, msg.participant || msg.from, d.inviteCode, d.inviteExpiration)
console.log(info.subject, info.size + ' members')
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)
await client.changeEphemeralTimer('120363000000000000@g.us', 0)
Communities
WhatsApp Communities are a superset of groups — a parent container that can hold multiple linked
sub-groups plus an automatic general-chat group.
const community = await client.createCommunity('My Community', 'A place for discussion')
await client.deactivateCommunity('120363000000000001@g.us')
const linked = await client.linkGroupsToCommunity(
'120363000000000001@g.us',
['120363000000000002@g.us',
'120363000000000003@g.us']
)
await client.unlinkGroupFromCommunity(
'120363000000000001@g.us',
'120363000000000002@g.us'
)
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')
Join / Leave a Newsletter
await client.joinNewsletter('120363000000000004@newsletter')
await client.leaveNewsletter('120363000000000004@newsletter')
Query Newsletter Metadata
const meta = await client.queryNewsletterMetadata('120363000000000004@newsletter')
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)
console.log(bp.email)
console.log(bp.website)
console.log(bp.address)
console.log(bp.description)
}
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:
| 1 | 1 s |
| 2 | 2 s |
| 3 | 4 s |
| 4 | 8 s |
| 5 | 15 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:
| 1 | Download encrypted blob from CDN URL |
| 2 | HKDF-SHA256(mediaKey, "", "WhatsApp <Type> Keys", 112) → expanded key material |
| 3 | Split: [0:16] = IV · [16:48] = AES cipher key · [48:80] = HMAC key |
| 4 | Verify: HMAC-SHA256(macKey, IV ∥ ciphertext)[:10] must equal last 10 bytes of blob |
| 5 | Decrypt: AES-256-CBC(cipherKey, IV, ciphertext) — strip last 10 bytes first |
HKDF info strings:
image / sticker | WhatsApp Image Keys |
video | WhatsApp Video Keys |
audio / voice | WhatsApp Audio Keys |
document | WhatsApp 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()
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):
iphone_15_pro | iPhone 15 Pro | 17.4.1 |
iphone_15 | iPhone 15 | 17.4.1 |
iphone_14_pro | iPhone 14 Pro | 16.7.5 |
iphone_14 | iPhone 14 | 16.7.5 |
iphone_13_pro | iPhone 13 Pro | 16.7.5 |
iphone_13 | iPhone 13 | 16.7.5 |
iphone_12_pro | iPhone 12 Pro | 15.8.2 |
iphone_12 | iPhone 12 | 15.8.2 |
iphone_11_pro | iPhone 11 Pro | 15.8.2 |
iphone_11 | iPhone 11 | 15.8.2 |
iphone_se3 | iPhone SE (3rd gen) | 16.7.5 |
iphone_xs | iPhone Xs | 15.8.2 |
iOS User-Agent format: WhatsApp/<version> iOS/<osVersion> Device/<model>
Android Profiles
Available values for WA_DEVICE when WA_OS=android:
pixel_8_pro | Pixel 8 Pro | 14 |
pixel_8 | Pixel 8 | 14 |
pixel_7 | Pixel 7 | 14 |
pixel_7a | Pixel 7a | 14 |
samsung_s24_ultra | Samsung Galaxy S24 Ultra | 14 |
samsung_s24 | Samsung Galaxy S24 | 14 |
samsung_s23_ultra | Samsung Galaxy S23 Ultra | 14 |
samsung_s23 | Samsung Galaxy S23 | 14 |
samsung_a55 | Samsung Galaxy A55 | 14 |
oneplus_12 | OnePlus 12 | 14 |
oneplus_11 | OnePlus 11 | 13 |
xiaomi_14 | Xiaomi 14 | 14 |
xiaomi_13 | Xiaomi 13 | 13 |
oppo_find_x7 | OPPO Find X7 | 14 |
realme_gt5 | realme GT 5 Pro | 14 |
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:
WA_DEVICE_MODEL | Device model string (e.g. SM-S928B) |
WA_DEVICE_MANUFACTURER | Manufacturer name (e.g. samsung) |
WA_DEVICE_OS_VERSION | OS version string (e.g. 14) |
WA_DEVICE_BUILD | Build fingerprint (e.g. UP1A.231005.007) |
WA_DEVICE_MODEL_ID | Model ID slug (e.g. samsung-sm-s928b) |
WA_DEVICE_RAM | Usable 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' })
WA_SIM_MCC | Mobile country code of the SIM (e.g. 226) |
WA_SIM_MNC | Mobile 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
WA_VERSION | Pin 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_TOKEN | Override the static token used in registration token computation. iOS only — Android has no static token. Overrides both the consumer and the Business constant. |
WA_BUSINESS | Register 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:
| 1 | WA_VERSION, from the shell or from a .env file in the directory the command runs in |
| 2 | the 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:
| iOS | looked up on the App Store | falls back to a version compiled into the library, which goes stale |
| Android | read from the APK the registration token material came from | wa 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
wa refresh-version 5568936182750
+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' })
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
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
.