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
    tokenId
    Type
    integer
    Description

    The 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 secret
    Type
    string
    Description

    64 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.

  1. Collect every query parameter you intend to send, including tokenId and, if you want one, expires. Leave signature out: it is the thing you are about to produce.
  2. 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.
  3. 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 is rawurlencode; in JavaScript, encodeURIComponent.
  4. Join the encoded pairs with &.
  5. Put snap-signed-v1 and a single newline character in front of the whole thing. This prefix is part of what you sign.
  6. 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.

EXAMPLE
text/plain
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.

GET
/api/v1/snap/signed
<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
sign.php
<?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
);
Node.js
sign.mjs
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_signature
    Type
    401
    Description

    A 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_expired
    Type
    401
    Description

    A 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.

401
application/json
{
    "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.

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.