Rust Screenshot API — Capture Websites with reqwest

Take website screenshots in Rust with one reqwest call. No headless Chrome binary needed. Get PNG, WebP, PDF from any URL. Free tier included.

Vitalii Holben Updated

The Rust screenshot API from ScreenshotRun turns any URL into a PNG, JPEG, WebP, AVIF, or PDF with a single reqwest call. No headless Chrome binary to bundle, no chromiumoxide or headless_chrome crate to wrestle with. Your compiled binary stays at 10 MB instead of ballooning to 1-2 GB with a Chromium dependency.

Rust developers who need website screenshots usually reach for headless_chrome or chromiumoxide. Both wrap the Chrome DevTools Protocol and work fine on your machine. Deploy to a Docker container or CI pipeline and the problems start: matching the crate version to the installed Chrome binary, configuring font packages on Alpine, managing shared memory for each browser process. A REST API call through reqwest avoids all of that.

First Rust screenshot in 30 seconds

Open the dashboard, create an account, and copy your API key from the settings panel. The free tier gives you 200 captures per month without a credit card.

Add these dependencies to your Cargo.toml:

[dependencies]
reqwest = { version = "0.12", features = ["json"] }
tokio = { version = "1", features = ["full"] }

Then capture your first screenshot:

use std::fs;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let client = reqwest::Client::new();

    let bytes = client
        .get("https://api.screenshotrun.com/v1/screenshots/capture")
        .header("Authorization", "Bearer YOUR_API_KEY")
        .query(&[
            ("url", "https://github.com"),
            ("format", "png"),
            ("response_type", "image"),
        ])
        .send()
        .await?
        .error_for_status()?
        .bytes()
        .await?;

    fs::write("github.png", &bytes)?;
    println!("Saved: github.png ({} KB)", bytes.len() / 1024);

    Ok(())
}

No Chromium download, no DevTools Protocol handshake. Our servers launch a headless Chrome instance, render the page fully, including JavaScript and lazy-loaded images, then stream the bytes back over HTTP.

Reusable reqwest screenshot function

A helper function with configurable parameters that you can call from anywhere in your application:

use reqwest::Client;
use std::collections::HashMap;

async fn capture_screenshot(
    client: &Client,
    url: &str,
    format: &str,
    width: u32,
    full_page: bool,
) -> Result<Vec<u8>, reqwest::Error> {
    let mut params = HashMap::new();
    params.insert("url", url.to_string());
    params.insert("format", format.to_string());
    params.insert("width", width.to_string());
    params.insert("full_page", full_page.to_string());
    params.insert("response_type", "image".to_string());

    let bytes = client
        .get("https://api.screenshotrun.com/v1/screenshots/capture")
        .query(¶ms)
        .send()
        .await?
        .error_for_status()?
        .bytes()
        .await?;

    Ok(bytes.to_vec())
}

Usage:

use std::env;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let api_key = env::var("SCREENSHOTRUN_KEY")?;

    let client = Client::builder()
        .default_headers({
            let mut headers = reqwest::header::HeaderMap::new();
            headers.insert(
                "Authorization",
                format!("Bearer {}", api_key).parse()?,
            );
            headers
        })
        .timeout(std::time::Duration::from_secs(60))
        .build()?;

    let screenshot = capture_screenshot(
        &client, "https://github.com", "webp", 1920, true
    ).await?;

    std::fs::write("github-full.webp", &screenshot)?;
    println!("Captured full-page WebP screenshot");

    Ok(())
}

The key loads from an environment variable at runtime. reqwest::Client pools connections internally, so sharing one instance across your application is the idiomatic approach and saves you from opening extra sockets.

Screenshot API integration with Axum

If you run an Axum web server, you can expose a proxy endpoint that keeps the API key server-side. The frontend requests screenshots through your backend without ever seeing the credentials.

use axum::{extract::Query, http::StatusCode, response::IntoResponse, routing::get, Router};
use reqwest::Client;
use serde::Deserialize;
use std::sync::Arc;

#[derive(Deserialize)]
struct ScreenshotParams {
    url: String,
    format: Option<String>,
    width: Option<u32>,
}

async fn screenshot_handler(
    Query(params): Query<ScreenshotParams>,
    client: axum::extract::Extension<Arc<Client>>,
) -> impl IntoResponse {
    let mut query = vec![
        ("url", params.url),
        ("format", params.format.unwrap_or("png".into())),
        ("response_type", "image".into()),
    ];
    if let Some(w) = params.width {
        query.push(("width", w.to_string()));
    }

    match client.get("https://api.screenshotrun.com/v1/screenshots/capture")
        .query(&query)
        .send()
        .await
    {
        Ok(resp) if resp.status().is_success() => {
            let content_type = resp.headers()
                .get("content-type")
                .and_then(|v| v.to_str().ok())
                .unwrap_or("image/png")
                .to_string();
            let bytes = resp.bytes().await.unwrap_or_default();
            (StatusCode::OK, [(axum::http::header::CONTENT_TYPE, content_type)], bytes).into_response()
        }
        Ok(resp) => StatusCode::from_u16(resp.status().as_u16())
            .unwrap_or(StatusCode::BAD_GATEWAY)
            .into_response(),
        Err(_) => StatusCode::BAD_GATEWAY.into_response(),
    }
}

Register it in your router with a shared Client:

let client = Arc::new(
    Client::builder()
        .default_headers({
            let mut h = reqwest::header::HeaderMap::new();
            h.insert("Authorization", format!("Bearer {}", api_key).parse().unwrap());
            h
        })
        .build()
        .unwrap(),
);

let app = Router::new()
    .route("/api/screenshot", get(screenshot_handler))
    .layer(axum::extract::Extension(client));

Your frontend calls /api/screenshot?url=https://example.com&format=png and gets the image directly. The API key stays in the server process.

Available parameters

Pass each option as a query string pair. Here are the ones you will reach for most often:

ParameterTypeDefaultDescription
urlstringrequiredTarget URL to screenshot
formatstringpngImage format: png, jpeg, webp, avif, pdf
widthu321280Browser viewport width (px)
heightu32720Browser viewport height (px)
full_pageboolfalseScroll and capture the full document
devicestringdesktopEmulate desktop, mobile, or tablet
retinaboolfalse2x device pixel ratio
dark_modeboolfalseEnable prefers-color-scheme: dark
selectorstringCSS selector to crop to that element
delayu320Pause N ms before the capture fires
block_cookiesboolfalseDismiss cookie consent popups
block_adsboolfalseStrip ad placements from the page
block_chatsboolfalseHide live-chat overlays
cssstringCustom stylesheet injected before render
jsstringScript executed before the screenshot

See the API documentation for the complete parameter reference and response schemas.

Capture in dark mode, by CSS selector, or with injected styles

Dark mode

Add dark_mode=true to the query. The API sets prefers-color-scheme: dark before the page loads, so any site supporting the media query renders its dark theme:

let bytes = client
    .get("https://api.screenshotrun.com/v1/screenshots/capture")
    .query(&[
        ("url", "https://github.com"),
        ("dark_mode", "true"),
        ("response_type", "image"),
    ])
    .send().await?
    .error_for_status()?
    .bytes().await?;

Element capture

Pass a CSS selector to crop the screenshot to a specific element:

.query(&[
    ("url", "https://github.com/anthropics"),
    ("selector", ".js-repo-list"),
    ("response_type", "image"),
])

CSS injection

Hide banners or adjust styles before the capture fires:

.query(&[
    ("url", "https://example.com"),
    ("css", ".promo-banner { display: none !important; }"),
    ("response_type", "image"),
])

Stack all three in one request. A single reqwest call can crop to an element, flip the color scheme to dark, and inject a custom stylesheet without extra round trips.

Error handling and retries in Rust

The ? operator handles the happy path, but production code needs retry logic for rate limits and transient server errors:

use std::time::Duration;
use tokio::time::sleep;

async fn capture_with_retry(
    client: &Client,
    url: &str,
    max_retries: u32,
) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
    let params = [
        ("url", url),
        ("format", "png"),
        ("response_type", "image"),
    ];

    for attempt in 0..=max_retries {
        let resp = client
            .get("https://api.screenshotrun.com/v1/screenshots/capture")
            .query(¶ms)
            .send()
            .await?;

        let status = resp.status().as_u16();

        if resp.status().is_success() {
            return Ok(resp.bytes().await?.to_vec());
        }

        if status == 429 {
            let wait = resp.headers()
                .get("retry-after")
                .and_then(|v| v.to_str().ok())
                .and_then(|v| v.parse::<u64>().ok())
                .unwrap_or(5);
            sleep(Duration::from_secs(wait)).await;
            continue;
        }

        if status >= 500 && attempt < max_retries {
            sleep(Duration::from_secs((attempt + 1) as u64)).await;
            continue;
        }

        return Err(format!("HTTP {}", status).into());
    }

    Err("max retries exceeded".into())
}

A 429 means you hit the rate limit. The code reads the Retry-After header and sleeps that many seconds before trying again. For 5xx errors each retry backs off a little longer. Client errors (wrong credentials, malformed parameters) bail out on the spot because retrying won't fix them.

Screenshot API vs. headless_chrome and chromiumoxide

The headless_chrome and chromiumoxide crates give you full browser control: form filling, multi-step navigation, running JS against the page. When the only goal is capturing a URL as an image, they bring significant overhead.

Self-hosted (headless_chrome / chromiumoxide)ScreenshotRun API
Binary size1-2 GB with bundled ChromiumYour binary stays at its normal size
DependenciesChrome binary + matching crate versionreqwest + tokio (likely already in your project)
Memory per capture200-500 MB per browser tabZero on your server
CI/CD buildsInstall Chrome in Docker, slow layer cachingNo browser layer needed
Cookie bannersWrite dismiss logic per site, keep it updatedSingle flag: block_cookies=true
Font renderingShip font packages with every build targetUniform rendering on every capture
Alpine LinuxComplex Chromium setup, frequent breakageWorks anywhere reqwest compiles
CostServer CPU/RAM plus ongoing maintenanceFree tier covers 200/month, pay-as-you-go after

When your code needs to log in, fill forms, or evaluate JavaScript across multiple steps, headless_chrome or chromiumoxide earn their weight. When the job is simply turning a URL into an image, a reqwest GET replaces the entire browser stack and keeps your binary lean.

Rust teams typically use this for website change monitoring, generating dashboard reports on a schedule, and running visual regression checks inside CI pipelines.

FAQ#

Send a GET request to the ScreenshotRun API using the reqwest crate. Pass the target URL and desired format as query parameters. The API renders the page on its servers and returns the image bytes — no local browser binary required.

reqwest is the standard choice. It supports async with tokio, handles connection pooling automatically, and the query builder maps cleanly to API parameters. No extra crates needed beyond reqwest and tokio.

Yes. Add full_page=true to the query string. The API scrolls the entire page, waits for lazy-loaded content, and returns a single image covering the full document height.

Create a GET handler that reads query parameters from the frontend, forwards them to the ScreenshotRun API with your key in the Authorization header, and streams the response bytes back. The API key never reaches the client.

PNG, JPEG, WebP, AVIF, and PDF. Set the format parameter in the query string. Each format streams back as raw bytes that you write directly with std::fs::write.

For simple URL-to-image capture, yes. reqwest skips browser startup, DevTools Protocol negotiation, and local rendering. The API server handles all of that. Your code sends one HTTP request and receives bytes back, typically in 2-5 seconds.

Written by

Vitalii Holben

Co-founder and CEO of ScreenshotRun. Full-stack developer building screenshot API tools that help developers capture, automate, and monitor web pages at scale. Based in Europe, shipping code since 201...

Screenshot API in a few clicks

Free plan includes 200 screenshots every month. No credit card required.

Sign up → copy your key → make your first call