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_...) andRS_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.URLSearchParamsgets most of this right but encodes~and leaves*as it is, the opposite of the server. Theencodefunction 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-controlon the redirect is safe. cache_available=truestops 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
- Push
main.tsto a GitHub repository. - Create a new app in the Deno Deploy dashboard, link the repository and set the entrypoint to
main.ts. - Add
RS_PUBLIC_KEY_ID,RS_SECRET_KEYandALLOWED_HOSTSas environment variables. Mark the secret as secret. - 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_KEYandRS_PUBLIC_KEY_IDbelong 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
/shotcall 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.