upload
Upload media to WhatsApp’s CDN with automatic encryption.Vec<u8>
required
Raw media bytes to upload. The data is automatically encrypted using AES-256-CBC before uploading.
MediaType
required
Type of media being uploaded. Determines the CDN endpoint and encryption keys:
MediaType::Image- Images and stickersMediaType::Video- Video filesMediaType::Audio- Audio files and voice notesMediaType::Document- Documents and other filesMediaType::Sticker- Sticker imagesMediaType::StickerPack- Sticker pack ZIP filesMediaType::StickerPackThumbnail- Sticker pack thumbnail imagesMediaType::LinkThumbnail- Link preview thumbnailsMediaType::ProductCatalogImage- Product catalog images (unencrypted)
UploadOptions
required
Upload options. Use
Default::default() or UploadOptions::new() for default behavior. See UploadOptions below.struct
Contains all metadata needed to include the media in a message:Helper methods are provided for protobuf message construction, which requires
Vec<u8>:UploadResponse is #[non_exhaustive]. Field reads are unaffected; only exhaustive struct destructuring from outside the crate requires adding ...String
Full CDN URL where the encrypted file was uploaded
String
CDN path component (e.g.,
/v/t62.7118-24/12345_67890). Used for downloads.[u8; 32]
32-byte encryption key. Required for the recipient to decrypt the media. Use
media_key_vec() when building protobuf messages.[u8; 32]
SHA-256 hash of the encrypted file. Used for integrity verification during download. Use
file_enc_sha256_vec() when building protobuf messages.[u8; 32]
SHA-256 hash of the original (decrypted) file. Used for final validation after decryption. Use
file_sha256_vec() when building protobuf messages.u64
Original file size in bytes (before encryption)
i64
Unix timestamp (seconds) when the media key was generated. Set automatically by
upload() to the current time.Option<Vec<u8>>
Per-64-KiB HMAC-SHA256 table over the ciphertext that lets recipients seek and stream audio/video progressively without downloading the whole file. Generated automatically for audio and video uploads (
None otherwise). Assign it to AudioMessage.streaming_sidecar / VideoMessage.streaming_sidecar when building the message. Opt in/out explicitly with UploadOptions::with_streaming_sidecar(bool).Example: upload and send image
Example: upload video with progress
Example: upload document
Example: upload audio (voice note)
High-level message builders
Thewhatsapp_rust::media module turns an UploadResponse into a ready-to-send wa::Message, so you don’t hand-assemble the CDN/crypto fields (url, direct_path, media_key, file_sha256, file_enc_sha256, file_length, media_key_timestamp, streaming_sidecar) on every send. Each builder takes the upload result plus a typed options struct with sensible MIME defaults.
The
video_message and audio_message builders carry the upload’s streaming_sidecar (progressive-playback HMAC table) when present. For fields the options structs don’t expose (e.g. explicit image width/height), assemble the proto by hand as shown above.
UploadOptions
Options for customizing upload behavior.UploadOptions is #[non_exhaustive], so it cannot be constructed using struct literal syntax from outside the crate. Use UploadOptions::default() or UploadOptions::new() with builder methods instead.
Option<[u8; 32]>
default:"None"
When set, reuses the provided 32-byte media key instead of generating a new one. This is required when uploading a sticker pack thumbnail, which must share the same
media_key as the sticker pack ZIP.Option<bool>
default:"None"
Override streaming-sidecar generation.
None (default) generates the per-64-KiB HMAC-SHA256 streaming_sidecar only for audio and video; Some(true) forces it on; Some(false) forces it off. When produced, assign the resulting UploadResponse.streaming_sidecar to AudioMessage.streaming_sidecar / VideoMessage.streaming_sidecar so recipients can seek and stream progressively.Creating options
Example: Upload sticker pack thumbnail with shared key
MediaEncryptor
Chunk-based AES-256-CBC media encryptor. Processes plaintext incrementally without requiring a syncRead, enabling use with async streams, network sources, or any chunk-at-a-time producer.
Two output modes with zero duplicated crypto logic:
update()/finalize()— append encrypted blocks to aVec<u8>update_to_writer()/finalize_to_writer()— write to anyWriteimplementor you provide, batching output into ~8 KiB runs internally rather than staging the whole file
Creating an encryptor
MediaType
required
Type of media being encrypted. Determines the HKDF info string for key derivation.
[u8; 32]
Caller-supplied 32-byte key for
with_key(). Must be cryptographically random — reusing keys breaks confidentiality.Feeding plaintext
Feed plaintext in arbitrarily sized chunks. All complete blocks in a call are encrypted immediately. Only a trailing partial block (at most 15 bytes) is buffered across your calls.update_to_writer() also batches its ciphertext into ~8 KiB runs before writing, but only within a single call — it flushes everything to your writer before returning, so if you feed it small inputs you still get small, per-call writes rather than full 8 KiB runs. See the memory note below for details.
&[u8]
required
Plaintext bytes to encrypt. Can be any size — from a single byte to the entire file.
On
update_to_writer I/O error, the encryptor state is unspecified — discard it.Finalizing
Finalize applies PKCS7 padding to the remaining plaintext, encrypts the final block(s), and appends a 10-byte truncated HMAC-SHA256 MAC.struct
Encryption metadata (keys and hashes). The encrypted data was already written via
update/finalize calls.[u8; 32]
32-byte encryption key for the recipient to decrypt the media
[u8; 32]
SHA-256 hash of the original plaintext, computed on the fly during encryption
[u8; 32]
SHA-256 hash of the encrypted output (ciphertext + MAC)
u64
Original file size in bytes (before encryption)
Example: Chunk-based encryption to Vec
Example: Streaming encryption to a writer
The encryptor’s own internal state uses constant memory, regardless of file size, with one exception: the streaming sidecar. When enabled (the default for audio and video), it accumulates a per-64-KiB HMAC table — about 10 bytes per 64 KiB of ciphertext — until
finalize()/finalize_to_writer() returns it, so it grows slowly with file size even when your destination is a writer. This also doesn’t cover whatever destination you provide.With update()/finalize(), you’re only buffering the crypto state and at most 15 bytes of remainder plaintext. Your destination Vec itself still grows with the encrypted output.With update_to_writer()/finalize_to_writer(), ciphertext is also staged internally, bounding memory at crypto state + remainder + ~8 KiB. It’s flushed to your writer in ~8 KiB runs rather than once per 16-byte block, amortizing the Write call — but only within a single call, since nothing is held across calls. If you feed it small chunks (e.g. one 16-byte block per call), you still get small, per-call writes rather than 8 KiB runs. Your writer itself (e.g. a File) need not retain anything.Unlike encrypt_media_streaming, MediaEncryptor does not require a sync Read source, making it suitable for async contexts.Streaming encryption
For syncRead sources, encrypt_media_streaming provides a convenience wrapper around MediaEncryptor that reads in 8KB chunks:
R: Read
required
Source of plaintext media bytes. Can be a
File, Cursor<Vec<u8>>, or any Read implementor.W: Write
required
Destination for encrypted output. Receives ciphertext + 10-byte HMAC-SHA256 MAC — the exact bytes to upload to WhatsApp CDN.
MediaType
required
Type of media being encrypted. Determines the HKDF info string for key derivation.
Example: Encrypt from file to file
encrypt_media_streaming uses ~40KB of memory regardless of file size (8KB read buffer + crypto state). Internally it creates a MediaEncryptor and feeds 8KB chunks from the reader.encrypt_media_streaming_with_key
encrypt_media_streaming. It returns the same EncryptedMediaInfo (including the optional streaming_sidecar) but lets you control two things the convenience wrapper decides for you:
media_key— passSome(&key)to encrypt with a pre-existing 32-byte key (e.g. when re-encrypting for a shared-key sticker pack), orNoneto generate a fresh random key likeencrypt_media_streamingdoes.sidecar— passSome(true)/Some(false)to force the streaming sidecar on or off, orNoneto use the per-media-type default (generated for audio/video).
encrypt_media_streaming, which calls this with None, None.
Relationship to encrypt_media
When you call encrypt_media, it forwards through encrypt_media_with_key to encrypt_media_with_key_and_sidecar, which drives MediaEncryptor directly and sizes the output Vec exactly up front via encrypted_len:
data_to_upload used to start from Vec::new() and grow by doubling, so a 16 MiB video reallocated close to a dozen times on its way to 32 MiB of capacity — copying everything encrypted so far on each grow, and peaking (plaintext + old buffer + new buffer) near 64 MiB, about 4x the ~16 MiB ciphertext. encrypted_len (below) gives the exact output size up front, so this path now allocates once, peaking at plaintext + ciphertext ≈ 32 MiB instead.When to use each API
upload_stream (constant-memory)
upload() encrypts the whole plaintext into a Vec<u8> in memory before sending. For large files, upload_stream keeps memory constant by uploading already-encrypted ciphertext from any backing store (temp file, memory-mapped blob, …) without re-buffering it.
- Encrypt the plaintext into storage of your choice with
encrypt_media_streaming(orencrypt_media_streaming_with_key). It returns anEncryptedMediaInfocarrying the crypto metadata and the optionalstreaming_sidecar. - Pass that storage (as an
UploadSource) plus theEncryptedMediaInfotoupload_stream. The method never touches disk itself.
If the ciphertext already fits comfortably in memory, skip the custom source and pass it directly —
bytes::Bytes implements UploadSource, so client.upload_stream(bytes::Bytes::from(ciphertext_vec), info, media_type) works for the small-file case. The file-backed source above is what keeps memory constant for large media.UploadSource trait
upload_stream accepts anything implementing UploadSource:
Implement the trait yourself for a file-backed or network-backed source —
reader_from(offset) is what makes resumable uploads work without re-encrypting.
encrypted_len
Vec before streaming encryption, avoiding reallocation as the ciphertext grows.
upload() is unchanged and still the right choice when the plaintext fits in memory — both paths share the same retry/resumable-upload machinery. Reach for upload_stream only when you want to bound memory for large media.Resumable uploads
For files 5 MiB (5,242,880 bytes) or larger, the client automatically probes the CDN to check for a previous partial or complete upload of the same file before sending the full payload.Resume check flow
- A
POSTrequest is sent to the upload URL with?resume=1appended - The server responds with one of three states:
- Complete — the file already exists on the CDN. The existing
urlanddirect_pathare returned immediately with no upload - Resume — a partial upload exists. The client resumes from the given
byte_offset, appending&file_offset={offset}to the URL and sending only the remaining bytes - Not found — no previous upload exists. A full upload proceeds normally
- Complete — the file already exists on the CDN. The existing
The resume check is non-fatal. If the probe request itself fails (network error, unexpected response), the client silently falls back to a full upload. No special handling is needed in your code.
Resume check endpoint
Resumed upload endpoint
When a partial upload is detected at byte offsetN:
Media Encryption
All media uploaded to WhatsApp is end-to-end encrypted before transmission:Encryption Process
- Generate keys: Create random 32-byte
media_key - Derive keys: Use HKDF-SHA256 with media type info string to derive:
- 16-byte IV (initialization vector)
- 32-byte cipher key
- 32-byte MAC key
- Encrypt: Apply AES-256-CBC with PKCS7 padding
- Compute MAC: HMAC-SHA256 over IV + ciphertext, append first 10 bytes
- Upload: POST encrypted bytes to WhatsApp CDN
- Return metadata:
media_key, hashes, and CDN path for message
Key Derivation
Each media type uses a specific HKDF info string:media_key is shared with recipients through the encrypted message, allowing them to decrypt the media. ProductCatalogImage is the exception — it is uploaded without encryption and has no media key.
Encryption vs Decryption
The encryption process is the inverse of download decryption:MediaType
Specifies the type of media for encryption and CDN routing.variant
JPEG, PNG, or other image formats. Uses
/mms/image endpoint and "WhatsApp Image Keys" for HKDF.variant
MP4 or other video formats. Uses
/mms/video endpoint and "WhatsApp Video Keys" for HKDF.variant
Audio files and voice notes. Uses
/mms/audio endpoint and "WhatsApp Audio Keys" for HKDF.variant
PDF, DOCX, ZIP, and other document formats. Uses
/mms/document endpoint and "WhatsApp Document Keys" for HKDF.variant
Sticker images (WebP format). Uses
/mms/image endpoint and "WhatsApp Image Keys" for HKDF (same as images).variant
History sync data. Uses
/mms/md-msg-hist endpoint and "WhatsApp History Keys" for HKDF.variant
App state sync data. Uses
/mms/md-app-state endpoint and "WhatsApp App State Keys" for HKDF.variant
Sticker pack ZIP files. Uses
/mms/sticker-pack endpoint and "WhatsApp Sticker Pack Keys" for HKDF.variant
Sticker pack thumbnail images (JPEG). Uses
/mms/thumbnail-sticker-pack endpoint and "WhatsApp Sticker Pack Thumbnail Keys" for HKDF. Must be uploaded with the same media_key as the corresponding sticker pack ZIP — use UploadOptions::new().with_media_key(...).variant
Link preview thumbnails. Uses
/mms/thumbnail-link endpoint and "WhatsApp Link Thumbnail Keys" for HKDF.variant
Product catalog images for WhatsApp Business. Unencrypted —
is_encrypted() returns false. Uses /product/image endpoint (not under the /mms/ prefix). Matches WhatsApp Web’s CreateMediaKeys.js behavior which skips encryption for this type.Upload Endpoint
The upload endpoint is constructed as:{media_host}— CDN hostname from media connection (primary hosts tried first){upload_path}— Media type path fromMediaType::upload_path()(e.g.,/mms/image,/mms/video,/product/image){token}— Base64url-encodedfile_enc_sha256{auth}— Media connection auth token
Most media types use the
/mms/{type} prefix, but ProductCatalogImage uses /product/image directly. The upload_path() method returns the correct path for each media type.resume=1— probe for existing upload (resume check request)file_offset={N}— resume upload from byte offsetN
- Method:
POST - Content-Type:
application/octet-stream - Origin:
https://web.whatsapp.com - Body: Encrypted media bytes (or remaining bytes when resuming)
The upload automatically handles media connection refresh. If the connection is expired, it’s renewed before uploading. If the CDN returns HTTP 401 or 403, the client invalidates the cached credentials, fetches a fresh auth token, and retries the upload once.
Error Handling
Automatic retry on auth errors
The upload method automatically retries when WhatsApp’s CDN returns HTTP 401 or 403. On an auth error, the client invalidates the cached media connection, fetches fresh credentials from WhatsApp’s servers, and retries the upload once. If the retry also fails with an auth error, the error is returned to the caller. For non-auth HTTP errors (such as 500), the client tries the next available CDN host without refreshing credentials. Hosts are tried in priority order — primary hosts first, then fallback hosts.Recovering the host’s status by type
As of PR #1195,upload_error_from_response attaches the refusing host’s status as a typed HttpStatusError node, not only into the error message. If every host in the failover list refuses, the status of the last refusal survives into the returned error and is recoverable with ErrorChainExt::http_status() — no string matching required:
None doesn’t mean no HTTP exchange took place — only that no refused status was attached to this error. See download’s note on None for the same distinction on the mirrored path.
Common upload errors
error
CDN returned error status. Auth errors (401/403) are retried automatically with fresh credentials. Other errors are tried against alternate CDN hosts.
error
Media connection has no available CDN hosts.
error
Failed to encrypt media (rare, usually indicates invalid input).