message event as a WaIncomingMessageEvent.
The event payload
WaIncomingMessageEvent carries a rich key (a superset of Proto.IMessageKey) plus a few top-level fields. Pass the event (or just its key) verbatim to reply / edit / react / revoke / pin / keep.
You also receive your own outgoing messages here (multi-device sync), flagged with
key.fromMe === true. Filter them out if you only want inbound traffic.Extracting text
A message’s text lives in different fields depending on its type. A small helper covers the common cases:Identifying the message type
message is a protobuf union — inspect which field is set:
Sending receipts
client.message.sendReceipt marks messages as received/read/played. The easiest form takes the event(s) directly:
Calls
Read-only Incoming call signaling surfaces as the read-onlycall event (WaIncomingCallEvent). zapo reports calls — it does not place, accept, or reject them.
type (the signaling stage), callId, callCreatorJid / callerPnJid (who’s calling), isVideo, groupJid (group calls), and callerPushName. There is no API to answer a call.
Addons
Addons are encrypted follow-ups attached to a message: reactions, poll votes, and comments. They surface as themessage_addon event.
Automatic decryption
Addons are decrypted and emitted for you by default — just subscribe tomessage_addon:
Manual decryption
Passaddons: { autoDecrypt: false } to receive the encrypted payload and decrypt on demand from the originating message event:
Recovering unavailable messages
Some<message> stanzas arrive as an <unavailable/> placeholder rather than an encrypted body — a consumed view-once, a hosted or bot message the server could not fan out, or a plain fanout placeholder the primary still holds the plaintext for. All four flavors surface as the message_unavailable event with a kind discriminator.
Plain fanout placeholders (kind === 'other') are recoverable: the lib queues a PLACEHOLDER_MESSAGE_RESEND peer request to the primary device, and the recovered payload arrives later as a regular message event with the same key.id. Consumers know to wait for it via resendRequested:
resendRequested is false for the unrecoverable kinds ('view_once', 'hosted', 'bot'), for stanzas past the server age window (driven by the placeholder_message_resend_maximum_days_limit AB prop, not a hardcoded 30 days), and on mobile-primary sessions (there is no other device to ask). The lib does not time the pending resend out — a real primary can answer past the 30s peer-request default; wa-web behaves the same way.
The retry ladder comes first
A message that fails to decrypt is not handed straight to the placeholder resend. zapo first climbs the Signal retry ladder, because the sender only rebuilds its session once the receipt count goes high enough:Before 1.8.1 the handoff happened at count 3, which consumed that attempt and suppressed its receipt: the ladder stopped at 2, the peer kept re-encrypting on the same broken session, and the peer-data response came back over it and failed to decrypt too — leaving the message unrecoverable behind a message too far in future error. Moving the handoff to count 4 lets the session rebuild actually happen.
Protocol messages
Edits, revokes, and other protocol-level updates arrive onmessage_protocol as WaIncomingProtocolMessageEvent (it extends the message event with a protocolMessage field):
Requesting older history
The initial pairing flow streams a bounded window of message history. To pull older messages for a specific chat on demand, callclient.message.requestHistorySync:
history_sync_chunk event, same as the bootstrap history. Subscribe before calling if you need to react to it:
oldestMsgId, oldestMsgFromMe, and oldestMsgTimestampMs from the topmost message currently visible to page backwards correctly. Omit count to let the server apply its own default (~50).
Receipts (inbound)
When others read or play your messages, you receivereceipt events:
event.messageIds is the full set of stanza ids this receipt acknowledges. WhatsApp batches read/delivery receipts into a single <receipt> carrying a <list><item id=…/> block — messageIds mirrors wa-web’s externalIds: list items first, then event.stanzaId appended last. Single-message receipts contain just [event.stanzaId].
WaIncomingReceiptEvent also carries participantUsername?: string — the participant’s handle without the @, read from the stanza’s participant_username attribute when present.
receipt events still expose stanzaId / chatJid directly (they extend WaIncomingBaseEvent); the rename only applies to message, message_addon, and message_bot_chunk payloads, which now use event.key. event.stanzaId is still the last id in the batch — iterate messageIds to cover the rest.