Publishable keys and frontend screenshots

Payment and maps APIs taught developers to look for a "publishable key": a key you can put in a browser. RenderScreenshot solves the same problem with signed URLs. Your server signs a screenshot URL with a secret, the browser loads that URL like any image, and nobody who reads your page source can use it to make other screenshots. This guide explains the key types, the frontend patterns and the errors you can meet. It is part of the authentication guides.

The three key types

Prefix Name Where it lives What it can do
rs_live_ API key Server only, in an environment variable Authenticates API calls with Authorization: Bearer or ?api_key=
rs_secret_ Secret key Server only, never sent anywhere Creates HMAC-SHA256 signatures for signed URLs
rs_pub_ Public key ID In signed URLs, visible to everyone Tells the API which secret key to check the signature with

If you do not have an account yet, sign up and open the dashboard. The rs_pub_ and rs_secret_ keys come as a pair from the API Keys page of the dashboard. Think of the pair as one credential split in two: the public half travels in every URL, the secret half never leaves your server.

The rule is simple: no key in the browser can create a screenshot on its own. A rs_live_ key in frontend code, or in a URL with ?api_key=, is a key that anyone can copy and spend.

How a signed URL works

A signed URL is a normal GET request with three extra parameters:

https://api.renderscreenshot.com/v1/screenshot
  ?expires=1792040400
  &key_id=rs_pub_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
  &preset=og_card
  &url=https%3A%2F%2Fexample.com%2Fblog%2Fhello
  &signature=4f9c...e21a
  • key_id is your rs_pub_ key.
  • expires is a Unix timestamp. It is required, and it can be at most 30 days ahead.
  • signature is the HMAC-SHA256, in hex, of every other query parameter, keyed with your rs_secret_ key.

Because the signature covers every parameter, nobody can change the target URL, the preset or the expiry without breaking it. A changed URL fails with invalid_signature, an old one with expired_signature. Both are HTTP 401 and not retryable.

The canonical string

The API rebuilds the string to sign from the query it receives: all parameters except signature, sorted by name, form-encoded (space as +, and every character except A-Z a-z 0-9 - _ . ~ percent-encoded), joined with &. Your signer must produce exactly that string. Each language's standard URL encoder differs slightly from this rule, so each signer below adjusts it. The Go SDK's GenerateURL already follows the rule.

Node.js

// sign-screenshot-url.mjs  (Node 22, server side only)
import { createHmac } from 'node:crypto';

// Same encoding as the server: form encoding, space as "+", only A-Z a-z 0-9 - _ . ~ left as is.
const enc = (v) =>
  encodeURIComponent(String(v))
    .replace(/[!'()*]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase())
    .replace(/%20/g, '+');

export function signedScreenshotUrl(params, { keyId, secret, ttlSeconds }) {
  const maxTtl = 30 * 24 * 3600; // the API rejects anything longer
  const expires = Math.floor(Date.now() / 1000) + Math.min(ttlSeconds, maxTtl);
  const all = { ...params, key_id: keyId, expires };
  const query = Object.keys(all)
    .sort()
    .map((k) => `${enc(k)}=${enc(all[k])}`)
    .join('&');
  const signature = createHmac('sha256', secret).update(query).digest('hex');
  return `https://api.renderscreenshot.com/v1/screenshot?${query}&signature=${signature}`;
}

Python

# sign_screenshot_url.py  (Python 3.9+, server side only)
import hashlib
import hmac
import time
from urllib.parse import urlencode

MAX_TTL = 30 * 24 * 3600  # the API rejects anything longer


def signed_screenshot_url(params, key_id, secret, ttl_seconds):
    expires = int(time.time()) + min(ttl_seconds, MAX_TTL)
    all_params = {**params, "key_id": key_id, "expires": expires}
    query = urlencode(sorted(all_params.items()))  # sorted keys, form encoding
    signature = hmac.new(secret.encode(), query.encode(), hashlib.sha256).hexdigest()
    return f"https://api.renderscreenshot.com/v1/screenshot?{query}&signature={signature}"

Ruby

# sign_screenshot_url.rb  (Ruby 3.0+, server side only)
require 'cgi'
require 'openssl'

MAX_TTL = 30 * 24 * 3600 # the API rejects anything longer

def signed_screenshot_url(params, key_id:, secret:, ttl_seconds:)
  expires = Time.now.to_i + [ttl_seconds, MAX_TTL].min
  all = params.merge(key_id: key_id, expires: expires)
  # Sorted keys, form encoding (CGI.escape: space as "+", "*" as %2A, "~" kept)
  query = all.sort_by { |k, _| k.to_s }.map { |k, v| "#{CGI.escape(k.to_s)}=#{CGI.escape(v.to_s)}" }.join('&')
  signature = OpenSSL::HMAC.hexdigest('SHA256', secret, query)
  "https://api.renderscreenshot.com/v1/screenshot?#{query}&signature=#{signature}"
end

PHP

<?php
// sign_screenshot_url.php  (PHP 8.0+, server side only)
function signedScreenshotUrl(array $params, string $keyId, string $secret, int $ttlSeconds): string
{
    $maxTtl = 30 * 24 * 3600; // the API rejects anything longer
    $params['key_id'] = $keyId;
    $params['expires'] = time() + min($ttlSeconds, $maxTtl);
    ksort($params, SORT_STRING);
    // urlencode() gives form encoding but turns "~" into %7E; the API keeps "~"
    $query = str_replace('%7E', '~', http_build_query($params, '', '&', PHP_QUERY_RFC1738));
    $signature = hash_hmac('sha256', $query, $secret);
    return "https://api.renderscreenshot.com/v1/screenshot?{$query}&signature={$signature}";
}

Java

// SignScreenshotUrl.java  (Java 11+, server side only)
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.Map;
import java.util.TreeMap;
import java.util.stream.Collectors;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;

public final class SignScreenshotUrl {
    private static final long MAX_TTL = 30L * 24 * 3600; // the API rejects anything longer

    // Form encoding as the API expects: space as "+", "*" as %2A, "~" kept.
    private static String enc(String s) {
        return URLEncoder.encode(s, StandardCharsets.UTF_8).replace("*", "%2A").replace("%7E", "~");
    }

    public static String sign(Map<String, String> params, String keyId, String secret, long ttlSeconds)
            throws Exception {
        TreeMap<String, String> all = new TreeMap<>(params); // sorted by key
        all.put("key_id", keyId);
        all.put("expires", Long.toString(Instant.now().getEpochSecond() + Math.min(ttlSeconds, MAX_TTL)));
        String query = all.entrySet().stream()
            .map(e -> enc(e.getKey()) + "=" + enc(e.getValue()))
            .collect(Collectors.joining("&"));
        Mac mac = Mac.getInstance("HmacSHA256");
        mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
        StringBuilder hex = new StringBuilder();
        for (byte b : mac.doFinal(query.getBytes(StandardCharsets.UTF_8))) hex.append(String.format("%02x", b));
        return "https://api.renderscreenshot.com/v1/screenshot?" + query + "&signature=" + hex;
    }
}

Go

// The Go SDK signs for you once it has the key pair.
client, err := rs.New(os.Getenv("RS_API_KEY"),
    rs.WithSigningKey(os.Getenv("RS_SECRET_KEY")),
    rs.WithPublicKeyID(os.Getenv("RS_PUB_KEY")),
)
if err != nil {
    log.Fatal(err)
}

signedURL, err := client.GenerateURL(
    rs.URL("https://example.com/blog/hello").Preset("og_card"),
    time.Now().Add(24*time.Hour),
    "", "", // use the keys configured on the client
)

Tested on 2026-10-15 with Node 22.18, Python 3.14, Ruby 4.0, PHP 8.5, OpenJDK 21 and Go 1.27 (Go SDK from its repository): for a target URL that contains spaces, ?, &, *, ~, (, !, ) and ', every signer produced the same canonical string, and every signature verified with the same parsing and canonical-string code the API uses.

One trap is worth knowing. URLSearchParams in JavaScript leaves * unencoded, but the canonical string encodes it as %2A. A signer built on URLSearchParams works for most URLs and then fails with invalid_signature on the first URL that contains a *. PHP's urlencode, Ruby's URI.encode_www_form and Java's URLEncoder each have a similar difference with ~ or *, which is why the signers above correct it.

Frontend patterns

Pattern 1: render the signed URL on the server

The simplest pattern. Your server builds the page and puts a signed URL straight into the HTML:

<img
  src="https://api.renderscreenshot.com/v1/screenshot?expires=...&key_id=rs_pub_...&url=...&signature=..."
  width="1200" height="630"
  alt="Preview of the linked article">

Use it for og:image tags, link previews in server-rendered pages and emails. The signed URLs for OG images guide shows this in six languages.

Pattern 2: a small signing endpoint

For single-page apps, add one backend route that returns a signed URL. Keep it narrow: it must decide what may be signed, or it becomes a free screenshot service for anyone who finds it.

// GET /api/preview-url?path=/blog/hello  (any Node framework; plain handler shown)
import { signedScreenshotUrl } from './sign-screenshot-url.mjs';

export function previewUrlHandler(req, res) {
  const path = new URL(req.url, 'http://localhost').searchParams.get('path') ?? '/';
  if (!/^\/[a-z0-9\-/]*$/i.test(path)) {
    res.writeHead(400).end('bad path');
    return;
  }
  const url = signedScreenshotUrl(
    { url: `https://example.com${path}`, preset: 'og_card' }, // only your own site
    { keyId: process.env.RS_PUB_KEY, secret: process.env.RS_SECRET_KEY, ttlSeconds: 86400 }
  );
  res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ url }));
}

Tested on 2026-10-15 with Node 22.18: path=/blog/hello returned a signed URL, and /../etc, @evil.com and /a?b were rejected with HTTP 400.

The browser fetches /api/preview-url, then sets the returned URL as an image src. Add authentication and rate limiting to this route like any other. The security guide explains why the target host must be fixed or allowlisted.

Pattern 3: store the result instead of signing

If the image does not change per viewer, call the API once from your backend and keep the X-Cache-URL from the response. It is a public CDN address with no key and no signature, valid until X-Cache-Expires. See the X-Cache-URL guide.

Pattern Key in the browser Each view renders? Good for
Signed URL in server HTML None (only rs_pub_) Each request to the signed URL is an API request Meta tags, emails, server-rendered pages
Signing endpoint None (only rs_pub_) Same Single-page apps that need dynamic targets
Stored X-Cache-URL None No Images shown many times

Domain restrictions and domainnotallowed

The API reference lists a domain_not_allowed error: HTTP 403, type authorization_error, not retryable. If you receive it, the request is valid but the account may not capture that target domain. Do not retry it; show your fallback image and check the target.

Do not build your own protection on the Referer or Origin header. Any HTTP client can send any value in those headers. For a URL that is visible in public HTML, the signature and the expiry are the protection.

Troubleshooting

Symptom Likely cause Fix
401 invalid_signature on every URL Wrong secret, or a signer that does not sort or encode like the API Use one of the tested signers above; check that key_id and secret are from the same pair
401 invalid_signature only for some target URLs Encoding difference on characters such as *, ', ( or spaces Encode as shown above; do not use URLSearchParams for the canonical string
URL works, then breaks after a template change A parameter was added or reordered by a framework after signing Sign the final parameter set; do not let templates add parameters afterwards
401 expired_signature expires is in the past, or your server clock is wrong Sync the server clock with NTP; sign with a TTL longer than the page cache
Images break for visitors weeks later Cached HTML still holds an expired URL Keep the page cache shorter than the URL TTL, or use a stored X-Cache-URL
403 domain_not_allowed Target domain not permitted for the account Do not retry; check the target
Your own signing endpoint is used by strangers It signs any URL it receives Fix the host on the server side, require a session and rate-limit the route

Related in this topic

Was this page helpful?