Deno Deploy Screenshot Service with Web Crypto

A Deno Deploy handler that turns /shot?url= into a signed RenderScreenshot link, with a host allowlist and Web Crypto HMAC. Tested with deno test.

By Simon Chiu · Updated

A short Deno handler gives you permanent screenshot links on your own domain: https://shots.your-app.deno.dev/shot?url=https://docs.example.com/pricing. Each request signs a fresh RenderScreenshot signed URL with Web Crypto and redirects to it. The link in your email, dashboard or Markdown file never expires, the secret never leaves Deno Deploy, and there is not a single dependency to install.

It is one of the screenshot API integrations. The same signing code also runs in a Cloudflare Pages Function, because both runtimes use the standard crypto.subtle API.

What you can build

  • Live thumbnails in an internal dashboard: <img src="/shot?url=..."> for each customer site, refreshed once a day by the cache TTL.
  • Screenshots in a README or Notion page that stay current without a CI job.
  • A shared screenshot endpoint for several apps in different languages, so only one service holds the signing secret.

Prerequisites

  • Deno 2 locally for testing. Deno Deploy runs the same file.
  • A signing key pair: RS_PUBLIC_KEY_ID (rs_pub_...) and RS_SECRET_KEY (rs_secret_...). See API keys.
  • ALLOWED_HOSTS: a comma-separated list of the hostnames you will pay to capture.

The handler

Save as main.ts:

// main.ts: a screenshot link service for Deno Deploy.
// GET /shot?url=https://docs.example.com/page&preset=og_card
//   -> 302 to a signed RenderScreenshot URL that is valid for 1 to 2 hours.
// Env: RS_PUBLIC_KEY_ID (rs_pub_...), RS_SECRET_KEY (rs_secret_...),
//      ALLOWED_HOSTS (comma-separated hostnames you are willing to pay for).

const ENDPOINT = Deno.env.get("RS_ENDPOINT") ??
  "https://api.renderscreenshot.com/v1/screenshot";
const KEY_ID = Deno.env.get("RS_PUBLIC_KEY_ID") ?? "";
const SECRET = Deno.env.get("RS_SECRET_KEY") ?? "";
const ALLOWED_HOSTS = new Set(
  (Deno.env.get("ALLOWED_HOSTS") ?? "").split(",").map((h) => h.trim()).filter(
    Boolean,
  ),
);
const PRESETS = new Set(["og_card", "twitter_card", "full_page"]);
const LIFETIME = 60 * 60; // seconds

// Import the HMAC key once per isolate, not once per request.
const hmacKey = SECRET
  ? await crypto.subtle.importKey(
    "raw",
    new TextEncoder().encode(SECRET),
    { name: "HMAC", hash: "SHA-256" },
    false,
    ["sign"],
  )
  : null;

// Encode like the API does: space -> "+", keep only A-Z a-z 0-9 - _ . ~
function encode(value: string): string {
  return encodeURIComponent(value)
    .replace(
      /[!'()*]/g,
      (c) => "%" + c.charCodeAt(0).toString(16).toUpperCase(),
    )
    .replace(/%20/g, "+");
}

export async function sign(
  params: Record<string, string>,
  nowSeconds: number,
): Promise<string> {
  if (!hmacKey) throw new Error("RS_SECRET_KEY is not set");
  // Round the expiry up to the next full hour plus one hour, so every request
  // inside the same hour gets the same URL and browsers can cache the redirect.
  const expires = String((Math.floor(nowSeconds / LIFETIME) + 2) * LIFETIME);
  const query = Object.entries({ ...params, key_id: KEY_ID, expires })
    .map(([k, v]) => `${encode(k)}=${encode(v)}`)
    .sort()
    .join("&");
  const mac = await crypto.subtle.sign(
    "HMAC",
    hmacKey,
    new TextEncoder().encode(query),
  );
  const hex = Array.from(
    new Uint8Array(mac),
    (b) => b.toString(16).padStart(2, "0"),
  ).join("");
  return `${ENDPOINT}?${query}&signature=${hex}`;
}

function problem(status: number, message: string): Response {
  return Response.json({ error: message }, { status });
}

export async function handler(req: Request): Promise<Response> {
  const { pathname, searchParams } = new URL(req.url);
  if (pathname !== "/shot") return problem(404, "not found");

  let target: URL;
  try {
    target = new URL(searchParams.get("url") ?? "");
  } catch {
    return problem(400, "url must be an absolute http(s) URL");
  }
  // Without this check the service signs anything for anyone: a free screenshot proxy on your bill.
  if (
    !/^https?:$/.test(target.protocol) || !ALLOWED_HOSTS.has(target.hostname)
  ) {
    return problem(403, `host not allowed: ${target.hostname}`);
  }
  const preset = searchParams.get("preset") ?? "og_card";
  if (!PRESETS.has(preset)) {
    return problem(400, `preset not allowed: ${preset}`);
  }

  const signed = await sign(
    { url: target.href, preset, cache_available: "true", cache_ttl: "86400" },
    Math.floor(Date.now() / 1000),
  );
  return new Response(null, {
    status: 302,
    headers: { location: signed, "cache-control": "public, max-age=600" },
  });
}

if (import.meta.main) Deno.serve(handler);

RS_ENDPOINT exists only so you can point the handler at a local stub in tests. Leave it unset in production.

Why these details

  • The key import runs once. Deno Deploy keeps an isolate alive between requests, so top-level await crypto.subtle.importKey(...) pays the cost once per isolate.
  • The encoder is not URLSearchParams. The API checks the signature against a string where a space is + and ~ stays literal. URLSearchParams gets most of this right but encodes ~ and leaves * as it is, the opposite of the server. The encode function matches the server exactly. The reasoning behind this format is in our post on signed URL design.
  • Expiry is rounded to the hour. All requests in the same hour produce the same signed URL, so the 10-minute cache-control on the redirect is safe.
  • cache_available=true stops double billing. A browser that loads the same thumbnail twice in a day gets the stored render for 0 credits. See caching.

Test before you deploy

Save the tests as main_test.ts:

// main_test.ts: run with `deno test --allow-env`
Deno.env.set("RS_PUBLIC_KEY_ID", "rs_pub_test");
Deno.env.set("RS_SECRET_KEY", "rs_secret_test");
Deno.env.set("ALLOWED_HOSTS", "example.com");
const { handler } = await import("./main.ts");

Deno.test("signs allowed hosts", async () => {
  const res = await handler(
    new Request("http://localhost/shot?url=https://example.com/a%20b"),
  );
  if (res.status !== 302) throw new Error(`status ${res.status}`);
  const location = new URL(res.headers.get("location")!);
  if (location.searchParams.get("url") !== "https://example.com/a%20b") {
    throw new Error(`url ${location.searchParams.get("url")}`);
  }
  if (!/^[0-9a-f]{64}$/.test(location.searchParams.get("signature")!)) {
    throw new Error("signature is not 64 hex characters");
  }
});

Deno.test("refuses other hosts", async () => {
  const res = await handler(
    new Request("http://localhost/shot?url=https://attacker.test/"),
  );
  if (res.status !== 403) throw new Error(`status ${res.status}`);
});

The environment is set before the dynamic import(), because main.ts reads it at load time. Run the server locally with:

ALLOWED_HOSTS=example.com deno run --allow-net --allow-env main.ts
curl -i "http://localhost:8000/shot?url=https://example.com"

Deploy

  1. Push main.ts to a GitHub repository.
  2. Create a new app in the Deno Deploy dashboard, link the repository and set the entrypoint to main.ts.
  3. Add RS_PUBLIC_KEY_ID, RS_SECRET_KEY and ALLOWED_HOSTS as environment variables. Mark the secret as secret.
  4. Open /shot?url= with one of your hosts. Your browser should land on a PNG.

No --allow-* flags are needed on Deno Deploy. The platform grants network and environment access.

Parameter mapping

Query on /shot Sent to the API Note
url url Must be http(s) and in ALLOWED_HOSTS
preset preset Limited to og_card, twitter_card, full_page; see presets
(fixed) cache_available=true, cache_ttl=86400 Raise the TTL for slow-changing pages; maximum 2592000
(fixed) key_id, expires, signature Added by sign()

To accept more options, such as width or dark_mode, add them to the object passed to sign(). Validate each value first; only the GET parameters work in a signed URL.

Errors and retries

The handler only signs, so its own errors are input errors: 400 for a bad url or preset, 403 for a host outside the list. Render errors come from RenderScreenshot after the redirect. The browser shows them as a broken image. Copy the location header and open it with curl -i to see the JSON. The most common ones:

  • 401 invalid_signature: RS_SECRET_KEY and RS_PUBLIC_KEY_ID belong to different accounts.
  • 401 expired_signature: the signing server clock is wrong. Deno Deploy clocks are synced, so check your local machine.
  • 429 too_many_requests: a page with many thumbnails sent more requests than your plan allows per minute. Lazy-load the images or raise the TTL.

Limits and costs

  • One credit per fresh render. Cached repeats inside the TTL cost 0. See pricing.
  • Deno Deploy counts each /shot call as a request. The image bytes do not pass through Deno, so they do not count toward its bandwidth.
  • A signed URL may last 30 days at most. This handler uses 1 to 2 hours, because the link users keep is /shot, not the signed URL.

Troubleshooting

Symptom Cause Fix
Every request returns 403 ALLOWED_HOSTS is empty or has a www. mismatch List each hostname exactly as it appears in the URL
RS_SECRET_KEY is not set in the logs Variable missing in this deployment Add it in the app settings and redeploy
Image is a day old cache_ttl=86400 returned the stored render Lower the TTL, or add a version parameter to the target URL

Tested on 2026-10-15 with Deno 2.9.6: deno check, deno lint and deno test passed. A local run produced redirects whose signatures passed a copy of the API's own signature check, and the redirect target returned a PNG from a local API stub. Not tested on a live Deno Deploy app.

Frequently asked questions

Does the RenderScreenshot Node SDK run on Deno?
You do not need it for this. The handler signs URLs with crypto.subtle, which is built into Deno, so the service has no dependencies at all.
Why does the handler need an allowlist?
Without one, anyone who finds the /shot endpoint can screenshot any site on your credits. ALLOWED_HOSTS limits signing to hostnames you choose.
Why redirect instead of returning the image?
A redirect keeps the image bytes off your Deno Deploy bandwidth and lets the browser fetch them from RenderScreenshot. If you need the image on your own domain, fetch it with a Bearer key and stream it back instead.

Related in this topic

Try RenderScreenshot free

150 free credits every month, no credit card required.