# Authentication and API keys

> HTML version: https://storeshot.art/docs/authentication/

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](/docs/credits/)) |
| `ss_test_…` | test | nothing: sample images of the exact size (see [Test mode](/docs/test-mode/)) |

Create keys in the [developer console](https://console.storeshot.art/) 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`).

<!-- tabs -->
```sh
curl -sS https://api.storeshot.art/v1/me \
  -H "Authorization: Bearer $STORESHOT_API_KEY" \
  | jq '{environment: .credential.environment, scopes: .credential.scopes}'
```

```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](/docs/api/) 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.

<!-- tabs -->
```sh
# 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'
```

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

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

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](https://console.storeshot.art/), 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](/docs/credits/).

<!-- tabs -->
<!-- doctest: approve-device=on expect=400 -->
```sh
# 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}'
```

<!-- doctest: approve-device=on expect=400 -->
```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);
```

<!-- doctest: approve-device=on expect=400 -->
```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](/docs/errors/): 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.

```sh
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](/docs/generations/#idempotency).

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