Skip to main content
Media is sent through the same client.message.send method, using a typed media content object. The builder fills in the protocol-managed fields (encryption keys, SHA-256 digests, direct path, upload) for you — you provide the source and, optionally, a mimetype.
For usable media, install @zapo-js/media-utils and wire a processor through the media client option. Media still uploads without it, but without a processor it has no thumbnail/preview, dimensions, or waveform — so it may arrive as a plain attachment.

Mimetype resolution

mimetype is optional. The builder resolves it in this order:
  1. The mimetype you pass on the content object wins.
  2. If a WaMediaProcessor with detectMimetype is configured, the builder calls it (sniffing magic bytes). @zapo-js/media-utils implements this on top of file-type ^19 — install file-type to enable detection.
  3. Otherwise the builder throws for image/video/audio/document/ptv messages.
Stickers default to image/webp when no mimetype is set. Readable stream inputs with no mimetype are staged to a temp file before detection runs.

Media input

The media field accepts several input types:
Prefer a file path (string) or a Readable stream over a Buffer/Uint8Array. zapo streams media through the pipeline without buffering the whole file in memory — passing a path or stream keeps memory flat regardless of file size. Reading a large file into a Buffer first defeats that and is discouraged. (Buffer is also avoided internally in favor of Uint8Array.)

Images

Video

For a round push-to-video (PTV) message, use type: 'ptv' with the same shape.

Audio & voice notes

Voice notes render best as Opus in an OGG container. Enable media processing to auto-generate waveforms and normalize voice notes.

Documents

Stickers

For a full sticker pack, use type: 'sticker-pack' with stickers, a trayIcon, and pack metadata (stickerPackId, name, publisher).

View-once

Wrap image/video/audio as view-once with the send option:

Pre-upload and reuse

Sometimes you want to encrypt and upload media once and then reference the same descriptor across multiple sends — a broadcast asset, a stock reply, or building a raw proto payload yourself. client.message.upload(source, options) runs the encrypt / media_conn / CDN upload / parse flow the send path uses, but returns the descriptor without sending anything.
The upload runs against your connected session (the host token comes from a media_conn IQ). Uint8Array sources take a zero-temp-file fast path; a file path or Readable stream is staged to a temp file so the encrypt pass can hash it deterministically.

WaUploadMediaOptions

WaMediaUploadResult

mediaKey is sensitive key material — treat it like a password. Don’t log it, ship it to third parties, or persist it in unencrypted stores. Anyone with the key can decrypt the CDN blob.

Standalone crypto & transfer

For workflows that need to run encryption/decryption outside a session — a background worker crunching stored ciphertext, a custom relay — WaMediaCrypto and WaMediaTransferClient (plus their result and option types) are re-exported from the package root:
Same primitives the upload / download paths use; the coordinator method just wraps them with the session-bound media_conn handshake.

Downloading incoming media

The message coordinator decrypts and downloads media from an incoming event. Three flavors are available — prefer the streaming ones:
download() / downloadToFile() stream the media and keep memory flat regardless of size. downloadBytes() materializes the whole file in memory — reach for it only on small media, and cap it with maxBytes.
All three accept either a WaIncomingMessageEvent or a raw Proto.IMessage, plus optional WaDownloadMediaOptions (for example maxBytes to cap downloadBytes).

Without a connected client

downloadMediaMessage is a free function that mirrors client.message.download but does not need a paired session. The encrypted-media metadata travels inside the (already decrypted) message itself, so you can re-download media from a persisted event long after the original socket is gone — useful for offline workers, archive replays, or anything that processes stored messages without spinning up a WaClient.
Accepts a WaIncomingMessageEvent or a raw Proto.IMessage, returns a Readable you own (pipe it or .destroy() it — an unconsumed stream leaks the socket). MAC + SHA-256 verification runs as bytes are consumed, same semantics as the coordinator method. Throws when the message has no downloadable media. For lower-level access — when you want to do the CDN fetch yourself, hand the keys to another process, or just inspect what’s downloadable — resolveMediaPayload returns the keys + hashes without doing any I/O:
Returns null when the message has no downloadable media, or when the proto carried no directPath / mediaKey. It unwraps ephemeralMessage, viewOnceMessage / viewOnceMessageV2, and documentWithCaptionMessage before resolving. Supported kinds: image, video (gif when gifPlayback), audio (ptt when ptt), document, sticker, ptv.
payload.mediaKey is the AES/MAC seed for the encrypted blob — treat it like a secret. Don’t log it, don’t put it in error messages, and don’t ship it to a third-party service unless that’s the whole point of your pipeline.

Requesting a reupload

The CDN drops old media blobs — a download* on a message surfaced by history sync can answer 404/410 well after the original send. client.message.requestMediaReupload() runs the round-trip WhatsApp Web uses to recover: encrypt a server-error receipt with the message’s media key, send it as an ack, and await the mediaretry notification the sender’s primary device answers with. On success only the directPath changes — the media key, hashes, and length of the original message stay valid, so downloading with a spread of the new path works.
Pass an explicit WaMediaRetryRequest ({ messageId, chatJid, mediaKey, fromMe, participant? }) when you hold the ids and the media key but not the decoded message — the event form pulls those out for you and rejects newsletter messages plus messages with no downloadable media. options.timeoutMs bounds the wait for the notification; the coordinator does not throw on not_found / general_error (the sender no longer holds the file, or the primary could not re-seal it) — check result.result for one of 'success' | 'not_found' | 'decryption_error' | 'general_error'.
Requests are deduplicated by messageId: concurrent calls for the same message share one round-trip and one server-error receipt.

Media processing

For proper media, use a media processor. Install @zapo-js/media-utils and pass one through the media client option — it probes and processes media (dimensions, duration, thumbnails, waveforms, voice-note normalization) before upload. Without it, media still uploads but lacks this processing:
@zapo-js/media-utils shells out to ffmpeg/ffprobe and uses sharp. Make sure those binaries are available in your environment.
Last modified on August 15, 2026