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:

The endpoint:

  1. Looks up your API key by access_key
  2. Verifies the HMAC with your full key as secret
  3. Renders the screenshot (or serves from 24h cache)
  4. 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