Docs / Start here
Screenshot URLs
Every screenshot Screenery stores has a URL on cdn.screenery.dev. The URL names the screenshot, not a file, so it keeps working while the image behind it changes.
The grammar
https://cdn.screenery.dev/{org}/{project}/{asset}@{ref}.{ext}
https://cdn.screenery.dev/acme/dashboard/checkout/payment-form@latest.png
| Part | Means |
|---|---|
| {org} | Your organization. |
| {project} | The project inside it. |
| {asset} | The screenshot's name. It may contain /. |
| {ref} | A channel such as latest, or a version such as v_a1b2c3d4e5f6. Leave out @{ref} and you get @latest. |
| {ext} | The stored format: png, jpg, webp, avif, gif, webm or mp4. |
Screenery rejects a URL that does not fit this grammar. It never repairs one. Uppercase letters, percent-encoding, empty segments and .. all answer 400 invalid_request. Checkout/Form@latest.png is not quietly turned into checkout/form.
Names
| Part | Allowed |
|---|---|
| {org} | 2–32 characters of a-z 0-9 -. |
| {project} | 1–64 characters of a-z 0-9 -. |
| {asset} | Segments of a-z 0-9 . _ - joined by /, up to 64 characters each and 128 in all. A segment may not start with a dot, and the name may not end in a media extension such as .png. |
When CI pushes a folder, the asset name is the file's path inside that folder with the extension removed. test-results/checkout/payment-form.png becomes checkout/payment-form.
There is no viewport or variant suffix. A viewport is an ordinary last path segment, so put it last:
https://cdn.screenery.dev/acme/dashboard/checkout/payment-form/mobile@latest.png https://cdn.screenery.dev/acme/dashboard/checkout/payment-form/desktop@latest.png
Treat a name like a route. Screenery never renames an asset and has no redirects, so renaming a screenshot test breaks every link to the old name. On the next default-branch push the old @latest URL answers 404. See what a channel holds.
Channels and versions
A channel is a name that points at a version and moves when you push or promote: @latest, @main, @pr-12, a release tag such as @v2.3.1. Channel names use a-z 0-9 . _ -, up to 64 characters, and may not start with v_ or a dot.
A version is v_ followed by 12 hex characters. It names one stored image and never changes. Use a version URL when a page must keep showing exactly this image.
Which pushes move which channel is covered in How @latest moves.
Light, dark and other themes
A screenshot can have a dark version, or another theme, under the same URL. Ask for it with ?theme=:
https://cdn.screenery.dev/acme/www/home/hero@latest.png https://cdn.screenery.dev/acme/www/home/hero@latest.png?theme=dark
A missing theme is never a 404. If the screenshot has no dark version, the second URL serves the first image. So a page can ask for dark on every screenshot without checking which ones have it. The response says what it served in x-screenery-theme: the theme's name, or base when it fell back.
To capture one, save the dark screenshot beside the normal one with .dark before the extension, in the same format, and push both in the same push:
test-results/home/hero.png → home/hero test-results/home/hero.dark.png → home/hero, ?theme=dark
dark needs no setup. Other themes are listed in screenery.config.json, for example { "themes": ["dark", "high-contrast"] }, and then hero.high-contrast.png answers ?theme=high-contrast. A product whose normal look is dark sets { "baseTheme": "dark", "themes": ["light"] } and saves hero.light.png instead. light has to be listed: only dark is read without it. Any other dotted name, such as hero.v2.png, stays an ordinary screenshot.
- A theme does not count as a screenshot toward your plan's screenshot limit. It does count toward stored bytes and files per push. Free allows 2 themes (light and dark); pro allows 6.
- A theme always has its screenshot's visibility.
- theme works on channel URLs only. A version URL (@v_…) names one image, so ?theme= there answers 400.
- A signed URL covers the theme it was signed with. Sign …/hero@latest.png?theme=dark to get a link to the dark image.
Formats
The extension must match the format that was stored. Screenery does not convert or resize today. A .webp URL for a stored PNG answers 501 not_implemented, not a converted image.
| Extension | Content type |
|---|---|
| .png | image/png |
| .jpg | image/jpeg |
| .webp | image/webp |
| .avif | image/avif |
| .gif | image/gif |
| .webm | video/webm |
| .mp4 | video/mp4 |
The URL accepts resize and frame parameters (w, h, fit, q, dpr, frame, bg, t, trim), but they are not available yet: any of them answers 501. theme picks a theme. Any other query parameter answers 400, and viewport= is reserved.
Video URLs support range requests, so a browser can seek. Images are always sent whole.
Responses
| Status | When |
|---|---|
| 200 | The image or video. |
| 206 | Part of a video, for a range request. |
| 304 | Your cached copy of a channel URL is still current. |
| 400 | The URL does not fit the grammar. |
| 404 | No such screenshot, or you lack the credential to see it. |
| 405 | A method other than GET, HEAD or OPTIONS. |
| 501 | A different format than the stored one, or a resize parameter. |
Errors are JSON with a code, a message and a request_id. Every response also carries the request id in x-screenery-request-id. Quote it when you ask for help.
The CDN sets no cookies and allows any origin to fetch an image (Access-Control-Allow-Origin: *).
Caching
A public version URL never changes, so it is cached for a year and marked immutable.
A channel URL is looked up on every request. A promotion is a pointer change, and nothing has to be purged. The response names the version it served in x-screenery-version and in its ETag, so a client can revalidate with If-None-Match and get a 304. A browser, or a CDN or proxy of your own, may keep showing a public channel image for up to five minutes (max-age=300, s-maxage=300) before it asks again.
Responses for unlisted and private assets are marked private, so shared caches do not keep them. They are never marked immutable. A browser keeps one only while the credential still works: until a signed URL's exp, and for at most five minutes with a read token. A channel URL is still held for at most five minutes either way. After you revoke a token, a browser that already loaded the image stops showing it within about five minutes.
Unlisted and private version URLs carry an ETag too. When the browser asks again with If-None-Match and a credential that still works, it gets a 304 instead of downloading the image again. Without a working credential the answer is 404, as always.
Visibility
Each asset has one of three visibility levels. Each level opens with exactly one kind of credential:
| Level | What opens it |
|---|---|
| public | The bare URL. No credential. |
| unlisted | A signed URL, which expires. |
| private | A read token. |
A refused request answers 404, never 403. Screenery does not confirm that a private screenshot exists.
You choose public or private when you create a project. Later, an admin can change the project's default on the console's Access screen, including to unlisted, and can override it for one asset on that asset's page.
Signed URLs
A signed URL adds sv, sig, exp and kid to the query string. It works until its expiry, with 60 seconds of allowance for clock skew. Signed URLs open unlisted assets.
From the console: open the asset and use the share link. Pick 1 hour, 24 hours or 30 days.
From the CLI: screenery sign signs with your project's signing key.
npx screenery sign acme/dashboard/checkout/payment-form@latest.png --exp 30d
- --exp takes a number and a unit (s, m, h, d). The default is 7d.
- It needs SCREENERY_SIGNING_KID and SCREENERY_SIGNING_KEY. Create the key under Signing keys on the Access screen. The key is shown once.
- It also needs SCREENERY_TOKEN, to look up the asset's visibility before it signs. Since CLI 0.4.0, GitHub OIDC is used for this when the job has it.
screenery sign refuses a private asset. A signature does not open a private asset, so the URL would answer 404. The command prints nothing on stdout and exits 1. Use a read token instead. Signing a public asset works, with a warning: the bare URL already works.
Retiring a signing key stops every URL signed with it. After a rotation the old key keeps verifying until you retire it.
Private assets
A private asset needs a read token (sk_live_read_…). Send it in a header when you can:
curl -H "Authorization: Bearer $READ_TOKEN" \ https://cdn.screenery.dev/acme/dashboard/checkout/payment-form@latest.png
An <img> tag cannot send a header, so the token may go in the query string instead, as ?token=…. Anyone who can read that page can read the token.
A read token only opens assets that match its glob, and only if the request's Referer matches its referrer list when it has one. How to create and scope one is in Credentials.
Embedding
A public channel URL is ordinary Markdown. Paste it once and it updates itself:

Related guides: Markdown image syntax for size, alignment and dark mode, and README screenshots that update themselves for the whole setup.
If you are unsure which format was stored, ask the embeds endpoint. It answers with the right URL and ready-made Markdown and HTML:
curl -H "Authorization: Bearer $SCREENERY_TOKEN" \ https://app.screenery.dev/v1/projects/acme/dashboard/embeds/checkout/payment-form@latest
Only a public asset gets a snippet. For unlisted and private assets the endpoint returns the facts, says which credential the URL needs, and leaves the snippet empty. A signed URL expires, so it does not belong in a snippet you paste once and forget.
To embed a private asset in a page anyway, an admin can create an embed token on the Access screen: a read token that never expires and must name at least one literal path segment, such as checkout/*. Call the embeds endpoint with that token and ?embed_token=1, and the snippet comes back with ?token= built in.
Tools that speak oEmbed, such as Notion, can discover public screenshots through https://app.screenery.dev/v1/oembed?url=…. It needs no credential and answers 404 for anything that is not public.