Call page.screenshot({ path, type: "png" }), close the browser in finally, then upload the saved PNG to https://imgd.dev/v1/upload. The result contains a public image URL that works immediately.

imgd.dev costs a one-time $1 per GB of storage. The service has no free tier, so fund the account before the first upload. Every imgd.dev image is public. Never capture authenticated pages, private dashboards, secrets, or personal data for this workflow.

What the example does

The Puppeteer guide documents Page.screenshot() for page capture. The path option writes the output to a file.

This example uses these steps:

  1. Open a new headless browser.
  2. Set a fixed 1440 by 900 viewport.
  3. Open the requested HTTP or HTTPS page.
  4. Save a full-page PNG.
  5. Close the browser in finally.
  6. Read the closed file with readFile.
  7. Upload it with Node fetch, Blob, and FormData.
  8. Consume every HTTP response body.
  9. Verify that the public URL returns an image.

The imgd.dev upload limit is 20 MB. The service accepts JPEG, PNG, GIF, WebP, and AVIF.

Install the example

Save this package file as examples/workflows/puppeteer/package.json:

{
  "name": "imgd-puppeteer-screenshot",
  "private": true,
  "version": "0.1.0",
  "type": "module",
  "engines": {
    "node": ">=20"
  },
  "dependencies": {
    "puppeteer": "25.8.0"
  }
}

Install Puppeteer in that directory:

cd examples/workflows/puppeteer
npm install

Puppeteer installs a compatible browser unless your environment changes its download settings.

Complete capture and upload script

Save this file as examples/workflows/puppeteer/capture-and-upload.mjs:

import { readFile } from "node:fs/promises";
import { extname, resolve } from "node:path";
import puppeteer from "puppeteer";

const UPLOAD_URL = "https://imgd.dev/v1/upload";

function requiredEnv(name) {
  const value = process.env[name];
  if (!value) {
    throw new Error(`Set the ${name} environment variable.`);
  }
  return value;
}

function errorMessage(error) {
  return error instanceof Error ? error.message : String(error);
}

function parseJson(text, service) {
  try {
    const value = JSON.parse(text);
    if (!value || typeof value !== "object" || Array.isArray(value)) {
      throw new Error("The response is not a JSON object.");
    }
    return value;
  } catch (error) {
    throw new Error(`${service} returned invalid JSON: ${errorMessage(error)}`);
  }
}

async function captureScreenshot(targetUrl, outputPath) {
  let browser;
  let captureError;

  try {
    browser = await puppeteer.launch({ headless: true });
    const page = await browser.newPage();
    await page.setViewport({ width: 1440, height: 900, deviceScaleFactor: 1 });
    const navigation = await page.goto(targetUrl, {
      waitUntil: "networkidle2",
      timeout: 45_000,
    });
    if (navigation && !navigation.ok()) {
      throw new Error(`Page navigation failed with HTTP ${navigation.status()}.`);
    }
    await page.screenshot({ path: outputPath, type: "png", fullPage: true });
  } catch (error) {
    captureError = error;
    throw error;
  } finally {
    if (browser) {
      try {
        await browser.close();
      } catch (closeError) {
        if (!captureError) {
          throw closeError;
        }
        console.error(`Browser close also failed: ${errorMessage(closeError)}`);
      }
    }
  }
}

async function uploadScreenshot(path, key) {
  const bytes = await readFile(path);
  const form = new FormData();
  form.set("file", new Blob([bytes], { type: "image/png" }), "screenshot.png");

  const response = await fetch(UPLOAD_URL, {
    method: "POST",
    headers: { Authorization: `Bearer ${key}` },
    body: form,
    signal: AbortSignal.timeout(60_000),
  });
  const text = await response.text();
  const payload = parseJson(text, "imgd.dev");

  if (!response.ok) {
    const code = typeof payload.error === "string" ? payload.error : "unknown_error";
    const fix = typeof payload.fix === "string" ? payload.fix : "Read the response body.";
    throw new Error(`Upload failed with HTTP ${response.status} and ${code}: ${fix}`);
  }

  if (typeof payload.hash !== "string" || !/^[0-9a-f]{64}$/.test(payload.hash)) {
    throw new Error("The success response has no valid image hash.");
  }
  if (typeof payload.url !== "string") {
    throw new Error("The success response has no public URL.");
  }

  const publicUrl = new URL(payload.url);
  if (
    publicUrl.protocol !== "https:"
    || publicUrl.hostname !== "i.imgd.dev"
    || publicUrl.pathname !== `/i/${payload.hash}`
  ) {
    throw new Error("The success response has an unexpected public URL.");
  }

  return payload;
}

async function verifyPublicUrl(url) {
  const response = await fetch(url, {
    headers: { Accept: "image/*" },
    signal: AbortSignal.timeout(60_000),
  });
  const contentType = response.headers.get("content-type") ?? "";
  const _body = await response.arrayBuffer();

  if (!response.ok || !contentType.startsWith("image/")) {
    throw new Error(
      `Public URL verification failed with HTTP ${response.status} and Content-Type ${contentType}.`,
    );
  }

  console.error(`Verified ${url} with HTTP ${response.status} and Content-Type ${contentType}.`);
}

async function main() {
  const key = requiredEnv("IMGD_KEY");
  const targetUrl = new URL(process.argv[2] ?? "https://example.com");
  if (!new Set(["http:", "https:"]).has(targetUrl.protocol)) {
    throw new Error("Use an HTTP or HTTPS page URL.");
  }

  const outputPath = resolve(process.argv[3] ?? "screenshot.png");
  if (extname(outputPath).toLowerCase() !== ".png") {
    throw new Error("Use a .png output path.");
  }

  await captureScreenshot(targetUrl.href, outputPath);
  const upload = await uploadScreenshot(outputPath, key);
  await verifyPublicUrl(upload.url);
  console.log(JSON.stringify(upload, null, 2));
}

main().catch((error) => {
  console.error(errorMessage(error));
  process.exitCode = 1;
});

The browser closes after capture success and capture failure. The file writer finishes before readFile opens the PNG for upload.

The script calls text() or arrayBuffer() on each HTTP response. This action consumes and releases the response body on error paths.

Run the capture and upload

Store IMGD_KEY outside the repository. Do not pass it to page JavaScript or put it in a URL.

cd examples/workflows/puppeteer
test -n "$IMGD_KEY"
node capture-and-upload.mjs https://example.com screenshot.png | tee upload.json

A new screenshot returns HTTP 202 with this complete JSON shape:

{
  "hash": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
  "url": "https://i.imgd.dev/i/0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
  "status": "processing",
  "mime": "image/png",
  "bytes": 18452,
  "width": 1440,
  "height": 2400,
  "alt_text": null,
  "unpublish_at": null,
  "note": "the url works immediately, serving a blurred placeholder until the moderation check finishes"
}

An identical screenshot can return HTTP 200 with "deduplicated": true.

The public URL works immediately. It can show a blurred image while moderation runs. The same URL shows the full screenshot after a live result.

Verify the result

The script verifies the response status and Content-Type. Also inspect the image metadata with the returned hash:

HASH="$(jq -r '.hash' upload.json)"
curl --fail --silent --show-error \
  -H "Authorization: Bearer $IMGD_KEY" \
  "https://imgd.dev/v1/images/$HASH" | jq .

A live result includes the final dimensions and generated alt text:

{
  "hash": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
  "url": "https://i.imgd.dev/i/0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
  "status": "live",
  "mime": "image/png",
  "bytes": 18452,
  "width": 1440,
  "height": 2400,
  "alt_text": "A full-page screenshot of the Example Domain page.",
  "category": "safe",
  "nsfw": false,
  "violence": false,
  "filename": "screenshot.png",
  "unpublish_at": null,
  "published": true,
  "created_at": 1787184000
}

A review result is terminal and stays blurred. A blocked result removes the bytes. Do not retry identical blocked bytes.

Recover from errors

Status Meaning Recovery
401 The key is missing or invalid. Set the correct IMGD_KEY. Do not retry the same invalid key.
402 The account has no storage. Buy storage and verify funding, then retry. There is no free tier.
402 The account quota is too small. Add the suggested storage or delete images, then retry.
413 The full-page PNG exceeds 20 MB. Reduce the viewport, disable fullPage, or compress the image.
415 The body is not a valid accepted image. Keep type: "png" and the image/png Blob type together.
429 The key exceeded the upload rate. Wait for the next minute, then retry.

A representative 413 response is:

{
  "error": "file_too_large",
  "fix": "file exceeds 20MB; resize the image below that and retry",
  "bytes": 24117248,
  "max_bytes": 20971520
}

Navigation errors happen before upload. Check the target status, DNS, certificate, and page timeout before another attempt.

Security notes

  • Use a clean browser session without saved cookies or credentials.
  • Do not capture a page that contains secrets or customer data.
  • Keep the browser sandbox enabled.
  • Keep IMGD_KEY outside the page context and source code.
  • Do not write the key to logs, URLs, or screenshot text.
  • Treat the local PNG and returned URL as public data.
  • Remove the local PNG after use if local retention is not required.

When imgd.dev fits

imgd.dev fits public browser evidence, visual reports, and documentation screenshots. The content-addressed URL stays stable for identical PNG bytes.

It does not fit private browser evidence, authenticated screenshots, files above 20 MB, video, or a free trial. Use private artifact storage for sensitive captures.

Next guides

For an agent, follow the integration procedure. For human setup, open the start page. Read the service evaluation before you select a host.

Sources