Docs menu

Authentication and API keys

Bearer API keys for live and test mode, scopes, creating and revoking keys, request IDs and client labels.

View as Markdown

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

PrefixEnvironmentWhat it costs
ss_live_…live1 credit per image (see Credits)
ss_test_…testnothing: 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.

ScopeAllowsIn a new key by default
generations:writeCreate, cancel and delete generations and screenshot sets; projects and palettesyes
generations:readRead and list generations, screenshot sets and projectsyes
uploads:writeUpload, read, list and delete uploadsyes
usage:readGET /credits and GET /usageyes
keys:manageCreate, list and revoke API keys; GET /activityno
billing:readRead credit packs, plans, checkouts and the subscriptionno
billing:writeBuy credits and manage the plan (live keys only)no
webhooks:readRead webhooks and their deliveriesno
webhooks:writeCreate, change, test and delete webhooksno

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); // 204

Python

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)  # 204

To rotate a key without downtime: create the new key, deploy it, then revoke the old one.

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):

  1. POST /auth/device, with no credential, returns a user_code (like BCDF-GHJK), a console link verification_uri_complete, a secret device_code, expires_in (600 seconds) and interval (5 seconds).
  2. 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:read and webhooks:read.
  3. Poll POST /auth/device/token every interval seconds with the device_code. Once approved it returns the new key in access_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.

errorWhat to do
authorization_pendingKeep polling every interval seconds
slow_downYou polled too fast: add 5 seconds to your interval (Retry-After has the new value)
access_deniedYour human denied it (or the account can’t take another key): stop
expired_tokenNobody approved it within 10 minutes: start again
invalid_grantUnknown or already used device_code: start again
invalid_request, unsupported_grant_typeFix 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.