Baileys uses WhatsApp’s binary protocol for efficient communication. This guide explains the protocol structure and how messages are encoded/decoded.
Binary Protocol Overview
WhatsApp uses a custom binary protocol instead of plain XML to reduce bandwidth and improve performance. Messages are encoded as binary nodes with a compact token-based encoding system.
Key Components
- Binary Nodes: Structured data units with tags, attributes, and content
- Token Dictionary: Predefined tokens for common strings
- Binary Encoding: Efficient byte-level encoding
- Compression: Optional frame compression
Binary Node Structure
From src/WABinary/types.ts:9-13:
export type BinaryNode = {
tag: string
attrs: { [key: string]: string }
content?: BinaryNode[] | string | Uint8Array
}
Components
Tag: Identifies the node type
const node = {
tag: 'message', // Node type
attrs: { ... },
content: [ ... ]
}
Attributes: Key-value metadata
const node = {
tag: 'iq',
attrs: {
id: 'msg-id-123',
type: 'get',
xmlns: 'encrypt'
},
content: [ ... ]
}
Content: Can be nested nodes, strings, or binary data
// Nested nodes
const nodeWithChildren = {
tag: 'iq',
attrs: { id: '1' },
content: [
{ tag: 'query', attrs: {}, content: 'some data' },
{ tag: 'list', attrs: {}, content: [ ... ] }
]
}
// String content
const textNode = {
tag: 'body',
attrs: {},
content: 'Hello, World!'
}
// Binary content
const binaryNode = {
tag: 'enc',
attrs: {},
content: new Uint8Array([1, 2, 3, 4])
}
Token-Based Encoding
To reduce message size, WhatsApp uses a dictionary of predefined tokens for common strings.
Token Types
From src/WABinary/constants.ts:1-19:
export const TAGS = {
LIST_EMPTY: 0,
DICTIONARY_0: 236,
DICTIONARY_1: 237,
DICTIONARY_2: 238,
DICTIONARY_3: 239,
INTEROP_JID: 245,
FB_JID: 246,
AD_JID: 247,
LIST_8: 248,
LIST_16: 249,
JID_PAIR: 250,
HEX_8: 251,
BINARY_8: 252,
BINARY_20: 253,
BINARY_32: 254,
NIBBLE_8: 255,
PACKED_MAX: 127
}
Single-Byte Tokens
Common strings encoded as single bytes (0-235):
// From constants.ts:1056-1293
export const SINGLE_BYTE_TOKENS = [
'',
'xmlstreamstart',
'xmlstreamend',
's.whatsapp.net',
'type',
'participant',
'from',
'receipt',
'id',
'notification',
// ... 200+ more tokens
]
Example: The string "type" is encoded as byte 4 instead of 4 bytes.
Double-Byte Tokens
Less common strings use two bytes (dictionary index + token index):
// From constants.ts:21-1054
export const DOUBLE_BYTE_TOKENS = [
[ // Dictionary 0
'read-self',
'active',
'fbns',
'protocol',
// ...
],
[ // Dictionary 1
'reject',
'dirty',
'announcement',
// ...
],
// ... more dictionaries
]
Encoding Process
From src/WABinary/encode.ts:5-12:
export const encodeBinaryNode = (
node: BinaryNode,
opts = constants,
buffer: number[] = [0]
): Buffer => {
const encoded = encodeBinaryNodeInner(node, opts, buffer)
return Buffer.from(encoded)
}
Encoding Steps
- Write list header - Number of elements (tag + attributes + content)
- Write tag - Encoded as token or raw string
- Write attributes - Each key-value pair encoded
- Write content - Recursively encode based on type
String Encoding
From src/WABinary/encode.ts:179-209:
const writeString = (str?: string) => {
if (str === undefined || str === null) {
pushByte(TAGS.LIST_EMPTY)
return
}
// Check if string is in token map
const tokenIndex = TOKEN_MAP[str]
if (tokenIndex) {
if (typeof tokenIndex.dict === 'number') {
pushByte(TAGS.DICTIONARY_0 + tokenIndex.dict)
}
pushByte(tokenIndex.index)
}
// Check if string can be packed as nibbles (numbers, -, .)
else if (isNibble(str)) {
writePackedBytes(str, 'nibble')
}
// Check if string can be packed as hex
else if (isHex(str)) {
writePackedBytes(str, 'hex')
}
// Try to encode as JID
else {
const decodedJid = jidDecode(str)
if (decodedJid) {
writeJid(decodedJid)
} else {
writeStringRaw(str) // Fallback: raw UTF-8
}
}
}
Packed Encoding
Nibble Packing: Stores numbers and special chars (-, .) as 4-bit values
// "123-456" packed into fewer bytes
const packNibble = (char: string) => {
switch (char) {
case '-': return 10
case '.': return 11
case '\0': return 15
default:
if (char >= '0' && char <= '9') {
return char.charCodeAt(0) - '0'.charCodeAt(0)
}
}
}
Hex Packing: Stores hex strings (0-9, A-F) efficiently
// "ABCD1234" packed as 4-bit values
const packHex = (char: string) => {
if (char >= '0' && char <= '9') {
return char.charCodeAt(0) - '0'.charCodeAt(0)
}
if (char >= 'A' && char <= 'F') {
return 10 + char.charCodeAt(0) - 'A'.charCodeAt(0)
}
// ...
}
JID Encoding
WhatsApp IDs (JIDs) are specially encoded:
const writeJid = ({ domainType, device, user, server }: FullJid) => {
if (typeof device !== 'undefined') {
// Device JID (e.g., "123@s.whatsapp.net:1")
pushByte(TAGS.AD_JID)
pushByte(domainType || 0)
pushByte(device || 0)
writeString(user)
} else {
// Simple JID pair (e.g., "123@s.whatsapp.net")
pushByte(TAGS.JID_PAIR)
if (user.length) {
writeString(user)
} else {
pushByte(TAGS.LIST_EMPTY)
}
writeString(server)
}
}
Decoding Process
From src/WABinary/decode.ts:9-18:
export const decompressingIfRequired = async (buffer: Buffer) => {
if (2 & buffer.readUInt8()) {
// Bit 1 set: compressed
buffer = await inflatePromise(buffer.slice(1))
} else {
// No compression, skip 0x00 prefix
buffer = buffer.slice(1)
}
return buffer
}
Decoding Steps
- Decompress - If compression flag is set
- Read list size - Number of elements
- Read tag - First element is always the tag
- Read attributes - Pairs of key-value strings
- Read content - If list size is even, read content
From src/WABinary/decode.ts:251-296:
const listSize = readListSize(readByte()!)
const header = readString(readByte()!)
const attrs: BinaryNode['attrs'] = {}
let data: BinaryNode['content']
// Read attributes (pairs)
const attributesLength = (listSize - 1) >> 1
for (let i = 0; i < attributesLength; i++) {
const key = readString(readByte()!)
const value = readString(readByte()!)
attrs[key] = value
}
// Read content if present
if (listSize % 2 === 0) {
const tag = readByte()!
if (isListTag(tag)) {
data = readList(tag) // Child nodes
} else {
// Binary or string content
switch (tag) {
case TAGS.BINARY_8:
data = readBytes(readByte()!)
break
case TAGS.BINARY_20:
data = readBytes(readInt20())
break
case TAGS.BINARY_32:
data = readBytes(readInt(4))
break
default:
data = readString(tag)
}
}
}
return { tag: header, attrs, content: data }
WhatsApp frames are wrapped with:
From src/Defaults/index.ts:34:
export const NOISE_WA_HEADER = Buffer.from([87, 65, 6, DICT_VERSION])
// WA (0x57 0x41) + Protocol Version (6) + Dictionary Version (3)
Compression
Frames can be optionally compressed using zlib:
// First byte indicates compression
// 0x00 = uncompressed
// 0x02 = compressed (zlib inflate)
if (2 & buffer.readUInt8()) {
buffer = await inflatePromise(buffer.slice(1))
}
Common Node Examples
Query Node
const queryNode: BinaryNode = {
tag: 'iq',
attrs: {
id: 'msg-123',
type: 'get',
xmlns: 'encrypt',
to: 's.whatsapp.net'
},
content: [
{
tag: 'count',
attrs: {},
content: undefined
}
]
}
This queries the server for pre-key count.
Message Node
const messageNode: BinaryNode = {
tag: 'message',
attrs: {
id: 'msg-456',
type: 'text',
from: '1234567890@s.whatsapp.net',
to: '0987654321@s.whatsapp.net',
t: '1678901234'
},
content: [
{
tag: 'enc',
attrs: { v: '2', type: 'msg' },
content: new Uint8Array([/* encrypted data */])
}
]
}
Presence Node
const presenceNode: BinaryNode = {
tag: 'presence',
attrs: {
name: 'John Doe',
type: 'available'
}
}
Working with Binary Nodes
Creating Nodes
import { encodeBinaryNode } from '@whiskeysockets/baileys'
const node: BinaryNode = {
tag: 'iq',
attrs: { id: '1', type: 'get' },
content: [
{ tag: 'ping', attrs: {} }
]
}
const encoded = encodeBinaryNode(node)
// Send via WebSocket
Parsing Nodes
import { decodeBinaryNode } from '@whiskeysockets/baileys'
const buffer = Buffer.from([/* binary data */])
const node = await decodeBinaryNode(buffer)
console.log('Tag:', node.tag)
console.log('Attrs:', node.attrs)
console.log('Content:', node.content)
import {
getBinaryNodeChild,
getBinaryNodeChildren,
getAllBinaryNodeChildren
} from '@whiskeysockets/baileys'
// Get first child with specific tag
const countNode = getBinaryNodeChild(node, 'count')
// Get all children with specific tag
const refNodes = getBinaryNodeChildren(node, 'ref')
// Get all child nodes (of any type)
const allChildren = getAllBinaryNodeChildren(node)
Converting to String (Debug)
import { binaryNodeToString } from '@whiskeysockets/baileys'
const xmlString = binaryNodeToString(node)
console.log(xmlString)
// Output: <iq id="1" type="get"><ping/></iq>
Protocol Constants
From src/Defaults/index.ts:
// Noise protocol header
export const NOISE_MODE = 'Noise_XX_25519_AESGCM_SHA256\0\0\0\0'
export const NOISE_WA_HEADER = Buffer.from([87, 65, 6, 3])
// Dictionary version
export const DICT_VERSION = 3
// Key bundle type
export const KEY_BUNDLE_TYPE = Buffer.from([5])
// Default ephemeral message duration
export const WA_DEFAULT_EPHEMERAL = 7 * 24 * 60 * 60
Best Practices
Use Helper Functions: Baileys provides utilities for working with binary nodes. Use them instead of manual parsing.
// Good
const child = getBinaryNodeChild(node, 'query')
// Avoid
const child = Array.isArray(node.content)
? node.content.find(n => typeof n === 'object' && n.tag === 'query')
: null
Type Safety: Always check content types before accessing:
if (Array.isArray(node.content)) {
// Process as child nodes
for (const child of node.content) {
if (typeof child === 'object' && child.tag) {
// Process child node
}
}
} else if (typeof node.content === 'string') {
// Process as text
} else if (node.content instanceof Uint8Array) {
// Process as binary
}
Enable trace logging to see binary node XML representations:
import makeWASocket from '@whiskeysockets/baileys'
import P from 'pino'
const sock = makeWASocket({
logger: P({ level: 'trace' })
})
Output example:
{
"xml": "<iq id='1' type='get'><ping/></iq>",
"msg": "xml send"
}
Learn More
To understand the underlying cryptography:
- Libsignal Protocol: End-to-end encryption protocol
- Noise Protocol Framework: Handshake patterns and session keys
Next Steps