Authentication and API keys
Bearer API keys for live and test mode, scopes, creating and revoking keys, request IDs and client labels.
Every request to https://api.storeshot.art/v1 carries a credential in the Authorization
header: Authorization: Bearer <credential>. Never put a credential in a URL, a query string or
an app you ship.
API keys
| Prefix | Environment | What it costs |
|---|---|---|
ss_live_… | live | 1 credit per image (see Credits) |
ss_test_… | test | nothing: sample images of the exact size (see Test mode) |
Create keys in the developer console under API keys. The
full key is shown once: StoreShot stores only its SHA-256 hash and shows the first characters
(prefix) afterwards. The samples in these docs read the key from the STORESHOT_API_KEY
environment variable, so set it once in your shell, for example
export STORESHOT_API_KEY='ss_test_…'.
The developer console signs you in with an access token instead of a key. Access tokens hold every scope and are meant for the console and StoreShot’s own apps, not for your integration.
Check a key
GET /me tells you which account, environment and scopes a credential has. The Node.js and
Python samples on this page continue from this first one (api, headers).
curl
curl -sS https://api.storeshot.art/v1/me \
-H "Authorization: Bearer $STORESHOT_API_KEY" \
| jq '{environment: .credential.environment, scopes: .credential.scopes}'Node.js
const api = 'https://api.storeshot.art/v1';
const headers = { Authorization: `Bearer ${process.env.STORESHOT_API_KEY}` };
const me = await (await fetch(`${api}/me`, { headers })).json();
console.log(me.credential.environment, me.credential.scopes);Python
import os, requests
api = "https://api.storeshot.art/v1"
headers = {"Authorization": f"Bearer {os.environ['STORESHOT_API_KEY']}"}
me = requests.get(f"{api}/me", headers=headers).json()
print(me["credential"]["environment"], me["credential"]["scopes"])A missing, malformed, unknown, revoked or expired credential answers 401 UNAUTHORIZED, always
with the same body.
Scopes
Each operation needs one scope (the API reference lists it per operation). A key
without it gets 403 FORBIDDEN with details.reason: insufficient_scope and
details.requiredScope.
| Scope | Allows | In a new key by default |
|---|---|---|
generations:write | Create, cancel and delete generations and screenshot sets; projects and palettes | yes |
generations:read | Read and list generations, screenshot sets and projects | yes |
uploads:write | Upload, read, list and delete uploads | yes |
usage:read | GET /credits and GET /usage | yes |
keys:manage | Create, list and revoke API keys; GET /activity | no |
billing:read | Read credit packs, plans, checkouts and the subscription | no |
billing:write | Buy credits and manage the plan (live keys only) | no |
webhooks:read | Read webhooks and their deliveries | no |
webhooks:write | Create, change, test and delete webhooks | no |
Give each key only what its job needs: a CI job that renders screenshots needs
generations:write and uploads:write, nothing else.
Create, list and revoke keys
The console is the usual place to manage keys. The API does the same with a key that holds
keys:manage: a key creates keys of its own environment only (a test key cannot create a live
key: 403, details.reason: environment_not_grantable) and never with scopes it does not hold
itself (scope_not_grantable). expiresAt (optional) ends a key automatically, at most 365 days
ahead.
curl
# Create a test key for CI with two scopes. .key is the secret, returned only in this answer.
CREATED=$(curl -sS https://api.storeshot.art/v1/api-keys \
-H "Authorization: Bearer $STORESHOT_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"name":"CI","environment":"test","scopes":["generations:write","uploads:write"]}')
NEW_KEY_ID=$(echo "$CREATED" | jq -r .id)
echo "$CREATED" | jq '{id, prefix, environment, scopes}'
# List the account's keys (prefixes only, never the secret)
curl -sS https://api.storeshot.art/v1/api-keys \
-H "Authorization: Bearer $STORESHOT_API_KEY" \
| jq -r '.data[] | "\(.prefix)… \(.name) \(.status)"'
# Revoke it: 204, and the key stops working at once
curl -sS -X DELETE "https://api.storeshot.art/v1/api-keys/$NEW_KEY_ID" \
-H "Authorization: Bearer $STORESHOT_API_KEY" -o /dev/null -w '%{http_code}\n'Node.js
const created = await (
await fetch(`${api}/api-keys`, {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({
name: 'CI',
environment: 'test',
scopes: ['generations:write', 'uploads:write'],
}),
})
).json();
// created.key is the secret, returned only in this answer: store it in your CI secrets now.
console.log(created.id, created.prefix, created.scopes);
const keys = await (await fetch(`${api}/api-keys`, { headers })).json();
for (const key of keys.data) console.log(`${key.prefix}… ${key.name} ${key.status}`);
const revoked = await fetch(`${api}/api-keys/${created.id}`, { method: 'DELETE', headers });
console.log(revoked.status); // 204Python
created = requests.post(
f"{api}/api-keys",
headers=headers,
json={"name": "CI", "environment": "test", "scopes": ["generations:write", "uploads:write"]},
).json()
# created["key"] is the secret, returned only in this answer: store it in your CI secrets now.
print(created["id"], created["prefix"], created["scopes"])
for key in requests.get(f"{api}/api-keys", headers=headers).json()["data"]:
print(f"{key['prefix']}… {key['name']} {key['status']}")
revoked = requests.delete(f"{api}/api-keys/{created['id']}", headers=headers)
print(revoked.status_code) # 204To rotate a key without downtime: create the new key, deploy it, then revoke the old one.
Link an agent without a key
An agent, a CLI or a script that has no key can get one with its human’s approval, without anyone copying a key around (device authorization, RFC 8628):
POST /auth/device, with no credential, returns auser_code(likeBCDF-GHJK), a console linkverification_uri_complete, a secretdevice_code,expires_in(600 seconds) andinterval(5 seconds).- Show your human the link and the code. They sign in to the
developer console, check the request and approve or deny it.
They may untick scopes, or add
billing:readandwebhooks:read. - Poll
POST /auth/device/tokeneveryintervalseconds with thedevice_code. Once approved it returns the new key inaccess_token, exactly once.
Ask only for the scopes the job needs with scope (space-separated; by default the four scopes a
new key gets). keys:manage, billing:write and webhooks:write cannot be requested this way.
client_name is shown to your human as an unverified name, and environment (live by default)
is fixed. The key belongs to the human’s account: confirm with GET /me which account it is
before you use it. The account’s first key carries the free trial credits.
curl
# 1. Start: no credential needed
START=$(curl -sS https://api.storeshot.art/v1/auth/device \
-H 'Content-Type: application/json' \
-d '{"client_name":"my-script","environment":"test","scope":"generations:read usage:read"}')
echo "Open $(echo "$START" | jq -r .verification_uri_complete) and confirm the code $(echo "$START" | jq -r .user_code)"
DEVICE_CODE=$(echo "$START" | jq -r .device_code)
INTERVAL=$(echo "$START" | jq -r .interval)
# 2. Poll every INTERVAL seconds until your human approves or denies
while :; do
sleep "$INTERVAL"
TOKEN=$(curl -sS https://api.storeshot.art/v1/auth/device/token \
--data-urlencode 'grant_type=urn:ietf:params:oauth:grant-type:device_code' \
--data-urlencode "device_code=$DEVICE_CODE")
case $(echo "$TOKEN" | jq -r '.error // "approved"') in
approved) break ;;
authorization_pending) ;;
slow_down) INTERVAL=$((INTERVAL + 5)) ;;
*) echo "$TOKEN" | jq -r .error_description; exit 1 ;;
esac
done
# 3. .access_token is the new key, shown only now: store it as a secret, never print it
NEW_KEY=$(echo "$TOKEN" | jq -r .access_token)
curl -sS https://api.storeshot.art/v1/me -H "Authorization: Bearer $NEW_KEY" \
| jq '{environment: .credential.environment, scopes: .credential.scopes}'Node.js
const device = await (
await fetch(`${api}/auth/device`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
client_name: 'my-script',
environment: 'test',
scope: 'generations:read usage:read',
}),
})
).json();
console.log(`Open ${device.verification_uri_complete} and confirm the code ${device.user_code}`);
let interval = device.interval;
let token;
for (;;) {
await new Promise((resolve) => setTimeout(resolve, interval * 1000));
const res = await fetch(`${api}/auth/device/token`, {
method: 'POST',
body: new URLSearchParams({
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
device_code: device.device_code,
}),
});
token = await res.json();
if (res.ok) break;
if (token.error === 'slow_down') interval += 5;
else if (token.error !== 'authorization_pending') throw new Error(token.error_description);
}
// token.access_token is the new key, shown only now: store it as a secret, never print it.
const linked = await (
await fetch(`${api}/me`, { headers: { Authorization: `Bearer ${token.access_token}` } })
).json();
console.log(linked.credential.environment, linked.credential.scopes);Python
import time
device = requests.post(
f"{api}/auth/device",
json={"client_name": "my-script", "environment": "test", "scope": "generations:read usage:read"},
).json()
print(f"Open {device['verification_uri_complete']} and confirm the code {device['user_code']}")
interval = device["interval"]
while True:
time.sleep(interval)
res = requests.post(
f"{api}/auth/device/token",
data={
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
"device_code": device["device_code"],
},
)
token = res.json()
if res.ok:
break
if token["error"] == "slow_down":
interval += 5
elif token["error"] != "authorization_pending":
raise SystemExit(token["error_description"])
# token["access_token"] is the new key, shown only now: store it as a secret, never print it.
linked = requests.get(f"{api}/me", headers={"Authorization": f"Bearer {token['access_token']}"}).json()
print(linked["credential"]["environment"], linked["credential"]["scopes"])POST /auth/device/token is the one endpoint whose refusals are not the usual
error envelope: like every OAuth client expects, they are 400 with
{ "error", "error_description" }. The X-Request-Id header is still there.
error | What to do |
|---|---|
authorization_pending | Keep polling every interval seconds |
slow_down | You polled too fast: add 5 seconds to your interval (Retry-After has the new value) |
access_denied | Your human denied it (or the account can’t take another key): stop |
expired_token | Nobody approved it within 10 minutes: start again |
invalid_grant | Unknown or already used device_code: start again |
invalid_request, unsupported_grant_type | Fix the request (see the sample) |
Starting requests is limited per IP address (10 a minute, 60 an hour, at most 20 waiting at a
time: 429 RATE_LIMITED with Retry-After). Device authorization can be switched off on a server;
it then answers 404 NOT_FOUND, and keys come from the console as usual.
Request IDs
Every response carries an X-Request-Id header, and every error body repeats it as
error.requestId. Quote it when you contact support. You can send your own ID (1–128 characters of
A-Z a-z 0-9 _ - =) to find a request in your own logs; anything else is replaced by a new one.
curl -sS -D - -o /dev/null https://api.storeshot.art/v1/me \
-H "Authorization: Bearer $STORESHOT_API_KEY" \
-H 'X-Request-Id: deploy-42-step-3' | grep -i '^x-request-id'
Idempotency keys
POST /generations, POST /screenshot-sets and POST /billing/checkout accept an
Idempotency-Key header (1–255 printable ASCII characters). Repeating the request with the same
key and body returns the original result (with Idempotent-Replayed: true) instead of creating or
charging it twice, so a timeout or a crashed job can always retry safely. The same key with a
different body is 409 IDEMPOTENCY_CONFLICT. See Generations.
Client labels
Optionally send X-Storeshot-Client: <surface>/<name> (surface api, web, mcp or cli; for
example cli/release-bot@1.2.0) to label your traffic in GET /usage?groupBy=source. It is a
label for your reports, not authentication.