Signed URLs
A signed URL is a GET /v1/screenshot link that proves it came from you without carrying an API key. You sign it on your server, then put it anywhere a browser or crawler will fetch it: an og:image tag, an <img src>, an email, a no-code tool. Anyone can load the URL until it expires, but nobody can change it. This page is part of the API documentation. For server-to-server calls, use a Bearer token instead.
What you need
Signed URLs use a key pair, separate from your rs_live_ API key. See API keys for all prefixes.
| Key | Prefix | Where it goes |
|---|---|---|
| Public key ID | rs_pub_ |
In the URL, as key_id. It is safe to publish. |
| Secret key | rs_secret_ |
Only on your server. It signs the URL and is never sent. |
To get a pair, open API Keys in the dashboard, select Create Key and choose Signing key pair. The dashboard shows the public key ID and the secret key once; copy both. Owners and admins can create and revoke pairs (see team roles). Revoking either key of a pair revokes both. See create a signing key pair.
Where signed URLs work
A signed URL authenticates only a GET (or HEAD) request to /v1/screenshot with no request body. The signature covers the query string only, so every option must be a flat query parameter of the GET endpoint. POST /v1/screenshot, batch, cache, presets and devices ignore key_id, expires and signature and answer missing_credentials; call them with your rs_live_ key as a Bearer token.
The public key ID is not an API key. Sent as a Bearer token or as api_key, an rs_pub_ or rs_secret_ value is rejected with missing_credentials, so publishing the rs_pub_ ID in signed URLs does not let anyone call the API on your account.
How the server checks a signed URL
When a GET /v1/screenshot request arrives with key_id, expires and signature, RenderScreenshot does this, in order:
- It looks up
key_id. The key must start withrs_pub_and must not be revoked. - It checks
expires. A value that is not a Unix timestamp in seconds, or that is more than 30 days ahead, fails withinvalid_expiration. A past timestamp fails withexpired_signature. - It takes every query parameter except
signature, sorts thename=valuepairs, form-encodes them and computes HMAC-SHA256 with the secret key that belongs to the public key. For a pair made with Signing key pair, that is the pair's own secret. The secret key must be in the same workspace as the public key: a secret key of another workspace never verifies. - It compares that hex digest with
signaturein constant time. A mismatch fails withinvalid_signature.
If all four pass, the request is billed to the account like any other render, and it uses the storage, webhook, logs and cache of the public key's workspace.
The string to sign
Build the string from all parameters you will send, including key_id and expires:
- Encode each name and value as an HTML form would: a space becomes
+. OnlyA-Z a-z 0-9 - _ . ~stay as they are. Everything else becomes%XXwith uppercase hex. - Join each pair as
name=value, sort the pairs as plain strings, then join them with&. expiresis a Unix timestamp in seconds.- Compute HMAC-SHA256 of that string with the secret key and write it as lowercase hex.
- Send the same string as the query, then append
&signature=<hex>.
For url=https://example.com/search?q=dark mode&lang=fr and preset=og_card, the string to sign is:
expires=1792644628&key_id=rs_pub_...&preset=og_card&url=https%3A%2F%2Fexample.com%2Fsearch%3Fq%3Ddark+mode%26lang%3Dfr
Signing code
Each listing is complete and reads the keys from RS_PUBLIC_KEY_ID and RS_SECRET_KEY, the same variables the CLI uses. Tested on 2026-10-15 with Node 22.18, Python 3.14, Ruby 4.0, PHP 8.5, Go 1.27 and Java 21. We checked every output with a copy of the API's own verification code, using a target URL that contains spaces, ~*'()!+, %2F and non-ASCII characters.
Node.js
import { createHmac } from 'node:crypto'; const ENDPOINT = 'https://api.renderscreenshot.com/v1/screenshot'; // Percent-encode exactly like the server (Ruby CGI.escape): // keep A-Z a-z 0-9 - _ . ~, write a space as "+", encode everything else. function encode(value) { return encodeURIComponent(String(value)) .replace(/[!'()*]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase()) .replace(/%20/g, '+'); } export function signedScreenshotUrl(params, { keyId, secret, expiresAt }) { const fields = { ...params, key_id: keyId, expires: Math.floor(expiresAt.getTime() / 1000) }; const query = Object.entries(fields) .map(([k, v]) => `${encode(k)}=${encode(v)}`) .sort() .join('&'); const signature = createHmac('sha256', secret).update(query).digest('hex'); return `${ENDPOINT}?${query}&signature=${signature}`; } const url = signedScreenshotUrl( { url: 'https://example.com/search?q=dark mode&lang=fr', preset: 'og_card' }, { keyId: process.env.RS_PUBLIC_KEY_ID, // rs_pub_... secret: process.env.RS_SECRET_KEY, // rs_secret_..., server side only expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days }, ); console.log(url);
Python
import hashlib import hmac import os import time from urllib.parse import quote_plus ENDPOINT = 'https://api.renderscreenshot.com/v1/screenshot' def encode(value): # Same rules as the server: space -> "+", only A-Z a-z 0-9 - _ . ~ unescaped. return quote_plus(str(value), safe='') def signed_screenshot_url(params, key_id, secret, expires_at): fields = {**params, 'key_id': key_id, 'expires': int(expires_at)} query = '&'.join(sorted(f'{encode(k)}={encode(v)}' for k, v in fields.items())) signature = hmac.new(secret.encode(), query.encode(), hashlib.sha256).hexdigest() return f'{ENDPOINT}?{query}&signature={signature}' url = signed_screenshot_url( {'url': 'https://example.com/search?q=dark mode&lang=fr', 'preset': 'og_card'}, key_id=os.environ['RS_PUBLIC_KEY_ID'], # rs_pub_... secret=os.environ['RS_SECRET_KEY'], # rs_secret_..., server side only expires_at=time.time() + 7 * 24 * 3600, # 7 days ) print(url)
Ruby
require 'cgi' require 'openssl' ENDPOINT = 'https://api.renderscreenshot.com/v1/screenshot' def signed_screenshot_url(params, key_id:, secret:, expires_at:) fields = params.merge('key_id' => key_id, 'expires' => expires_at.to_i) query = fields.map { |k, v| "#{CGI.escape(k.to_s)}=#{CGI.escape(v.to_s)}" }.sort.join('&') signature = OpenSSL::HMAC.hexdigest('SHA256', secret, query) "#{ENDPOINT}?#{query}&signature=#{signature}" end url = signed_screenshot_url( { 'url' => 'https://example.com/search?q=dark mode&lang=fr', 'preset' => 'og_card' }, key_id: ENV.fetch('RS_PUBLIC_KEY_ID'), # rs_pub_... secret: ENV.fetch('RS_SECRET_KEY'), # rs_secret_..., server side only expires_at: Time.now + (7 * 24 * 3600) # 7 days ) puts url
PHP
<?php const ENDPOINT = 'https://api.renderscreenshot.com/v1/screenshot'; // Same rules as the server: space -> "+", only A-Z a-z 0-9 - _ . ~ unescaped. function rs_encode(string $value): string { return str_replace('%20', '+', rawurlencode($value)); } function signedScreenshotUrl(array $params, string $keyId, string $secret, int $expiresAt): string { $params['key_id'] = $keyId; $params['expires'] = (string) $expiresAt; $pairs = []; foreach ($params as $key => $value) { $pairs[] = rs_encode((string) $key) . '=' . rs_encode((string) $value); } sort($pairs, SORT_STRING); $query = implode('&', $pairs); $signature = hash_hmac('sha256', $query, $secret); return ENDPOINT . '?' . $query . '&signature=' . $signature; } echo signedScreenshotUrl( ['url' => 'https://example.com/search?q=dark mode&lang=fr', 'preset' => 'og_card'], getenv('RS_PUBLIC_KEY_ID'), // rs_pub_... getenv('RS_SECRET_KEY'), // rs_secret_..., server side only time() + 7 * 24 * 3600 // 7 days ), PHP_EOL;
Go
package main import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "fmt" "net/url" "os" "sort" "strconv" "strings" "time" ) const endpoint = "https://api.renderscreenshot.com/v1/screenshot" func signedScreenshotURL(params map[string]string, keyID, secret string, expiresAt time.Time) string { fields := map[string]string{"key_id": keyID, "expires": strconv.FormatInt(expiresAt.Unix(), 10)} for k, v := range params { fields[k] = v } // url.QueryEscape uses the same rules as the server: space -> "+". pairs := make([]string, 0, len(fields)) for k, v := range fields { pairs = append(pairs, url.QueryEscape(k)+"="+url.QueryEscape(v)) } sort.Strings(pairs) query := strings.Join(pairs, "&") mac := hmac.New(sha256.New, []byte(secret)) mac.Write([]byte(query)) return endpoint + "?" + query + "&signature=" + hex.EncodeToString(mac.Sum(nil)) } func main() { signed := signedScreenshotURL( map[string]string{"url": "https://example.com/search?q=dark mode&lang=fr", "preset": "og_card"}, os.Getenv("RS_PUBLIC_KEY_ID"), // rs_pub_... os.Getenv("RS_SECRET_KEY"), // rs_secret_..., server side only time.Now().Add(7*24*time.Hour), ) fmt.Println(signed) }
Java
import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.time.Duration; import java.time.Instant; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.HexFormat; import java.util.List; import java.util.Map; import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; public class SignedUrl { static final String ENDPOINT = "https://api.renderscreenshot.com/v1/screenshot"; // Same rules as the server: space -> "+", only A-Z a-z 0-9 - _ . ~ unescaped. static String encode(String value) { return URLEncoder.encode(value, StandardCharsets.UTF_8) .replace("*", "%2A") .replace("%7E", "~"); } static String signedScreenshotUrl(Map<String, String> params, String keyId, String secret, Instant expiresAt) throws Exception { Map<String, String> fields = new HashMap<>(params); fields.put("key_id", keyId); fields.put("expires", Long.toString(expiresAt.getEpochSecond())); List<String> pairs = new ArrayList<>(); fields.forEach((k, v) -> pairs.add(encode(k) + "=" + encode(v))); Collections.sort(pairs); String query = String.join("&", pairs); Mac mac = Mac.getInstance("HmacSHA256"); mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); String signature = HexFormat.of().formatHex(mac.doFinal(query.getBytes(StandardCharsets.UTF_8))); return ENDPOINT + "?" + query + "&signature=" + signature; } public static void main(String[] args) throws Exception { String url = signedScreenshotUrl( Map.of("url", "https://example.com/search?q=dark mode&lang=fr", "preset", "og_card"), System.getenv("RS_PUBLIC_KEY_ID"), // rs_pub_... System.getenv("RS_SECRET_KEY"), // rs_secret_..., server side only Instant.now().plus(Duration.ofDays(7)) ); System.out.println(url); } }
Pass every value as the exact string you want the API to receive. Write booleans as true, not True or 1. Each listing produces the same URL and signature as the API's reference vector: for url=https://example.com/path?a=1&b=two words, width=1200, height=630, format=png, full_page=true, key_id=rs_pub_vector123, expires=1893456000 and secret rs_secret_vector456, the signature is ff30f019a228e751b71f4bdca8c5341c33eaac0985a18426882525de00ba329a.
The SDKs build the same string for you. Give the client your secret key as the signing key and your rs_pub_ value as the public key ID:
| SDK | Configure | Sign |
|---|---|---|
| Node.js | new Client(apiKey, { signingKey, publicKeyId }) |
client.generateUrl(options, expiresAt) |
| Python | Client(api_key, signing_key=..., public_key_id=...) |
client.generate_url(options, expires_at) |
| Ruby | Client.new(api_key, signing_key: ..., public_key_id: ...) |
client.generate_url(options, expires_at: ...) |
| PHP | new Client($apiKey, signingKey: ..., publicKeyId: ...) |
$client->generateUrl($options, $expiresAt) |
| Go | renderscreenshot.New(apiKey, WithSigningKey(...), WithPublicKeyID(...)) |
client.GenerateURL(opts, expiresAt, "", "") |
| Java | new Client(apiKey, baseUrl, timeout, signingKey, publicKeyId) |
client.generateUrl(options, expiresAt) |
From the terminal, rs signed-url does the same. In Postman, the collection's pre-request script signs URLs for you.
Choosing an expiry
expires is required. RenderScreenshot does not accept signed URLs that never expire. The expiry must be at most 30 days after the request arrives. The API rejects a later expiry with invalid_expiration (it allows 5 minutes for clock drift), and the rs signed-url command refuses to sign one.
- og:image tags: social crawlers often fetch the image days after the page is shared, and some fetch it again later. Sign at render time with a long expiry, and regenerate the URL each time the page is rendered.
- Dashboards and emails: use the shortest expiry that covers the time the reader needs.
- Images that must live forever: do not use a signed URL. Store the file in your own bucket and serve that copy.
Each fetch of a signed URL is a normal render and uses a credit. To let repeat fetches reuse an earlier render for 0 credits, sign cache_available=true into the URL (and optionally cache_ttl). See caching for the cache rules and the X-Cache-URL CDN link.
Signature errors
A failed check returns HTTP 401 with JSON like this (see invalid_signature for a step-by-step fix):
{ "error": { "type": "authentication_error", "code": "invalid_signature", "message": "Signature verification failed", "retryable": false, "docs": "https://renderscreenshot.com/docs/errors/invalid-signature" }, "request_id": "3f1c9a52-7d4e-4b8a-9e21-6c0d5b7a8f13" }
| Symptom | Cause | Fix |
|---|---|---|
invalid_signature |
A parameter was added or changed after signing, for example a template appends &format=webp |
Put every parameter into the signing function. Never edit a signed URL. |
invalid_signature |
Spaces encoded as %20, or ~ encoded as %7E |
Use the encoders above. encodeURIComponent and URLSearchParams alone do not match. |
invalid_signature |
The target URL was encoded twice (%253A in the query) |
Pass the raw target URL to the signing function and let it encode once. |
invalid_signature |
Pairs not sorted, or signed in a different order than sent | Sort the encoded name=value strings and send the same string you signed. |
invalid_signature |
Signed with the rs_live_ key or the wrong secret |
Sign with the rs_secret_ key that belongs to the same account as the key_id. |
expired_signature |
expires is in the past, or the clock on the signing server is behind |
Sign at render time. Check that your server syncs its clock. |
invalid_expiration |
expires is more than 30 days ahead, is not a number, or was sent in milliseconds |
Send Unix seconds (divide milliseconds by 1000). Keep the expiry within 30 days. |
missing_credentials |
key_id is unknown, revoked, or not an rs_pub_ key |
Check the public key ID and that the key pair is still active. |
missing_credentials |
The signed URL was sent as a POST, with a body, or to an endpoint other than /v1/screenshot |
Signed URLs work only for GET /v1/screenshot. Use a Bearer token elsewhere. |
| HTTP 429 after many 401s | Requests without a valid credential are rate-limited per IP at 10 per minute | Fix the signature; waiting does not make a bad URL pass. |
Before you publish a batch of signed URLs, check one from your server. This call is a real render and uses a credit:
curl -sS -o /dev/null -w '%{http_code}\n' "$SIGNED_URL"
200 means the signature passed. A 401 body tells you which check failed. Retrying does not help: every 401 on this page stays until you change the URL or the keys. The error codes page lists every other response. For frontend patterns that use a signing pair, read the publishable keys guide. To call the API without an SDK, see the raw HTTP API guide.
Last verified 2026-10-18 against the API's signature check (SignedUrlVerifier) and the reference test vectors.