DEV Community

Custodia-Admin
Custodia-Admin

Posted on • Originally published at pagebolt.dev

Screenshot API and HTML to PDF in Rust with reqwest

Screenshot API and HTML to PDF in Rust with reqwest

If you've tried running headless Chrome from a Rust binary, you know the friction: chromiumoxide pulls in a large async runtime dependency surface, headless_chrome is fragile against Chrome version bumps, and your final binary now ships with — or implicitly requires — a 300 MB Chrome installation. For many backend services, that's the wrong tradeoff.

A REST screenshot API gives you the same rendered output without any of that. Your Rust binary stays lean. You call an endpoint, get back bytes. This guide covers doing exactly that with PageBolt using reqwest, tokio, and serde_json.

Why Not chromiumoxide or headless_chrome?

Both crates work. The maintenance cost is the problem:

  • Chrome binary coupling — Your service breaks whenever the system Chrome version diverges from what the crate expects.
  • Docker image bloat — A minimal Rust app image is ~10 MB. Add headless Chrome and you're at 1–2 GB.
  • Memory management — You own the browser process lifecycle. Leaked browser instances in long-running services cause gradual OOM.
  • Async complexitychromiumoxide requires a dedicated async task to drive the CDP event loop, adding architectural overhead even for simple captures.

For occasional screenshot or PDF generation — monitoring jobs, invoice endpoints, CI snapshot runs — the REST approach is simpler and cheaper.

Setup

Add these to Cargo.toml:

[dependencies]
reqwest = { version = "0.12", features = ["json"] }
tokio = { version = "1", features = ["full"] }
serde_json = "1"
Enter fullscreen mode Exit fullscreen mode

The Simplest Screenshot

use std::fs;

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

    let bytes = client
        .post("https://pagebolt.dev/api/v1/screenshot")
        .header("x-api-key", &api_key)
        .json(&serde_json::json!({
            "url": "https://example.com",
            "fullPage": true,
            "blockBanners": true
        }))
        .send()
        .await?
        .error_for_status()?
        .bytes()
        .await?;

    fs::write("screenshot.png", &bytes)?;
    println!("Saved {} bytes", bytes.len());
    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

No browser. No subprocess. No system dependencies beyond the Rust toolchain.

Use Case 1: Website Monitoring Service

Capture a snapshot on a schedule and persist it to disk with a timestamp. Run this from a tokio::time::interval loop or a cron-triggered binary.

use std::fs;
use chrono::Utc;

async fn capture_snapshot(
    client: &reqwest::Client,
    api_key: &str,
    url: &str,
    output_dir: &str,
) -> Result<(), Box<dyn std::error::Error>> {
    let bytes = client
        .post("https://pagebolt.dev/api/v1/screenshot")
        .header("x-api-key", api_key)
        .json(&serde_json::json!({
            "url": url,
            "fullPage": true,
            "blockBanners": true,
            "blockAds": true
        }))
        .send()
        .await?
        .error_for_status()?
        .bytes()
        .await?;

    let filename = format!(
        "{}/{}.png",
        output_dir,
        Utc::now().format("%Y-%m-%dT%H-%M-%SZ")
    );
    fs::write(&filename, &bytes)?;
    println!("Snapshot saved: {filename}");
    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

Diff consecutive snapshots with image + imageproc to detect unexpected layout changes or error pages before your users do.

Use Case 2: PDF Invoice Generation in an Axum Service

Render an HTML invoice template and return it as a PDF — directly from an Axum handler.

use axum::{extract::State, response::Response, http::header};
use std::sync::Arc;

struct AppState {
    http: reqwest::Client,
    pagebolt_key: String,
}

async fn generate_invoice(
    State(state): State<Arc<AppState>>,
    // In a real handler, extract invoice data from request body
) -> Result<Response<axum::body::Body>, StatusCode> {
    let invoice_html = format!(r#"
        <!DOCTYPE html>
        <html>
        <head><style>
            body {{ font-family: sans-serif; padding: 40px; }}
            h1 {{ color: #1a1a2e; }}
            .total {{ font-size: 1.5rem; font-weight: bold; margin-top: 2rem; }}
        </style></head>
        <body>
            <h1>Invoice #1042</h1>
            <p>Date: 2026-03-25</p>
            <table>
                <tr><td>API access — Growth plan</td><td>$79.00</td></tr>
            </table>
            <div class="total">Total: $79.00</div>
        </body>
        </html>
    "#);

    let bytes = state
        .http
        .post("https://pagebolt.dev/api/v1/pdf")
        .header("x-api-key", &state.pagebolt_key)
        .json(&serde_json::json!({
            "html": invoice_html,
            "pdfOptions": { "format": "A4" }
        }))
        .send()
        .await
        .map_err(|_| StatusCode::BAD_GATEWAY)?
        .error_for_status()
        .map_err(|_| StatusCode::BAD_GATEWAY)?
        .bytes()
        .await
        .map_err(|_| StatusCode::BAD_GATEWAY)?;

    Ok(Response::builder()
        .header(header::CONTENT_TYPE, "application/pdf")
        .header(header::CONTENT_DISPOSITION, "attachment; filename=\"invoice-1042.pdf\"")
        .body(axum::body::Body::from(bytes))
        .unwrap())
}
Enter fullscreen mode Exit fullscreen mode

The handler renders any HTML — Tera templates, Askama, raw strings — and returns a PDF stream directly to the client. No wkhtmltopdf, no LaTeX, no browser process.

Use Case 3: Visual Regression Testing

Capture before/after screenshots in CI and store them as build artifacts. Then diff with a pixel-level library.

async fn capture_for_test(
    client: &reqwest::Client,
    api_key: &str,
    url: &str,
    label: &str,
) -> Result<(), Box<dyn std::error::Error>> {
    let bytes = client
        .post("https://pagebolt.dev/api/v1/screenshot")
        .header("x-api-key", api_key)
        .json(&serde_json::json!({
            "url": url,
            "fullPage": true,
            "blockBanners": true,
            "blockTrackers": true
        }))
        .send()
        .await?
        .error_for_status()?
        .bytes()
        .await?;

    fs::write(format!("snapshots/{label}.png"), &bytes)?;
    println!("Captured {label}");
    Ok(())
}

// In your test runner:
// capture_for_test(&client, &key, "https://staging.yourapp.com", "post-deploy").await?;
// Then diff "baseline.png" vs "post-deploy.png" with the `image` crate
Enter fullscreen mode Exit fullscreen mode

This works cleanly in a cargo test context with #[tokio::test] — no separate browser infrastructure needed in CI.

Getting a Base64 Response Instead of Binary

For cases where you want to process the image in memory without writing to disk first, use response_type: "json":

let resp: serde_json::Value = client
    .post("https://pagebolt.dev/api/v1/screenshot")
    .header("x-api-key", &api_key)
    .json(&serde_json::json!({
        "url": "https://example.com",
        "response_type": "json"
    }))
    .send()
    .await?
    .error_for_status()?
    .json()
    .await?;

let b64 = resp["image"].as_str().unwrap();
// decode with base64::engine::general_purpose::STANDARD.decode(b64)
Enter fullscreen mode Exit fullscreen mode

Free Tier

PageBolt's free tier includes 100 requests per month — no credit card required. It's enough to prototype all three use cases above and validate the integration before committing to a paid plan.

Get started: pagebolt.dev

Top comments (0)