download
Download and decrypt media from a message.&dyn Downloadable
required
Any message type that implements the
Downloadable trait. Includes:ImageMessageVideoMessageAudioMessageDocumentMessageStickerMessageExternalBlobReference(app state)HistorySyncNotification
Vec<u8>
Decrypted media bytes. For encrypted media (E2EE), automatically decrypts using AES-256-CBC and verifies HMAC-SHA256. For plaintext media (newsletters/channels), validates SHA-256 hash.
Example: download image
Example: download with error handling
Recovering the CDN status by type
download still returns bare anyhow::Error, but as of PR #1195 the status the CDN refused is recoverable by type via ErrorChainExt::http_status() instead of only by parsing the message:
None doesn’t mean no HTTP exchange took place — it means no refused status was attached. A socket that never connected reports None, but so does a body that downloaded successfully and then failed decryption or hash validation: neither case has a CDN status to recover, so there’s nothing to forward upstream. Inspect the underlying cause (cause.source() or the message) rather than assuming either case is always yours to fix.
Automatic retry and URL re-derivation
All download methods handle three categories of CDN errors automatically:- Auth errors (401/403): The client invalidates the cached media connection, fetches fresh credentials, and retries the download once.
- Media not found (404/410): When a media URL has expired or the file has been relocated, the CDN returns 404 or 410. The client treats this the same as an auth error — it invalidates the cached connection, re-derives download URLs with fresh credentials and hosts, and retries once. This matches WhatsApp Web’s
MediaNotFoundErrorhandling. - Other errors (e.g., 500): The client tries the next available CDN host without refreshing credentials. Hosts are tried in priority order (primary first, then fallback).
download_to_writer), every attempt — including the first — starts by truncating the writer to empty via DownloadWriter::truncate and rewinding it, so a host that streamed out plaintext before failing its MAC can’t leave a tail behind a shorter successful retry. If every host fails, the writer is truncated to empty one final time on a best-effort basis rather than left holding unverified bytes. For in-memory downloads (download()), each retry gets a fresh buffer rather than reusing the previous one, for the same reason.
If every retry is exhausted, the status of the final refusal survives into the error download/download_to_writer return — recover it with ErrorChainExt::http_status() as shown above rather than parsing the message.
This classification reads
status_code off a successfully completed HTTP exchange — it never sees a status that a custom HttpClient implementation turned into an Err. If you provide your own HttpClient, see the non-2xx-as-Ok contract it must follow for this retry logic to work at all.static_url media (newsletter/channel content fetched from a fixed CDN URL) skips the media-conn round trip entirely: download and its siblings only ask the server for hosts when the downloadable has no static_url.download_to_writer
Download media to a writer using streaming when available, with automatic buffered fallback. This is the method to use for downloading straight to a file — pass aFile or BufWriter<File>.
When the HTTP client supports streaming (supports_streaming() returns true), the entire HTTP download, decryption, and file write happen in a single blocking thread with ~40KB memory usage regardless of file size. When streaming is not available, the method automatically falls back to a buffered download — fetching the full response into memory, then decrypting and writing to the writer. This ensures download_to_writer works with any HttpClient implementation.
The writer must implement DownloadWriter rather than plain Write + Seek — see that section for why, and for what a custom writer needs to add.
&dyn Downloadable
required
Message containing downloadable media
W: DownloadWriter + Send + 'static
required
Writer for streaming output. Must implement
DownloadWriter and be Send + ‘static for use in blocking task.W
Returns the writer after a successful download, holding exactly the decrypted media and seeked back to position 0 — nothing a caller left in it beforehand, and no tail from a host that failed partway through, survives.
Example: streaming download
When using an HTTP client that supports streaming (like the default
UreqHttpClient), memory usage is constant ~40KB (8KB read buffer + decryption state). HTTP clients that don’t support streaming fall back to buffered downloads, which load the full file into memory before writing.If the download fails on every host, the writer is emptied too, on a best-effort basis: a sink that refuses to empty is logged rather than replacing the download’s own error. This only matters to a caller who kept a separate handle to the writer (e.g. a shared/cloneable writer), since
download_to_writer otherwise consumes it and returns nothing on failure.DownloadWriter trait
download_to_writer needs more than Write + Seek from its sink: it needs to be able to empty it.
seek alone can’t remove bytes that are already there — shortening a sink requires a concrete operation (File::set_len, Vec::truncate) that no std trait exposes. DownloadWriter::truncate names that operation, which is what lets download_to_writer guarantee: exactly the media on success, and empty on failure on a best-effort basis (a sink that refuses to empty during that final cleanup is logged, not treated as fatal — see the note above).
Every attempt begins by truncating the writer to 0, which clears its length, and then seeking it to 0, which resets its position — two separate operations that both matter on an append-mode File: appending forces every write to the file’s current end regardless of position, so truncating to 0 is what makes that end (and therefore the next write) land at the start; a bare seek would change the position but not the end, and appending would still write there. On success, download_to_writer performs one further seek(0) after the writer’s own bytes are in place, which is the “seeked back to position 0” postcondition on the returned writer described above.
Built-in implementations
Implementing DownloadWriter for a custom writer
Any writer type used withdownload_to_writer that isn’t one of the built-ins above — including a wrapper around one, like a progress-reporting adapter — needs its own DownloadWriter impl. For a wrapper, this is normally a one-line delegation:
ProgressWriter example, including its Write and Seek impls.
download_from_params
Download and decrypt media from raw CDN parameters without the original message. The parameters are bundled into aDownloadParams struct.
&DownloadParams
required
The CDN/crypto fields needed to fetch and decrypt the media. Build one with
DownloadParams::encrypted.Vec<u8>
Decrypted media bytes
Example: download from stored metadata
download_from_params_to_writer
Streaming variant ofdownload_from_params that writes to a writer. Same writer contract as download_to_writer: the writer must implement DownloadWriter, and holds exactly the decrypted media on success.
&DownloadParams
required
The CDN/crypto fields needed to fetch and decrypt the media. See
DownloadParams.W: DownloadWriter + Send + 'static
required
Writer for streaming output
W
Returns the writer after a successful download, holding exactly the decrypted media
DownloadParams
ADownloadable built from raw CDN fields, for re-downloading media without the original message.
DownloadParams::encrypted
Convenience constructor for encrypted (E2EE) media — fillsmedia_key and file_enc_sha256 as Some(...).
DownloadParams implements Downloadable, so it works with download, download_to_writer, download_from_params, download_from_params_to_writer, and MediaDownloader.
MediaDownloader
Downloads and decrypts media from the CDN with no connectedClient. Everything a download needs beyond the CDN hosts already lives in the Downloadable itself — MediaDownloader takes the hosts (and, optionally, an auth token) up front instead of asking a live session for them, so a persisted reference (for example a stored DownloadParams) stays downloadable after the client has disconnected.
A live Client still needs a session to fetch its hosts (cached and refreshed automatically, not re-fetched on every call — see Automatic retry and URL re-derivation); MediaDownloader is the path for callers that have no session to ask with at all — a background worker, a queue consumer, or a CLI tool operating on data a paired client saved earlier.
Arc<dyn HttpClient>
required
The same
HttpClient implementation you pass to ClientBuilder. See HTTP Client Trait.Arc<dyn Runtime>
required
Runtime abstraction used to run blocking decrypt/streaming work.
MediaRoute
required
The CDN hosts to try, in order, and an optional auth token. See
MediaRoute below.download and download_to_writer mirror Client::download and Client::download_to_writer — same streaming/buffered branching, same retry-on-another-host behavior across the route’s hosts, same writer contract for the streaming variant. The one difference is the auth-refresh budget: a Client gets one retry with freshly fetched credentials after an auth or not-found error; MediaDownloader has no session to refresh credentials from, so that class of error is terminal on the first attempt.
Example: download after the session is gone
MediaDownloadError
MediaDownloadError is #[non_exhaustive]; match it with a wildcard (_ => ) arm so a future variant doesn’t break your build.
Client::download and Client::download_to_writer keep returning anyhow::Error: they already try a credential refresh before the error would escape, so the extra classification has nothing left to add there.
MediaRoute and MediaHost
MediaRoute replaces the old MediaConnection type — it is the low-level input to DownloadUtils::prepare_download_requests and to MediaDownloader. Where MediaConnection required an auth: String, MediaRoute makes it auth: Option<String>, because the CDN gates a download on the signed direct_path and its hash token, not on a session credential.
MediaRoute’s Debug implementation is hand-written to print auth: Some("<redacted>") / auth: None instead of the token itself, so a stray {:?} or tracing field can’t leak a live credential into a log.fetch_sticker_pack
Fetch first-party sticker pack metadata (and the per-sticker download handles) from the WhatsApp CDN.&str
required
The first-party sticker pack ID (typically extracted from a received
sticker_pack_message).&str
required
BCP-47 locale tag for localized name / publisher strings. Pass
"en" to match whatsmeow’s default.StickerPack
Pack metadata plus a
Vec<StickerPackItem> of individual stickers. Each StickerPackItem implements Downloadable, so you can pass it straight to client.download(...).https://static.whatsapp.net/sticker?lottie=1&cat=sticker_pack_data&id={pack_id}&lg={locale}, parses the JSON envelope, and constructs the StickerPack. The endpoint is unauthenticated — the call works whether or not you are paired.
A non-2xx response fails with the same contract as download: the status is recoverable via ErrorChainExt::http_status() (added in PR #1195), not only by parsing the error message.
StickerPack
StickerPackItem
The CDN response is a JSON envelope, not a protobuf message —
StickerPack / StickerPackItem live in wacore::sticker_pack and are independent of waproto::whatsapp::StickerPackMessage (which represents the inline pack-bubble in a chat). The struct mirrors whatsmeow’s FirstPartyStickerPack.Downloadable Trait
TheDownloadable trait provides a generic interface for downloading media from any message type.
fn() -> Option<&str>
WhatsApp CDN path for the media file
fn() -> Option<&[u8]>
32-byte encryption key. Present for E2EE media,
None for plaintext (newsletter/channel) media.fn() -> Option<&[u8]>
SHA-256 hash of the encrypted file. Used for encrypted media validation.
fn() -> Option<&[u8]>
SHA-256 hash of the decrypted file. Used for plaintext media validation.
fn() -> Option<u64>
Original file size in bytes
fn() -> MediaType
Media type for HKDF key derivation (
Image, Video, Audio, Document, etc.)fn() -> Option<&str>
default:"None"
Static CDN URL for direct download. Present on newsletter/channel media, bypasses host construction.
fn() -> bool
default:"media_key().is_some()"
Returns
true if media is encrypted (has media_key), false for plaintext mediaBuilt-in Implementations
TheDownloadable trait is automatically implemented for:
wa::message::ImageMessagewa::message::VideoMessagewa::message::AudioMessagewa::message::DocumentMessagewa::message::StickerMessagewa::ExternalBlobReference(app state)wa::message::HistorySyncNotification
MediaType
Media type enum for encryption/decryption.Image/Sticker→"WhatsApp Image Keys"Video→"WhatsApp Video Keys"Audio→"WhatsApp Audio Keys"Document→"WhatsApp Document Keys"History→"WhatsApp History Keys"AppState→"WhatsApp App State Keys"StickerPack→"WhatsApp Sticker Pack Keys"StickerPackThumbnail→"WhatsApp Sticker Pack Thumbnail Keys"LinkThumbnail→"WhatsApp Link Thumbnail Keys"
MediaType methods
Upload paths
ProductCatalogImage is unencrypted — is_encrypted() returns false. This matches WhatsApp Web’s behavior where CreateMediaKeys.js skips encryption for product catalog images. Its upload path is /product/image (not under the /mms/ prefix like other media types).Media Decryption
WhatsApp uses different handling for encrypted (E2EE) and plaintext media:Encrypted Media (E2EE)
- Download encrypted bytes from CDN
- Verify HMAC-SHA256 (last 10 bytes)
- Decrypt using AES-256-CBC with keys derived from
media_keyvia HKDF - Return decrypted plaintext
media_key is expanded using HKDF-SHA256 to derive:
- 16-byte IV
- 32-byte cipher key
- 32-byte MAC key
Plaintext media (newsletter/channel)
- Download plaintext bytes from CDN (often via
static_url) - Verify SHA-256 hash matches
file_sha256 - Return plaintext (no decryption needed)
Newsletter and channel media is not encrypted. The library automatically detects this when
media_key is absent and switches to plaintext validation.Example: detect media type
DownloadUtils
Low-level static methods for media decryption and validation. These are re-exported fromwacore::download and useful when you need fine-grained control over the download pipeline.