Signed URLs
Embed a ScreenshotAPI render directly in client-side HTML without exposing your API key. HMAC-signed, time-limited screenshot URLs — how to mint them, how they are verified, and what the signature covers.
A signed URL is a screenshot request that carries its own proof of authorisation, so you can put it straight into an <img src> without shipping your API key to the browser. It looks like an ordinary GET /api/v1/screenshot call with three extra parameters:
https://screenshotapi.to/api/v1/screenshot?url=https%3A%2F%2Fexample.com&width=1200&keyPrefix=sk_live_a1b2&expires=1786000000&sig=9f2c...Your server mints it. The browser only ever sees the finished URL, which is valid for the exact request you signed and expires on the timestamp you chose.
Signed URLs are available on the Growth plan and above. On Free and
Starter the endpoint answers 402 Payment Required with a
PLAN_UPGRADE_REQUIRED code and an upgradeUrl. If you only need a keyless
image and do not mind our limits, use the free screenshot
URL instead — no account required.
Why not just put the API key in the URL?
Because anything in front-end HTML is public. An API key in an <img src> is readable by anyone who views source, and it is a full credential: it can create screenshots, read your usage, and — through the rest of the API — manage your keys.
A signed URL is deliberately narrower:
- It authorises one exact request. The signature covers every query parameter, so nobody can repoint it at another site, widen the viewport to burn your quota faster, or switch the output to PDF.
- It expires. After the
expirestimestamp it is inert. - It is read-only. A signed URL works on
GET /api/v1/screenshotand nowhere else. It cannot be used onPOST /api/v1/screenshot, and it cannot list, create, or revoke API keys.
Renders made through a signed URL are metered against your account exactly like any other authenticated render.
Minting a signed URL
Option 1 — ask the API (no crypto on your side)
POST /api/v1/screenshot/sign takes the query parameters you want and returns a finished URL. Authenticate with your API key, from your server.
curl -X POST https://screenshotapi.to/api/v1/screenshot/sign \
-H "x-api-key: $SCREENSHOTAPI_KEY" \
-H "content-type: application/json" \
-d '{
"params": { "url": "https://example.com", "width": "1200", "fullPage": "true" },
"expiresInSeconds": 3600
}'const res = await fetch('https://screenshotapi.to/api/v1/screenshot/sign', {
method: 'POST',
headers: {
'x-api-key': process.env.SCREENSHOTAPI_KEY,
'content-type': 'application/json'
},
body: JSON.stringify({
params: { url: 'https://example.com', width: '1200', fullPage: 'true' },
expiresInSeconds: 3600
})
})
const { url, expiresAt } = await res.json()import os, requests
res = requests.post(
"https://screenshotapi.to/api/v1/screenshot/sign",
headers={"x-api-key": os.environ["SCREENSHOTAPI_KEY"]},
json={
"params": {"url": "https://example.com", "width": "1200", "fullPage": "true"},
"expiresInSeconds": 3600,
},
)
signed = res.json()["url"]The response is:
{
"url": "https://screenshotapi.to/api/v1/screenshot?...&sig=...",
"expiresAt": "2026-08-03T13:14:15.000Z",
"keyPrefix": "sk_live_a1b2"
}expiresInSeconds defaults to 3600 and may be at most 604800 (7 days). Your parameters are validated before signing, so a URL this endpoint returns is one the renderer will accept.
Option 2 — sign offline (no round trip)
If you are generating many URLs, sign them yourself. There is no extra secret to manage: your signing secret is the SHA-256 of your API key.
The canonical form is exact — sort every query parameter by name, including expires and keyPrefix, exclude sig, and HMAC-SHA256 the resulting query string.
import { createHash, createHmac } from 'node:crypto'
function signScreenshotUrl(apiKey, params, expiresInSeconds = 3600) {
const signingSecret = createHash('sha256').update(apiKey).digest('hex')
const url = new URL('https://screenshotapi.to/api/v1/screenshot')
for (const [key, value] of Object.entries(params)) {
url.searchParams.set(key, String(value))
}
url.searchParams.set('keyPrefix', apiKey.slice(0, 12))
url.searchParams.set(
'expires',
String(Math.floor(Date.now() / 1000) + expiresInSeconds)
)
const canonical = new URLSearchParams(
[...url.searchParams.entries()].sort(([a], [b]) => a.localeCompare(b))
).toString()
url.searchParams.set(
'sig',
createHmac('sha256', signingSecret).update(canonical).digest('hex')
)
return url.toString()
}
const src = signScreenshotUrl(process.env.SCREENSHOTAPI_KEY, {
url: 'https://example.com',
width: 1200
})import hashlib, hmac, string, time
from urllib.parse import urlencode
# The canonical string is serialised with the URL-encoded form rules the API uses.
# Python's urlencode() is not interchangeable here: it leaves "~" unescaped and escapes "*".
_SAFE = set(string.ascii_letters + string.digits + "*-._")
def _encode(value: str) -> str:
out = []
for byte in value.encode("utf-8"):
char = chr(byte)
if char in _SAFE:
out.append(char)
elif char == " ":
out.append("+")
else:
out.append("%%%02X" % byte)
return "".join(out)
def sign_screenshot_url(api_key: str, params: dict, expires_in_seconds: int = 3600) -> str:
signing_secret = hashlib.sha256(api_key.encode()).hexdigest()
query = {str(k): str(v) for k, v in params.items()}
query["keyPrefix"] = api_key[:12]
query["expires"] = str(int(time.time()) + expires_in_seconds)
canonical = "&".join(f"{_encode(k)}={_encode(v)}" for k, v in sorted(query.items()))
query["sig"] = hmac.new(
signing_secret.encode(), canonical.encode(), hashlib.sha256
).hexdigest()
return "https://screenshotapi.to/api/v1/screenshot?" + urlencode(query)The signing secret grants the ability to mint render URLs against your account. Keep it on your server, exactly like the API key it is derived from — never ship it to the browser. Only the finished URL is safe to publish.
Using one
Once minted, it is just a URL:
<img
src="https://screenshotapi.to/api/v1/screenshot?url=https%3A%2F%2Fexample.com&width=1200&keyPrefix=sk_live_a1b2&expires=1786000000&sig=9f2c..."
alt="Screenshot of example.com"
width="1200"
/>Pair it with cacheTtl so repeat views are served from cache and do not consume your allowance:
...&cacheTtl=86400&keyPrefix=...&expires=...&sig=...Cached responses do not count against your monthly quota, so a signed URL embedded on a busy page costs you one render per cache window rather than one per visitor.
Parameters
| Parameter | Required | Description |
|---|---|---|
keyPrefix | yes | The first 12 characters of the API key that signed the URL. Public — it identifies, it does not authorise. |
expires | yes | Unix timestamp (seconds) after which the URL stops working. Covered by the signature. |
sig | yes | Hex HMAC-SHA256 of the sorted query string, keyed by SHA-256 of the API key. |
Every other parameter is an ordinary screenshot option.
Responses
| Status | Meaning |
|---|---|
200 | The image bytes, exactly as GET /api/v1/screenshot would return them. |
402 | PLAN_UPGRADE_REQUIRED — the account is below the Growth plan, or is out of quota. The body carries upgradeUrl. |
403 | INVALID_SIGNED_URL — the signature does not match, the URL has expired, or the key has been revoked. |
Entitlement is re-checked when the URL is redeemed, not only when it is minted: if a plan lapses, previously issued signed URLs stop working.
Notes
- Revoking an API key immediately invalidates every URL signed with it.
- Rotating a key changes its SHA-256, and therefore its signing secret — re-mint any long-lived URLs after a rotation.
- The signature covers the parameters, not the response. Two identical signed URLs from different keys hit the same render cache, which is intended.