Signed URLs (/v1/render)
For embeddable use cases — <img src="…">, Markdown images, no-code tools (Bubble, Webflow), email content — use signed URLs instead of POST + JSON.
Concept
A signed URL is a GET /v1/render?... URL with two extra params:
access_key— your API key's short identifier (visible)signature— HMAC-SHA256 of the canonical query string
The endpoint:
- Looks up your API key by
access_key - Verifies the HMAC with your full key as secret
- Renders the screenshot (or serves from 24h cache)
- Streams the binary directly with the right MIME type
Build a signed URL
The signature is over the canonical query string: params alphabetically sorted, signature excluded, RFC 3986 encoded.
PHP
$secret = 'sho_live_a1b2c3d4e5f6_d8c7b6a5...'; // your full key
$short = 'sho_live_a1b2c3d4e5f6'; // visible part
$params = [
'url' => 'https://example.com',
'format' => 'png',
'full_page' => '1',
'access_key' => $short,
];
ksort($params);
$canonical = http_build_query($params, '', '&', PHP_QUERY_RFC3986);
$sig = hash_hmac('sha256', $canonical, $secret);
$url = "https://websitescreenshotapi.net/api/v1/render?{$canonical}&signature={$sig}";
echo "<img src=\"{$url}\" />";
Node.js
import crypto from 'node:crypto';
function signed(params, secret, base = 'https://websitescreenshotapi.net/api/v1/render') {
const sorted = Object.keys(params).sort()
.map(k => `${encodeURIComponent(k)}=${encodeURIComponent(params[k])}`)
.join('&');
const sig = crypto.createHmac('sha256', secret).update(sorted).digest('hex');
return `${base}?${sorted}&signature=${sig}`;
}
const url = signed({
url: 'https://example.com',
format: 'png',
full_page: '1',
access_key: 'sho_live_a1b2c3d4e5f6',
}, process.env.SCREENSHOTAPI_SECRET);
console.log(url);
Python
import hmac, hashlib
from urllib.parse import urlencode, quote
def signed(params, secret, base='https://websitescreenshotapi.net/api/v1/render'):
canonical = '&'.join(f"{quote(k, safe='')}={quote(str(v), safe='')}"
for k, v in sorted(params.items()))
sig = hmac.new(secret.encode(), canonical.encode(), hashlib.sha256).hexdigest()
return f"{base}?{canonical}&signature={sig}"
url = signed({
'url': 'https://example.com',
'format': 'png',
'full_page': '1',
'access_key': 'sho_live_a1b2c3d4e5f6',
}, secret)
Caching
Signed URLs are cache-friendly. The same url + options rendered again within 24h returns the existing file with X-Cache: HIT and does not debit credits.
GET /api/v1/render?... HTTP/1.1
HTTP/1.1 200 OK
Content-Type: image/png
X-Cache: HIT # this hit was served from cache (free)
Cache-Control: public, max-age=3600, immutable
Supported options
All options from POST /v1/screenshot work as query params (only flat values, not nested objects):
?url=...
&format=png
&viewport_width=1280
&viewport_height=720
&full_page=1
&block_ads=1
&block_cookie_banners=1
&dark_mode=0
&device=iphone_15_pro
&wait_until=load
&delay=500
&selector=#main
For nested options (like clip, cookies, headers), use POST /v1/screenshot instead.
Errors
Signed-URL errors are returned as JSON with the appropriate HTTP status:
HTTP/1.1 401 Unauthorized
Content-Type: application/json
{ "error": "invalid_access_key" }
HTTP/1.1 403 Forbidden
Content-Type: application/json
{ "error": "invalid_signature" }
HTTP/1.1 410 Gone
Content-Type: application/json
{ "error": "result_expired" }
Cost
Signed URLs cost the same as POST /v1/screenshot. Cache hits = free.
When NOT to use signed URLs
- For animations / videos (use
POST /v1/animateasync + webhook) - When you need to read the response body (use
POST /v1/screenshot) - For URLs that change often (cache won't help)