Fetch only. No browser in your deploy.

Website screenshots
from Val Town

Vals are Deno functions with a wall-clock limit and no browser. The Val Town docs point at Browserbase for scraping; for screenshots you can skip the browser session and make one GET request.

200 renders a month, no card.

Why Chromium does not fit in Val Town

Val Town runs each val in a Deno isolate. You get fetch, environment variables, and a small standard library for blob and SQLite storage. You do not get Chromium, and the web scraping guide in the Val Town docs sends you to Browserbase when a page needs a real browser. That is a reasonable answer for interaction-heavy scraping, but a screenshot is a single image, and a session-based browser API is more machinery than the job needs.

The val below is an HTTP val: it receives a request, calls the screenshot endpoint with the key stored in your Val Town environment variables, and returns the image. The second val caches the result in Val Town blob storage so a shared link does not re-render on every visit.

References: Val Town docs: web scraping (Browserbase examples), Val Town blob storage (std/blob).

The integration: one GET request

Call GET https://app.snap-render.com/v1/screenshot with the target in the url parameter and your key in the X-API-Key header. The response body is the finished image (or PDF when format=pdf). Ads and cookie banners are blocked by default.

Environment variable
# Val Town: Settings > Environment Variables
# Name:  SNAPRENDER_API_KEY
# Value: sk_live_your_key_here
HTTP val: screenshot
export default async function (req: Request): Promise<Response> {
  const target = new URL(req.url).searchParams.get('url');
  if (!target) {
    return Response.json({ error: 'pass ?url=https://example.com' }, { status: 400 });
  }

  const params = new URLSearchParams({
    url: target,
    format: 'png',
    width: '1280',
    height: '800',
    cache: 'true',
    cache_ttl: '86400',
  });

  const upstream = await fetch(`https://app.snap-render.com/v1/screenshot?${params}`, {
    headers: { 'X-API-Key': Deno.env.get('SNAPRENDER_API_KEY') ?? '' },
  });

  if (!upstream.ok) {
    const body = await upstream.json().catch(() => null);
    // 429: RATE_LIMITED or QUOTA_EXCEEDED. 400: INVALID_URL or BLOCKED_URL.
    return Response.json({ error: body?.error ?? upstream.statusText }, { status: upstream.status });
  }

  return new Response(upstream.body, { headers: { 'Content-Type': 'image/png' } });
}

Storing the image

blob.set accepts any BodyInit, so an ArrayBuffer stores as-is. Call blob.delete(key) from a cron val if you want the cache to expire.

HTTP val with blob storage cache
import { blob } from "https://esm.town/v/std/blob";

export default async function (req: Request): Promise<Response> {
  const target = new URL(req.url).searchParams.get('url');
  if (!target) return new Response('pass ?url=', { status: 400 });

  const key = `shot:${target}`;
  try {
    const cached = await blob.get(key);
    return new Response(await cached.arrayBuffer(), {
      headers: { 'Content-Type': 'image/jpeg', 'X-Source': 'blob' },
    });
  } catch {
    // blob.get throws when the key does not exist; fall through to capture
  }

  const upstream = await fetch(
    `https://app.snap-render.com/v1/screenshot?url=${encodeURIComponent(target)}&format=jpeg&quality=85`,
    { headers: { 'X-API-Key': Deno.env.get('SNAPRENDER_API_KEY') ?? '' } },
  );
  if (!upstream.ok) return new Response('capture failed', { status: 502 });

  const bytes = await upstream.arrayBuffer();
  await blob.set(key, bytes);

  return new Response(bytes, { headers: { 'Content-Type': 'image/jpeg', 'X-Source': 'api' } });
}

Caching, quota, and errors

Caching

Caching is off by default. Pass cache=true and a cache_ttl in seconds (default 86400, maximum depends on plan). A repeat request for the same URL and parameters inside that window is served from the cache and does not count against your monthly quota. The X-Cache response header reports HIT or MISS, and X-Remaining-Credits shows what is left this month.

Error handling

  • 400 INVALID_URL or BLOCKED_URL: fix the input; private and local addresses are refused.
  • 401 missing or wrong X-API-Key.
  • 408 RENDER_TIMEOUT or TARGET_TIMEOUT: the page took longer than 30 seconds; retry with a simpler URL.
  • 429 RATE_LIMITED (per-minute burst, retry after a minute) or QUOTA_EXCEEDED (monthly allowance; the body names the next plan).
  • 502 RENDER_FAILED or TARGET_UNREACHABLE: the target site is down or refused the request.

Every error body is { "error": { "code", "message", "status" } }.

Full parameter list, POST with raw HTML, signed URLs, and batch jobs are in the API reference. The OpenAPI spec is at /openapi.json.

Val Town screenshot FAQ

Can I run a headless browser inside a val?+

No. Vals are Deno isolates without a browser binary or subprocess access. The Val Town docs recommend Browserbase for scraping that needs a browser session. For screenshots, one fetch to a screenshot API returns the finished image without a session.

How do I keep my API key out of the val source?+

Store it under Environment Variables in your Val Town settings and read it with Deno.env.get. Public vals expose their source, so never paste the key inline.

Can I schedule captures?+

Yes. Create a cron val that calls the same fetch and writes the result to blob storage or emails it with the std/email module.

Do cached screenshots count against my quota?+

No. Requests served from the API cache (cache=true inside cache_ttl) are not billed as renders. Blob storage on the val side adds a second layer so shared links do not touch the API at all.

Is there a free tier?+

Yes. The free plan includes 200 renders a month with every feature enabled, and no card is required.

Other serverless runtimes

Language quickstarts: Node.js, Python, cURL. Cost comparison: Puppeteer on Lambda vs an API.

Or skip the setup entirely

Everything in this guide is one GET request with SnapRender. No browser to babysit, no timeouts to tune.

200 screenshots a month free. First render in under a minute.

Keep the browser out of your deploy

Get a free API key, 200 renders a month, no card.