Skip to main content

Overview

This guide covers uploading and downloading media (images, videos, audio, documents, stickers) with automatic encryption and decryption.

Downloading Media

From Message Objects

Download media directly from message types that implement Downloadable:
use wacore::download::Downloadable;
use whatsapp_rust::client::Client;

match event {
    Event::Message(message, info) => {
        // Image
        if let Some(img) = &message.image_message {
            let data = client.download(img.as_ref()).await?;
            std::fs::write("image.jpg", data)?;
        }
        
        // Video
        if let Some(video) = &message.video_message {
            let data = client.download(video.as_ref()).await?;
            std::fs::write("video.mp4", data)?;
        }
        
        // Audio
        if let Some(audio) = &message.audio_message {
            let data = client.download(audio.as_ref()).await?;
            let ext = if audio.ptt() { "ogg" } else { "mp3" };
            std::fs::write(format!("audio.{}", ext), data)?;
        }
        
        // Document
        if let Some(doc) = &message.document_message {
            let data = client.download(doc.as_ref()).await?;
            let filename = doc.file_name.as_deref().unwrap_or("document");
            std::fs::write(filename, data)?;
        }
        
        // Sticker
        if let Some(sticker) = &message.sticker_message {
            let data = client.download(sticker.as_ref()).await?;
            std::fs::write("sticker.webp", data)?;
        }
    }
    _ => {}
}
See Download API reference for full details.

Streaming Download

For large files, use streaming to avoid memory overhead:
use std::fs::File;

if let Some(video) = &message.video_message {
    let file = File::create("video.mp4")?;
    let file = client.download_to_writer(video.as_ref(), file).await?;
    println!("Video downloaded with constant memory usage!");
}
See Download API reference for streaming details.
When the HTTP client supports streaming (like the default UreqHttpClient), downloads use ~40KB of memory regardless of file size (8KB buffer + decryption state). If the HTTP client doesn’t support streaming, download_to_writer automatically falls back to a buffered download — the file is fetched into memory first, then decrypted and written. This fallback ensures the method works with any HttpClient implementation.

From Raw Parameters

Download media when you have the raw encryption parameters:
use wacore::download::MediaType;

let data = client.download_from_params(
    "/v/t62.1234-5/...",          // direct_path
    &media_key,                    // media_key (32 bytes)
    &file_sha256,                  // file_sha256
    &file_enc_sha256,              // file_enc_sha256
    12345,                         // file_length
    MediaType::Image,              // media_type
).await?;

std::fs::write("downloaded.jpg", data)?;
See Download API reference for raw parameter downloads.

Streaming from Raw Parameters

let file = File::create("large_video.mp4")?;
let file = client.download_from_params_to_writer(
    direct_path,
    &media_key,
    &file_sha256,
    &file_enc_sha256,
    file_length,
    MediaType::Video,
    file,
).await?;
See Download API reference for streaming from raw parameters.

Thumbnails

Thumbnails are not generated by the library — you provide them as raw JPEG bytes when constructing media messages. WhatsApp clients use these thumbnails as low-resolution previews while the full media is downloading.

Setting thumbnails on media messages

Set the jpeg_thumbnail field on image, video, or document messages:
use waproto::whatsapp as wa;

let upload = client.upload(image_data, MediaType::Image, Default::default()).await?;

let message = wa::Message {
    image_message: Some(Box::new(wa::message::ImageMessage {
        url: Some(upload.url),
        direct_path: Some(upload.direct_path),
        media_key: Some(upload.media_key_vec()),
        file_sha256: Some(upload.file_sha256_vec()),
        file_enc_sha256: Some(upload.file_enc_sha256_vec()),
        file_length: Some(upload.file_length),
        media_key_timestamp: Some(upload.media_key_timestamp),
        mimetype: Some("image/jpeg".to_string()),
        jpeg_thumbnail: Some(thumbnail_bytes), // raw JPEG bytes
        width: Some(1920),
        height: Some(1080),
        ..Default::default()
    })),
    ..Default::default()
};

Thumbnail fields by message type

Message typeInline thumbnail fieldServer-hosted thumbnail fields
ImageMessagejpeg_thumbnailthumbnail_direct_path, thumbnail_sha256, thumbnail_enc_sha256
VideoMessagejpeg_thumbnailthumbnail_direct_path, thumbnail_sha256, thumbnail_enc_sha256
DocumentMessagejpeg_thumbnailthumbnail_direct_path, thumbnail_sha256, thumbnail_enc_sha256
StickerMessagepng_thumbnail
Inline thumbnails (jpeg_thumbnail / png_thumbnail) are embedded directly in the encrypted message payload. Server-hosted thumbnail fields (thumbnail_direct_path, etc.) reference a separately uploaded thumbnail that clients can download independently. For most use cases, inline thumbnails are sufficient.

Reusing thumbnails from incoming messages

When re-uploading media (for example, after processing or transcoding), you can clone the thumbnail from the original message:
if let Some(img) = &message.image_message {
    let data = client.download(img.as_ref()).await?;
    let upload = client.upload(data, MediaType::Image, Default::default()).await?;

    let reply = wa::Message {
        image_message: Some(Box::new(wa::message::ImageMessage {
            url: Some(upload.url),
            direct_path: Some(upload.direct_path),
            media_key: Some(upload.media_key_vec()),
            file_sha256: Some(upload.file_sha256_vec()),
            file_enc_sha256: Some(upload.file_enc_sha256_vec()),
            file_length: Some(upload.file_length),
            media_key_timestamp: Some(upload.media_key_timestamp),
            mimetype: img.mimetype.clone(),
            jpeg_thumbnail: img.jpeg_thumbnail.clone(), // reuse from original
            height: img.height,
            width: img.width,
            ..Default::default()
        })),
        ..Default::default()
    };
}
If you don’t need to modify the media, use CDN reuse instead — it forwards the entire message (including thumbnails) without downloading or re-uploading anything.
The LinkThumbnail variant in MediaType supports downloading thumbnails for URL previews in ExtendedTextMessage:
use wacore::download::MediaType;

// LinkThumbnail uses HKDF info "WhatsApp Link Thumbnail Keys"
let thumbnail = client.download_from_params(
    thumbnail_direct_path,
    &thumbnail_media_key,
    &thumbnail_sha256,
    &thumbnail_enc_sha256,
    thumbnail_length,
    MediaType::LinkThumbnail,
).await?;

Forwarding media via CDN reuse

When you receive a media message (image, video, audio, document, sticker), you can “forward” it by reusing the original CDN fields directly — no download or re-upload needed. This is instant regardless of file size because no media bytes are transferred. The technique works because every media message contains all the CDN metadata (url, direct_path, media_key, file_sha256, etc.) needed for recipients to fetch the blob. You simply clone those fields into a new wa::Message and call send_message:
use waproto::whatsapp as wa;

/// Reuses the original CDN blob, only swaps the caption.
/// Instant regardless of file size — no download or re-upload.
fn forward_image(original: &wa::message::ImageMessage) -> wa::Message {
    wa::Message {
        image_message: Some(Box::new(wa::message::ImageMessage {
            caption: Some("Forwarded image".to_string()),
            ..*original.clone() // reuses url, direct_path, media_key, hashes, thumbnail, etc.
        })),
        ..Default::default()
    }
}

// In your message handler:
if let Some(img) = &message.image_message {
    let forwarded = forward_image(img);
    client.send_message(destination_jid, forwarded).await?;
}
The same pattern works for any media type:
// Forward a video, keeping all CDN fields intact
if let Some(vid) = &message.video_message {
    let forwarded = wa::Message {
        video_message: Some(Box::new(wa::message::VideoMessage {
            caption: Some("Forwarded video".to_string()),
            ..*vid.clone()
        })),
        ..Default::default()
    };
    client.send_message(destination_jid, forwarded).await?;
}
Only use download + upload when you need the plaintext bytes (processing, transcoding, re-encoding). To forward existing media unchanged, reuse the original message’s CDN fields directly.

When CDN reuse won’t work

CDN URLs expire after some time. If the original media URL has expired, recipients won’t be able to download it. In that case, use media reupload to request the server to provide a fresh download path, or fall back to downloading and re-uploading the media.

Uploading Media

Basic Upload

use wacore::download::MediaType;
use whatsapp_rust::upload::UploadResponse;

// Read file data
let data = std::fs::read("photo.jpg")?;

// Upload with automatic encryption
let upload: UploadResponse = client.upload(data, MediaType::Image, Default::default()).await?;

println!("URL: {}", upload.url);
println!("Direct path: {}", upload.direct_path);
println!("File SHA256: {:?}", upload.file_sha256);
See Upload API reference for full details.

Using Upload Response in Messages

use waproto::whatsapp as wa;

let upload = client.upload(image_data, MediaType::Image, Default::default()).await?;

let message = wa::Message {
    image_message: Some(Box::new(wa::message::ImageMessage {
        url: Some(upload.url),
        direct_path: Some(upload.direct_path),
        media_key: Some(upload.media_key_vec()),
        file_sha256: Some(upload.file_sha256_vec()),
        file_enc_sha256: Some(upload.file_enc_sha256_vec()),
        file_length: Some(upload.file_length),
        media_key_timestamp: Some(upload.media_key_timestamp),
        mimetype: Some("image/jpeg".to_string()),
        caption: Some("Check out this image!".to_string()),
        width: Some(1920),
        height: Some(1080),
        ..Default::default()
    })),
    ..Default::default()
};

let result = client.send_message(to, message).await?;

Sending media as an album

You can group multiple images and/or videos into an album. See Sending Messages - Album messages for a full walkthrough of the parent/child album pattern using AlbumMessage and wrap_as_album_child.

Resumable uploads

For files 5 MiB or larger, the client automatically checks whether a previous upload of the same file exists on the CDN before uploading the full payload. This avoids re-uploading data that the server already has. The resume check flow works as follows:
  1. The client sends a ?resume=1 probe to the CDN for each host
  2. If the server responds with complete, the existing URL is returned immediately — no upload occurs
  3. If the server responds with a byte offset, only the remaining bytes are uploaded using the file_offset query parameter
  4. If the server responds with not found (or the check fails), a full upload proceeds
Resumable upload detection is fully transparent. You call client.upload() the same way regardless of file size. The resume check is non-fatal — if it fails for any reason, the client falls back to a full upload.
// Large files automatically use resumable upload logic
let video_bytes = std::fs::read("large_video.mp4")?; // e.g., 50 MiB
let upload = client.upload(video_bytes, MediaType::Video, Default::default()).await?;
// If a partial upload existed, only the remaining bytes were sent

Media Types

Available Media Types

pub enum MediaType {
    Image,
    Video,
    Audio,
    Document,
    History,
    AppState,
    Sticker,
    StickerPack,
    StickerPackThumbnail,
    LinkThumbnail,
    ProductCatalogImage,
}

Media type properties

use wacore::download::MediaType;

// Get MMS type for upload URLs
let mms_type = MediaType::Image.mms_type();
// Returns: "image", "video", "audio", "document"

// Get app info string for HKDF key derivation
let info = MediaType::Video.app_info();
// Returns: "WhatsApp Video Keys"

// Get upload path for CDN routing
let path = MediaType::Image.upload_path();
// Returns: "/mms/image"

// Check if media type uses E2E encryption
let encrypted = MediaType::ProductCatalogImage.is_encrypted();
// Returns: false (product catalog images are unencrypted)
ProductCatalogImage is the only unencrypted media type. It uploads to /product/image instead of the /mms/ prefix used by other types. This matches WhatsApp Web’s behavior where CreateMediaKeys.js skips encryption for product catalog images.

Encryption Details

Upload Encryption

Media is automatically encrypted before upload:
  1. Generate random 32-byte media_key
  2. Derive encryption keys using HKDF-SHA256:
    • IV: 16 bytes
    • Cipher key: 32 bytes
    • MAC key: 32 bytes
  3. Encrypt with AES-256-CBC
  4. Append HMAC-SHA256 MAC (10 bytes)
  5. Compute SHA256 hashes of plaintext and ciphertext
// Encryption happens automatically in upload()
let data = std::fs::read("file.pdf")?;
let upload = client.upload(data, MediaType::Document, Default::default()).await?;

// The UploadResponse contains all encryption metadata
// media_key, file_sha256, file_enc_sha256 are [u8; 32] arrays
println!("Media key: {:?}", upload.media_key);
println!("File length: {} bytes", upload.file_length);

Chunk-based encryption with MediaEncryptor

MediaEncryptor provides a chunk-based API for encrypting media incrementally. Unlike encrypt_media_streaming, it doesn’t require a sync Read source — you can feed plaintext in arbitrarily sized chunks, making it suitable for async streams and network sources:
use wacore::upload::MediaEncryptor;
use wacore::download::MediaType;

let mut enc = MediaEncryptor::new(MediaType::Video)?;
let mut encrypted = Vec::new();

// Feed plaintext in any chunk size
for chunk in video_chunks {
    enc.update(&chunk, &mut encrypted);
}

// Finalize: PKCS7 pad + append 10-byte HMAC-SHA256 MAC
let info = enc.finalize(&mut encrypted)?;
// info.media_key, info.file_sha256, info.file_enc_sha256, info.file_length
You can also write directly to a file or writer instead of buffering in memory:
let mut enc = MediaEncryptor::new(MediaType::Video)?;
let mut writer = File::create("large_video.enc")?;

for chunk in video_chunks {
    enc.update_to_writer(&chunk, &mut writer)?;
}

let info = enc.finalize_to_writer(&mut writer)?;
See Upload API reference for full details.

Streaming encryption from a reader

For sync Read sources, encrypt_media_streaming wraps MediaEncryptor and reads in 8KB chunks:
use wacore::upload::encrypt_media_streaming;
use wacore::download::MediaType;
use std::fs::File;

let reader = File::open("large_video.mp4")?;
let writer = File::create("large_video.enc")?;

let info = encrypt_media_streaming(reader, writer, MediaType::Video)?;
// info.media_key, info.file_sha256, info.file_enc_sha256, info.file_length
See Upload API reference for full details.

Download Decryption

Decryption is automatic when downloading:
  1. Fetch encrypted data from WhatsApp CDN
  2. Verify MAC (last 10 bytes)
  3. Decrypt with AES-256-CBC
  4. Verify plaintext SHA256
  5. Return decrypted data
// Decryption happens automatically in download()
let data = client.download(image_message.as_ref()).await?;
// `data` is already decrypted and verified
Never skip SHA256 verification! The library automatically verifies both encrypted and decrypted hashes to ensure data integrity.

Error Handling

Download Errors

use anyhow::Result;

async fn safe_download(
    client: &Client,
    downloadable: &dyn Downloadable,
) -> Result<Vec<u8>> {
    match client.download(downloadable).await {
        Ok(data) => {
            println!("✅ Downloaded {} bytes", data.len());
            Ok(data)
        }
        Err(e) => {
            if e.to_string().contains("invalid mac") {
                eprintln!("❌ Media corrupted (MAC mismatch)");
            } else if e.to_string().contains("status") {
                eprintln!("❌ Download failed (HTTP error)");
            } else {
                eprintln!("❌ Download error: {:?}", e);
            }
            Err(e)
        }
    }
}

Upload Errors

async fn safe_upload(
    client: &Client,
    data: Vec<u8>,
    media_type: MediaType,
) -> Result<UploadResponse> {
    match client.upload(data, media_type, Default::default()).await {
        Ok(upload) => {
            println!("✅ Uploaded to: {}", upload.url);
            Ok(upload)
        }
        Err(e) => {
            eprintln!("❌ Upload failed: {:?}", e);
            Err(e)
        }
    }
}

Advanced Usage

Custom Media Processing

use image::DynamicImage;

// Download and process image
if let Some(img) = &message.image_message {
    let data = client.download(img.as_ref()).await?;
    
    // Load with image crate
    let image = image::load_from_memory(&data)?;
    
    // Resize
    let thumbnail = image.resize(200, 200, image::imageops::FilterType::Lanczos3);
    
    // Save
    thumbnail.save("thumbnail.jpg")?;
}

Streaming with Progress

use std::io::{Write, Seek};
use std::sync::{Arc, Mutex};

struct ProgressWriter<W> {
    inner: W,
    total: Arc<Mutex<usize>>,
}

impl<W: Write> Write for ProgressWriter<W> {
    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
        let n = self.inner.write(buf)?;
        *self.total.lock().unwrap() += n;
        println!("Downloaded {} bytes", *self.total.lock().unwrap());
        Ok(n)
    }
    
    fn flush(&mut self) -> std::io::Result<()> {
        self.inner.flush()
    }
}

impl<W: Seek> Seek for ProgressWriter<W> {
    fn seek(&mut self, pos: std::io::SeekFrom) -> std::io::Result<u64> {
        self.inner.seek(pos)
    }
}

let file = File::create("video.mp4")?;
let progress_writer = ProgressWriter {
    inner: file,
    total: Arc::new(Mutex::new(0)),
};

let _file = client.download_to_writer(video.as_ref(), progress_writer).await?;

Automatic retry and failover

Both download and upload methods automatically retry when WhatsApp’s CDN returns certain HTTP errors. The client handles three categories of errors: Auth errors (401/403): The client invalidates the cached MediaConn, fetches fresh credentials from WhatsApp’s servers, and retries the operation once with new auth tokens. Media not found (404/410): When a download URL has expired or the media has been moved, the CDN returns 404 or 410. The client treats this the same as an auth error — it invalidates the cached media 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. Media connections include multiple hosts sorted by priority (primary hosts first, then fallback hosts), and the client iterates through them sequentially.
This retry behavior is fully transparent — no changes are needed in your code. Both download and upload handle auth refresh, URL re-derivation, and host failover automatically, with a maximum of one credential refresh per operation.
For streaming downloads (download_to_writer), the writer is automatically seeked back to position 0 before each retry so partial writes don’t corrupt the output.

Additional retry logic

For transient errors beyond auth failures, you can implement your own retry wrapper:
use tokio::time::{sleep, Duration};

async fn download_with_retry(
    client: &Client,
    downloadable: &dyn Downloadable,
    max_retries: u32,
) -> Result<Vec<u8>> {
    let mut attempt = 0;
    loop {
        match client.download(downloadable).await {
            Ok(data) => return Ok(data),
            Err(e) if attempt < max_retries => {
                attempt += 1;
                eprintln!("Retry {}/{}: {:?}", attempt, max_retries, e);
                sleep(Duration::from_secs(2u64.pow(attempt))).await;
            }
            Err(e) => return Err(e),
        }
    }
}

Media connection

Automatic management

The client automatically manages media connection tokens, including refreshing them when they expire or when the CDN rejects them with 401/403/404/410:
// Automatically refreshed when expired or rejected
let upload = client.upload(data, MediaType::Image, Default::default()).await?;
let download = client.download(downloadable).await?;

// No manual token management needed!

Host types and failover order

Each media connection includes multiple CDN hosts with a host_type field indicating whether the host is "primary" or "fallback". The client sorts hosts with primary hosts first, so uploads and downloads always try the fastest hosts before falling back to alternatives. Each host may also include a fallback_hostname for additional resilience.

Token expiration

Media connections have two TTL values:
  • ttl — how long the route/host information is valid
  • auth_ttl — how long the auth token specifically is valid (may be shorter than ttl)
The client uses the minimum of these two values to determine when the cached connection has expired. When expired, the next media operation automatically fetches fresh credentials.

Internal refresh

Media connection refresh and invalidation are managed internally by the client. When the CDN rejects a request (401/403/404/410), the client automatically invalidates the cached connection and fetches fresh credentials before retrying. You don’t need to manage this manually.

Best Practices

1
Use Streaming for Large Files
2
// ❌ Bad: Loads entire file into memory
let data = client.download(video.as_ref()).await?;
std::fs::write("video.mp4", data)?;

// ✅ Good: Constant memory usage (with streaming HTTP client)
let file = File::create("video.mp4")?;
let _file = client.download_to_writer(video.as_ref(), file).await?;
3
download_to_writer works with any HTTP client. With streaming-capable clients (like UreqHttpClient), memory usage is constant ~40KB. With non-streaming clients, it falls back to buffered downloads automatically.
4
Verify Media Type
5
Check mimetype before processing:
6
if let Some(img) = &message.image_message {
    if let Some(mimetype) = &img.mimetype {
        match mimetype.as_str() {
            "image/jpeg" | "image/png" => {
                // Process image
            }
            _ => {
                eprintln!("Unsupported image type: {}", mimetype);
            }
        }
    }
}
7
Handle Missing Fields
8
Media messages may have optional fields:
9
if let Some(doc) = &message.document_message {
    let filename = doc.file_name.as_deref().unwrap_or("unknown");
    let mimetype = doc.mimetype.as_deref().unwrap_or("application/octet-stream");
    let size = doc.file_length.unwrap_or(0);
    
    println!("Document: {} ({}, {} bytes)", filename, mimetype, size);
}
10
Set Proper Dimensions
11
For images and videos, include dimensions:
12
use image::GenericImageView;

let img = image::open("photo.jpg")?;
let (width, height) = img.dimensions();

let message = wa::Message {
    image_message: Some(Box::new(wa::message::ImageMessage {
        url: Some(upload.url),
        direct_path: Some(upload.direct_path),
        media_key: Some(upload.media_key_vec()),
        file_sha256: Some(upload.file_sha256_vec()),
        file_enc_sha256: Some(upload.file_enc_sha256_vec()),
        file_length: Some(upload.file_length),
        media_key_timestamp: Some(upload.media_key_timestamp),
        mimetype: Some("image/jpeg".to_string()),
        width: Some(width),
        height: Some(height),
        ..Default::default()
    })),
    ..Default::default()
};

Next Steps