Skip to main content

download

Download and decrypt media from a message.
Only use download when you need the plaintext bytes (processing, transcoding, re-upload). To forward existing media unchanged, reuse the original message’s CDN fields directly — no download required. See media forwarding via CDN reuse.
download and its siblings need a connected Client for most media — they ask the server for CDN hosts and an auth token (cached and refreshed automatically, not fetched on every call). The exception is static_url media (newsletter/channel content), which skips that round trip entirely — see the note below. If you’ve persisted a message’s CDN fields and want to download after the session has disconnected, use MediaDownloader instead — it needs no Client at all.
&dyn Downloadable
required
Any message type that implements the Downloadable trait. Includes:
  • ImageMessage
  • VideoMessage
  • AudioMessage
  • DocumentMessage
  • StickerMessage
  • ExternalBlobReference (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 MediaNotFoundError handling.
  • 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).
For streaming downloads (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 a File 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

Breaking change (as of PR #1197): download_to_writer, download_from_params_to_writer, and MediaDownloader::download_to_writer now take W: DownloadWriter instead of W: Write + Seek. std::fs::File, std::io::Cursor<Vec<u8>>, std::io::Cursor<&mut Vec<u8>>, std::io::BufWriter<W: DownloadWriter>, and &mut W where W: DownloadWriter all implement it already, so call sites using those types need no changes. A custom writer type needs one additional method — see below.
download_to_writer needs more than Write + Seek from its sink: it needs to be able to empty it.
Media is authenticated by a single MAC over the whole ciphertext, so decryption has necessarily streamed plaintext into the writer by the time a forged body is caught, and a retry against the next host may end up writing fewer bytes than the attempt it replaces. Rewinding with 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 with download_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:
See Streaming with Progress for the full 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 a DownloadParams 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

DownloadParams implements Downloadable, so you can also pass it straight to download: client.download(&params).await?.

download_from_params_to_writer

Streaming variant of download_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

A Downloadable built from raw CDN fields, for re-downloading media without the original message.

DownloadParams::encrypted

Convenience constructor for encrypted (E2EE) media — fills media_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 connected Client. 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

with_default_hosts routes through DEFAULT_MEDIA_HOSTS with no auth token, which matches how WhatsApp Web’s own download URL builder works — auth is an upload concern, not a download one. Pass an explicit MediaRoute to new if you need specific hosts or want to carry a token you obtained another way.

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.
Breaking change (as of PR #1194): wacore::download::MediaConnection ({ hosts: Vec<MediaHost>, auth: String }) no longer exists. If you built one directly and passed it to DownloadUtils::prepare_download_requests, migrate by constructing the equivalent MediaRoute in its place: MediaRoute::authenticated(hosts, auth). (The From<&MediaConn> for MediaRoute impl is unrelated to this migration — it converts whatsapp_rust’s own mediaconn::MediaConn, the server response a connected Client refreshes internally, and is what Client::prepare_requests uses under the hood.) Client::download and friends are unaffected either way; this only touches callers using the lower-level DownloadUtils type directly.

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(...).
Under the hood the client GETs 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

The Downloadable 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 media

Built-in Implementations

The Downloadable trait is automatically implemented for:
  • wa::message::ImageMessage
  • wa::message::VideoMessage
  • wa::message::AudioMessage
  • wa::message::DocumentMessage
  • wa::message::StickerMessage
  • wa::ExternalBlobReference (app state)
  • wa::message::HistorySyncNotification

MediaType

Media type enum for encryption/decryption.
Each media type has specific HKDF info strings used for key derivation:
  • 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 unencryptedis_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)

  1. Download encrypted bytes from CDN
  2. Verify HMAC-SHA256 (last 10 bytes)
  3. Decrypt using AES-256-CBC with keys derived from media_key via HKDF
  4. Return decrypted plaintext
The media_key is expanded using HKDF-SHA256 to derive:
  • 16-byte IV
  • 32-byte cipher key
  • 32-byte MAC key

Plaintext media (newsletter/channel)

  1. Download plaintext bytes from CDN (often via static_url)
  2. Verify SHA-256 hash matches file_sha256
  3. 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 from wacore::download and useful when you need fine-grained control over the download pipeline.

Key methods