Signed URLs
A signed URL is a capture request that proves, on its own, that whoever built it holds one of your API tokens. You can hand it to a browser, a CDN or an email client, because the token itself never travels with it.
Why signed URLs exist
The thing that usually wants a screenshot is a browser. An <img> tag cannot send an
Authorization header, and putting a token where a browser can read it publishes it, as the
authentication page warns. The usual way out is to
proxy every capture through your own backend, which means running a service whose only job is to add a header.
A signed URL removes that service. You build the URL wherever you already render your page, sign it with a
secret that never leaves your server, and put the result in the src. Anyone who sees the URL can
fetch exactly the capture it describes and nothing else: they cannot change the page it points at, widen the
viewport, or use it to make any other request against your account.
Because the answer is a plain GET with cache headers, a CDN in front of it works the way it does
for any other image.
What you need
Two values, both belonging to one API token, both under Settings » API tokens.
-
Name
tokenIdTypeintegerDescriptionThe id of the token, shown next to it in the token list as
42|…. It is the number in front of the pipe in the token you copied when you created it. It is not a secret, and it travels in the URL as an ordinary parameter. -
Name
signing secretTypestringDescription64 lowercase hexadecimal characters, revealed on demand from the token list. This is the key you sign with, and it is as sensitive as the token itself: keep it in an environment variable or a secrets manager, never in anything a browser receives.
The token's permissions still
apply. A URL signed with a screenshot only token that asks for pdf=1 is refused with
a 403, exactly as a bearer request would be.
How to sign
Signing is six steps over the parameters you are about to send. Follow them exactly: the server rebuilds the same string from the request it receives and compares the two.
-
Collect every query parameter you intend to send, including
tokenIdand, if you want one,expires. Leavesignatureout: it is the thing you are about to produce. - Sort the parameters by name, comparing the raw bytes of the names. That is a plain bytewise sort, not a locale-aware or case-insensitive one, so uppercase letters sort before lowercase ones.
-
Percent encode each name and each value with RFC 3986 rules, then join each pair with
=. A space becomes%20, never+, and-,_,.and~are the only punctuation left alone. In PHP that israwurlencode; in JavaScript,encodeURIComponent. - Join the encoded pairs with
&. -
Put
snap-signed-v1and a single newline character in front of the whole thing. This prefix is part of what you sign. -
Take the HMAC-SHA256 of that string, keyed with the signing secret. Use the secret as it is written, its
64 characters as ASCII bytes; do not decode it back into 32 bytes first. Write the result as lowercase
hexadecimal and send it as
signature.
The order you put the parameters in the finished URL does not matter, and neither does whether your HTTP
client spells a space as %20 or +. The server decodes the query first and rebuilds
the canonical string from the decoded values, so only the values themselves are signed.
A worked example
Check your implementation against this before you wire it to a real token. The secret below is not a real one, and every value on this page is produced by the same code that verifies your requests.
secret 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
parameters
url https://example.com/pricing
width 1200
height 630
tokenId 42
expires 1767225600
canonical string (the \n after the prefix is a real newline)
snap-signed-v1
expires=1767225600&height=630&tokenId=42&url=https%3A%2F%2Fexample.com%2Fpricing&width=1200
signature fb13a974f037f44544653893b3468696f2357ac5c4460dde3c3e61c34b5dc514
Which makes this the URL to put in the src.
<img src="https://screenshotbuddy.io/api/v1/snap/signed?url=https%3A%2F%2Fexample.com%2Fpricing&width=1200&height=630&tokenId=42&expires=1767225600&signature=fb13a974f037f44544653893b3468696f2357ac5c4460dde3c3e61c34b5dc514" alt="">
Reference implementations
Both of these produce the signature in the worked example above when you hand them the same secret and parameters.
<?php
function signedSnapUrl(array $query, int $tokenId, string $secret, ?int $expires = null): string
{
$query['tokenId'] = (string) $tokenId;
if ($expires !== null) {
$query['expires'] = (string) $expires;
}
ksort($query, SORT_STRING);
$pairs = [];
foreach ($query as $name => $value) {
$pairs[] = rawurlencode($name) . '=' . rawurlencode((string) $value);
}
$query['signature'] = hash_hmac(
'sha256',
"snap-signed-v1\n" . implode('&', $pairs),
$secret
);
return 'https://screenshotbuddy.io/api/v1/snap/signed?' . http_build_query($query, '', '&', PHP_QUERY_RFC3986);
}
echo signedSnapUrl(
['url' => 'https://example.com/pricing', 'width' => '1200', 'height' => '630'],
42,
getenv('SCREENSHOTBUDDY_SIGNING_SECRET'),
time() + 3600
);
import { createHmac } from 'node:crypto';
const encode = (value) =>
encodeURIComponent(value).replace(/[!'()*]/g, (c) =>
'%' + c.charCodeAt(0).toString(16).toUpperCase()
);
export function signedSnapUrl(query, tokenId, secret, expires = null) {
const params = { ...query, tokenId: String(tokenId) };
if (expires !== null) {
params.expires = String(expires);
}
const canonical = Object.keys(params)
.sort()
.map((name) => `${encode(name)}=${encode(params[name])}`)
.join('&');
const signature = createHmac('sha256', secret)
.update(`snap-signed-v1\n${canonical}`)
.digest('hex');
const url = new URL('https://screenshotbuddy.io/api/v1/snap/signed');
for (const [name, value] of Object.entries({ ...params, signature })) {
url.searchParams.set(name, value);
}
return url.toString();
}
console.log(
signedSnapUrl(
{ url: 'https://example.com/pricing', width: '1200', height: '630' },
42,
process.env.SCREENSHOTBUDDY_SIGNING_SECRET,
Math.floor(Date.now() / 1000) + 3600
)
);
Array.prototype.sort with no comparator sorts by UTF-16 code unit, which is the bytewise order
this scheme asks for as long as your parameter names are ASCII, and every parameter this API accepts is. The
encode helper exists because encodeURIComponent leaves
!, ', (, ) and * alone while RFC 3986 does
not.
Expiring a URL
Send expires as a Unix timestamp in seconds and the URL stops working after that moment. It is
optional: without it, a signed URL keeps working for as long as the token behind it does.
It is an ordinary signed parameter, so nobody can push it back without the secret. Choose it from how long the page that carries the URL is expected to live. A dashboard that reloads every few minutes wants a short one; a capture linked from an email that people open days later wants a long one, or none at all.
A URL past its expiry is answered with 401 and the
signed_url_expired code, which is the one failure worth
handling on its own: it means your signing code is fine and the URL simply needs minting again.
When a signature fails
Both failures answer 401 with the usual error envelope, so branch on code.
-
Name
invalid_signatureType401DescriptionA signed URL did not verify. Either the signature does not match the query, or the token it names is unknown, revoked or expired. Rebuild the URL from the canonical form on the signed URLs page, and check that nothing was appended to it after it was signed.
-
Name
signed_url_expiredType401DescriptionA signed URL is past the `expires` moment it was signed with. The signature itself was fine, so sign a new URL with a later expiry.
{
"code": "invalid_signature",
"message": "This signed URL is not valid. Check that the signature covers every query parameter, and that the token it names still exists.",
"request_id": "1f3c0e6a-6a5d-4a4c-9a3f-0a3f7d1c2b84"
}
An unknown token id, a revoked token and a wrong signature all answer
invalid_signature, in the same words. That is deliberate:
distinguishing them would let anyone walk the id space and find out which tokens exist. Repeated failures from
one address are refused with a 429 for a while, so a signing bug in a loop backs off rather than
hammering the endpoint.
fbclid or
utm_source to the URL on its way to us. If a URL that worked in testing fails in the wild,
compare what actually arrived with what you signed.
Rotating the secret
Rotating a token gives it a new signing secret along with its new value, and deleting a token takes its secret with it. Either way, every URL already signed with the old secret stops verifying immediately.
That is what you want after a leak, and it is worth knowing before a routine rotation: anything already published with a long lived signed URL, an email that went out last week, a cached page, will start showing a broken image. If that matters, sign with a token you rotate on your own schedule and keep a second one for the URLs you cannot recall.
Caching a signed URL
Our own cache works exactly as it does for a bearer
request, and it is the same cache: a signed URL and a bearer request for the same capture on the same account
share one entry, so whichever arrives second is a free X-Cache: HIT.
On top of that, a signed answer says what caches in front of us may do with it. A rendered answer carries
Cache-Control: public with a max-age of the rendering's own lifetime, shortened to
whatever is left of expires so that no cache outlives the URL. Send cache=0 and it
is no-store instead, which is also what every refusal carries: a 402 that a CDN kept
would go on serving a broken image long after you topped up.
Send cacheTtl to choose that lifetime, the same way you would on /snap.
Get the request right before you sign it
A signed URL is only as good as the parameters inside it, and changing your mind about one means signing again. Settle the options in the playground first.