Skip to main content
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

  1. Write list header - Number of elements (tag + attributes + content)
  2. Write tag - Encoded as token or raw string
  3. Write attributes - Each key-value pair encoded
  4. 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

  1. Decompress - If compression flag is set
  2. Read list size - Number of elements
  3. Read tag - First element is always the tag
  4. Read attributes - Pairs of key-value strings
  5. 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 }

Frame Format

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)

Extracting 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
}

Debugging Tools

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