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

> You sign on your server, with the signing secret of a token. The secret stays there; only the resulting signature goes into the URL. Find both the token id and its signing secret under [Settings, API tokens](https://screenshotbuddy.io/settings/api-tokens).

## 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](https://screenshotbuddy.io/documentation/authentication.md) 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.

You do not always have to build one. An [asynchronous batch](https://screenshotbuddy.io/documentation/batch-renders.md) hands back a signed URL per finished item, minted for you with the token you polled with, which is how the results of a batch are collected at all. They are these same URLs under the same rules, so everything below describes what you are given as well as what you build.

## What you need

Two values, both belonging to one API token, both under [Settings, API tokens](https://screenshotbuddy.io/settings/api-tokens).

| Value | Type | Description |
| --- | --- | --- |
| `tokenId` | integer | 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. |
| signing secret | string | 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](https://screenshotbuddy.io/documentation/authentication.md) 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.

```text
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`.

```html
<img src="https://api.screenshotbuddy.io/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
<?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://api.screenshotbuddy.io/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
);
```

```javascript
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://api.screenshotbuddy.io/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, and it is the same URL every time you mint it, which is what an embed wants. See caching a signed URL below.

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

| Code | Status | Meaning |
| --- | --- | --- |
| `invalid_signature` | 401 | 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. |
| `signed_url_expired` | 401 | 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. |

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

> Every query parameter is covered by the signature, so adding one breaks it. The usual way this happens is not your code: it is something appending a tracking parameter such as `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](https://screenshotbuddy.io/documentation/authentication.md) 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](https://screenshotbuddy.io/documentation/caching.md) 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`.

That is what lets a page of embeds behave. A list with twenty thumbnails in it fetches twenty images the moment it loads, and once each capture has been made every one of those fetches is a hit. Hits are counted against a [rate limit](https://screenshotbuddy.io/documentation/rate-limits.md) of their own, 300 per minute flat, rather than against the renders your plan allows per minute. A page full of embeds is paced by that far larger number, and it leaves your render allowance for the captures that have still to be made.

Both budgets are counted in full on a signed URL, against the account whose token signed it, exactly as they are on a bearer request, and the credits are spent the same way. What a signed answer never carries is the numbers: the `X-RateLimit-` and `X-Credits-` headers are taken off before it leaves, because whoever fetched the image is not who your plan size and your spending are for. A fetch that goes over a limit is still refused with a `429` and a `Retry-After`, so pace a page of embeds by the limits themselves rather than by what an answer tells you.

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.

Leaving `expires` off is what makes those caches worth having. The signature is a function of the parameters alone, so the same capture signed with the same secret produces the same signature every time, and the URL your page renders today is character for character the URL it rendered yesterday. A browser or a CDN that already holds that image recognises it and does not come back to us for it. Add an `expires` and the URL changes every time you build it, which is a fresh entry in every cache and a fresh fetch from us, so keep it for the URLs that ought to stop working rather than for the embeds you want cached.

Send `cacheTtl` to choose that lifetime, the same way you would on `/snap`.
