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.
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.