# StoreShot docs (full text) > Every StoreShot docs page as Markdown, in the order of the docs navigation: the guides, then > the API reference and the changelog (34 pages). Generated at build from > https://storeshot.art/docs/; each page starts with its title and its HTML URL. The short index > is https://storeshot.art/llms.txt; AI agents start with the "Quickstart for AI agents" page. --- # StoreShot docs > HTML version: https://storeshot.art/docs/ StoreShot turns raw app screenshots into App Store and Google Play images at exactly the sizes the stores require, with a REST API at `https://api.storeshot.art/v1`. Create a free test key in the developer console (https://console.storeshot.art/), then follow the quickstart. ## Start here - [Quickstart](https://storeshot.art/docs/quickstart.md): From a raw screenshot to a store-ready App Store image with two API calls, in curl, Node.js or Python. - [Authentication and API keys](https://storeshot.art/docs/authentication.md): Bearer API keys for live and test mode, scopes, creating and revoking keys, request IDs and client labels. - [Test mode](https://storeshot.art/docs/test-mode.md): Build and test your integration for free with ss_test_ keys, which return sample images of exactly the requested size. - [Quickstart for AI agents](https://storeshot.art/docs/agents-quickstart.md): Exact steps for Claude Code, Cursor and other coding agents - check the key, upload raw screenshots, quote and create a screenshot set, poll it and save the images in fastlane folders. - [MCP server](https://storeshot.art/docs/mcp.md): StoreShot tools for Claude Code, Cursor, VS Code, Windsurf and Codex: the MCP server. - [TypeScript SDK](https://storeshot.art/docs/sdk.md): The official TypeScript and JavaScript SDK, @draggable/storeshot-sdk - a typed client with waits, uploads, archives, safe retries and webhook verification for Node.js, Bun, Deno and edge runtimes. - [CLI](https://storeshot.art/docs/cli.md): The StoreShot command line tool for terminals, shell agents and CI: screenshot sets and fastlane folders in one command. ## Guides - [Capture raw screenshots](https://storeshot.art/docs/capture.md): Take the raw screenshots StoreShot needs from an iOS Simulator, an Android emulator or device, an Expo, React Native or Flutter web build, or a Maestro flow, with a clean status bar. - [Uploads](https://storeshot.art/docs/uploads.md): Hand a screenshot to StoreShot once (multipart, from a URL or with a presigned upload) and reference its id in generations and sets. - [Generations](https://storeshot.art/docs/generations.md): Turn one screenshot into one store-ready image - create, wait or poll, idempotent retries, list, cancel, delete and the device catalogue. - [Screenshot sets](https://storeshot.art/docs/screenshot-sets.md): A whole store listing in one call - quote it, create it, poll it and download every image as a ZIP, also in fastlane folders. - [Webhooks](https://storeshot.art/docs/webhooks.md): Get a signed HTTPS callback when a generation or screenshot set finishes or credits run low, and verify it. - [Credits and billing](https://storeshot.art/docs/credits.md): What a generation costs, refunds, the free trial credits, checking your balance and usage, and the plans and credit packs. - [Brand kits (styles)](https://storeshot.art/docs/styles.md): Save your palette, logo, background and caption tone once and reuse them with styleId in any generation or screenshot set. ## API reference - [API reference](https://storeshot.art/docs/api.md): every operation, parameter, field and webhook event, one page per resource - [Errors](https://storeshot.art/docs/errors.md): The error envelope, request IDs, what to retry, and every error code with its HTTP status. - [Rate limits](https://storeshot.art/docs/rate-limits.md): Requests per minute per key and per account, the RateLimit headers, 429 and Retry-After, and the generation caps per plan. - [API changelog](https://storeshot.art/docs/changelog.md): Every version of the StoreShot API contract, newest first. - OpenAPI 3.1 contract: https://api.storeshot.art/v1/openapi.json - Error codes: https://storeshot.art/docs/errors/ - API overview with code samples: https://storeshot.art/developers.md - llms.txt: https://storeshot.art/llms.txt --- # Quickstart > HTML version: https://storeshot.art/docs/quickstart/ This guide takes one raw app screenshot and turns it into a 1290 × 2796 App Store image. It uses a free test key, so nothing is charged while you try it. ## Before you start - A raw screenshot of your app saved as `home.png` (a simulator or device capture; PNG, JPEG or WebP). - One of: `curl` and `jq`, Node.js 18 or later, or Python 3 with the `requests` package. ## 1. Create a test key Sign in to the [developer console](https://console.storeshot.art/), open **API keys** and create a key with the **Test** environment. It starts with `ss_test_` and is shown once: copy it into your environment as `STORESHOT_API_KEY`. Keep keys out of source control and out of apps you ship. Test keys run the whole API (uploads, validation, the queue, statuses, idempotency and result URLs) but never spend credits: each generation returns a sample image of exactly the requested size. ## 2. Upload a screenshot and generate an image Pick your language. Each sample uploads `home.png` with `POST /uploads`, then creates one generation with `POST /generations` and waits up to 60 seconds for it to finish. ```sh # STORESHOT_API_KEY holds your key (an ss_test_… key is free while you integrate) # 1. Upload a raw screenshot UPLOAD=$(curl -sS -H "Authorization: Bearer $STORESHOT_API_KEY" \ -F file=@home.png https://api.storeshot.art/v1/uploads | jq -r .id) # 2. Generate a 1290×2796 App Store image (waits up to 60 s) curl -sS https://api.storeshot.art/v1/generations \ -H "Authorization: Bearer $STORESHOT_API_KEY" \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: home-en-1' -H 'Prefer: wait=60' \ -d '{"inputs":[{"uploadId":"'$UPLOAD'"}],"store":"app_store", "device":"app_store_1290x2796","app":{"name":"Trailmate"}}' \ | jq '{status, url: .result.image.url}' ``` ```js import { readFile } from 'node:fs/promises'; const api = 'https://api.storeshot.art/v1'; const headers = { Authorization: `Bearer ${process.env.STORESHOT_API_KEY}` }; const form = new FormData(); form.append('file', new Blob([await readFile('home.png')]), 'home.png'); const upload = await (await fetch(`${api}/uploads`, { method: 'POST', headers, body: form })).json(); const res = await fetch(`${api}/generations`, { method: 'POST', headers: { ...headers, 'Content-Type': 'application/json', 'Idempotency-Key': 'home-en-1', Prefer: 'wait=60' }, body: JSON.stringify({ inputs: [{ uploadId: upload.id }], store: 'app_store', device: 'app_store_1290x2796', app: { name: 'Trailmate' }, }), }); const generation = await res.json(); console.log(generation.status, generation.result?.image.url); ``` ```python import os, requests api = "https://api.storeshot.art/v1" h = {"Authorization": f"Bearer {os.environ['STORESHOT_API_KEY']}"} upload = requests.post(f"{api}/uploads", headers=h, files={"file": open("home.png", "rb")}).json() generation = requests.post( f"{api}/generations", headers={**h, "Idempotency-Key": "home-en-1", "Prefer": "wait=60"}, json={"inputs": [{"uploadId": upload["id"]}], "store": "app_store", "device": "app_store_1290x2796", "app": {"name": "Trailmate"}}, ).json() print(generation["status"], (generation["result"] or {}).get("image", {}).get("url")) ``` ## 3. Read the result `POST /generations` answers `200` with the finished generation, or `202` when it is still running after the wait. In that case poll `GET /generations/{id}` every few seconds until `status` is `succeeded`, `failed` or `canceled`. - `status: succeeded`: `result.image.url` is the PNG to download. The URL is signed and expires after 15 minutes; get the generation again for a fresh one. - `status: failed`: `error.code` says why (for example `GENERATION_FAILED`), and a live key's credit is refunded automatically. The `Idempotency-Key` header makes the request safe to retry: the same key and body return the original generation instead of creating (or charging) a second one. ## 4. Go live Create a key with the **Live** environment (`ss_live_…`) and use it in place of the test key. Live generations cost credits: your first API key comes with 4 free credits, and the [pricing page](/pricing/) lists plans and credit packs, which you buy in the [console](https://console.storeshot.art/billing). Without enough credits a request answers `402 INSUFFICIENT_CREDITS` and nothing is created. ## Next steps - [Authentication](/docs/authentication/): scopes, creating and revoking keys, request IDs. - [Generations](/docs/generations/): every option, polling, idempotent retries, cancel and delete. - [Screenshot sets](/docs/screenshot-sets/): a whole store listing in one call, as a ZIP in fastlane folders. - [Uploads](/docs/uploads/): URL imports and presigned uploads. - [Webhooks](/docs/webhooks/): signed callbacks when a generation or set finishes. - [API reference](/docs/api/): every operation, field and error code. --- # 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 `. 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`). ```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. ```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/). ```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}' ``` ```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](/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 `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. --- # Test mode > HTML version: https://storeshot.art/docs/test-mode/ A test key (`ss_test_…`) runs the whole API exactly like a live key (validation, uploads, the queue, statuses, idempotency, signed result URLs, screenshot sets, archives and webhooks) but never spends credits and never runs an image model. Every generation returns a sample image of exactly the requested size, so your code, your file handling and your fastlane setup can be finished before you pay for anything. ## What is different | | Test key (`ss_test_…`) | Live key (`ss_live_…`) | |---|---|---| | Credits | never charged (`creditsCharged: 0`) | 1 per image | | Result image | a sample image of exactly the requested size | your generated marketing image | | Caption | yours, or `Sample caption (test mode)` when you send none | yours, or written from the screenshot | | Generation caps | not counted (in progress, per UTC day) | counted (see [Rate limits](/docs/rate-limits/)) | | Screenshot sets in progress | at most 3 at once | count as one generation each | | Results kept | deleted 30 days after they finish | until you delete them | | Usage reports | not counted in `GET /usage` | counted | | Requests per minute | counted like live keys | counted | The two environments are separate: a test key sees and uses only test uploads, generations, sets and webhooks, and a live key only live ones. Using a test upload with a live key (or the other way round) is `400 INVALID_INPUT`. ## Try it Upload a screenshot, then ask for a Google Play phone image by size instead of a device id. The answer is the finished generation (test generations take a second or two), and the sample image has exactly the size you asked for. ```sh UPLOAD=$(curl -sS https://api.storeshot.art/v1/uploads \ -H "Authorization: Bearer $STORESHOT_API_KEY" -F file=@home.png | jq -r .id) GENERATION=$(curl -sS https://api.storeshot.art/v1/generations \ -H "Authorization: Bearer $STORESHOT_API_KEY" \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: test-mode-try-1' -H 'Prefer: wait=60' \ -d '{"inputs":[{"uploadId":"'"$UPLOAD"'"}],"store":"google_play", "size":{"width":1080,"height":1920},"app":{"name":"Trailmate"}}') echo "$GENERATION" | jq '{status, environment, creditsCharged, caption: .result.caption, size: {width: .result.image.width, height: .result.image.height}}' # Download the sample image (the URL is signed for 15 minutes) curl -sS -o sample.png "$(echo "$GENERATION" | jq -r .result.image.url)" ``` ## Going live 1. Create a key with the **Live** environment in the console and use it instead of the test key. Uploads, generations and webhooks of the test environment stay where they are; create them again with the live key. 2. Make sure the account has credits: `GET /credits`. Your first API key brings 4 free credits; plans and packs are on the [pricing page](/pricing/). Without enough credits a generation or set answers `402 INSUFFICIENT_CREDITS` and nothing is created or charged. 3. Quote screenshot sets with `?dryRun=true` before you create them ([Screenshot sets](/docs/screenshot-sets/#quote-first)). --- # Quickstart for AI agents > HTML version: https://storeshot.art/docs/agents-quickstart/ This page is written for AI coding agents (and the people who run them). Follow the steps in order: they turn the raw screenshots of an app into every App Store and Google Play image of its listing with one [screenshot set](/docs/screenshot-sets/). Every command is plain `curl` and `jq`, and runs as is in one shell session. At the end you will find a prompt to paste into [Claude Code or Cursor](#prompts-for-claude-code-and-cursor). Your agent speaks MCP? The [MCP server](/docs/mcp/) offers the same workflow as tools and links the account without copying a key. Writing TypeScript or JavaScript? The [TypeScript SDK](/docs/sdk/) does each step in one call. Working in a shell or CI? The [CLI](/docs/cli/) runs the whole workflow as one `storeshot set` command. ## 1. Check the API key The key is in the `STORESHOT_API_KEY` environment variable. Send it as `Authorization: Bearer $STORESHOT_API_KEY` and check it with `GET /me`. ```sh [ -n "$STORESHOT_API_KEY" ] || echo 'STORESHOT_API_KEY is not set: ask your human for a key' curl -sS https://api.storeshot.art/v1/me -H "Authorization: Bearer $STORESHOT_API_KEY" \ | jq '{environment: .credential.environment, scopes: .credential.scopes}' ``` - **Not set, or `401 UNAUTHORIZED`:** no key? Use the device flow: start it with `POST /auth/device`, show your human the console link and the code, and poll until they approve ([how](/docs/authentication/#link-an-agent-without-a-key)); ask for a test key (`"environment":"test"`) to start. Or ask your human to create a key in the developer console (https://console.storeshot.art/, API keys) and to export it as `STORESHOT_API_KEY` in the environment you run in. A test key (`ss_test_…`) is free. - Never ask for the key in the chat, never print it, and never write it to a file you commit. - `environment: "test"`: everything works and costs nothing, but the images are samples of exactly the right size ([test mode](/docs/test-mode/)). `"live"`: real images, 1 credit each. ## 2. Capture the raw screenshots Use the screenshots exactly as the simulator or device captures them: StoreShot adds the background, the caption and the device, at the sizes each store requires. - One screenshot per screen, in the order the listing should show them, all in portrait. - PNG, JPEG or WebP (not HEIC), within the [upload limits](/docs/uploads/#limits). - iOS Simulator: `xcrun simctl io booted screenshot screen-1.png`. Android emulator or device: `adb exec-out screencap -p > screen-1.png`. Screenshots already in the repository are fine too. - A clean status bar, web builds (Expo, React Native, Flutter) and Maestro flows: [Capture raw screenshots](/docs/capture/). - In a shell, the [CLI](/docs/cli/#capture-the-raw-screenshots) (2.60.0 or later) chains it all: `storeshot capture ios` (or `android`, or `web `) once per screen, then `storeshot set screens --store app_store,google_play --app-name --dry-run --json` to quote, the same without `--dry-run` plus `--out . --layout fastlane` to create, wait and save (`storeshot pull ` saves a set again later). - Also collect the app name, a one-line description and a short caption (a few words) per screen. ## 3. Upload them Upload each file once with `POST /uploads` and keep the ids. ```sh UPLOAD_IDS=() for file in screen-1.png screen-2.png; do UPLOAD_IDS+=("$(curl -sS https://api.storeshot.art/v1/uploads \ -H "Authorization: Bearer $STORESHOT_API_KEY" -F "file=@$file" | jq -r .id)") done printf '%s\n' "${UPLOAD_IDS[@]}" ``` ## 4. Quote the set Build the request once, then quote it with `?dryRun=true`: it is validated like a real create, but nothing is created or charged. ```sh SET_BODY=$(jq -n --arg s1 "${UPLOAD_IDS[0]}" --arg s2 "${UPLOAD_IDS[1]}" '{ app: {name: "Trailmate", description: "Offline hiking maps"}, screens: [ {input: {uploadId: $s1}, caption: "Find every trail"}, {input: {uploadId: $s2}, caption: "Works offline"} ], stores: [{store: "app_store"}, {store: "google_play"}] }') curl -sS 'https://api.storeshot.art/v1/screenshot-sets?dryRun=true' \ -H "Authorization: Bearer $STORESHOT_API_KEY" -H 'Content-Type: application/json' \ -d "$SET_BODY" | jq '{images, credits, availableCredits, sufficient}' ``` The create request (step 5) is the same body without `?dryRun=true`, plus an `Idempotency-Key`: ```http POST https://api.storeshot.art/v1/screenshot-sets Authorization: Bearer $STORESHOT_API_KEY Content-Type: application/json Idempotency-Key: trailmate-listing-1 { "app": {"name": "Trailmate", "description": "Offline hiking maps"}, "screens": [ {"input": {"uploadId": "6f1c1f0e-8a5e-4b8e-9a51-0f0e1d2c3b4a"}, "caption": "Find every trail"}, {"input": {"uploadId": "7a2d3c4b-5e6f-4a7b-8c9d-0e1f2a3b4c5d"}, "caption": "Works offline"} ], "stores": [{"store": "app_store"}, {"store": "google_play"}] } ``` - Without `devices`, each store gets the sizes it requires: two App Store sizes and one Google Play size, so 2 screens make 6 images. Add `"featureGraphic": true` to the Google Play store for its 1024 × 500 banner. - With a live key the set costs `credits` (1 per image). Tell your human the number and `availableCredits`, and create the set only after they agree. A test key shows `credits: 0`. - `sufficient: false` means the create would fail with `402`: ask your human to buy credits. ## 5. Create it and wait Create the set with an `Idempotency-Key`: if the request times out, send it again with the same key and body and you get the same set, never a second charge. Use a new key for a new listing. Then poll every few seconds until `status` is no longer `queued` or `running`. ```sh SET_ID=$(curl -sS https://api.storeshot.art/v1/screenshot-sets \ -H "Authorization: Bearer $STORESHOT_API_KEY" -H 'Content-Type: application/json' \ -H 'Idempotency-Key: trailmate-listing-1' -d "$SET_BODY" | jq -r .id) while :; do SET=$(curl -sS "https://api.storeshot.art/v1/screenshot-sets/$SET_ID" \ -H "Authorization: Bearer $STORESHOT_API_KEY") case "$(echo "$SET" | jq -r .status)" in queued|running) sleep 3 ;; *) break ;; esac done echo "$SET" | jq '{status, counts, creditsCharged, creditsRefunded}' ``` `succeeded` means every image is ready. `partially_failed` or `failed`: failed images are refunded; `POST /screenshot-sets/{id}/retry` makes them again (see [Screenshot sets](/docs/screenshot-sets/#retry-cancel-and-delete)). ## 6. Save the images Download the set as a ZIP in the folders fastlane reads and unzip them in the project root. The `manifest.json` in the ZIP says whether every image is there. ```sh curl -sS -o storeshot-fastlane.zip \ "https://api.storeshot.art/v1/screenshot-sets/$SET_ID/archive?layout=fastlane" \ -H "Authorization: Bearer $STORESHOT_API_KEY" unzip -o -q storeshot-fastlane.zip 'fastlane/*' unzip -p storeshot-fastlane.zip manifest.json | jq '{complete, missing}' find fastlane -name '*.png' | sort ``` - App Store images land in `fastlane/screenshots/en-US/` (for `fastlane deliver`), Google Play images in `fastlane/metadata/android/en-US/images/` (for `fastlane supply`, which replaces the screenshots already in the Play listing). - Without `?layout=fastlane` the ZIP has one folder per store and device (`//.png`); ask your human where to put them if the project has no fastlane. ## When something goes wrong Every error has the same shape: `{"error": {"code", "message", "requestId", "docUrl"}}`. | Status | What to do | |---|---| | `401 UNAUTHORIZED` | The key is missing, wrong or revoked: ask your human for a new one. | | `402 INSUFFICIENT_CREDITS` | Stop. Give your human `details.topUpUrl` to buy credits; do not retry. | | `400 INVALID_INPUT` | Fix the request: `details` names the field. | | `409` | The resource is in the wrong state (for example the set is still running): read `message`. | | `429 RATE_LIMITED`, `429 CONCURRENCY_LIMITED` | Wait the `Retry-After` seconds, then send the same request again. | | `5xx` | Wait a few seconds and send the same request with the same `Idempotency-Key`. | When you ask your human (or support) for help, quote the `requestId`. All codes: [Errors](/docs/errors/). ## Prompts for Claude Code and Cursor Paste this prompt into your agent from the app's repository, with `STORESHOT_API_KEY` exported in the environment the agent runs commands in. Edit the stores or captions to taste. ```text Use the StoreShot API (https://api.storeshot.art/v1) to create my App Store and Google Play screenshots. Follow the agent quickstart: https://storeshot.art/docs/agents-quickstart.md (all docs: https://storeshot.art/llms.txt). My API key is in the STORESHOT_API_KEY environment variable; send it as "Authorization: Bearer $STORESHOT_API_KEY". If it is not set, get one with the device flow (POST /auth/device: show me the link and the code, I approve it in the console), or ask me to create a key; never print or commit it. 1. Find my app's raw screenshots (simulator or device captures) and its name and one-line description. 2. Upload each screenshot with POST /uploads (multipart field "file"). 3. Build one screenshot set for both stores with one short caption per screen. Quote it first with POST /screenshot-sets?dryRun=true and tell me how many credits it costs. 4. Create it with POST /screenshot-sets and an Idempotency-Key, poll GET /screenshot-sets/{id} until it is final, then download GET /screenshot-sets/{id}/archive?layout=fastlane and unzip it in the project root (fastlane folders). If my key starts with ss_test_ the images are free samples; tell me when to switch to an ss_live_ key. ``` - **Claude Code:** start `claude` in the repository and paste the prompt. It asks before running each command unless you allow `curl`, `jq` and `unzip`. - **Cursor:** open the repository, start a chat in Agent mode and paste the prompt. Its terminal must see the variable: export it in your shell profile, then restart Cursor. To make every future session follow these steps, add this to the project's `CLAUDE.md`, `AGENTS.md` or Cursor rules: ```text ## Store screenshots Make App Store and Google Play screenshots with StoreShot: https://storeshot.art/docs/agents-quickstart.md The key is in STORESHOT_API_KEY: never print or commit it. Quote with POST /screenshot-sets?dryRun=true and ask before spending credits with an ss_live_ key. Save the archive with ?layout=fastlane in the project root. ``` Everything else: [llms.txt](https://storeshot.art/llms.txt), every docs page in one file ([llms-full.txt](https://storeshot.art/llms-full.txt)) and the [API reference](/docs/api/). --- # MCP server > HTML version: https://storeshot.art/docs/mcp/ The StoreShot MCP server lets coding agents call StoreShot as tools: read the store rules, upload raw screenshots from your disk, create a [screenshot set](/docs/screenshot-sets/), follow it and save the images into your project, fastlane folders included. It runs on your machine (stdio) and needs Node 18 or later. It is published on [npm](https://www.npmjs.com/package/@draggable/storeshot-mcp) as `@draggable/storeshot-mcp` under the Apache-2.0 licence, with its source on [GitHub](https://github.com/Draggable/storeshot-mcp). Writing your own integration instead? Use the [TypeScript SDK](/docs/sdk/). ## Add it to your agent ```sh claude mcp add storeshot -- npx -y @draggable/storeshot-mcp ``` Cursor (`.cursor/mcp.json`) and Windsurf (`~/.codeium/windsurf/mcp_config.json`): ```json { "mcpServers": { "storeshot": { "command": "npx", "args": ["-y", "@draggable/storeshot-mcp"] } } } ``` VS Code (`.vscode/mcp.json`): ```json { "servers": { "storeshot": { "type": "stdio", "command": "npx", "args": ["-y", "@draggable/storeshot-mcp"] } } } ``` Codex (`~/.codex/config.toml`): ```toml [mcp_servers.storeshot] command = "npx" args = ["-y", "@draggable/storeshot-mcp"] ``` ## Link your account You do not need to copy an API key. The first tool call answers with a link and a code: open the link, sign in to the developer console and approve. The server then receives a key of its own (see [Authentication](/docs/authentication/)) and stores it in `~/.config/storeshot/mcp-credentials.json`, readable only by you. To unlink, revoke the key in the console and delete that file. You can also set `STORESHOT_API_KEY` in the server's environment. ## Test and live keys Test keys never spend credits and return sample images of the exact store sizes ([Test mode](/docs/test-mode/)); live keys make the real images and spend credits: `generate_screenshot` costs 1 credit and `create_screenshot_set` 1 credit per image (ask for a quote with `dryRun: true` first). Set `STORESHOT_ENVIRONMENT` to `test` or `live` before linking to choose the key's environment; the console shows it before you approve. ## Tools | Tool | What it does | Credits | | ------------------------ | ------------------------------------------------------------- | ----------------------- | | `get_store_requirements` | Screenshot rules of the App Store and Google Play | free | | `list_devices` | Output sizes and their device ids | free | | `upload_screenshot` | Uploads a local PNG, JPEG or WebP screenshot | free | | `generate_screenshot` | One image for one size | 1 (live keys) | | `create_screenshot_set` | A whole listing in one job; `dryRun` quotes | 1 per image (live keys) | | `get_job_status` | Status of a generation or a set, with optional waiting | free | | `download_results` | Saves the images into a folder (`store` or `fastlane` layout) | free | | `get_credits` | Your credit balance | free | | `link_status` | Whether the server is linked, or the link to approve | free | ## Your files The server reads only the image files your agent names, and only PNG, JPEG or WebP files leave your machine. Downloads are written only inside the folder you ask for, never through symbolic links, and never over existing files unless the agent passes `overwrite: true`. Set `STORESHOT_MCP_ROOT` to your project folder to keep both inside it. --- # TypeScript SDK > HTML version: https://storeshot.art/docs/sdk/ `@draggable/storeshot-sdk` is the official client for the StoreShot API. It is typed from the [OpenAPI contract](/docs/api/), has no runtime dependencies and runs on Node.js 18 or later, Bun, Deno and edge runtimes. It is published on [npm](https://www.npmjs.com/package/@draggable/storeshot-sdk) under the Apache-2.0 licence, with its source on [GitHub](https://github.com/Draggable/storeshot-sdk). ## Install ```sh npm install @draggable/storeshot-sdk ``` ## Set up the client `new Storeshot()` reads the API key from the `STORESHOT_API_KEY` environment variable (or pass `apiKey`). Start with a free test key: everything works, but the images are samples of the exact size and no credits are used ([Test mode](/docs/test-mode/)). Live keys make the real images and spend 1 credit per image. Call the API from your server or build script, never from a browser: the SDK refuses to run in one by default, because every visitor could read the key. On an edge runtime, pass the key explicitly: `new Storeshot({ apiKey: env.STORESHOT_API_KEY })`. A tool without a key can get one with its human's approval: a client created with `new Storeshot({ apiKey: null })` can call only `client.auth.*`, and `auth.linkAccount` runs the device flow described in [Authentication](/docs/authentication/#link-an-agent-without-a-key). ## From one screenshot to a listing From one raw screenshot (`home.png`) to an App Store listing in fastlane's folder layout: ```js import { Storeshot } from '@draggable/storeshot-sdk'; const storeshot = new Storeshot(); // reads STORESHOT_API_KEY const upload = await storeshot.uploads.create({ file: './home.png' }); const set = await storeshot.screenshotSets.create({ app: { name: 'Trailmate' }, screens: [{ input: { uploadId: upload.id }, caption: 'Find every trail' }], stores: [{ store: 'app_store' }], }); const done = await storeshot.waitForScreenshotSet(set); console.log(done.status, `${done.counts.succeeded}/${done.images} images`); await storeshot.screenshotSets.downloadArchive(done.id, 'listing.zip', { layout: 'fastlane' }); ``` Unzip `listing.zip` in your project root: fastlane `deliver` reads `fastlane/screenshots/`. With a live key, ask for the price first: `await storeshot.screenshotSets.quote(body)` returns `images`, `credits` and `sufficient` and creates nothing. For a single image use `storeshot.generations.create()` and `storeshot.waitForGeneration()`; `storeshot.devices.list()` lists the output sizes. See [Screenshot sets](/docs/screenshot-sets/) and [Generations](/docs/generations/) for the request fields. ## What the client does for you - **Waits:** `waitForGeneration` long-polls with `Prefer: wait`, `waitForScreenshotSet` polls every 3 seconds; both return the final resource (check `status`) and give up after 10 minutes unless you set `timeoutMs`. - **Uploads and downloads:** `uploads.create` takes a file path, bytes, a `Blob`, a stream or a `url`, and checks the 20 MiB limit before sending ([Uploads](/docs/uploads/)). `downloadArchive` never leaves a partial file behind. - **Retries:** `429` and busy `503` answers are retried after `Retry-After` for every request; other server and network errors only where repeating is safe (`GET`, creates with an `Idempotency-Key`, quotes). `generations.create` and `screenshotSets.create` always send an `Idempotency-Key`, so a retry never charges twice; pass your own `idempotencyKey` to make re-running a script safe too. - **Errors:** every failure is a `StoreshotError` with the API `code`, `message`, `requestId` and `details` ([Errors](/docs/errors/)). - **Webhooks:** `constructWebhookEvent` and `verifyWebhookSignature` check the `Storeshot-Signature` header against the raw body with Web Crypto ([Webhooks](/docs/webhooks/)). - **Keys stay secret:** the SDK never logs the key and masks it in `toString()`, `JSON.stringify()` and error messages. ## Versions The package version follows the StoreShot releases; `CONTRACT_VERSION` is the API contract its types were generated from. The API changes only additively within a major version, so a newer API keeps working with an older SDK. The README on npm has the full method reference, one method per API operation, and the changelog lists what each version added. --- # CLI > HTML version: https://storeshot.art/docs/cli/ The StoreShot CLI turns raw app screenshots into every App Store and Google Play image of your listing from a terminal, a shell agent or a CI job, and saves them into your project, fastlane folders included. It needs Node.js 18.3 or later and runs as `storeshot` once installed. It is published on [npm](https://www.npmjs.com/package/@draggable/storeshot-cli) as `@draggable/storeshot-cli` under the Apache-2.0 licence, with its source on [GitHub](https://github.com/Draggable/storeshot-cli). Writing your own integration instead? Use the [TypeScript SDK](/docs/sdk/). ## Install ```sh npx @draggable/storeshot-cli --help ``` Or install it once with `npm install -g @draggable/storeshot-cli` and run `storeshot`. Another publisher's npm package is called `storeshot`: it is a different tool, so always give npx the scoped name. If a global install fails because a `storeshot` command already exists, uninstall that package or keep using `npx @draggable/storeshot-cli`. ## Link your account ```sh storeshot login ``` The CLI prints a link and a code. Open the link, check the code and approve it in the console: the CLI stores a **test** key (see [test mode](/docs/test-mode/)) in `~/.config/storeshot/cli-credentials.json`. Pass `--env live` for a live key. An agent whose shell waits for a command to exit can run `storeshot login --no-wait`, show the code, and run `storeshot login` again once it is approved. In CI, set `STORESHOT_API_KEY` from a secret instead (see [authentication](/docs/authentication/)). `storeshot logout` forgets the stored key on this machine; revoke the key itself in the console. ## A whole listing in one command Put the screenshots and a `captions.json` in a folder: ```json { "app": { "name": "Trailmate", "description": "Offline hiking maps" }, "screens": [ { "file": "01-map.png", "caption": "Find every trail" }, { "file": "02-offline.png", "caption": "Works offline" } ] } ``` ```sh storeshot set ./screens --store app_store,google_play --out . --layout fastlane ``` This creates one [screenshot set](/docs/screenshot-sets/), waits for it and writes `fastlane/screenshots/en-US/` and `fastlane/metadata/android/en-US/images/` under the project root. Add `--dry-run` to see the images and credits first, `--feature-graphic` for the Google Play feature graphic, or `--devices ` to choose the sizes (`storeshot devices` lists them). Without `--layout fastlane`, `--out` gets `//NN.png`. ## Capture the raw screenshots `storeshot capture` (CLI 2.60.0 or later) takes the raw screenshots with the tools you already have: `xcrun simctl` for an iOS Simulator, `adb` for an Android emulator or device, or the Playwright of your project for a web build (see [Capture raw screenshots](/docs/capture/) for what each needs). It writes PNG files plus a `captions.json` that `storeshot set` reads as is: ```sh storeshot capture ios --name 01-map --caption "Find every trail" --clean-status-bar storeshot capture android --name 02-offline --caption "Works offline" --clean-status-bar storeshot capture web http://localhost:8081 --route /,/settings --wait-for '[data-testid="ready"]' storeshot set ./screens --store app_store,google_play --app-name Trailmate --out . --layout fastlane ``` `--clean-status-bar` shows 9:41 with full signal and battery, and is undone on exit, on errors and on Ctrl-C (after a killed run, `--reset-status-bar` restores it). Web captures use the viewport and pixel ratio of a StoreShot device (`--device`, default `app_store_1290x2796`), so each PNG has exactly that size; simulator and emulator captures are never resized. `capture` never installs a tool and needs no key: a missing tool exits 12, no running simulator or device exits 5. ## Commands | Command | What it does | | -------------------- | --------------------------------------------------------------------- | | `storeshot login` | Links this machine to your account and stores a key | | `storeshot logout` | Forgets the stored key (`--all`: test and live) | | `storeshot whoami` | The account and the key in use, never the key itself | | `storeshot credits` | Your credit balance | | `storeshot devices` | Output sizes and their device ids (`--store`, `--include-deprecated`) | | `storeshot generate` | One image per screenshot (1–10 files) for one size | | `storeshot set` | A whole listing: every screen on every device, one charge | | `storeshot status` | A set or generation; `--wait` waits until it is final | | `storeshot pull` | Saves the images of a set or generation into `--out` | | `storeshot capture` | Raw screenshots from a simulator, emulator or web build (2.60.0+) | | `storeshot schema` | Prints the JSON Schema of a command, offline | `storeshot --help` lists every flag. A wait that times out leaves the job running: run `storeshot status --wait`, then `storeshot pull --out .`. ## Live credits Test mode is the default. A live charge needs a live key, `--env live` or `STORESHOT_ENVIRONMENT=live`, and then `--yes` (or a "y" at the prompt on a terminal). `--max-credits ` refuses anything above `n`. Failed images are refunded (see [credits](/docs/credits/)). ## CI Store an API key as the secret `STORESHOT_API_KEY` and pin the CLI version, so every run uses the same CLI. A GitHub Actions job (a test key makes sample images for free; a live key also needs `--yes` and a `--max-credits` cap): ```yaml jobs: screenshots: runs-on: ubuntu-latest permissions: contents: read steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Store screenshots env: STORESHOT_API_KEY: ${{ secrets.STORESHOT_API_KEY }} run: > npx --yes @draggable/storeshot-cli@2.60.0 set ./screens --store app_store,google_play --out . --layout fastlane --idempotency-key "storeshot-$GITHUB_RUN_ID" --json > storeshot.json - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: store-screenshots path: | fastlane/screenshots fastlane/metadata/android ``` An `--idempotency-key` never charges twice. A rerun of the job uploads the screenshots again, so it ends with `IDEMPOTENCY_CONFLICT` (exit 2) and the first set's id in `error.details.screenshotSetId`: save that set with `storeshot pull --out . --layout fastlane`. ## Scripts and agents `--json` writes exactly one JSON document to stdout, with `schemaVersion`, `ok`, `exitCode`, `data` and `error`; `storeshot schema ` prints its JSON Schema. Exit codes are stable: 0 ok, 3 not logged in, 4 not enough credits, 9 some images failed, 10 still running, 11 live spend not confirmed, 12 local file problem (see [errors](/docs/errors/) for the API codes in `error.code`). The schemas (JSON Schema 2020-12) are also published at their `$id` URLs. Within `schemaVersion` 1 changes are only additive: new optional fields and new error codes. - [Index](https://storeshot.art/schemas/cli/v1/index.json): the command → schema file map that `storeshot schema` prints - [`envelope`](https://storeshot.art/schemas/cli/v1/envelope.json): storeshot --json envelope - [`capture`](https://storeshot.art/schemas/cli/v1/capture.json): storeshot capture --json - [`credits`](https://storeshot.art/schemas/cli/v1/credits.json): storeshot credits --json - [`devices`](https://storeshot.art/schemas/cli/v1/devices.json): storeshot devices --json - [`generate`](https://storeshot.art/schemas/cli/v1/generate.json): storeshot generate --json - [`login`](https://storeshot.art/schemas/cli/v1/login.json): storeshot login --json - [`logout`](https://storeshot.art/schemas/cli/v1/logout.json): storeshot logout --json - [`pull`](https://storeshot.art/schemas/cli/v1/pull.json): storeshot pull --json - [`set`](https://storeshot.art/schemas/cli/v1/set.json): storeshot set --json - [`status`](https://storeshot.art/schemas/cli/v1/status.json): storeshot status --json - [`whoami`](https://storeshot.art/schemas/cli/v1/whoami.json): storeshot whoami --json - [`captions`](https://storeshot.art/schemas/cli/v1/captions.json): storeshot set --captions captions.json - [`capture-pages`](https://storeshot.art/schemas/cli/v1/capture-pages.json): storeshot capture web --pages pages.json --- # Capture raw screenshots > HTML version: https://storeshot.art/docs/capture/ StoreShot turns raw app screenshots into store images: it adds the background, the caption and the device at every size each store requires. This page shows how to take those raw screenshots with the tools you already have. Nothing here needs a StoreShot key; upload the files afterwards ([next steps](#next-upload-and-create-a-set)). The [StoreShot CLI](/docs/cli/#capture-the-raw-screenshots) (2.60.0 or later) wraps these recipes in one command, `storeshot capture ios|android|web`, with device presets, a status bar cleanup that is always undone, and a `captions.json` that `storeshot set` reads. ## What to capture - One unedited screenshot per screen, in the order the listing should show them, all in portrait. - PNG straight from the simulator, emulator or browser. JPEG and WebP work too, HEIC does not; see the [upload limits](/docs/uploads/#limits). - Any phone or tablet size works: StoreShot renders every store size from it. An image whose short edge is under 300 px or over 2600 px, or that is much longer and thinner than a phone, is refused with `NOT_A_SCREENSHOT`. - A tidy status bar (9:41, full signal and battery) and no personal data on screen. ## iOS Simulator Needs macOS with Xcode. Boot a simulator (open the Simulator app, or run `xcrun simctl boot "iPhone 16 Pro"`), open the screen you want, then: ```sh # The booted simulators: with several, use a UDID from this list instead of "booted" xcrun simctl list devices booted # A clean status bar: 9:41, full signal and battery xcrun simctl status_bar booted override --time 9:41 --dataNetwork wifi --wifiMode active \ --wifiBars 3 --cellularMode active --cellularBars 4 --batteryState charged --batteryLevel 100 mkdir -p screens xcrun simctl io booted screenshot --type=png screens/01-map.png # Put the status bar back xcrun simctl status_bar booted clear ``` - With several simulators booted, `booted` picks one of them: pass a UDID from the list instead. - `status_bar … clear` removes every override, including ones you set yourself. - Some simulator runtimes ignore status bar overrides (reported for iOS 16.1, 16.2 and 17): check the PNG. - Simulator sizes such as 1320 × 2868 (iPhone 16 Pro Max) need not be App Store sizes: StoreShot renders those. - Physical iPhones and iPads have no command line screenshot tool: use the simulator. ## Android emulator or device Needs the Android SDK platform-tools (`adb`) on your `PATH`, and an emulator or a device with USB debugging on. Demo mode gives a clean status bar: ```sh adb devices SERIAL=emulator-5554 # a serial from the list above # A clean status bar: demo mode with 9:41, full signal and battery, no notification icons adb -s "$SERIAL" shell settings put global sysui_demo_allowed 1 adb -s "$SERIAL" shell am broadcast -a com.android.systemui.demo -e command enter adb -s "$SERIAL" shell am broadcast -a com.android.systemui.demo -e command clock -e hhmm 0941 adb -s "$SERIAL" shell am broadcast -a com.android.systemui.demo -e command network -e wifi show -e level 4 adb -s "$SERIAL" shell am broadcast -a com.android.systemui.demo -e command network -e mobile show -e datatype none -e level 4 adb -s "$SERIAL" shell am broadcast -a com.android.systemui.demo -e command battery -e level 100 -e plugged false adb -s "$SERIAL" shell am broadcast -a com.android.systemui.demo -e command notifications -e visible false mkdir -p screens adb -s "$SERIAL" exec-out screencap -p > screens/01-map.png # Leave demo mode adb -s "$SERIAL" shell am broadcast -a com.android.systemui.demo -e command exit ``` - `adb exec-out` passes the PNG bytes unchanged. Windows PowerShell's `>` re-encodes them: there, run `adb shell screencap -p /sdcard/screen.png` and `adb pull /sdcard/screen.png` instead. - Demo mode differs between Android versions and device makers, and some ignore parts of it. Physical devices may need "Allow demo mode" in the developer options. - The last command ends demo mode, also one you had turned on yourself. `adb shell settings delete global sysui_demo_allowed` removes the permission again. ## Web builds with Playwright An Expo, React Native or Flutter web build can be captured in a browser at the exact pixel size of a StoreShot device. Build and serve it first: - Expo or React Native: `npx expo export -p web`, then serve `dist/` (for example with `npx serve dist`). - Flutter: `flutter build web`, then serve `build/web/`. Install Playwright in your project (`npm install -D playwright`, then `npx playwright install chromium`), save this as `capture-web.mjs`, list your screens in `pages`, and run it with `BASE_URL=http://localhost:3000 node capture-web.mjs`: ```js // One PNG per page at the exact pixel size of a StoreShot device (viewport × pixel ratio). // BASE_URL=http://localhost:3000 DEVICE=app_store_1290x2796 node capture-web.mjs import { mkdirSync } from 'node:fs'; import { chromium } from 'playwright'; const PRESETS = { app_store_1284x2778: { width: 428, height: 926, dpr: 3 }, app_store_1290x2796: { width: 430, height: 932, dpr: 3 }, app_store_1179x2556: { width: 393, height: 852, dpr: 3 }, app_store_2064x2752: { width: 1032, height: 1376, dpr: 2 }, app_store_1668x2388: { width: 834, height: 1194, dpr: 2 }, google_play_1080x1920: { width: 360, height: 640, dpr: 3 }, google_play_1080x2400: { width: 360, height: 800, dpr: 3 }, google_play_1080x2160: { width: 360, height: 720, dpr: 3 }, google_play_1200x1920: { width: 600, height: 960, dpr: 2 }, google_play_1600x2560: { width: 800, height: 1280, dpr: 2 }, }; // The screens to capture, in listing order (Flutter's default URLs look like /#/settings). const pages = [ { path: '/', name: '01-home' }, { path: '/settings', name: '02-settings' }, ]; const base = process.env.BASE_URL ?? 'http://localhost:3000'; const preset = PRESETS[process.env.DEVICE ?? 'app_store_1290x2796']; const out = process.env.OUT ?? 'screens'; // Expo/React Native web: a testID such as [data-testid="home-screen"]; Flutter web: flutter-view const waitFor = process.env.WAIT_FOR ?? 'body'; mkdirSync(out, { recursive: true }); const browser = await chromium.launch(); const context = await browser.newContext({ viewport: { width: preset.width, height: preset.height }, deviceScaleFactor: preset.dpr, isMobile: true, hasTouch: true, reducedMotion: 'reduce', serviceWorkers: 'block', }); for (const { path, name } of pages) { const page = await context.newPage(); await page.goto(new URL(path, base).href, { waitUntil: 'load' }); await page.waitForSelector(waitFor, { state: 'visible' }); await page.evaluate(() => document.fonts.ready); await page.waitForTimeout(Number(process.env.DELAY_MS ?? 300)); await page.screenshot({ path: `${out}/${name}.png`, animations: 'disabled', caret: 'hide' }); await page.close(); } await browser.close(); ``` The viewport and pixel ratio of each StoreShot device (`DEVICE`): | Device | Viewport (CSS px) | Pixel ratio | PNG | |---|---|---|---| | `app_store_1284x2778` | 428 × 926 | 3 | 1284 × 2778 | | `app_store_1290x2796` | 430 × 932 | 3 | 1290 × 2796 | | `app_store_1179x2556` | 393 × 852 | 3 | 1179 × 2556 | | `app_store_2064x2752` | 1032 × 1376 | 2 | 2064 × 2752 | | `app_store_1668x2388` | 834 × 1194 | 2 | 1668 × 2388 | | `google_play_1080x1920` | 360 × 640 | 3 | 1080 × 1920 | | `google_play_1080x2400` (deprecated) | 360 × 800 | 3 | 1080 × 2400 | | `google_play_1080x2160` | 360 × 720 | 3 | 1080 × 2160 | | `google_play_1200x1920` | 600 × 960 | 2 | 1200 × 1920 | | `google_play_1600x2560` | 800 × 1280 | 2 | 1600 × 2560 | Waiting for the screen: - The script waits for the `load` event, for the `WAIT_FOR` selector, for web fonts, then `DELAY_MS` (300 by default). `networkidle` is not reliable with development servers. - Expo and React Native web render `testID` as `data-testid`: use `WAIT_FOR='[data-testid="home-screen"]'`. - Flutter web: use `WAIT_FOR=flutter-view` (older releases: `flt-glass-pane`) and `DELAY_MS=1000`, because the canvas paints after the element exists. Limitations: - A web build is not the native app: fonts, platform controls, safe areas and some plugins differ. Prefer the simulator or emulator for the final listing. - Only screens with their own URL open this way. For screens behind taps or a login, add those steps to the script before `page.screenshot`, or use Maestro. - Flutter's default URLs contain `#`: use paths such as `/#/settings`. ## Maestro flows [Maestro](https://maestro.dev/) drives the app on a simulator or emulator, so it reaches screens behind taps. Add `takeScreenshot` where you want a raw screenshot: ```yaml appId: com.example.trailmate --- - launchApp - takeScreenshot: 01-map - tapOn: 'Offline maps' - takeScreenshot: 02-offline ``` Run the flow and collect the PNGs: ```sh maestro test --test-output-dir maestro-out flow.yaml mkdir -p screens find maestro-out -name '*.png' -exec cp {} screens/ \; ls screens ``` - Maestro writes the screenshots into the `takeScreenshot` folder of its output; where that folder is depends on the Maestro version and on `--test-output-dir`, so the commands search for them. - Maestro does not change the status bar: run the iOS or Android commands above before and after the flow. ## Next: upload and create a set Upload each screenshot and create a [screenshot set](/docs/screenshot-sets/) with one caption per screen: - with `curl`: steps 3–6 of the [quickstart for AI agents](/docs/agents-quickstart/); - from an AI coding tool: the [MCP server](/docs/mcp/) (`upload_screenshot`, then `create_screenshot_set`); - from a terminal or CI: the [CLI](/docs/cli/) (`storeshot set`, then `storeshot pull`); - from TypeScript or JavaScript: the [SDK](/docs/sdk/). --- # Uploads > HTML version: https://storeshot.art/docs/uploads/ Generations and screenshot sets take their screenshots as uploads: you hand an image over once and pass its `id`. There are three ways to upload, and each ends in the same `ready` upload with the same metadata (`width`, `height`, `orientation`, `sha256`). Uploads need the `uploads:write` scope. The Node.js and Python samples on this page continue from the first one (`api`, `headers`). ## Multipart Send the file as the `file` part of a `multipart/form-data` body. Simple, and right for most integrations. ```sh UPLOAD=$(curl -sS https://api.storeshot.art/v1/uploads \ -H "Authorization: Bearer $STORESHOT_API_KEY" -F file=@home.png) UPLOAD_ID=$(echo "$UPLOAD" | jq -r .id) echo "$UPLOAD" | jq '{id, status, width, height, orientation}' ``` ```js import { readFile } from 'node:fs/promises'; const api = 'https://api.storeshot.art/v1'; const headers = { Authorization: `Bearer ${process.env.STORESHOT_API_KEY}` }; const form = new FormData(); form.append('file', new Blob([await readFile('home.png')]), 'home.png'); const upload = await (await fetch(`${api}/uploads`, { method: 'POST', headers, body: form })).json(); console.log(upload.id, upload.status, upload.width, upload.height); ``` ```python import os, requests api = "https://api.storeshot.art/v1" headers = {"Authorization": f"Bearer {os.environ['STORESHOT_API_KEY']}"} with open("home.png", "rb") as f: upload = requests.post(f"{api}/uploads", headers=headers, files={"file": f}).json() print(upload["id"], upload["status"], upload["width"], upload["height"]) ``` ## From a URL Send `{ "url": "https://…" }` and the server fetches the image once. Only `https://` URLs on port 443 that resolve to public addresses are fetched, with at most 3 redirects and 15 seconds in total; a failure is `422 URL_FETCH_FAILED` with `details.reason`. Only the origin and path of the URL are stored (`sourceUrl`). ```sh curl -sS https://api.storeshot.art/v1/uploads \ -H "Authorization: Bearer $STORESHOT_API_KEY" \ -H 'Content-Type: application/json' \ -d '{"url":"https://example.com/screenshots/home.png"}' \ | jq '{id, status, source, sourceUrl}' ``` ```js const imported = await ( await fetch(`${api}/uploads`, { method: 'POST', headers: { ...headers, 'Content-Type': 'application/json' }, body: JSON.stringify({ url: 'https://example.com/screenshots/home.png' }), }) ).json(); console.log(imported.id, imported.status, imported.sourceUrl); ``` ```python imported = requests.post( f"{api}/uploads", headers=headers, json={"url": "https://example.com/screenshots/home.png"} ).json() print(imported["id"], imported["status"], imported["sourceUrl"]) ``` ## Presigned upload For large files, or to keep image bytes off your API traffic: ask for an upload URL with the exact type and size, `PUT` the file there from your server with exactly the returned `headers` (they are signed), then complete the upload. The URL is valid for 15 minutes; a pending upload that is never completed expires after 24 hours. Completing before the file arrived is `409 UPLOAD_NOT_READY` (`details.reason: object_missing`): upload, then retry. ```sh PRESIGNED=$(curl -sS https://api.storeshot.art/v1/uploads/presign \ -H "Authorization: Bearer $STORESHOT_API_KEY" \ -H 'Content-Type: application/json' \ -d '{"contentType":"image/png","bytes":'"$(wc -c < home.png)"'}') curl -sS -X PUT --data-binary @home.png -H 'Content-Type: image/png' \ "$(echo "$PRESIGNED" | jq -r .uploadUrl)" curl -sS -X POST "https://api.storeshot.art/v1/uploads/$(echo "$PRESIGNED" | jq -r .upload.id)/complete" \ -H "Authorization: Bearer $STORESHOT_API_KEY" | jq '{id, status, source, bytes}' ``` ```js const bytes = await readFile('home.png'); const presigned = await ( await fetch(`${api}/uploads/presign`, { method: 'POST', headers: { ...headers, 'Content-Type': 'application/json' }, body: JSON.stringify({ contentType: 'image/png', bytes: bytes.length }), }) ).json(); const put = await fetch(presigned.uploadUrl, { method: presigned.method, headers: { 'Content-Type': presigned.headers['Content-Type'] }, body: bytes, }); if (!put.ok) throw new Error(`upload failed: ${put.status}`); const completed = await ( await fetch(`${api}/uploads/${presigned.upload.id}/complete`, { method: 'POST', headers }) ).json(); console.log(completed.id, completed.status, completed.source); ``` ```python with open("home.png", "rb") as f: data = f.read() presigned = requests.post( f"{api}/uploads/presign", headers=headers, json={"contentType": "image/png", "bytes": len(data)}, ).json() requests.put(presigned["uploadUrl"], data=data, headers=presigned["headers"]).raise_for_status() completed = requests.post( f"{api}/uploads/{presigned['upload']['id']}/complete", headers=headers ).json() print(completed["id"], completed["status"], completed["source"]) ``` ## Read, list and delete ```sh # One upload, by id curl -sS "https://api.storeshot.art/v1/uploads/$UPLOAD_ID" \ -H "Authorization: Bearer $STORESHOT_API_KEY" | jq '{id, status, expiresAt}' # Newest first; pass nextCursor back as ?cursor= for the next page curl -sS 'https://api.storeshot.art/v1/uploads?limit=20' \ -H "Authorization: Bearer $STORESHOT_API_KEY" | jq '{count: (.data | length), nextCursor}' # Delete: the file is removed now (204) curl -sS -X DELETE "https://api.storeshot.art/v1/uploads/$UPLOAD_ID" \ -H "Authorization: Bearer $STORESHOT_API_KEY" -o /dev/null -w '%{http_code}\n' ``` ## Limits - Formats: PNG, JPEG or WebP, detected from the file's bytes (HEIC is refused with `415 UNSUPPORTED_FORMAT`). - Size: up to 20 MiB, 50 megapixels and 16384 px on the longest edge. - Stored uploads per account (pending and ready together): | Plan | Uploads | Total size | |---|---|---| | No plan | 500 | 2048 MB | | Pro | 1000 | 5120 MB | | Agency | 2000 | 10240 MB | | Scale | 5000 | 20480 MB | Over the quota a new upload is `403 FORBIDDEN` with `details.reason: upload_quota_exceeded` (`details.kind` `count` or `bytes`): delete uploads you no longer need. A file over the size limits is `413 IMAGE_TOO_LARGE`; anything that is not a PNG, JPEG or WebP image (by its bytes, never its name) is `415 UNSUPPORTED_FORMAT`. Export HEIC photos as PNG or JPEG first. ## Retention Uploads are deleted 30 days after they were created (their file is removed; generations made from them keep their results). Pending or failed uploads expire after 24 hours. `DELETE` removes the file at once. A generation queued from an upload that expired before it started fails with `UPLOAD_NOT_READY` and, with a live key, its credit is refunded. Every retention period of the API is listed in the [data retention policy](/data-retention/). --- # Generations > HTML version: https://storeshot.art/docs/generations/ A generation turns one uploaded screenshot into one marketing image of exactly the size a store requires. Creating one needs `generations:write`; reading needs `generations:read` or `generations:write`. To make every image of a store listing at once, use a [screenshot set](/docs/screenshot-sets/). ```text POST ──▶ queued ──worker──▶ running ──▶ succeeded (result.image.url) │ └──────▶ failed (error.code; a live credit is refunded) └──cancel──▶ canceled (a live credit is refunded) ``` The Node.js and Python samples on this page continue from the first one (`api`, `headers`, `upload`). ## Create and wait Send one input (`uploadId`, or `url` to import an image in the same call), the `store`, and either a `device` id from [`GET /devices`](#devices) or a `size` of that store. `app.name` is required; `caption`, `palette`, `logo` and `metadata` are optional (without a caption or palette StoreShot derives them from the screenshot). **Brand kits.** Send `styleId` to apply a saved brand kit: it fills the palette and logo you leave out and adds its background mood and caption tone. Fields you send win. See [Brand kits](/docs/styles/). `Prefer: wait=60` holds the answer until the generation is final, for at most 60 seconds: `200` with the finished generation, or `202` if it is still running (keep polling then). ```sh UPLOAD=$(curl -sS https://api.storeshot.art/v1/uploads \ -H "Authorization: Bearer $STORESHOT_API_KEY" -F file=@home.png | jq -r .id) BODY='{"inputs":[{"uploadId":"'"$UPLOAD"'"}],"store":"app_store","device":"app_store_1290x2796", "app":{"name":"Trailmate","description":"Offline hiking maps"}, "caption":"Find every trail, even offline", "palette":[{"hex":"#0F766E","name":"Teal"}], "metadata":{"campaign":"spring","locale":"en-US"}}' GENERATION=$(curl -sS https://api.storeshot.art/v1/generations \ -H "Authorization: Bearer $STORESHOT_API_KEY" \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: home-en-spring' -H 'Prefer: wait=60' \ -d "$BODY") GENERATION_ID=$(echo "$GENERATION" | jq -r .id) echo "$GENERATION" | jq '{id, status, creditsCharged, image: .result.image}' ``` ```js import { readFile } from 'node:fs/promises'; const api = 'https://api.storeshot.art/v1'; const headers = { Authorization: `Bearer ${process.env.STORESHOT_API_KEY}` }; const form = new FormData(); form.append('file', new Blob([await readFile('home.png')]), 'home.png'); const upload = await (await fetch(`${api}/uploads`, { method: 'POST', headers, body: form })).json(); const created = await fetch(`${api}/generations`, { method: 'POST', headers: { ...headers, 'Content-Type': 'application/json', 'Idempotency-Key': 'home-en-spring', Prefer: 'wait=60', }, body: JSON.stringify({ inputs: [{ uploadId: upload.id }], store: 'app_store', device: 'app_store_1290x2796', app: { name: 'Trailmate', description: 'Offline hiking maps' }, caption: 'Find every trail, even offline', palette: [{ hex: '#0F766E', name: 'Teal' }], metadata: { campaign: 'spring', locale: 'en-US' }, }), }); let generation = await created.json(); console.log(created.status, generation.status, generation.result?.image.url); ``` ```python import os, requests api = "https://api.storeshot.art/v1" headers = {"Authorization": f"Bearer {os.environ['STORESHOT_API_KEY']}"} with open("home.png", "rb") as f: upload = requests.post(f"{api}/uploads", headers=headers, files={"file": f}).json() created = requests.post( f"{api}/generations", headers={**headers, "Idempotency-Key": "home-en-spring", "Prefer": "wait=60"}, json={ "inputs": [{"uploadId": upload["id"]}], "store": "app_store", "device": "app_store_1290x2796", "app": {"name": "Trailmate", "description": "Offline hiking maps"}, "caption": "Find every trail, even offline", "palette": [{"hex": "#0F766E", "name": "Teal"}], "metadata": {"campaign": "spring", "locale": "en-US"}, }, ) generation = created.json() print(created.status_code, generation["status"], (generation["result"] or {}).get("image")) ``` A `succeeded` generation has `result.image` (`url`, `contentType`, `width`, `height`), a best-effort `result.thumbnail` and `result.urlsExpireAt`: the URLs are signed for 15 minutes, so download the image right away or `GET` the generation again for fresh URLs. A `failed` one has `error.code` (`GENERATION_FAILED`, `GENERATION_TIMEOUT`, `NOT_A_SCREENSHOT`, `UPLOAD_NOT_READY` or `INTERNAL_ERROR`) and, with a live key, `creditsRefunded: 1`. ## Poll Without `Prefer: wait` (or when it answered `202`), poll `GET /generations/{id}` every 2–5 seconds until the status is `succeeded`, `failed` or `canceled`. `?wait=` works on the `GET` too. ```sh while :; do STATUS=$(curl -sS "https://api.storeshot.art/v1/generations/$GENERATION_ID" \ -H "Authorization: Bearer $STORESHOT_API_KEY" | jq -r .status) case "$STATUS" in queued|running) sleep 3 ;; *) break ;; esac done echo "$STATUS" ``` ```js for (;;) { generation = await (await fetch(`${api}/generations/${generation.id}`, { headers })).json(); if (generation.status !== 'queued' && generation.status !== 'running') break; await new Promise((resolve) => setTimeout(resolve, 3000)); } console.log(generation.status, generation.result?.image.url ?? generation.error); ``` ```python import time while True: generation = requests.get(f"{api}/generations/{generation['id']}", headers=headers).json() if generation["status"] not in ("queued", "running"): break time.sleep(3) print(generation["status"], (generation["result"] or {}).get("image") or generation["error"]) ``` ## Idempotency Send an `Idempotency-Key` with every create. Repeating the same request with the same key returns the original generation with `Idempotent-Replayed: true`: nothing is created or charged again, even when the first request is still in flight. The same key with a different body (or from a key of the other environment) is `409 IDEMPOTENCY_CONFLICT` with `details.generationId`. A refused request (any `4xx`) stores nothing, so you can retry it with the same key, for example after topping up credits. ```sh curl -sS -o /dev/null -D - https://api.storeshot.art/v1/generations \ -H "Authorization: Bearer $STORESHOT_API_KEY" \ -H 'Content-Type: application/json' -H 'Idempotency-Key: home-en-spring' \ -d "$BODY" | grep -i '^idempotent-replayed' ``` ## List `GET /generations` lists the account's generations, newest first, 20 per page (`limit` up to 100), without deleted ones. Filters: `status`, `environment` (`live` or `test`), `metadata` (`key:value`), `screenshotSetId`, `source` and `projectId`. Pass `nextCursor` back as `cursor` with the same filters for the next page. ```sh curl -sS 'https://api.storeshot.art/v1/generations?status=succeeded&metadata=campaign:spring&limit=10' \ -H "Authorization: Bearer $STORESHOT_API_KEY" \ | jq -r '.data[] | "\(.id) \(.status) \(.device)"' ``` ## Cancel and delete Only a `queued` generation can be canceled (`200`, credit refunded). Once a worker has started it, cancel answers `409 GENERATION_STATE_CONFLICT` with `details.status`, and the generation finishes normally. Test generations start almost at once, so this sample usually shows the `409`. ```sh curl -sS -X POST "https://api.storeshot.art/v1/generations/$GENERATION_ID/cancel" \ -H "Authorization: Bearer $STORESHOT_API_KEY" | jq '{status, error: .error.code}' ``` Only a finished generation can be deleted (`204`; while it is queued or running, `409`). Delete removes the image and thumbnail at once; the generation stays readable by id with `deletedAt` set and `result: null`, and leaves the lists. Live results are kept until you delete them; test results are deleted 30 days after they finish. ```sh curl -sS -X DELETE "https://api.storeshot.art/v1/generations/$GENERATION_ID" \ -H "Authorization: Bearer $STORESHOT_API_KEY" -o /dev/null -w '%{http_code}\n' curl -sS "https://api.storeshot.art/v1/generations/$GENERATION_ID" \ -H "Authorization: Bearer $STORESHOT_API_KEY" | jq '{status, deletedAt, result}' ``` ## Devices `GET /devices` (no key needed) lists every size StoreShot produces, per store, with its label and orientations. A `device` or `size` that is not in this catalogue is `400 INVALID_INPUT`. The Google Play feature graphic is `google_play_1024x500` (landscape only). `google_play_1080x2400` is marked `deprecated` (it breaks Google Play's 2:1 limit): use `google_play_1080x2160`. ```sh curl -sS https://api.storeshot.art/v1/devices \ | jq -r '.data[] | "\(.id) \(.width)x\(.height) \(.label)\(if .deprecated then " (deprecated)" else "" end)"' ``` For each store's official rules (required sizes, counts, formats) see `GET /stores/{store}/requirements` in the [catalog reference](/docs/api/catalog/). --- # Screenshot sets > HTML version: https://storeshot.art/docs/screenshot-sets/ A screenshot set turns 1–10 screenshots into every image of a store listing: each screen on every device of one or both stores, plus an optional Google Play feature graphic, with one caption per screen and one palette for all. Each image is a normal [generation](/docs/generations/), with the same statuses, refunds and test-mode behaviour. Sets need the generation scopes. The Node.js and Python samples on this page continue from the first one (`api`, `headers`, `screens`). ## Upload the screens A set takes uploads only (import URLs with [`POST /uploads`](/docs/uploads/#from-a-url) first). ```sh SCREEN_1=$(curl -sS https://api.storeshot.art/v1/uploads \ -H "Authorization: Bearer $STORESHOT_API_KEY" -F file=@screen-1.png | jq -r .id) SCREEN_2=$(curl -sS https://api.storeshot.art/v1/uploads \ -H "Authorization: Bearer $STORESHOT_API_KEY" -F file=@screen-2.png | jq -r .id) SET_BODY='{ "app": {"name": "Trailmate", "description": "Offline hiking maps"}, "screens": [ {"input": {"uploadId": "'"$SCREEN_1"'"}, "caption": "Find every trail"}, {"input": {"uploadId": "'"$SCREEN_2"'"}, "caption": "Works offline"} ], "stores": [ {"store": "app_store", "devices": ["app_store_1290x2796"]}, {"store": "google_play", "featureGraphic": true} ], "metadata": {"release": "2.4"} }' ``` ```js import { readFile, writeFile } from 'node:fs/promises'; const api = 'https://api.storeshot.art/v1'; const headers = { Authorization: `Bearer ${process.env.STORESHOT_API_KEY}` }; const uploadFile = async (file) => { const form = new FormData(); form.append('file', new Blob([await readFile(file)]), file); return (await fetch(`${api}/uploads`, { method: 'POST', headers, body: form })).json(); }; const screens = [await uploadFile('screen-1.png'), await uploadFile('screen-2.png')]; const setBody = JSON.stringify({ app: { name: 'Trailmate', description: 'Offline hiking maps' }, screens: [ { input: { uploadId: screens[0].id }, caption: 'Find every trail' }, { input: { uploadId: screens[1].id }, caption: 'Works offline' }, ], stores: [ { store: 'app_store', devices: ['app_store_1290x2796'] }, { store: 'google_play', featureGraphic: true }, ], metadata: { release: '2.4' }, }); const jsonHeaders = { ...headers, 'Content-Type': 'application/json' }; ``` ```python import os, requests api = "https://api.storeshot.art/v1" headers = {"Authorization": f"Bearer {os.environ['STORESHOT_API_KEY']}"} def upload_file(name): with open(name, "rb") as f: return requests.post(f"{api}/uploads", headers=headers, files={"file": f}).json() screens = [upload_file("screen-1.png"), upload_file("screen-2.png")] set_body = { "app": {"name": "Trailmate", "description": "Offline hiking maps"}, "screens": [ {"input": {"uploadId": screens[0]["id"]}, "caption": "Find every trail"}, {"input": {"uploadId": screens[1]["id"]}, "caption": "Works offline"}, ], "stores": [ {"store": "app_store", "devices": ["app_store_1290x2796"]}, {"store": "google_play", "featureGraphic": True}, ], "metadata": {"release": "2.4"}, } ``` - `screens`: 1–10, in listing order, each with a `caption` (1–1000 characters) used on every device. - `stores`: one or both, each once. Without `devices` a store gets the sizes its required groups ask for: the App Store `app_store_1290x2796` and `app_store_1179x2556`, Google Play `google_play_1080x1920`. Pass `devices` (at most one per device class) to choose, and pay for, fewer images. - `featureGraphic` (Google Play): `true` makes one 1024 × 500 image from screen 1, or `{ "screen": 2, "caption": "…" }`. - `palette` and `logo` apply to every image; without a palette one is extracted from screen 1 and used for the whole set. A set has at most 50 images. **Brand kits.** Send `styleId` to apply a saved brand kit: it fills the palette and logo you leave out and adds its background mood and caption tone. Fields you send win. See [Brand kits](/docs/styles/). The example makes 2 screens × (1 App Store + 1 Google Play device) + 1 feature graphic = 5 images. ## Quote first `?dryRun=true` validates the request exactly like a create and answers what it would charge, with a breakdown per device. Nothing is created or charged. With a live key every image costs 1 credit; test keys show `credits: 0`. ```sh curl -sS 'https://api.storeshot.art/v1/screenshot-sets?dryRun=true' \ -H "Authorization: Bearer $STORESHOT_API_KEY" -H 'Content-Type: application/json' \ -d "$SET_BODY" | jq '{images, credits, availableCredits, sufficient, breakdown: [.breakdown[] | "\(.device) × \(.images)"]}' ``` ```js const quote = await ( await fetch(`${api}/screenshot-sets?dryRun=true`, { method: 'POST', headers: jsonHeaders, body: setBody }) ).json(); console.log(quote.images, quote.credits, quote.sufficient); ``` ```python quote = requests.post( f"{api}/screenshot-sets", params={"dryRun": "true"}, headers=headers, json=set_body ).json() print(quote["images"], quote["credits"], quote["sufficient"]) ``` A quote is not a reservation. The create charges all images at once or none: without enough credits it is `402 INSUFFICIENT_CREDITS` with `details.requiredCredits` and nothing is created. ## Create and poll Create the set with an `Idempotency-Key` (a retried request returns the same set), then poll it every few seconds until `status` is final: `succeeded`, `partially_failed`, `failed` or `canceled`. `counts` shows how many images are in each state. ```sh SET_ID=$(curl -sS https://api.storeshot.art/v1/screenshot-sets \ -H "Authorization: Bearer $STORESHOT_API_KEY" -H 'Content-Type: application/json' \ -H 'Idempotency-Key: trailmate-2.4' -d "$SET_BODY" | jq -r .id) while :; do SET=$(curl -sS "https://api.storeshot.art/v1/screenshot-sets/$SET_ID" \ -H "Authorization: Bearer $STORESHOT_API_KEY") case "$(echo "$SET" | jq -r .status)" in queued|running) sleep 3 ;; *) break ;; esac done echo "$SET" | jq '{status, counts, creditsCharged, creditsRefunded}' ``` ```js let set = await ( await fetch(`${api}/screenshot-sets`, { method: 'POST', headers: { ...jsonHeaders, 'Idempotency-Key': 'trailmate-2.4' }, body: setBody, }) ).json(); while (set.status === 'queued' || set.status === 'running') { await new Promise((resolve) => setTimeout(resolve, 3000)); set = await (await fetch(`${api}/screenshot-sets/${set.id}`, { headers })).json(); } console.log(set.status, set.counts); ``` ```python import time created = requests.post( f"{api}/screenshot-sets", headers={**headers, "Idempotency-Key": "trailmate-2.4"}, json=set_body, ).json() screenshot_set = created while screenshot_set["status"] in ("queued", "running"): time.sleep(3) screenshot_set = requests.get(f"{api}/screenshot-sets/{created['id']}", headers=headers).json() print(screenshot_set["status"], screenshot_set["counts"]) ``` A set counts as one generation towards the "in progress" cap (test keys: at most 3 sets in progress) and with all its images towards the daily cap. Its images are also listed by `GET /generations?screenshotSetId=`. ## Download the images `GET /screenshot-sets/{id}/archive` streams a ZIP of the succeeded images and a `manifest.json` (which lists any image that is missing). The default layout is one folder per store and device; `?layout=fastlane` uses the folders `fastlane deliver` and `fastlane supply` read (`fastlane/screenshots/en-US/…` and `fastlane/metadata/android/en-US/images/…`). Before any image succeeded the archive is `409`. ```sh curl -sS -o listing.zip "https://api.storeshot.art/v1/screenshot-sets/$SET_ID/archive" \ -H "Authorization: Bearer $STORESHOT_API_KEY" curl -sS -o fastlane.zip "https://api.storeshot.art/v1/screenshot-sets/$SET_ID/archive?layout=fastlane" \ -H "Authorization: Bearer $STORESHOT_API_KEY" ls -l listing.zip fastlane.zip ``` ```js const archive = await fetch(`${api}/screenshot-sets/${set.id}/archive?layout=fastlane`, { headers }); if (!archive.ok) throw new Error(`archive: ${archive.status}`); await writeFile('fastlane.zip', Buffer.from(await archive.arrayBuffer())); ``` ```python archive = requests.get( f"{api}/screenshot-sets/{created['id']}/archive", params={"layout": "fastlane"}, headers=headers, ) archive.raise_for_status() with open("fastlane.zip", "wb") as f: f.write(archive.content) ``` Unzip the fastlane archive in your project root and run `fastlane deliver` / `fastlane supply`. `supply` replaces the screenshots already in your Play listing for the folders it uploads. ## Retry, cancel and delete `POST /screenshot-sets/{id}/retry` makes a new attempt (at most 5 per image, 1 credit each with a live key, all or none) for every failed or canceled image. When nothing can be retried it answers `409 SCREENSHOT_SET_STATE_CONFLICT` and charges nothing, so repeating a retry is safe. Test sets always succeed, so here the retry shows that `409`: ```sh curl -sS -X POST "https://api.storeshot.art/v1/screenshot-sets/$SET_ID/retry" \ -H "Authorization: Bearer $STORESHOT_API_KEY" | jq '{retry, error: .error.code}' ``` `POST /screenshot-sets/{id}/cancel` cancels (and refunds) every image that has not started. `DELETE /screenshot-sets/{id}` deletes every image's files once nothing is queued or running (`204`). ```sh curl -sS -X DELETE "https://api.storeshot.art/v1/screenshot-sets/$SET_ID" \ -H "Authorization: Bearer $STORESHOT_API_KEY" -o /dev/null -w '%{http_code}\n' ``` --- # Webhooks > HTML version: https://storeshot.art/docs/webhooks/ Instead of polling, register an HTTPS endpoint and StoreShot `POST`s a signed event to it when a generation finishes, a screenshot set finishes or your live credit balance runs low. Webhooks need the `webhooks:write` scope (and `webhooks:read` to read them), which new API keys do **not** have by default: create the key with them in the console. The Node.js and Python samples on this page continue from the first one (`api`, `headers`, `webhook`). ## Create a webhook The URL must be `https://` on port 443 and resolve only to public addresses (private, loopback and link-local hosts are `422 WEBHOOK_URL_REJECTED`). `events` takes 1–4 of `generation.succeeded`, `generation.failed` (failed or canceled), `screenshot_set.completed` and `credits.low` (live only, when a charge takes the balance below `creditsLowThreshold`). A key creates webhooks of its own environment: a test webhook receives the events of test generations and sets only. The response carries the signing secret once, in `secret.value`; store it with your receiver. ```sh WEBHOOK=$(curl -sS https://api.storeshot.art/v1/webhooks \ -H "Authorization: Bearer $STORESHOT_API_KEY" -H 'Content-Type: application/json' \ -d '{"url":"https://example.com/webhooks/storeshot", "events":["generation.succeeded","generation.failed","screenshot_set.completed"], "description":"Release pipeline"}') WEBHOOK_ID=$(echo "$WEBHOOK" | jq -r .id) STORESHOT_WEBHOOK_SECRET=$(echo "$WEBHOOK" | jq -r .secret.value) # store it with your receiver echo "$WEBHOOK" | jq '{id, url, events, environment, secret: .secret.hint}' ``` ```js const api = 'https://api.storeshot.art/v1'; const headers = { Authorization: `Bearer ${process.env.STORESHOT_API_KEY}` }; const webhook = await ( await fetch(`${api}/webhooks`, { method: 'POST', headers: { ...headers, 'Content-Type': 'application/json' }, body: JSON.stringify({ url: 'https://example.com/webhooks/storeshot', events: ['generation.succeeded', 'generation.failed', 'screenshot_set.completed'], description: 'Release pipeline', }), }) ).json(); const webhookSecret = webhook.secret.value; // shown once: store it with your receiver console.log(webhook.id, webhook.url, webhook.secret.hint); ``` ```python import os, requests api = "https://api.storeshot.art/v1" headers = {"Authorization": f"Bearer {os.environ['STORESHOT_API_KEY']}"} webhook = requests.post( f"{api}/webhooks", headers=headers, json={ "url": "https://example.com/webhooks/storeshot", "events": ["generation.succeeded", "generation.failed", "screenshot_set.completed"], "description": "Release pipeline", }, ).json() webhook_secret = webhook["secret"]["value"] # shown once: store it with your receiver print(webhook["id"], webhook["url"], webhook["secret"]["hint"]) ``` After creation the URL is only shown masked (scheme, host and the last characters). An account has at most 10 webhooks per environment. ## Send a test event `POST /webhooks/{id}/test` sends a `webhook.test` event whatever the webhook's `events` (at most 10 per webhook per hour). Use it to check your receiver and its signature check end to end. ```sh curl -sS -X POST "https://api.storeshot.art/v1/webhooks/$WEBHOOK_ID/test" \ -H "Authorization: Bearer $STORESHOT_API_KEY" | jq . ``` ```js const sent = await ( await fetch(`${api}/webhooks/${webhook.id}/test`, { method: 'POST', headers }) ).json(); console.log(sent.eventId, sent.deliveryId); ``` ```python sent = requests.post(f"{api}/webhooks/{webhook['id']}/test", headers=headers).json() print(sent["eventId"], sent["deliveryId"]) ``` ## Events Every delivery is a `POST` with a JSON body like this one (the exact bytes are signed; the body also carries `apiVersion`, the contract version it was built with): ```json { "id": "evt_3f2a9c1d0b8e4f6a9d2c7b1e0a5f4c3d", "type": "generation.succeeded", "created": "2026-10-22T10:00:24.000Z", "environment": "test", "data": { "id": "0b8e7a52-…", "status": "succeeded" } } ``` | `type` | Sent when | `data` | |---|---|---| | `generation.succeeded` | a generation succeeded | the generation, without its signed URLs: fetch it for fresh ones | | `generation.failed` | a generation failed or was canceled (`data.status` tells which) | the generation | | `screenshot_set.completed` | a set reached a final status (again after a retry finishes) | the screenshot set | | `credits.low` | live only: a charge took the balance below `creditsLowThreshold` | `{ balances, total, threshold }` | | `webhook.test` | `POST /webhooks/{id}/test` | `{ webhookId, message }` | The images of a set are generations too, so they also send `generation.*` events. Headers: `Storeshot-Event-Id` (the same on every attempt), `Storeshot-Event-Type`, `Storeshot-Delivery-Id`, `Storeshot-Delivery-Attempt` and `Storeshot-Signature`. The full payload schemas are in the [webhooks reference](/docs/api/webhooks/). ## Verify the signature `Storeshot-Signature: t=,v1=`, where `v1` is HMAC-SHA256 with your webhook secret over `.`. During a secret rotation a second `v1` (the previous secret) follows, so accept any matching `v1`. Verify the **raw bytes** before parsing the JSON, compare in constant time and reject a `t` more than 5 minutes from now. These two functions are the ones StoreShot's own tests run: ```js // docs/api/snippets/verify-webhook.mjs (Node ≥ 18, imported verbatim by the tests) // rawBody: the exact request bytes (Buffer or string), before any JSON parsing. import { createHmac, timingSafeEqual } from 'node:crypto'; export function verifyStoreshotSignature(rawBody, header, secret, toleranceSec = 300, now = Date.now() / 1000) { const items = header.split(',').map((p) => p.trim().split('=')); const ts = items.filter(([k]) => k === 't').map(([, v]) => v); if (ts.length !== 1 || !/^\d+$/.test(ts[0]) || Math.abs(now - Number(ts[0])) > toleranceSec) return false; const expected = createHmac('sha256', secret).update(`${ts[0]}.`).update(rawBody).digest(); return items.some(([k, v]) => { if (k !== 'v1') return false; const got = Buffer.from(v ?? '', 'hex'); return got.length === expected.length && timingSafeEqual(got, expected); }); } ``` ```python # docs/api/snippets/verify_webhook.py (Python ≥ 3.8, run by the tests via python3) import hashlib, hmac, time def verify_storeshot_signature(raw_body: bytes, header: str, secret: str, tolerance=300, now=None) -> bool: items = [p.strip().split("=", 1) for p in header.split(",") if "=" in p] ts = [v for k, v in items if k == "t"] if len(ts) != 1 or not ts[0].isdigit() or abs((now or time.time()) - int(ts[0])) > tolerance: return False expected = hmac.new(secret.encode(), ts[0].encode() + b"." + raw_body, hashlib.sha256).hexdigest() return any(hmac.compare_digest(expected, v) for k, v in items if k == "v1") ``` Answer any `2xx` within 10 seconds, then do the work. Delivery is at least once and unordered: deduplicate on `Storeshot-Event-Id`, and fetch the resource when order matters. ## Deliveries and retries A delivery that does not get a `2xx` within 10 seconds (another status, a timeout, a connection or TLS error, or a redirect, which is never followed) is retried: | Attempt | After the previous one (±10 %) | |---|---| | 2 | 1 minute | | 3 | 5 minutes | | 4 | 30 minutes | | 5 | 1 hour | | 6 | 3 hours | | 7 | 6 hours | | 8 | 12 hours | After the 8th failed attempt (about 23 hours) the delivery is `exhausted`. A destination that became private fails at once without retries. `GET /webhooks/{id}/deliveries` shows every delivery (`pending`, `succeeded`, `exhausted`, `failed`, `canceled`) with each attempt's time, latency, status code, error class and the first 256 characters of your answer, for 30 days. ```sh curl -sS "https://api.storeshot.art/v1/webhooks/$WEBHOOK_ID/deliveries?limit=10" \ -H "Authorization: Bearer $STORESHOT_API_KEY" \ | jq -r '.data[] | "\(.eventType) \(.status) attempts=\(.attempts)"' ``` ## Rotate the secret, disable, delete `POST /webhooks/{id}/rotate-secret` returns a new secret once. For `gracePeriodSeconds` (default 86400, at most 604800; `0` drops the old secret at once) every delivery is signed with both, so you can deploy the new secret without losing events. `PATCH` changes the URL, events, description or `enabled`; a disabled webhook receives nothing. `DELETE` removes the webhook with its pending deliveries and their log (`204`). ```sh curl -sS -X POST "https://api.storeshot.art/v1/webhooks/$WEBHOOK_ID/rotate-secret" \ -H "Authorization: Bearer $STORESHOT_API_KEY" -H 'Content-Type: application/json' \ -d '{"gracePeriodSeconds":3600}' | jq '{hint: .secret.hint, previousExpiresAt: .secret.previousExpiresAt}' curl -sS -X PATCH "https://api.storeshot.art/v1/webhooks/$WEBHOOK_ID" \ -H "Authorization: Bearer $STORESHOT_API_KEY" -H 'Content-Type: application/json' \ -d '{"enabled":false}' | jq '{id, enabled}' curl -sS -X DELETE "https://api.storeshot.art/v1/webhooks/$WEBHOOK_ID" \ -H "Authorization: Bearer $STORESHOT_API_KEY" -o /dev/null -w '%{http_code}\n' ``` --- # Credits and billing > HTML version: https://storeshot.art/docs/credits/ Live API keys (`ss_live_…`) spend credits; test keys (`ss_test_…`) never do ([Test mode](/docs/test-mode/)). ## What things cost | What | Credits | |---|---| | One generation (one image) with a live key | 1 | | A screenshot set with a live key | 1 per image (screens × devices, plus the feature graphic) | | Anything with a test key | 0 | | Uploads, palettes, store requirements, webhooks, reads | 0 | Your first API key comes with 4 free credits for live images (once per account). - A credit is charged when the generation or set is created (`creditsCharged`), all at once for a set: without enough credits the request is `402 INSUFFICIENT_CREDITS` (`details.requiredCredits`, `details.availableCredits`, `details.topUpUrl`) and nothing is created. - Quote a set first with `POST /screenshot-sets?dryRun=true`: it answers `credits` and whether your balance is `sufficient`, and charges nothing. - A generation that fails, or that you cancel before it starts, is refunded automatically (`creditsRefunded`). Retrying failed images of a set charges only the images it retries. - Credits are spent from your monthly plan first, then from trial credits, then from purchased packs. Plan credits reset every month (they do not roll over); purchased credits do not expire. ## Check the balance and usage `GET /credits` and `GET /usage` need the `usage:read` scope. Usage covers live generations of the whole account (every key and surface) per UTC day, per API key (`groupBy=key`) or per source (`groupBy=source`), for a range of up to 366 days (`from` inclusive, `to` exclusive, `YYYY-MM-DD`). ```sh curl -sS https://api.storeshot.art/v1/credits \ -H "Authorization: Bearer $STORESHOT_API_KEY" | jq . curl -sS 'https://api.storeshot.art/v1/usage?from=2026-10-01&to=2026-11-01&groupBy=key' \ -H "Authorization: Bearer $STORESHOT_API_KEY" \ | jq -r '.data[] | "\(.key) generations=\(.generations) credits=\(.creditsUsed)"' ``` ```js const api = 'https://api.storeshot.art/v1'; const headers = { Authorization: `Bearer ${process.env.STORESHOT_API_KEY}` }; const credits = await (await fetch(`${api}/credits`, { headers })).json(); console.log('credits left:', credits.total, credits.balances); const usage = await ( await fetch(`${api}/usage?from=2026-10-01&to=2026-11-01&groupBy=source`, { headers }) ).json(); for (const bucket of usage.data) console.log(bucket.key, bucket.generations, bucket.creditsUsed); ``` ```python import os, requests api = "https://api.storeshot.art/v1" headers = {"Authorization": f"Bearer {os.environ['STORESHOT_API_KEY']}"} credits = requests.get(f"{api}/credits", headers=headers).json() print("credits left:", credits["total"], credits["balances"]) usage = requests.get( f"{api}/usage", headers=headers, params={"from": "2026-10-01", "to": "2026-11-01"} ).json() for day in usage["data"]: if day["generations"]: print(day["key"], day["generations"], day["creditsUsed"]) ``` A [webhook](/docs/webhooks/) with the `credits.low` event tells you when a charge takes the live balance below a threshold you choose. ## Monthly plans | Plan | Price | Credits / month | Per credit | Requests / min (key · account) | Concurrent | Daily credits | |---|---|---|---|---|---|---| | Pro | $12 / month | 60 | $0.20 | 120 · 240 | 4 | 200 | | Agency | $29 / month | 150 | $0.19 | 120 · 240 | 6 | 500 | | Scale | $79 / month | 430 | $0.18 | 300 · 600 | 10 | 1000 | | No plan | $0 | packs only | — | 60 · 120 | 3 | 100 | ## Credit packs | Pack | Price | Per credit | |---|---|---| | 15 credits | $3.99 | $0.27 | | 50 credits | $11.99 | $0.24 | Buy a plan or a pack in the [developer console](https://console.storeshot.art/billing); the [pricing page](/pricing/) compares them. Checkouts through the API (`POST /billing/checkout`) need a live key with `billing:write` or a console session; test keys never buy. --- # Brand kits (styles) > HTML version: https://storeshot.art/docs/styles/ A brand kit (a *style* in the API) saves the look of your store images once: colours, a logo, a background and a caption tone. Send its `id` as `styleId` when you create a [generation](/docs/generations/) or a [screenshot set](/docs/screenshot-sets/) instead of repeating them. Brand kits are free. Creating, changing and deleting one needs `generations:write`; reading needs `generations:read` or `generations:write`. Brand kits belong to the account: every key of the account sees them, in either environment. The Node.js and Python samples on this page continue from the first one (`api`, `headers`, `logo`). ## What a brand kit holds | Field | What it is | |---|---| | `name` | 1–100 characters, required. Not unique. | | `palette` | Up to 8 colours (`{ "hex": "#0F766E", "name": "Teal" }`). | | `logo` | A copy of one of your uploads (PNG, JPEG or WebP, at most 5 MB and 4096 × 4096 pixels, from either environment). Deleting the upload later keeps the logo. | | `backgroundMood` | `minimal`, `light`, `dark`, `vibrant`, `gradient` or `textured`. | | `backgroundPrompt` | What the background looks like, in at most 200 characters on one line (no line breaks or control characters). It is passed to the model as a quoted description of the look, never as instructions. | | `captionTone` | The voice of the headline: `professional`, `friendly`, `playful`, `bold` or `premium`. A caption you send is kept word for word; the tone guides its typography and any wording the model writes. | | `referenceGenerationId` | Optional: a generation to copy colours and choices from ([below](#copy-a-look-from-a-generation)). | Every field except `name` is optional; a brand kit with only a name applies nothing. ## Create one Upload the logo first, like any image ([Uploads](/docs/uploads/)). Here `home.png` stands in for your logo file. ```sh LOGO=$(curl -sS https://api.storeshot.art/v1/uploads \ -H "Authorization: Bearer $STORESHOT_API_KEY" -F file=@home.png | jq -r .id) echo "$LOGO" ``` ```js import { readFile } from 'node:fs/promises'; const api = 'https://api.storeshot.art/v1'; const headers = { Authorization: `Bearer ${process.env.STORESHOT_API_KEY}` }; const jsonHeaders = { ...headers, 'Content-Type': 'application/json' }; const uploadFile = async (file) => { const form = new FormData(); form.append('file', new Blob([await readFile(file)]), file); return (await fetch(`${api}/uploads`, { method: 'POST', headers, body: form })).json(); }; const logo = await uploadFile('home.png'); console.log(logo.id, logo.status); ``` ```python import os, requests api = "https://api.storeshot.art/v1" headers = {"Authorization": f"Bearer {os.environ['STORESHOT_API_KEY']}"} def upload_file(name): with open(name, "rb") as f: return requests.post(f"{api}/uploads", headers=headers, files={"file": f}).json() logo = upload_file("home.png") print(logo["id"], logo["status"]) ``` Then create the brand kit with an `Idempotency-Key`. The answer is `201` with the brand kit and its `id`; `logo` is now the brand kit's own copy (`url`, signed for 15 minutes, and `expiresAt`). ```sh STYLE_BODY='{"name":"Trailmate brand", "palette":[{"hex":"#0F766E","name":"Teal"},{"hex":"#F8FAFC","name":"Off White"}], "logo":{"uploadId":"'"$LOGO"'"}, "backgroundMood":"textured", "backgroundPrompt":"soft paper texture with mountain line art", "captionTone":"friendly"}' STYLE=$(curl -sS https://api.storeshot.art/v1/styles \ -H "Authorization: Bearer $STORESHOT_API_KEY" \ -H 'Content-Type: application/json' -H 'Idempotency-Key: brand-kit-1' \ -d "$STYLE_BODY" | jq -r .id) echo "$STYLE" ``` ```js const created = await fetch(`${api}/styles`, { method: 'POST', headers: { ...jsonHeaders, 'Idempotency-Key': 'brand-kit-1' }, body: JSON.stringify({ name: 'Trailmate brand', palette: [ { hex: '#0F766E', name: 'Teal' }, { hex: '#F8FAFC', name: 'Off White' }, ], logo: { uploadId: logo.id }, backgroundMood: 'textured', backgroundPrompt: 'soft paper texture with mountain line art', captionTone: 'friendly', }), }); const style = await created.json(); console.log(created.status, style.id, style.name); ``` ```python created = requests.post( f"{api}/styles", headers={**headers, "Idempotency-Key": "brand-kit-1"}, json={ "name": "Trailmate brand", "palette": [ {"hex": "#0F766E", "name": "Teal"}, {"hex": "#F8FAFC", "name": "Off White"}, ], "logo": {"uploadId": logo["id"]}, "backgroundMood": "textured", "backgroundPrompt": "soft paper texture with mountain line art", "captionTone": "friendly", }, ) style = created.json() print(created.status_code, style["id"], style["name"]) ``` Repeating the request with the same key and body creates nothing: it answers the brand kit as it is now with `200` and `Idempotent-Replayed: true`. The same key with a different body is `409 IDEMPOTENCY_CONFLICT` with `details.styleId`. ```sh curl -sS -o /dev/null -D - https://api.storeshot.art/v1/styles \ -H "Authorization: Bearer $STORESHOT_API_KEY" \ -H 'Content-Type: application/json' -H 'Idempotency-Key: brand-kit-1' \ -d "$STYLE_BODY" | grep -i '^idempotent-replayed' ``` `GET /styles` lists your brand kits, most recently updated first, 20 per page (`limit` up to 100; pass `nextCursor` back as `cursor`). `GET /styles/{id}` reads one. ```sh curl -sS 'https://api.storeshot.art/v1/styles?limit=10' \ -H "Authorization: Bearer $STORESHOT_API_KEY" \ | jq -r '.data[] | "\(.id) \(.name) \(.backgroundMood) \(.captionTone)"' ``` ```js const styles = await (await fetch(`${api}/styles?limit=10`, { headers })).json(); for (const s of styles.data) console.log(s.id, s.name, s.backgroundMood, s.captionTone); ``` ```python styles = requests.get(f"{api}/styles", params={"limit": 10}, headers=headers).json() for s in styles["data"]: print(s["id"], s["name"], s["backgroundMood"], s["captionTone"]) ``` ## Use it Send `styleId` when you create a generation. This one sends no palette and no logo, so both come from the brand kit, together with its background mood, description and caption tone. ```sh SCREEN=$(curl -sS https://api.storeshot.art/v1/uploads \ -H "Authorization: Bearer $STORESHOT_API_KEY" -F file=@screen-1.png | jq -r .id) GENERATION=$(curl -sS https://api.storeshot.art/v1/generations \ -H "Authorization: Bearer $STORESHOT_API_KEY" \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: home-brand-kit' -H 'Prefer: wait=60' \ -d '{"inputs":[{"uploadId":"'"$SCREEN"'"}],"store":"app_store","device":"app_store_1290x2796", "app":{"name":"Trailmate"},"caption":"Find every trail, even offline", "styleId":"'"$STYLE"'"}') GENERATION_ID=$(echo "$GENERATION" | jq -r .id) echo "$GENERATION" | jq '{id, status, style, palette}' ``` ```js const screen = await uploadFile('screen-1.png'); const response = await fetch(`${api}/generations`, { method: 'POST', headers: { ...jsonHeaders, 'Idempotency-Key': 'home-brand-kit', Prefer: 'wait=60' }, body: JSON.stringify({ inputs: [{ uploadId: screen.id }], store: 'app_store', device: 'app_store_1290x2796', app: { name: 'Trailmate' }, caption: 'Find every trail, even offline', styleId: style.id, }), }); const generation = await response.json(); console.log(response.status, generation.status, generation.style); ``` ```python screen = upload_file("screen-1.png") response = requests.post( f"{api}/generations", headers={**headers, "Idempotency-Key": "home-brand-kit", "Prefer": "wait=60"}, json={ "inputs": [{"uploadId": screen["id"]}], "store": "app_store", "device": "app_store_1290x2796", "app": {"name": "Trailmate"}, "caption": "Find every trail, even offline", "styleId": style["id"], }, ) generation = response.json() print(response.status_code, generation["status"], generation.get("style")) ``` The generation's `style` names the brand kit it was made with and what it supplied: `{ "id": "…", "name": "Trailmate brand", "applied": ["palette", "logo", "backgroundMood", "backgroundPrompt", "captionTone"] }` (`null` when no brand kit was used). `palette` shows the colours used; `logo` stays `null` when the logo came from the brand kit. With a test key the result is a sample image as usual ([Test mode](/docs/test-mode/)); its colour differs from an unstyled one because the sample is seeded from the prompt, which now carries the brand kit. A screenshot set takes the same `styleId` and applies it to every image. It also works with `?dryRun=true`: the brand kit is checked and nothing is copied. Brand kits are free, so the quote is the same as without one. ```sh curl -sS 'https://api.storeshot.art/v1/screenshot-sets?dryRun=true' \ -H "Authorization: Bearer $STORESHOT_API_KEY" -H 'Content-Type: application/json' \ -d '{"app":{"name":"Trailmate"}, "screens":[{"input":{"uploadId":"'"$SCREEN"'"},"caption":"Find every trail"}], "stores":[{"store":"app_store","devices":["app_store_1290x2796"]}], "styleId":"'"$STYLE"'"}' | jq '{images, credits, sufficient}' ``` ```js const quote = await ( await fetch(`${api}/screenshot-sets?dryRun=true`, { method: 'POST', headers: jsonHeaders, body: JSON.stringify({ app: { name: 'Trailmate' }, screens: [{ input: { uploadId: screen.id }, caption: 'Find every trail' }], stores: [{ store: 'app_store', devices: ['app_store_1290x2796'] }], styleId: style.id, }), }) ).json(); console.log(quote.images, quote.credits, quote.sufficient); ``` ```python quote = requests.post( f"{api}/screenshot-sets", params={"dryRun": "true"}, headers=headers, json={ "app": {"name": "Trailmate"}, "screens": [{"input": {"uploadId": screen["id"]}, "caption": "Find every trail"}], "stores": [{"store": "app_store", "devices": ["app_store_1290x2796"]}], "styleId": style["id"], }, ).json() print(quote["images"], quote["credits"], quote["sufficient"]) ``` ## What wins: your fields The brand kit only fills what the request leaves out. | Input | You send it | You leave it out, the brand kit has it | Neither | |---|---|---|---| | `palette` | yours | the brand kit's | extracted from the screenshot (a set: from screen 1) | | `logo` | yours | the brand kit's | no logo | | background mood, description, caption tone | (no request field) | the brand kit's | not used | | `app`, `caption`, screens, devices, `metadata`, `projectId` | yours | (a brand kit holds none of these) | as without a brand kit | `style.applied` lists exactly what came from the brand kit; fields you sent yourself are not listed. A request that wants no logo or no brand kit colours does not send `styleId`. ## Change or delete it `PATCH /styles/{id}` changes only the fields you send: `null` clears `logo`, `backgroundMood`, `backgroundPrompt` or `captionTone`, and `palette: []` clears the colours. A new `logo` is copied like on create. `DELETE /styles/{id}` answers `204`. A generation or set keeps a copy of the brand kit as it was when it was created, including its own copy of the logo. Changing or deleting the brand kit never changes images you already made, or their retries; only new generations and sets use the new values. Once the brand kit is deleted, their `style.id` is `null` and `style.name` and `style.applied` stay. ```sh curl -sS -X PATCH "https://api.storeshot.art/v1/styles/$STYLE" \ -H "Authorization: Bearer $STORESHOT_API_KEY" -H 'Content-Type: application/json' \ -d '{"captionTone":"bold"}' | jq '{name, backgroundMood, captionTone, updatedAt}' ``` ```js const updated = await ( await fetch(`${api}/styles/${style.id}`, { method: 'PATCH', headers: jsonHeaders, body: JSON.stringify({ captionTone: 'bold' }), }) ).json(); console.log(updated.name, updated.captionTone); ``` ```python updated = requests.patch( f"{api}/styles/{style['id']}", headers=headers, json={"captionTone": "bold"} ).json() print(updated["name"], updated["captionTone"]) ``` ```sh curl -sS -X DELETE "https://api.storeshot.art/v1/styles/$STYLE" \ -H "Authorization: Bearer $STORESHOT_API_KEY" -o /dev/null -w '%{http_code}\n' curl -sS "https://api.storeshot.art/v1/generations/$GENERATION_ID" \ -H "Authorization: Bearer $STORESHOT_API_KEY" | jq .style ``` ```js const deleted = await fetch(`${api}/styles/${style.id}`, { method: 'DELETE', headers }); console.log(deleted.status); const after = await (await fetch(`${api}/generations/${generation.id}`, { headers })).json(); console.log(after.style); ``` ```python deleted = requests.delete(f"{api}/styles/{style['id']}", headers=headers) print(deleted.status_code) after = requests.get(f"{api}/generations/{generation['id']}", headers=headers).json() print(after.get("style")) ``` ## Copy a look from a generation `referenceGenerationId` (on create or `PATCH`) names a succeeded generation of yours, from either environment. Its colours, and its background and caption choices when it was made with a brand kit, are copied once into the fields the same request leaves out. Its image and its logo are never used. The id stays on the brand kit as `referenceGenerationId`, for reference only: later changes to the generation change nothing in the brand kit. Sending an id again copies again; `referenceGenerationId: null` forgets where the values came from and keeps them. The generation above was made with the brand kit before the `PATCH`, so this copies its colours, mood and description and the tone it used then (`friendly`), although the brand kit itself is deleted by now: ```sh curl -sS https://api.storeshot.art/v1/styles \ -H "Authorization: Bearer $STORESHOT_API_KEY" \ -H 'Content-Type: application/json' -H 'Idempotency-Key: brand-kit-from-home' \ -d '{"name":"Trailmate from home","referenceGenerationId":"'"$GENERATION_ID"'"}' \ | jq '{id, palette, backgroundMood, captionTone, referenceGenerationId}' ``` ## Limits and errors - At most 50 brand kits per account. - A logo is at most 5 MB and 4096 × 4096 pixels (PNG, JPEG or WebP). - `backgroundPrompt` is at most 200 characters on one line. | Situation | Answer | |---|---| | `styleId` unknown or not yours (create or dry run) | `400 INVALID_INPUT`, `issues[0].path` `["styleId"]`, "No such style." | | `GET`, `PATCH` or `DELETE` of an unknown, deleted or another account's brand kit | `404 NOT_FOUND` | | 50 brand kits already | `403 FORBIDDEN`, `details.reason` `style_limit_reached`, `details.limit` `50` | | Logo upload unknown, not ready, expired or another account's | `400 INVALID_INPUT`, `issues[0].path` `["logo","uploadId"]` | | Logo over 5 MB or 4096 × 4096 pixels | `400 INVALID_INPUT`, `issues[0].path` `["logo","uploadId"]` | | `referenceGenerationId` not a succeeded generation of yours | `400 INVALID_INPUT`, `issues[0].path` `["referenceGenerationId"]` | | Same `Idempotency-Key`, different body | `409 IDEMPOTENCY_CONFLICT`, `details.styleId` | | Set retry after its copy of the brand kit logo was deleted | `409 UPLOAD_NOT_READY`, `details.reason` `expired`, `details.path` `["styleId"]` | Every code is described in [Errors](/docs/errors/). ## How long it is kept - A brand kit and its copy of your logo are kept until you delete the brand kit. Deleting it deletes its logo copy at once. - Generations and screenshot sets made with a brand kit keep their own copy of its logo. That copy is deleted 30 days after the last generation or screenshot set that used it was created; a set retry after that answers `409 UPLOAD_NOT_READY` (see the table above). - Closing your account deletes your brand kits and those logo copies. See also the [data retention policy](/data-retention/). --- # StoreShot API reference > HTML version: https://storeshot.art/docs/api/ - Contract version: 1.18.0 (changelog: https://storeshot.art/docs/changelog.md) - Base URL: `https://api.storeshot.art/v1` (Production) - OpenAPI 3.1 document: https://api.storeshot.art/v1/openapi.json ## Credentials | Credential | Header | Description | |---|---|---| | API key | `Authorization: Bearer ss_live_… / ss_test_…` | Storeshot API key. Create one with POST /api-keys; the secret is shown once. | | Access token | `Authorization: Bearer JWT` | Draggable Auth access token (console and apps). Holds every scope. | ## Resources - [System](https://storeshot.art/docs/api/system.md): Service status. (2 operations) - [Catalog](https://storeshot.art/docs/api/catalog.md): Static reference data. (3 operations) - [Account](https://storeshot.art/docs/api/account.md): The authenticated caller. (1 operation) - [API keys](https://storeshot.art/docs/api/api-keys.md): Create, list and revoke API keys. (5 operations) - [Usage](https://storeshot.art/docs/api/usage.md): Credit balance and generation usage. (2 operations) - [Uploads](https://storeshot.art/docs/api/uploads.md): Hand over images once; reference them in generations. (6 operations) - [Generations](https://storeshot.art/docs/api/generations.md): Turn a screenshot into a store-ready marketing image. (5 operations) - [Screenshot sets](https://storeshot.art/docs/api/screenshot-sets.md): A whole store listing (every screen on every device) in one call. (7 operations) - [Projects](https://storeshot.art/docs/api/projects.md): Group generations and screenshot sets per app. (7 operations) - [Styles](https://storeshot.art/docs/api/styles.md): Brand kits: save a palette, logo, background and caption tone once and reuse them with `styleId`. (5 operations) - [Palettes](https://storeshot.art/docs/api/palettes.md): Free brand palettes from your images. (1 operation) - [Webhooks](https://storeshot.art/docs/api/webhooks.md): Signed HTTPS callbacks when generations and sets finish. (8 operations, 5 webhook events) - [Activity](https://storeshot.art/docs/api/activity.md): Who changed what in the account. (1 operation) - [Billing](https://storeshot.art/docs/api/billing.md): Buy credit packs (hosted checkout). (10 operations) - [Schemas](https://storeshot.art/docs/api/schemas.md): every object of the contract. The Storeshot public API. Generate App Store and Google Play marketing screenshots. ## Conventions - JSON fields are camelCase; enum values are lower snake_case; error codes are UPPER_SNAKE. - Collections are returned as `{ "data": [...] }`. - Every response carries an `X-Request-Id` header. Send your own (1–128 chars of `A-Z a-z 0-9 _ - =`) to correlate logs. - Every error uses the `ErrorEnvelope`: `{ "error": { "code", "message", "requestId", "docUrl", "details?" } }`. Error code reference: `/v1/docs/errors`. ## For AI agents Start with the agent quickstart (https://storeshot.art/docs/agents-quickstart.md) or https://storeshot.art/llms.txt (every docs page is also Markdown; all of it in one file: https://storeshot.art/llms-full.txt). The usual task, a store listing from raw app screenshots: 1. Read the key from the `STORESHOT_API_KEY` environment variable. If it is missing, stop and ask your human to create a test key (`ss_test_…`, free) in the developer console (https://console.storeshot.art/) and export it. Never print, log or commit a key, and never ask for one in chat. 2. `GET /me` checks the key; `credential.environment` is `test` (free sample images) or `live` (real images, credits). 3. `POST /uploads` once per raw screenshot (PNG, JPEG or WebP). 4. `POST /screenshot-sets?dryRun=true` quotes the set for free. With a live key, tell your human the `credits` before creating it. 5. `POST /screenshot-sets` with an `Idempotency-Key` (a retry never charges twice), then poll `GET /screenshot-sets/{id}` every 3–5 seconds until `status` is no longer `queued` or `running`. 6. `GET /screenshot-sets/{id}/archive?layout=fastlane` and unzip it in the project root (the folders fastlane `deliver` and `supply` read). **Costs.** A live key spends 1 credit per generated image (a set: screens × devices, +1 for a feature graphic), charged at create and refunded automatically for images that fail or are canceled before they start. Test keys never spend credits. Every other operation is free; each operation ends with its **Credits.** line. **Asynchronous work.** Creates answer `202` with a `queued` resource: poll it, send `Prefer: wait=60` on generations, or register a webhook. **Errors.** Retry `429` after `Retry-After` and `5xx` with the same `Idempotency-Key`; on `402 INSUFFICIENT_CREDITS` give your human `details.topUpUrl`; quote `X-Request-Id` when asking for help. ## Authentication Send `Authorization: Bearer `, where the credential is an API key (`ss_live_…` or `ss_test_…`) or a Draggable Auth access token. Never put credentials in the URL. Test keys (`ss_test_…`) never incur charges. Each operation lists the scope an API key needs; access tokens have every scope. No key yet? An agent can get one with the approval of its human: `POST /auth/device`, then poll `POST /auth/device/token` (RFC 8628 device authorization; it may be disabled on a server). ## Client identification Optionally send `X-Storeshot-Client: /`, where surface is `api`, `web`, `mcp` or `cli` and name is 1–100 characters of `A-Z a-z 0-9 . _ @ + -` (for example `cli/storeshot-cli@0.3.1`). It labels your usage (`GET /usage?groupBy=source`); it is not authentication. Without it, usage is labelled `api` and your `User-Agent` is recorded. ## Rate limits Authenticated requests are counted per minute in two buckets: per API key (`key`, 60 requests) and per account (`user`, 120 requests, shared by all API keys and access tokens of the account; access tokens use only this bucket). Test keys count like live keys. A request is counted only if every bucket allows it; a refused request counts nowhere. The numbers here are the current defaults and may differ per plan: `RateLimit-Policy` is authoritative. Every authenticated response carries `RateLimit-Policy` (each policy as `"name";q=;w=`) and `RateLimit` (the most restrictive policy as `"name";r=;t=`), following draft-ietf-httpapi-ratelimit-headers. Over the limit you get `429 RATE_LIMITED` with `Retry-After` and `details.scope`. Billed generations are also capped per account: 3 in progress at once (`429 CONCURRENCY_LIMITED`, retry after 10 s) and 100 per UTC day (`429 RATE_LIMITED`, `details.policy` `daily_generations`, retry after the next UTC midnight). `/health`, `/status`, `/devices`, this document and the docs are never rate limited. Browsers can read these headers and `X-Request-Id` (CORS). ## Uploads and retention Hand an image over once and reference its `id` in generations. Three ways: `POST /uploads` with `multipart/form-data` (one part `file`); `POST /uploads/presign`, then `PUT` the file to the returned URL from your server and call `POST /uploads/{id}/complete`; or `POST /uploads` with `{ "url": "https://…" }` to have the server fetch it. Each way ends in the same `ready` upload with the same metadata. Accepted: PNG, JPEG and WebP, detected from the bytes (never from the file name or declared type), at most 20 MB and 50 megapixels (16384 pixels per edge). HEIC/HEIF is not supported. An account can hold 500 uploads or 2 GB (pending and ready uploads count; deleting frees space). Test keys may upload too (same limits). URL imports fetch `https://` URLs on port 443 only, and only from public addresses (private, loopback, link-local and other special-purpose addresses are refused, also after a redirect), with at most 3 redirects and 15 seconds in total. Only the origin and path of the URL are stored. **Retention:** uploads, with their files, are deleted 30 days after creation (pending or failed uploads after 24 hours); you can delete one earlier with `DELETE /uploads/{id}`. Generated results are kept until you delete them (results of test keys: 30 days). ## Generations `POST /generations` turns one screenshot into one marketing image of exactly the size a store requires (a `GET /devices` id, or its `width`×`height`). It returns at once with `202` and a generation in `queued`; a worker moves it to `running`, then to `succeeded` (with `result.image.url`), `failed` (with `error.code`) or `canceled`. Retries after a temporary provider error happen inside `running`. **Waiting.** Poll `GET /generations/{id}` (every 2–5 seconds is plenty), or send `Prefer: wait=` (or `?wait=`, at most 60) on `POST /generations` or `GET /generations/{id}` to hold the response until the generation is final: `200` when it is, `202` when the time ran out, with `Preference-Applied: wait=` when the wait was honoured. A busy server may answer `202` at once without `Preference-Applied`; keep polling then. **Idempotency.** Send `Idempotency-Key: <1–255 printable ASCII characters>` with every `POST /generations`. Repeating a request with the same key and body returns the original generation (with `Idempotent-Replayed: true`) and never creates or charges a second one; the same key with a different body, or from a credential of the other environment, is `409 IDEMPOTENCY_CONFLICT`. Keys are kept at least as long as the generation exists. A refused request (4xx) stores nothing, so you can retry it with the same key. **Credits.** A generation with a live key costs 1 credit, charged when it is created (`creditsCharged`). If it fails, or you cancel it before it starts (`POST /generations/{id}/cancel`), the credit is refunded automatically (`creditsRefunded`). Without enough credits you get `402 INSUFFICIENT_CREDITS` with `details.topUpUrl`; nothing is created. Live generations also count towards the concurrency and daily caps (see Rate limits). **Test keys.** With an `ss_test_…` key everything works the same way (validation, uploads of the test environment, queue, statuses, idempotency, result URLs and delete), but no credits are used and no image model runs: the result is a sample image of exactly the requested size, and a missing caption becomes `Sample caption (test mode)`. Test generations do not count towards the generation caps. **Results and retention.** Result URLs are signed and expire after 15 minutes (`result.urlsExpireAt`); `GET` the generation again for fresh ones. Results are kept until you `DELETE /generations/{id}` (results of test keys are deleted 30 days after they finish); a deleted generation keeps its record with `deletedAt` set. Input uploads expire after 30 days, so a generation queued from an expired upload fails with `UPLOAD_NOT_READY` and is refunded. Samples in curl, Node.js and Python: [Quickstart](https://storeshot.art/docs/quickstart/). ## Screenshot sets `POST /screenshot-sets` turns 1–10 screenshots into a whole store listing in one call: every screen on every device of one or both stores, plus an optional Google Play feature graphic (`featureGraphic: true`), with one caption per screen and one palette and logo for all. Each image is a normal generation (listed by `GET /generations?screenshotSetId=`), so statuses, refunds and test keys work exactly as above. **Devices.** Without `devices`, a store gets the sizes its required screenshot groups ask for: the App Store `app_store_1290x2796` (6.9") and `app_store_1179x2556` (6.3"), Google Play `google_play_1080x1920`. Pass `devices` to choose fewer (or others, such as iPad or tablet sizes): only sizes that meet the store rules (`GET /stores/{store}/requirements`) are accepted, one per device class. A set has at most 50 images. **Credits.** With a live key every image costs 1 credit, so a set costs `screens × devices (+1 for the feature graphic)`. Ask first with `?dryRun=true`: the quote (`images`, `credits`, `availableCredits`, `sufficient`) validates the request exactly like a create but creates and charges nothing. The create charges all images at once or none (`402 INSUFFICIENT_CREDITS` with `details.requiredCredits`); the `202` response shows `creditsCharged`. A failed or canceled image is refunded; `POST /screenshot-sets/{id}/retry` creates new images for the failed ones only (1 credit each, again all or none). Repeating a retry never charges twice. A set counts as one generation towards the concurrency cap and all its images towards the daily cap. **Status and download.** `GET /screenshot-sets/{id}` reports `queued`, `running`, `succeeded`, `partially_failed`, `failed` or `canceled`, with `counts` and every image. `GET /screenshot-sets/{id}/archive` streams a ZIP of the succeeded images plus `manifest.json` (`complete: false` lists what is missing): `//.png`, or with `?layout=fastlane` the folders fastlane `deliver` (`fastlane/screenshots/en-US/`) and `supply` (`fastlane/metadata/android/en-US/images/`) read. `supply` replaces the images already in your Play listing. Samples in curl, Node.js and Python: [Quickstart](https://storeshot.art/docs/quickstart/). ## Webhooks Instead of polling, register an `https://` endpoint with `POST /webhooks` (scope `webhooks:write`, not granted by default) and receive a signed `POST` when a generation finishes (`generation.succeeded`, `generation.failed` incl. canceled), a screenshot set finishes (`screenshot_set.completed`) or the live balance falls below `creditsLowThreshold` (`credits.low`). A webhook belongs to one environment: test-key generations and sets reach test webhooks only. `POST /webhooks/{id}/test` sends a `webhook.test` event; `GET /webhooks/{id}/deliveries` shows every attempt. The event bodies are documented under `webhooks` in this document. **Delivery.** At least once, unordered, from `StoreshotWebhooks/1.0`. Any 2xx within 10 seconds acknowledges; redirects are not followed. Failures are retried after about 1 min, 5 min, 30 min, 1 h, 3 h, 6 h and 12 h (8 attempts, ~23 h), then the delivery is `exhausted`. Destinations must stay public: every attempt resolves the host again and refuses private addresses. Deduplicate on `Storeshot-Event-Id`. Generation payloads carry no download URLs: `GET /generations/{id}` for fresh ones. **Signatures.** `Storeshot-Signature: t=,v1=` where `v1` = HMAC-SHA256(secret, `.`). Compute it over the exact bytes received, compare in constant time, accept any `v1` (two during a secret rotation grace) and reject a `t` more than 5 minutes away. The secret (`whsec_…`) is shown only by create and `POST /webhooks/{id}/rotate-secret`. Verification code for Node and Python: `docs/api/webhooks.md`. **Limits.** 10 webhooks per environment, 10 test events per webhook per hour. URLs are shown masked (`https:///…`); delivery logs are kept 30 days. ## Billing Buy one-time credit packs: `GET /billing/packs` lists them (empty while web payments are switched off), `POST /billing/checkout` with a `pack` returns a hosted checkout `url` for the buyer, and `GET /billing/checkouts/{id}` reports `open`, `processing`, `paid`, `partially_refunded`, `refunded`, `expired` or `failed`. Purchased credits are added once the payment succeeds and never expire; the payment provider is the merchant of record and applies taxes at checkout. A refund takes back the refunded share of the credits as far as they are unused. Scopes `billing:read` and `billing:write` are not granted by default; checkouts need a live credential. At most 10 checkouts per account per hour. ## Helpers Free building blocks for agents (no credits; counted by the request rate limits only). `POST /palettes` extracts a brand palette from an upload or an `https://` URL with the same local algorithm as the Storeshot app (`extract`), or the dominant colour of up to 10 images (`dominant`); no AI model is involved and URL inputs are never stored. `GET /stores` and `GET /stores/{store}/requirements` describe what the App Store and Google Play need (formats, counts, sizes or dimension rules, required classes, the feature graphic), with the official pages they come from and which `GET /devices` sizes meet them. --- # System API reference > HTML version: https://storeshot.art/docs/api/system/ Service status. Contract 1.18.0; base URL `https://api.storeshot.art/v1`. Schemas: https://storeshot.art/docs/api/schemas.md ## GET /health **Service health** (operation `getHealth`). No authentication. Reports whether the API and its dependencies are healthy. No authentication. **Credits.** Free: no credits. **Responses:** | Status | Description | Schema | Headers | |---|---|---|---| | `200` | All dependencies are healthy. | `HealthReport` | `X-Request-Id` | | `503` | A dependency is down (`SERVICE_UNAVAILABLE`; `details` holds the report). | `ErrorEnvelope` | `X-Request-Id` | | `default` | Error (see ErrorEnvelope and /v1/docs/errors). | `ErrorEnvelope` | `X-Request-Id` | Example 200 response: ```json { "status": "healthy", "version": "string", "commit": "string", "checks": { "database": "up", "redis": "up" } } ``` ## GET /status **Public service status** (operation `getStatus`). No authentication. Coarse state of the API, the generation pipeline and the image and vision models over the last 15 minutes. Always 200, cached for 30 s, readable from any origin. No authentication. A model with too little recent traffic is `unknown`; the top-level status is the worst known component. **Credits.** Free: no credits. **Responses:** | Status | Description | Schema | Headers | |---|---|---|---| | `200` | The current status. | `StatusReport` | `X-Request-Id`, `Cache-Control` | | `default` | Error (see ErrorEnvelope and /v1/docs/errors). | `ErrorEnvelope` | `X-Request-Id` | Example 200 response: ```json { "status": "operational", "updatedAt": "2026-10-29T12:00:00.000Z", "windowSeconds": 900, "components": [ { "id": "api", "name": "Generation pipeline", "status": "operational" } ] } ``` --- # Catalog API reference > HTML version: https://storeshot.art/docs/api/catalog/ Static reference data. Contract 1.18.0; base URL `https://api.storeshot.art/v1`. Schemas: https://storeshot.art/docs/api/schemas.md ## GET /devices **List store screenshot sizes** (operation `listDevices`). No authentication. App Store and Google Play image sizes Storeshot can generate, including the Google Play 1024×500 feature graphic. No authentication. A deprecated size (`deprecated: true`) is still accepted but never a default; use its `replacedBy`. **When to use.** To pick `devices` for a screenshot set or the `device` of a generation; most callers can leave `devices` out and get the store defaults. **Credits.** Free: no credits. **Parameters:** | Name | In | Type | Required | Description | |---|---|---|---|---| | `store` | query | `StoreTarget` | no | | **Responses:** | Status | Description | Schema | Headers | |---|---|---|---| | `200` | Device catalogue. | `DeviceList` | `X-Request-Id` | | `400` | Invalid `store` (`INVALID_INPUT`). | `ErrorEnvelope` | `X-Request-Id` | | `default` | Error (see ErrorEnvelope and /v1/docs/errors). | `ErrorEnvelope` | `X-Request-Id` | Example 200 response: ```json { "data": [ { "id": "app_store_1284x2778", "store": "app_store", "kind": "screenshot", "label": "string", "deviceHint": "string", "width": 1, "height": 1, "orientations": [], "deprecated": true, "replacedBy": "google_play_1080x2160" } ] } ``` ## GET /stores **List stores** (operation `listStores`). Auth: API key or access token. The stores Storeshot targets. Any valid API key or access token; no scope required. **Credits.** Free: no credits. **Responses:** | Status | Description | Schema | Headers | |---|---|---|---| | `200` | The stores. | `StoreList` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `400` | `INVALID_INPUT`. A credential sent in the query string is refused before authentication, without RateLimit headers. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `401` | `UNAUTHORIZED`: missing, malformed, unknown, revoked or expired credential. | `ErrorEnvelope` | `X-Request-Id` | | `403` | `FORBIDDEN`: see `details.reason`. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `429` | `RATE_LIMITED`: the request rate of the API key (`details.scope` `key`) or of the account (`user`) is exhausted for this window, or too many failed authentication attempts came from this IP (no `details.scope` and no RateLimit headers). Retry after `Retry-After` seconds. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy`, `Retry-After` | | `default` | Error (see ErrorEnvelope and /v1/docs/errors). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | Example 200 response: ```json { "data": [ { "id": "app_store", "name": "string", "requirementsUrl": "/v1/stores/app_store/requirements" } ] } ``` ## GET /stores/{store}/requirements **Get store screenshot requirements** (operation `getStoreRequirements`). Auth: API key or access token. Formats, counts, sizes or dimension rules per device class, which classes are needed, and the Google Play feature graphic, with the official sources they come from. Each class lists the `GET /devices` sizes it covers and whether they meet the store rules. Any valid API key or access token; no scope required. **When to use.** Before choosing devices yourself, to check which sizes a store accepts and which device classes it requires. **Credits.** Free: no credits. **Parameters:** | Name | In | Type | Required | Description | |---|---|---|---|---| | `store` | path | `string` | yes | `app_store` or `google_play`. Anything else is 404. | **Responses:** | Status | Description | Schema | Headers | |---|---|---|---| | `200` | The requirements. | `StoreRequirements` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `400` | `INVALID_INPUT`. A credential sent in the query string is refused before authentication, without RateLimit headers. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `401` | `UNAUTHORIZED`: missing, malformed, unknown, revoked or expired credential. | `ErrorEnvelope` | `X-Request-Id` | | `403` | `FORBIDDEN`: see `details.reason`. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `404` | `NOT_FOUND`: no such resource for this caller. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `429` | `RATE_LIMITED`: the request rate of the API key (`details.scope` `key`) or of the account (`user`) is exhausted for this window, or too many failed authentication attempts came from this IP (no `details.scope` and no RateLimit headers). Retry after `Retry-After` seconds. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy`, `Retry-After` | | `default` | Error (see ErrorEnvelope and /v1/docs/errors). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | Example 200 response: ```json { "store": "app_store", "name": "string", "screenshots": { "formats": [ "image/png" ], "alpha": true, "maxFileBytes": 1, "count": { "minTotal": 0, "maxPerDeviceClass": 1 }, "sources": [ "apple_screenshot_specs" ] }, "deviceClasses": [ { "id": "phone", "family": "iphone", "label": "string", "requirement": "required", "count": { "min": 0, "max": 1 }, "maxFileBytes": 1, "acceptedSizes": [ {} ], "rules": { "minEdge": 1, "maxEdge": 1, "maxAspectRatio": 0, "exactAspectRatio": "string", "recommendedAspectRatios": [] }, "sources": [ "apple_screenshot_specs" ], "devices": [ {} ] } ], "requiredGroups": [ { "when": "app runs on iPad", "anyOf": [ "string" ], "sources": [ "apple_screenshot_specs" ] } ], "featureGraphic": { "width": 1, "height": 1, "formats": [ "image/png" ], "alpha": true, "maxFileBytes": 1, "required": true, "sources": [ "apple_screenshot_specs" ], "device": "google_play_1024x500" }, "sources": [ { "id": "apple_screenshot_specs", "title": "string", "url": "https://example.com", "checkedOn": "string" } ] } ``` --- # Account API reference > HTML version: https://storeshot.art/docs/api/account/ The authenticated caller. Contract 1.18.0; base URL `https://api.storeshot.art/v1`. Schemas: https://storeshot.art/docs/api/schemas.md ## GET /me **Who am I** (operation `getMe`). Auth: API key or access token. Describes the caller and the credential used. Any valid API key or access token; no scope required. **When to use.** First, to check that `STORESHOT_API_KEY` works and whether it is a test key (`credential.environment`). **Credits.** Free: no credits. **Responses:** | Status | Description | Schema | Headers | |---|---|---|---| | `200` | The caller. | `Me` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `400` | `INVALID_INPUT`. A credential sent in the query string is refused before authentication, without RateLimit headers. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `401` | `UNAUTHORIZED`: missing, malformed, unknown, revoked or expired credential. | `ErrorEnvelope` | `X-Request-Id` | | `403` | `FORBIDDEN`: see `details.reason`. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `429` | `RATE_LIMITED`: the request rate of the API key (`details.scope` `key`) or of the account (`user`) is exhausted for this window, or too many failed authentication attempts came from this IP (no `details.scope` and no RateLimit headers). Retry after `Retry-After` seconds. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy`, `Retry-After` | | `default` | Error (see ErrorEnvelope and /v1/docs/errors). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | Example 200 response: ```json { "user": { "id": "7d0e1a52-3c4b-4f6e-9a8d-2b1c0f9e8a7d", "displayName": "string", "avatarUrl": "string", "email": "string", "createdAt": "2026-10-21T12:00:00.000Z" }, "credential": { "type": "user_token", "environment": "live", "scopes": [ "generations:write" ], "apiKey": { "id": "7d0e1a52-3c4b-4f6e-9a8d-2b1c0f9e8a7d", "name": "string", "prefix": "string", "expiresAt": "2026-10-21T12:00:00.000Z" } } } ``` --- # API keys API reference > HTML version: https://storeshot.art/docs/api/api-keys/ Create, list and revoke API keys. Contract 1.18.0; base URL `https://api.storeshot.art/v1`. Schemas: https://storeshot.art/docs/api/schemas.md ## GET /api-keys **List API keys** (operation `listApiKeys`). Auth: API key with scope `keys:manage`, or access token. Requires `keys:manage`. Non-revoked keys, newest first (max 100). Secrets are never returned. An API key caller sees only keys of its own environment. **Credits.** Free: no credits. **Responses:** | Status | Description | Schema | Headers | |---|---|---|---| | `200` | Keys. | `ApiKeyList` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `400` | `INVALID_INPUT`. A credential sent in the query string is refused before authentication, without RateLimit headers. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `401` | `UNAUTHORIZED`: missing, malformed, unknown, revoked or expired credential. | `ErrorEnvelope` | `X-Request-Id` | | `403` | `FORBIDDEN`: see `details.reason`. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `429` | `RATE_LIMITED`: the request rate of the API key (`details.scope` `key`) or of the account (`user`) is exhausted for this window, or too many failed authentication attempts came from this IP (no `details.scope` and no RateLimit headers). Retry after `Retry-After` seconds. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy`, `Retry-After` | | `default` | Error (see ErrorEnvelope and /v1/docs/errors). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | Example 200 response: ```json { "data": [ { "id": "7d0e1a52-3c4b-4f6e-9a8d-2b1c0f9e8a7d", "name": "string", "prefix": "ss_live_AbCd", "environment": "live", "scopes": [], "status": "active", "createdAt": "2026-10-21T12:00:00.000Z", "lastUsedAt": "2026-10-21T12:00:00.000Z", "expiresAt": "2026-10-21T12:00:00.000Z" } ] } ``` ## POST /api-keys **Create an API key** (operation `createApiKey`). Auth: API key with scope `keys:manage`, or access token. Requires `keys:manage`. The full key is in this response only. An API key acting as the manager can only create keys for its own environment with a subset of its own scopes. **Credits.** Free: no credits. **Request body** (`application/json`, required, schema `CreateApiKeyRequest`): | Field | Type | Required | Description | |---|---|---|---| | `name` | `string` | yes | 1–64 characters after trimming; need not be unique. | | `environment` | `ApiKeyEnvironment` | yes | | | `scopes` | `array of ApiKeyScope` | no | Defaults to every scope except `keys:manage`, `billing:read`, `billing:write`, `webhooks:read` and `webhooks:write`. | | `expiresAt` | `string` | no | Optional RFC 3339 expiry, in the future and at most 365 days ahead. Format: date-time | Example request body: ```json { "name": "string", "environment": "live", "scopes": [ "generations:write" ], "expiresAt": "2026-10-29T12:00:00.000Z" } ``` **Responses:** | Status | Description | Schema | Headers | |---|---|---|---| | `201` | Created. Store `key` now; it is never shown again. | `CreatedApiKey` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `400` | `INVALID_INPUT`. A credential sent in the query string is refused before authentication, without RateLimit headers. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `401` | `UNAUTHORIZED`: missing, malformed, unknown, revoked or expired credential. | `ErrorEnvelope` | `X-Request-Id` | | `403` | `FORBIDDEN`: see `details.reason`. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `413` | `PAYLOAD_TOO_LARGE`. | `ErrorEnvelope` | `X-Request-Id` | | `429` | `RATE_LIMITED`: the request rate of the API key (`details.scope` `key`) or of the account (`user`) is exhausted for this window, or too many failed authentication attempts came from this IP (no `details.scope` and no RateLimit headers). Retry after `Retry-After` seconds. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy`, `Retry-After` | | `default` | Error (see ErrorEnvelope and /v1/docs/errors). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | Example 201 response: ```json { "id": "7d0e1a52-3c4b-4f6e-9a8d-2b1c0f9e8a7d", "name": "string", "prefix": "ss_live_AbCd", "environment": "live", "scopes": [ "generations:write" ], "status": "active", "createdAt": "2026-10-21T12:00:00.000Z", "lastUsedAt": "2026-10-21T12:00:00.000Z", "expiresAt": "2026-10-21T12:00:00.000Z", "key": "string" } ``` ## DELETE /api-keys/{id} **Revoke an API key** (operation `revokeApiKey`). Auth: API key with scope `keys:manage`, or access token. Requires `keys:manage`. Idempotent: revoking an already-revoked key returns 204. Takes effect on the next request. **Credits.** Free: no credits. **Parameters:** | Name | In | Type | Required | Description | |---|---|---|---|---| | `id` | path | `string` | yes | Format: uuid | **Responses:** | Status | Description | Schema | Headers | |---|---|---|---| | `204` | Revoked. | — | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `400` | `INVALID_INPUT`. A credential sent in the query string is refused before authentication, without RateLimit headers. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `401` | `UNAUTHORIZED`: missing, malformed, unknown, revoked or expired credential. | `ErrorEnvelope` | `X-Request-Id` | | `403` | `FORBIDDEN`: see `details.reason`. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `404` | `NOT_FOUND`: no such resource for this caller. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `429` | `RATE_LIMITED`: the request rate of the API key (`details.scope` `key`) or of the account (`user`) is exhausted for this window, or too many failed authentication attempts came from this IP (no `details.scope` and no RateLimit headers). Retry after `Retry-After` seconds. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy`, `Retry-After` | | `default` | Error (see ErrorEnvelope and /v1/docs/errors). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | ## POST /auth/device **Start device authorization** (operation `createDeviceAuthorization`). No authentication. No authentication. For an agent or tool without an API key (RFC 8628): returns a `user_code` and a console link for its human, and a secret `device_code` to poll `POST /auth/device/token` with every `interval` seconds. The human signs in to the developer console, checks the request and approves or denies it; on approval the next poll returns a new API key. Codes expire after `expires_in` seconds (10 minutes) and work once. Accepts JSON or `application/x-www-form-urlencoded`. Device authorization may be disabled on a server: then this answers 404 `NOT_FOUND`. Limited per IP address (429 `RATE_LIMITED` with `Retry-After`). **When to use.** When `STORESHOT_API_KEY` is not set: start here, show your human `verification_uri_complete` and `user_code`, then poll `POST /auth/device/token`. **Credits.** Free: no credits. **Request body** (`application/json`, optional, schema `DeviceAuthorizationRequest`): | Field | Type | Required | Description | |---|---|---|---| | `client_name` | `string` | no | Your tool's name, shown to the human as an unverified label. 1–64 printable ASCII characters. When absent, `client_id` is used instead (generic OAuth libraries send one). | | `scope` | `string` | no | Space-separated scopes for the new key. Default `generations:write generations:read uploads:write usage:read`. `keys:manage`, `billing:write` and `webhooks:write` cannot be requested. The human may narrow the list, or add `billing:read` and `webhooks:read`. | | `environment` | `ApiKeyEnvironment` | no | Environment of the new key. Default `live`. | Example request body: ```json { "client_name": "Claude Code", "scope": "generations:write generations:read uploads:write usage:read", "environment": "live" } ``` **Request body** (`application/x-www-form-urlencoded`, optional, schema `DeviceAuthorizationRequest`): | Field | Type | Required | Description | |---|---|---|---| | `client_name` | `string` | no | Your tool's name, shown to the human as an unverified label. 1–64 printable ASCII characters. When absent, `client_id` is used instead (generic OAuth libraries send one). | | `scope` | `string` | no | Space-separated scopes for the new key. Default `generations:write generations:read uploads:write usage:read`. `keys:manage`, `billing:write` and `webhooks:write` cannot be requested. The human may narrow the list, or add `billing:read` and `webhooks:read`. | | `environment` | `ApiKeyEnvironment` | no | Environment of the new key. Default `live`. | **Responses:** | Status | Description | Schema | Headers | |---|---|---|---| | `200` | Started. Show `verification_uri_complete` and `user_code` to the human. | `DeviceAuthorization` | `X-Request-Id`, `Cache-Control` | | `400` | `INVALID_INPUT`: see `details.issues`. | `ErrorEnvelope` | `X-Request-Id` | | `404` | `NOT_FOUND`: device authorization may be disabled on this server, and then both operations answer the `/v1` 404 envelope. | `ErrorEnvelope` | `X-Request-Id` | | `429` | `RATE_LIMITED`: too many requests from this IP address, or too many pending ones (`details.reason` `too_many_pending`). Retry after `Retry-After` seconds. | `ErrorEnvelope` | `X-Request-Id`, `Retry-After` | | `default` | Error (see ErrorEnvelope and /v1/docs/errors). | `ErrorEnvelope` | `X-Request-Id` | Example 200 response: ```json { "device_code": "string", "user_code": "BCDF-GHJK", "verification_uri": "https://console.storeshot.art/device", "verification_uri_complete": "https://console.storeshot.art/device?code=BCDF-GHJK", "expires_in": 600, "interval": 5 } ``` ## POST /auth/device/token **Poll for the device authorization result** (operation `createDeviceToken`). No authentication. No authentication. Send `grant_type=urn:ietf:params:oauth:grant-type:device_code` and the `device_code` from `POST /auth/device`, as `application/x-www-form-urlencoded` (RFC 8628 §3.4) or JSON. Poll every `interval` seconds until the human decides. Approved: 200 with the new API key in `access_token`, returned once; the code then answers `invalid_grant`. **Errors here are the one deliberate exception to `ErrorEnvelope`:** refusals are HTTP 400 `{ "error", "error_description" }` (RFC 6749 §5.2) with `error` `authorization_pending` (keep polling), `slow_down` (add 5 seconds to your interval; `Retry-After` gives the new one), `access_denied`, `expired_token` (start again), `invalid_grant`, `invalid_request` or `unsupported_grant_type`. The `X-Request-Id` header is on every response: quote it to support. Other failures (404 while device authorization is disabled on this server, 413, 5xx) use `ErrorEnvelope`. **When to use.** Every `interval` seconds after `POST /auth/device`, until it returns the key; add 5 s on `slow_down`; stop on `access_denied` or `expired_token`. **Credits.** Free: no credits. **Request body** (`application/x-www-form-urlencoded`, required, schema `DeviceTokenRequest`): | Field | Type | Required | Description | |---|---|---|---| | `grant_type` | `string` | yes | One of: "urn:ietf:params:oauth:grant-type:device_code" | | `device_code` | `string` | yes | | **Request body** (`application/json`, required, schema `DeviceTokenRequest`): | Field | Type | Required | Description | |---|---|---|---| | `grant_type` | `string` | yes | One of: "urn:ietf:params:oauth:grant-type:device_code" | | `device_code` | `string` | yes | | Example request body: ```json { "grant_type": "urn:ietf:params:oauth:grant-type:device_code", "device_code": "string" } ``` **Responses:** | Status | Description | Schema | Headers | |---|---|---|---| | `200` | Approved: the new API key. Store `access_token` now; it is never shown again. | `DeviceToken` | `X-Request-Id`, `Cache-Control`, `Pragma` | | `400` | OAuth error (RFC 6749 §5.2), not `ErrorEnvelope`. `slow_down` also carries `Retry-After`. | `DeviceTokenError` | `X-Request-Id`, `Retry-After` | | `404` | `NOT_FOUND`: device authorization may be disabled on this server, and then both operations answer the `/v1` 404 envelope. | `ErrorEnvelope` | `X-Request-Id` | | `default` | Error (see ErrorEnvelope and /v1/docs/errors). | `ErrorEnvelope` | `X-Request-Id` | Example 200 response: ```json { "access_token": "string", "token_type": "Bearer", "scope": "string", "key_id": "7d0e1a52-3c4b-4f6e-9a8d-2b1c0f9e8a7d", "environment": "live" } ``` --- # Usage API reference > HTML version: https://storeshot.art/docs/api/usage/ Credit balance and generation usage. Contract 1.18.0; base URL `https://api.storeshot.art/v1`. Schemas: https://storeshot.art/docs/api/schemas.md ## GET /credits **Credit balance** (operation `getCredits`). Auth: API key with scope `usage:read`, or access token. Requires `usage:read`. Remaining credits of the account, per balance. Reading never creates or grants credits. **When to use.** Before spending with a live key, to tell your human the balance (a screenshot-set quote shows it too). **Credits.** Free: no credits. **Responses:** | Status | Description | Schema | Headers | |---|---|---|---| | `200` | The balances. | `CreditBalances` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `400` | `INVALID_INPUT`. A credential sent in the query string is refused before authentication, without RateLimit headers. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `401` | `UNAUTHORIZED`: missing, malformed, unknown, revoked or expired credential. | `ErrorEnvelope` | `X-Request-Id` | | `403` | `FORBIDDEN`: see `details.reason`. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `429` | `RATE_LIMITED`: the request rate of the API key (`details.scope` `key`) or of the account (`user`) is exhausted for this window, or too many failed authentication attempts came from this IP (no `details.scope` and no RateLimit headers). Retry after `Retry-After` seconds. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy`, `Retry-After` | | `default` | Error (see ErrorEnvelope and /v1/docs/errors). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | Example 200 response: ```json { "balances": { "starter": 0, "subscription": 0, "purchased": 4 }, "total": 4 } ``` ## GET /usage **Generation usage** (operation `getUsage`). Auth: API key with scope `usage:read`, or access token. Requires `usage:read`. Live generations and the credits they used, for the whole account (every API key and surface) in `[from, to)`, bucketed by UTC day, API key or source. Test-key generations are free and not counted. A range may span at most 366 days. **Credits.** Free: no credits. **Parameters:** | Name | In | Type | Required | Description | |---|---|---|---|---| | `from` | query | `string` | no | Start, inclusive. `YYYY-MM-DD` means 00:00 UTC that day. Default: 00:00 UTC 29 days before the day of `to`. | | `to` | query | `string` | no | End, exclusive. `YYYY-MM-DD` includes that whole UTC day. Default: now. At most 366 days after `from`. | | `groupBy` | query | `UsageGroupBy` | no | Default: "day" | **Responses:** | Status | Description | Schema | Headers | |---|---|---|---| | `200` | The usage report. | `UsageReport` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `400` | `INVALID_INPUT`. A credential sent in the query string is refused before authentication, without RateLimit headers. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `401` | `UNAUTHORIZED`: missing, malformed, unknown, revoked or expired credential. | `ErrorEnvelope` | `X-Request-Id` | | `403` | `FORBIDDEN`: see `details.reason`. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `429` | `RATE_LIMITED`: the request rate of the API key (`details.scope` `key`) or of the account (`user`) is exhausted for this window, or too many failed authentication attempts came from this IP (no `details.scope` and no RateLimit headers). Retry after `Retry-After` seconds. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy`, `Retry-After` | | `default` | Error (see ErrorEnvelope and /v1/docs/errors). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | Example 200 response: ```json { "from": "2026-10-20T00:00:00.000Z", "to": "2026-10-21T14:03:12.000Z", "groupBy": "day", "data": [ { "key": "2026-10-20", "generations": 0, "succeeded": 0, "failed": 0, "refunded": 0, "pending": 0, "creditsUsed": 0 }, { "key": "2026-10-21", "generations": 3, "succeeded": 2, "failed": 0, "refunded": 1, "pending": 0, "creditsUsed": 2 } ], "totals": { "generations": 3, "succeeded": 2, "failed": 0, "refunded": 1, "pending": 0, "creditsUsed": 2 } } ``` --- # Uploads API reference > HTML version: https://storeshot.art/docs/api/uploads/ Hand over images once; reference them in generations. Contract 1.18.0; base URL `https://api.storeshot.art/v1`. Schemas: https://storeshot.art/docs/api/schemas.md ## GET /uploads **List uploads** (operation `listUploads`). Auth: API key with scope `uploads:write`, `generations:write` or `generations:read`, or access token. Requires `uploads:write`, `generations:write` or `generations:read`. Uploads of the account that are not deleted (every API key and environment), newest first. **Credits.** Free: no credits. **Parameters:** | Name | In | Type | Required | Description | |---|---|---|---|---| | `limit` | query | `integer` | no | 1–100, default 20. Default: 20 | | `cursor` | query | `string` | no | `nextCursor` of the previous page. | **Responses:** | Status | Description | Schema | Headers | |---|---|---|---| | `200` | A page of uploads. | `UploadList` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `400` | `INVALID_INPUT`. A credential sent in the query string is refused before authentication, without RateLimit headers. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `401` | `UNAUTHORIZED`: missing, malformed, unknown, revoked or expired credential. | `ErrorEnvelope` | `X-Request-Id` | | `403` | `FORBIDDEN`: see `details.reason`. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `429` | `RATE_LIMITED`: the request rate of the API key (`details.scope` `key`) or of the account (`user`) is exhausted for this window, or too many failed authentication attempts came from this IP (no `details.scope` and no RateLimit headers). Retry after `Retry-After` seconds. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy`, `Retry-After` | | `default` | Error (see ErrorEnvelope and /v1/docs/errors). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | Example 200 response: ```json { "data": [ { "id": "6f1c1f0e-8a5e-4b8e-9a51-0f0e1d2c3b4a", "status": "ready", "source": "multipart", "environment": "live", "contentType": "image/png", "bytes": 482113, "width": 1290, "height": 2796, "orientation": "portrait", "sha256": "9b1e5e5a3c0f8d2b7a6c4e1f0d9b8a7c6e5d4c3b2a1f0e9d8c7b6a5f4e3d2c1b", "sourceUrl": null, "expiresAt": "2026-11-20T10:00:00.000Z", "createdAt": "2026-10-21T10:00:00.000Z" } ], "nextCursor": "string" } ``` ## POST /uploads **Upload an image** (operation `createUpload`). Auth: API key with scope `uploads:write`, or access token. Requires `uploads:write`. Send either `multipart/form-data` with one file part `file`, or JSON `{ "url": "https://…" }` to have the server fetch the image (https only, public addresses only, at most 3 redirects and 15 seconds). PNG, JPEG or WebP, at most 20 MB and 50 megapixels; the format is detected from the bytes. HEIC is not supported. The upload is `ready` in the response. It is deleted 30 days after creation; generated results are kept until you delete them. **When to use.** Once per raw screenshot (or logo) before creating generations or sets, which take upload ids. Use multipart for local files and `{ "url" }` for public https images. **Credits.** Free: no credits (counts towards the upload quota). **Request body** (`multipart/form-data`, required): | Field | Type | Required | Description | |---|---|---|---| | `file` | `string` | yes | Exactly one file part named `file`: PNG, JPEG or WebP, at most 20 MB and 50 megapixels. Format: binary | **Request body** (`application/json`, required, schema `CreateUploadFromUrl`): | Field | Type | Required | Description | |---|---|---|---| | `url` | `string` | yes | An `https://` URL (port 443, no credentials, at most 2048 characters) that resolves to a public address. Fetched once, server-side. | Example request body: ```json { "url": "https://example.com/screenshots/home.png" } ``` **Responses:** | Status | Description | Schema | Headers | |---|---|---|---| | `201` | Created and ready. | `Upload` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `400` | `INVALID_INPUT`. A credential sent in the query string is refused before authentication, without RateLimit headers. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `401` | `UNAUTHORIZED`: missing, malformed, unknown, revoked or expired credential. | `ErrorEnvelope` | `X-Request-Id` | | `403` | `FORBIDDEN`: see `details.reason`. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `413` | `PAYLOAD_TOO_LARGE` (the request body is over its limit) or `IMAGE_TOO_LARGE` (see `details.reason`). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `415` | `UNSUPPORTED_FORMAT`: not a PNG, JPEG or WebP image (see `details.reason`). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `422` | `URL_FETCH_FAILED`: the URL could not be fetched (see `details.reason`). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `429` | `RATE_LIMITED`: the request rate of the API key (`details.scope` `key`) or of the account (`user`) is exhausted for this window, or too many failed authentication attempts came from this IP (no `details.scope` and no RateLimit headers). Retry after `Retry-After` seconds. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy`, `Retry-After` | | `503` | `SERVICE_UNAVAILABLE`: uploads are busy. Retry after `Retry-After` seconds. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy`, `Retry-After` | | `default` | Error (see ErrorEnvelope and /v1/docs/errors). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | Example 201 response: ```json { "id": "6f1c1f0e-8a5e-4b8e-9a51-0f0e1d2c3b4a", "status": "ready", "source": "multipart", "environment": "live", "contentType": "image/png", "bytes": 482113, "width": 1290, "height": 2796, "orientation": "portrait", "sha256": "9b1e5e5a3c0f8d2b7a6c4e1f0d9b8a7c6e5d4c3b2a1f0e9d8c7b6a5f4e3d2c1b", "sourceUrl": null, "expiresAt": "2026-11-20T10:00:00.000Z", "createdAt": "2026-10-21T10:00:00.000Z" } ``` ## POST /uploads/presign **Start a presigned upload** (operation `presignUpload`). Auth: API key with scope `uploads:write`, or access token. Requires `uploads:write`. Returns a pending upload and a URL to `PUT` the file to (from your server; valid 15 minutes), with the exact headers to send. Then call `POST /uploads/{id}/complete`. A pending upload that is never completed is deleted after 24 hours. **When to use.** Only when your server should send the bytes straight to storage; agents and scripts use `POST /uploads`. **Credits.** Free: no credits. **Request body** (`application/json`, required, schema `PresignUploadRequest`): | Field | Type | Required | Description | |---|---|---|---| | `contentType` | `string` | yes | The type of the file you will PUT: `image/png`, `image/jpeg` or `image/webp`. | | `bytes` | `integer` | yes | Exact size of the file you will PUT, at most 20 MB (20971520). | Example request body: ```json { "contentType": "image/png", "bytes": 482113 } ``` **Responses:** | Status | Description | Schema | Headers | |---|---|---|---| | `201` | Pending upload and its presigned URL. | `PresignedUpload` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `400` | `INVALID_INPUT`. A credential sent in the query string is refused before authentication, without RateLimit headers. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `401` | `UNAUTHORIZED`: missing, malformed, unknown, revoked or expired credential. | `ErrorEnvelope` | `X-Request-Id` | | `403` | `FORBIDDEN`: see `details.reason`. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `413` | `PAYLOAD_TOO_LARGE` (the request body is over its limit) or `IMAGE_TOO_LARGE` (see `details.reason`). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `415` | `UNSUPPORTED_FORMAT`: not a PNG, JPEG or WebP image (see `details.reason`). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `429` | `RATE_LIMITED`: the request rate of the API key (`details.scope` `key`) or of the account (`user`) is exhausted for this window, or too many failed authentication attempts came from this IP (no `details.scope` and no RateLimit headers). Retry after `Retry-After` seconds. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy`, `Retry-After` | | `default` | Error (see ErrorEnvelope and /v1/docs/errors). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | Example 201 response: ```json { "upload": { "id": "6f1c1f0e-8a5e-4b8e-9a51-0f0e1d2c3b4a", "status": "ready", "source": "multipart", "environment": "live", "contentType": "image/png", "bytes": 482113, "width": 1290, "height": 2796, "orientation": "portrait", "sha256": "9b1e5e5a3c0f8d2b7a6c4e1f0d9b8a7c6e5d4c3b2a1f0e9d8c7b6a5f4e3d2c1b", "sourceUrl": null, "expiresAt": "2026-11-20T10:00:00.000Z", "createdAt": "2026-10-21T10:00:00.000Z" }, "uploadUrl": "https://example.com", "method": "PUT", "headers": { "Content-Type": "image/png", "Content-Length": "482113" }, "uploadUrlExpiresAt": "2026-10-21T12:00:00.000Z" } ``` ## POST /uploads/{id}/complete **Complete a presigned upload** (operation `completeUpload`). Auth: API key with scope `uploads:write`, or access token. Requires `uploads:write`. Checks the file you PUT (size, format, caps) and makes the upload `ready`. Idempotent for a ready upload. `409 UPLOAD_NOT_READY` with `object_missing` means the file has not arrived yet: PUT it, then retry. A rejected file makes the upload `failed` and is deleted. **Credits.** Free: no credits. **Parameters:** | Name | In | Type | Required | Description | |---|---|---|---|---| | `id` | path | `string` | yes | Upload id. An unknown or malformed id is 404. | **Responses:** | Status | Description | Schema | Headers | |---|---|---|---| | `200` | Ready. | `Upload` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `400` | `INVALID_INPUT`. A credential sent in the query string is refused before authentication, without RateLimit headers. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `401` | `UNAUTHORIZED`: missing, malformed, unknown, revoked or expired credential. | `ErrorEnvelope` | `X-Request-Id` | | `403` | `FORBIDDEN`: see `details.reason`. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `404` | `NOT_FOUND`: no such resource for this caller. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `409` | `UPLOAD_NOT_READY`: see `details.reason`. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `413` | `PAYLOAD_TOO_LARGE` (the request body is over its limit) or `IMAGE_TOO_LARGE` (see `details.reason`). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `415` | `UNSUPPORTED_FORMAT`: not a PNG, JPEG or WebP image (see `details.reason`). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `429` | `RATE_LIMITED`: the request rate of the API key (`details.scope` `key`) or of the account (`user`) is exhausted for this window, or too many failed authentication attempts came from this IP (no `details.scope` and no RateLimit headers). Retry after `Retry-After` seconds. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy`, `Retry-After` | | `503` | `SERVICE_UNAVAILABLE`: uploads are busy. Retry after `Retry-After` seconds. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy`, `Retry-After` | | `default` | Error (see ErrorEnvelope and /v1/docs/errors). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | Example 200 response: ```json { "id": "6f1c1f0e-8a5e-4b8e-9a51-0f0e1d2c3b4a", "status": "ready", "source": "multipart", "environment": "live", "contentType": "image/png", "bytes": 482113, "width": 1290, "height": 2796, "orientation": "portrait", "sha256": "9b1e5e5a3c0f8d2b7a6c4e1f0d9b8a7c6e5d4c3b2a1f0e9d8c7b6a5f4e3d2c1b", "sourceUrl": null, "expiresAt": "2026-11-20T10:00:00.000Z", "createdAt": "2026-10-21T10:00:00.000Z" } ``` ## GET /uploads/{id} **Get an upload** (operation `getUpload`). Auth: API key with scope `uploads:write`, `generations:write` or `generations:read`, or access token. Requires `uploads:write`, `generations:write` or `generations:read`. Metadata only, in any status (deleted uploads included). **Credits.** Free: no credits. **Parameters:** | Name | In | Type | Required | Description | |---|---|---|---|---| | `id` | path | `string` | yes | Upload id. An unknown or malformed id is 404. | **Responses:** | Status | Description | Schema | Headers | |---|---|---|---| | `200` | The upload. | `Upload` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `400` | `INVALID_INPUT`. A credential sent in the query string is refused before authentication, without RateLimit headers. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `401` | `UNAUTHORIZED`: missing, malformed, unknown, revoked or expired credential. | `ErrorEnvelope` | `X-Request-Id` | | `403` | `FORBIDDEN`: see `details.reason`. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `404` | `NOT_FOUND`: no such resource for this caller. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `429` | `RATE_LIMITED`: the request rate of the API key (`details.scope` `key`) or of the account (`user`) is exhausted for this window, or too many failed authentication attempts came from this IP (no `details.scope` and no RateLimit headers). Retry after `Retry-After` seconds. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy`, `Retry-After` | | `default` | Error (see ErrorEnvelope and /v1/docs/errors). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | Example 200 response: ```json { "id": "6f1c1f0e-8a5e-4b8e-9a51-0f0e1d2c3b4a", "status": "ready", "source": "multipart", "environment": "live", "contentType": "image/png", "bytes": 482113, "width": 1290, "height": 2796, "orientation": "portrait", "sha256": "9b1e5e5a3c0f8d2b7a6c4e1f0d9b8a7c6e5d4c3b2a1f0e9d8c7b6a5f4e3d2c1b", "sourceUrl": null, "expiresAt": "2026-11-20T10:00:00.000Z", "createdAt": "2026-10-21T10:00:00.000Z" } ``` ## DELETE /uploads/{id} **Delete an upload** (operation `deleteUpload`). Auth: API key with scope `uploads:write`, or access token. Requires `uploads:write`. Deletes the file now; the upload keeps its metadata with status `deleted`. Idempotent. **Credits.** Free: no credits. **Parameters:** | Name | In | Type | Required | Description | |---|---|---|---|---| | `id` | path | `string` | yes | Upload id. An unknown or malformed id is 404. | **Responses:** | Status | Description | Schema | Headers | |---|---|---|---| | `204` | Deleted. | — | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `400` | `INVALID_INPUT`. A credential sent in the query string is refused before authentication, without RateLimit headers. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `401` | `UNAUTHORIZED`: missing, malformed, unknown, revoked or expired credential. | `ErrorEnvelope` | `X-Request-Id` | | `403` | `FORBIDDEN`: see `details.reason`. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `404` | `NOT_FOUND`: no such resource for this caller. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `429` | `RATE_LIMITED`: the request rate of the API key (`details.scope` `key`) or of the account (`user`) is exhausted for this window, or too many failed authentication attempts came from this IP (no `details.scope` and no RateLimit headers). Retry after `Retry-After` seconds. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy`, `Retry-After` | | `default` | Error (see ErrorEnvelope and /v1/docs/errors). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | --- # Generations API reference > HTML version: https://storeshot.art/docs/api/generations/ Turn a screenshot into a store-ready marketing image. Contract 1.18.0; base URL `https://api.storeshot.art/v1`. Schemas: https://storeshot.art/docs/api/schemas.md ## GET /generations **List generations** (operation `listGenerations`). Auth: API key with scope `generations:read` or `generations:write`, or access token. Requires `generations:read` or `generations:write`. Generations of the account that are not deleted (every API key and environment), newest first. **Credits.** Free: no credits. **Parameters:** | Name | In | Type | Required | Description | |---|---|---|---|---| | `limit` | query | `integer` | no | 1–100, default 20. Default: 20 | | `cursor` | query | `string` | no | `nextCursor` of the previous page. | | `status` | query | `GenerationStatus` | no | | | `metadata` | query | `string` | no | `:`: only generations whose metadata has exactly this value. | | `screenshotSetId` | query | `string` | no | Only the images of this screenshot set (every attempt). Format: uuid | | `environment` | query | `ApiKeyEnvironment` | no | | | `source` | query | `GenerationSource` | no | | | `projectId` | query | `string` | no | Since 1.15.0: only generations of this project (its own images and the images of its screenshot sets), or `none` for generations in no project. | **Responses:** | Status | Description | Schema | Headers | |---|---|---|---| | `200` | A page of generations. | `GenerationList` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `400` | `INVALID_INPUT`. A credential sent in the query string is refused before authentication, without RateLimit headers. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `401` | `UNAUTHORIZED`: missing, malformed, unknown, revoked or expired credential. | `ErrorEnvelope` | `X-Request-Id` | | `403` | `FORBIDDEN`: see `details.reason`. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `429` | `RATE_LIMITED`: the request rate of the API key (`details.scope` `key`) or of the account (`user`) is exhausted for this window, or too many failed authentication attempts came from this IP (no `details.scope` and no RateLimit headers). Retry after `Retry-After` seconds. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy`, `Retry-After` | | `default` | Error (see ErrorEnvelope and /v1/docs/errors). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | Example 200 response: ```json { "data": [ { "id": "0b8e7a52-41c6-4f38-9d0e-7f6a1c2b3d4e", "status": "succeeded", "environment": "live", "inputs": [ { "uploadId": "6f1c1f0e-8a5e-4b8e-9a51-0f0e1d2c3b4a", "sourceUrl": null } ], "store": "app_store", "device": "app_store_1290x2796", "size": { "width": 1290, "height": 2796 }, "orientation": "portrait", "app": { "name": "Trailmate", "description": "Offline hiking maps" }, "caption": "Find every trail, even offline", "palette": [ { "hex": "#0F766E", "name": "Teal" } ], "logo": null, "metadata": { "campaign": "spring", "locale": "en-US" }, "creditsCharged": 1, "creditsRefunded": 0, "result": { "image": { "url": "https://storage.example.com/platform/users/5b0c7d2e-1f3a-4c5b-8d6e-7f8091a2b3c4/generations/job-9a8b7c6d-5e4f-4a3b-9c2d-1e0f9a8b7c6d-a1.png?X-Amz-Expires=900&X-Amz-Signature=abc123", "contentType": "image/png", "width": 1290, "height": 2796 }, "thumbnail": { "url": "https://storage.example.com/platform/users/5b0c7d2e-1f3a-4c5b-8d6e-7f8091a2b3c4/generations/job-9a8b7c6d-5e4f-4a3b-9c2d-1e0f9a8b7c6d-a1.png.thumb.webp?X-Amz-Expires=900&X-Amz-Signature=def456", "contentType": "image/webp" }, "urlsExpireAt": "2026-10-22T10:15:00.000Z", "caption": "Find every trail, even offline", "palette": [ { "hex": "#0F766E", "name": "Teal" } ] }, "error": null, "createdAt": "2026-10-22T10:00:00.000Z", "startedAt": "2026-10-22T10:00:01.000Z", "finishedAt": "2026-10-22T10:00:24.000Z", "deletedAt": null, "source": "cli", "clientName": "cli/storeshot@1.2.0", "requestId": "3f1c2b4a-5d6e-4f70-8a9b-0c1d2e3f4a5b", "project": { "id": "2a4b6c8d-0e1f-4a2b-8c3d-4e5f6a7b8c9d", "name": "Trailmate" } } ], "nextCursor": "string" } ``` ## POST /generations **Create a generation** (operation `createGeneration`). Auth: API key with scope `generations:write`, or access token. Requires `generations:write`. Turns one screenshot into one store-ready marketing image of exactly the requested size. Costs 1 credit with a live key, charged now and refunded if the generation fails or is canceled before it starts; test keys (`ss_test_…`) are free and return a sample image. Answers `202` with the queued generation (poll `GET /generations/{id}`), or `200` when it is already final (after `Prefer: wait`, or a replay of a finished one). Send an `Idempotency-Key` so a retried request never creates (or charges) twice. **When to use.** For one image (one screenshot, one device). For a whole listing (several screens, devices or both stores) use `POST /screenshot-sets` instead. **Credits.** 1 credit with a live key, charged at create and refunded automatically if the generation fails or is canceled before it starts; free with a test key (`ss_test_…`). Without enough credits: `402 INSUFFICIENT_CREDITS`, nothing created. **Parameters:** | Name | In | Type | Required | Description | |---|---|---|---|---| | `wait` | query | `integer`, nullable | no | Seconds (0–60) to hold the response until the generation finishes. Overrides `Prefer: wait`. | | `idempotency-key` | header | `string` | no | 1–255 printable ASCII characters. Retrying with the same key and body returns the original generation (`Idempotent-Replayed: true`) instead of creating and charging another. Keys are kept as long as the generation exists. | | `prefer` | header | `string` | no | `wait=` (at most 60): hold the response until the generation finishes. | **Request body** (`application/json`, required, schema `CreateGenerationRequest`): | Field | Type | Required | Description | |---|---|---|---| | `inputs` | `array of GenerationInput` | yes | Exactly one screenshot in this version. | | `store` | `StoreTarget` | yes | | | `device` | `string` | no | A `GET /devices` id of the same store. Exactly one of `device` and `size` is required. | | `size` | `GenerationSize` | no | | | `orientation` | `Orientation` | no | Default: from `size` (wider than tall is landscape), else portrait (the feature graphic: landscape). Must agree with the size and the device. | | `app` | `object` | yes | | | `app.name` | `string` | yes | | | `app.description` | `string` | no | Default: "" | | `caption` | `string` | no | The marketing caption. Absent or blank: derived from the screenshot. | | `palette` | `array of PaletteColor` | no | 1–8 colours. Absent: extracted from the screenshot. | | `logo` | `GenerationInput` | no | Your app icon or logo (any shape; PNG, JPEG or WebP). | | `metadata` | `GenerationMetadata` | no | | | `projectId` | `string` | no | Since 1.15.0: put the generation in this project of yours (`GET /projects`). The project does not change how it is generated. Format: uuid | | `styleId` | `string` | no | Since 1.18.0: a style of yours (`GET /styles`). It fills `palette` and `logo` when you leave them out and adds its background mood, description and caption tone. It is copied into the generation: editing or deleting the style later changes nothing here. Format: uuid | Example request body: ```json { "inputs": [ { "uploadId": "6f1c1f0e-8a5e-4b8e-9a51-0f0e1d2c3b4a" } ], "store": "app_store", "device": "app_store_1290x2796", "orientation": "portrait", "app": { "name": "Trailmate", "description": "Offline hiking maps" }, "caption": "Find every trail, even offline", "palette": [ { "hex": "#0F766E", "name": "Teal" } ], "metadata": { "campaign": "spring", "locale": "en-US" } } ``` **Responses:** | Status | Description | Schema | Headers | |---|---|---|---| | `200` | Final already (waited, or a replay of a finished generation). | `Generation` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy`, `Preference-Applied`, `Vary`, `Location`, `Idempotent-Replayed` | | `202` | Accepted: queued or running. | `Generation` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy`, `Preference-Applied`, `Vary`, `Location`, `Idempotent-Replayed` | | `400` | `INVALID_INPUT`. A credential sent in the query string is refused before authentication, without RateLimit headers. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `401` | `UNAUTHORIZED`: missing, malformed, unknown, revoked or expired credential. | `ErrorEnvelope` | `X-Request-Id` | | `402` | `INSUFFICIENT_CREDITS`: nothing was created or charged. See `details.availableCredits` and `details.topUpUrl`. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `403` | `FORBIDDEN`: see `details.reason`. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `409` | `IDEMPOTENCY_CONFLICT` (the `Idempotency-Key` was used for a different request; `details.generationId`) or `UPLOAD_NOT_READY` (an input upload is not usable; `details.reason`, `details.path`). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `413` | `PAYLOAD_TOO_LARGE` (the JSON body is over 1 MB; refused before authentication, without RateLimit headers) or `IMAGE_TOO_LARGE` (a URL input is too large; see `details.reason`). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `415` | `UNSUPPORTED_FORMAT`: not a PNG, JPEG or WebP image (see `details.reason`). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `422` | `NOT_A_SCREENSHOT` (an input is not screenshot-sized; `details.path`) or `URL_FETCH_FAILED` (a URL input could not be fetched; see `details.reason`). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `429` | `RATE_LIMITED`: the request rate of the API key (`details.scope` `key`) or of the account (`user`) is exhausted for this window, or too many failed authentication attempts came from this IP (no `details.scope` and no RateLimit headers). Retry after `Retry-After` seconds. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy`, `Retry-After` | | `503` | `SERVICE_UNAVAILABLE`: uploads are busy. Retry after `Retry-After` seconds. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy`, `Retry-After` | | `default` | Error (see ErrorEnvelope and /v1/docs/errors). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | Example 200 response: ```json { "id": "0b8e7a52-41c6-4f38-9d0e-7f6a1c2b3d4e", "status": "succeeded", "environment": "live", "inputs": [ { "uploadId": "6f1c1f0e-8a5e-4b8e-9a51-0f0e1d2c3b4a", "sourceUrl": null } ], "store": "app_store", "device": "app_store_1290x2796", "size": { "width": 1290, "height": 2796 }, "orientation": "portrait", "app": { "name": "Trailmate", "description": "Offline hiking maps" }, "caption": "Find every trail, even offline", "palette": [ { "hex": "#0F766E", "name": "Teal" } ], "logo": null, "metadata": { "campaign": "spring", "locale": "en-US" }, "creditsCharged": 1, "creditsRefunded": 0, "result": { "image": { "url": "https://storage.example.com/platform/users/5b0c7d2e-1f3a-4c5b-8d6e-7f8091a2b3c4/generations/job-9a8b7c6d-5e4f-4a3b-9c2d-1e0f9a8b7c6d-a1.png?X-Amz-Expires=900&X-Amz-Signature=abc123", "contentType": "image/png", "width": 1290, "height": 2796 }, "thumbnail": { "url": "https://storage.example.com/platform/users/5b0c7d2e-1f3a-4c5b-8d6e-7f8091a2b3c4/generations/job-9a8b7c6d-5e4f-4a3b-9c2d-1e0f9a8b7c6d-a1.png.thumb.webp?X-Amz-Expires=900&X-Amz-Signature=def456", "contentType": "image/webp" }, "urlsExpireAt": "2026-10-22T10:15:00.000Z", "caption": "Find every trail, even offline", "palette": [ { "hex": "#0F766E", "name": "Teal" } ] }, "error": null, "createdAt": "2026-10-22T10:00:00.000Z", "startedAt": "2026-10-22T10:00:01.000Z", "finishedAt": "2026-10-22T10:00:24.000Z", "deletedAt": null, "source": "cli", "clientName": "cli/storeshot@1.2.0", "requestId": "3f1c2b4a-5d6e-4f70-8a9b-0c1d2e3f4a5b", "project": { "id": "2a4b6c8d-0e1f-4a2b-8c3d-4e5f6a7b8c9d", "name": "Trailmate" } } ``` Example 202 response: ```json { "id": "0b8e7a52-41c6-4f38-9d0e-7f6a1c2b3d4e", "status": "succeeded", "environment": "live", "inputs": [ { "uploadId": "6f1c1f0e-8a5e-4b8e-9a51-0f0e1d2c3b4a", "sourceUrl": null } ], "store": "app_store", "device": "app_store_1290x2796", "size": { "width": 1290, "height": 2796 }, "orientation": "portrait", "app": { "name": "Trailmate", "description": "Offline hiking maps" }, "caption": "Find every trail, even offline", "palette": [ { "hex": "#0F766E", "name": "Teal" } ], "logo": null, "metadata": { "campaign": "spring", "locale": "en-US" }, "creditsCharged": 1, "creditsRefunded": 0, "result": { "image": { "url": "https://storage.example.com/platform/users/5b0c7d2e-1f3a-4c5b-8d6e-7f8091a2b3c4/generations/job-9a8b7c6d-5e4f-4a3b-9c2d-1e0f9a8b7c6d-a1.png?X-Amz-Expires=900&X-Amz-Signature=abc123", "contentType": "image/png", "width": 1290, "height": 2796 }, "thumbnail": { "url": "https://storage.example.com/platform/users/5b0c7d2e-1f3a-4c5b-8d6e-7f8091a2b3c4/generations/job-9a8b7c6d-5e4f-4a3b-9c2d-1e0f9a8b7c6d-a1.png.thumb.webp?X-Amz-Expires=900&X-Amz-Signature=def456", "contentType": "image/webp" }, "urlsExpireAt": "2026-10-22T10:15:00.000Z", "caption": "Find every trail, even offline", "palette": [ { "hex": "#0F766E", "name": "Teal" } ] }, "error": null, "createdAt": "2026-10-22T10:00:00.000Z", "startedAt": "2026-10-22T10:00:01.000Z", "finishedAt": "2026-10-22T10:00:24.000Z", "deletedAt": null, "source": "cli", "clientName": "cli/storeshot@1.2.0", "requestId": "3f1c2b4a-5d6e-4f70-8a9b-0c1d2e3f4a5b", "project": { "id": "2a4b6c8d-0e1f-4a2b-8c3d-4e5f6a7b8c9d", "name": "Trailmate" } } ``` ## GET /generations/{id} **Get a generation** (operation `getGeneration`). Auth: API key with scope `generations:read` or `generations:write`, or access token. Requires `generations:read` or `generations:write`. Includes deleted generations (`deletedAt` set, `result` null). Every call returns freshly signed result URLs (valid 15 minutes). `Prefer: wait=` (or `?wait`) holds the response until it is final. **When to use.** To poll a generation every 2–5 seconds (or once with `Prefer: wait=60`) until `status` is `succeeded`, `failed` or `canceled`, and for fresh result URLs. **Credits.** Free: no credits. **Parameters:** | Name | In | Type | Required | Description | |---|---|---|---|---| | `id` | path | `string` | yes | Generation id. An unknown or malformed id is 404. | | `wait` | query | `integer`, nullable | no | Seconds (0–60) to hold the response until the generation finishes. Overrides `Prefer: wait`. | | `prefer` | header | `string` | no | `wait=` (at most 60): hold the response until the generation finishes. | **Responses:** | Status | Description | Schema | Headers | |---|---|---|---| | `200` | Final: succeeded, failed or canceled. | `Generation` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy`, `Preference-Applied`, `Vary` | | `202` | Not final yet: queued or running. | `Generation` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy`, `Preference-Applied`, `Vary` | | `400` | `INVALID_INPUT`. A credential sent in the query string is refused before authentication, without RateLimit headers. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `401` | `UNAUTHORIZED`: missing, malformed, unknown, revoked or expired credential. | `ErrorEnvelope` | `X-Request-Id` | | `403` | `FORBIDDEN`: see `details.reason`. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `404` | `NOT_FOUND`: no such resource for this caller. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `429` | `RATE_LIMITED`: the request rate of the API key (`details.scope` `key`) or of the account (`user`) is exhausted for this window, or too many failed authentication attempts came from this IP (no `details.scope` and no RateLimit headers). Retry after `Retry-After` seconds. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy`, `Retry-After` | | `default` | Error (see ErrorEnvelope and /v1/docs/errors). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | Example 200 response: ```json { "id": "0b8e7a52-41c6-4f38-9d0e-7f6a1c2b3d4e", "status": "succeeded", "environment": "live", "inputs": [ { "uploadId": "6f1c1f0e-8a5e-4b8e-9a51-0f0e1d2c3b4a", "sourceUrl": null } ], "store": "app_store", "device": "app_store_1290x2796", "size": { "width": 1290, "height": 2796 }, "orientation": "portrait", "app": { "name": "Trailmate", "description": "Offline hiking maps" }, "caption": "Find every trail, even offline", "palette": [ { "hex": "#0F766E", "name": "Teal" } ], "logo": null, "metadata": { "campaign": "spring", "locale": "en-US" }, "creditsCharged": 1, "creditsRefunded": 0, "result": { "image": { "url": "https://storage.example.com/platform/users/5b0c7d2e-1f3a-4c5b-8d6e-7f8091a2b3c4/generations/job-9a8b7c6d-5e4f-4a3b-9c2d-1e0f9a8b7c6d-a1.png?X-Amz-Expires=900&X-Amz-Signature=abc123", "contentType": "image/png", "width": 1290, "height": 2796 }, "thumbnail": { "url": "https://storage.example.com/platform/users/5b0c7d2e-1f3a-4c5b-8d6e-7f8091a2b3c4/generations/job-9a8b7c6d-5e4f-4a3b-9c2d-1e0f9a8b7c6d-a1.png.thumb.webp?X-Amz-Expires=900&X-Amz-Signature=def456", "contentType": "image/webp" }, "urlsExpireAt": "2026-10-22T10:15:00.000Z", "caption": "Find every trail, even offline", "palette": [ { "hex": "#0F766E", "name": "Teal" } ] }, "error": null, "createdAt": "2026-10-22T10:00:00.000Z", "startedAt": "2026-10-22T10:00:01.000Z", "finishedAt": "2026-10-22T10:00:24.000Z", "deletedAt": null, "source": "cli", "clientName": "cli/storeshot@1.2.0", "requestId": "3f1c2b4a-5d6e-4f70-8a9b-0c1d2e3f4a5b", "project": { "id": "2a4b6c8d-0e1f-4a2b-8c3d-4e5f6a7b8c9d", "name": "Trailmate" } } ``` Example 202 response: ```json { "id": "0b8e7a52-41c6-4f38-9d0e-7f6a1c2b3d4e", "status": "succeeded", "environment": "live", "inputs": [ { "uploadId": "6f1c1f0e-8a5e-4b8e-9a51-0f0e1d2c3b4a", "sourceUrl": null } ], "store": "app_store", "device": "app_store_1290x2796", "size": { "width": 1290, "height": 2796 }, "orientation": "portrait", "app": { "name": "Trailmate", "description": "Offline hiking maps" }, "caption": "Find every trail, even offline", "palette": [ { "hex": "#0F766E", "name": "Teal" } ], "logo": null, "metadata": { "campaign": "spring", "locale": "en-US" }, "creditsCharged": 1, "creditsRefunded": 0, "result": { "image": { "url": "https://storage.example.com/platform/users/5b0c7d2e-1f3a-4c5b-8d6e-7f8091a2b3c4/generations/job-9a8b7c6d-5e4f-4a3b-9c2d-1e0f9a8b7c6d-a1.png?X-Amz-Expires=900&X-Amz-Signature=abc123", "contentType": "image/png", "width": 1290, "height": 2796 }, "thumbnail": { "url": "https://storage.example.com/platform/users/5b0c7d2e-1f3a-4c5b-8d6e-7f8091a2b3c4/generations/job-9a8b7c6d-5e4f-4a3b-9c2d-1e0f9a8b7c6d-a1.png.thumb.webp?X-Amz-Expires=900&X-Amz-Signature=def456", "contentType": "image/webp" }, "urlsExpireAt": "2026-10-22T10:15:00.000Z", "caption": "Find every trail, even offline", "palette": [ { "hex": "#0F766E", "name": "Teal" } ] }, "error": null, "createdAt": "2026-10-22T10:00:00.000Z", "startedAt": "2026-10-22T10:00:01.000Z", "finishedAt": "2026-10-22T10:00:24.000Z", "deletedAt": null, "source": "cli", "clientName": "cli/storeshot@1.2.0", "requestId": "3f1c2b4a-5d6e-4f70-8a9b-0c1d2e3f4a5b", "project": { "id": "2a4b6c8d-0e1f-4a2b-8c3d-4e5f6a7b8c9d", "name": "Trailmate" } } ``` ## DELETE /generations/{id} **Delete a generation** (operation `deleteGeneration`). Auth: API key with scope `generations:write`, or access token. Requires `generations:write`. Deletes the result files of a finished generation now; the generation keeps its metadata with `deletedAt` set and no longer appears in lists. Idempotent. A queued or running generation cannot be deleted (`409 GENERATION_STATE_CONFLICT`): cancel it or wait. **Credits.** Free: no credits and no refund (the image was made). **Parameters:** | Name | In | Type | Required | Description | |---|---|---|---|---| | `id` | path | `string` | yes | Generation id. An unknown or malformed id is 404. | **Responses:** | Status | Description | Schema | Headers | |---|---|---|---| | `204` | Deleted. | — | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `400` | `INVALID_INPUT`. A credential sent in the query string is refused before authentication, without RateLimit headers. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `401` | `UNAUTHORIZED`: missing, malformed, unknown, revoked or expired credential. | `ErrorEnvelope` | `X-Request-Id` | | `403` | `FORBIDDEN`: see `details.reason`. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `404` | `NOT_FOUND`: no such resource for this caller. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `409` | `GENERATION_STATE_CONFLICT`: not possible in the current status (`details.status`, `details.action`). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `429` | `RATE_LIMITED`: the request rate of the API key (`details.scope` `key`) or of the account (`user`) is exhausted for this window, or too many failed authentication attempts came from this IP (no `details.scope` and no RateLimit headers). Retry after `Retry-After` seconds. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy`, `Retry-After` | | `default` | Error (see ErrorEnvelope and /v1/docs/errors). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | ## POST /generations/{id}/cancel **Cancel a generation** (operation `cancelGeneration`). Auth: API key with scope `generations:write`, or access token. Requires `generations:write`. Only a `queued` generation that has not started can be canceled; its credit is refunded. Canceling a canceled generation returns it again. Once it is running it cannot be canceled (`409 GENERATION_STATE_CONFLICT`). **Credits.** Free; refunds the credit of a generation that had not started. **Parameters:** | Name | In | Type | Required | Description | |---|---|---|---|---| | `id` | path | `string` | yes | Generation id. An unknown or malformed id is 404. | **Responses:** | Status | Description | Schema | Headers | |---|---|---|---| | `200` | Canceled. | `Generation` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `400` | `INVALID_INPUT`. A credential sent in the query string is refused before authentication, without RateLimit headers. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `401` | `UNAUTHORIZED`: missing, malformed, unknown, revoked or expired credential. | `ErrorEnvelope` | `X-Request-Id` | | `403` | `FORBIDDEN`: see `details.reason`. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `404` | `NOT_FOUND`: no such resource for this caller. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `409` | `GENERATION_STATE_CONFLICT`: not possible in the current status (`details.status`, `details.action`). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `429` | `RATE_LIMITED`: the request rate of the API key (`details.scope` `key`) or of the account (`user`) is exhausted for this window, or too many failed authentication attempts came from this IP (no `details.scope` and no RateLimit headers). Retry after `Retry-After` seconds. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy`, `Retry-After` | | `default` | Error (see ErrorEnvelope and /v1/docs/errors). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | Example 200 response: ```json { "id": "0b8e7a52-41c6-4f38-9d0e-7f6a1c2b3d4e", "status": "succeeded", "environment": "live", "inputs": [ { "uploadId": "6f1c1f0e-8a5e-4b8e-9a51-0f0e1d2c3b4a", "sourceUrl": null } ], "store": "app_store", "device": "app_store_1290x2796", "size": { "width": 1290, "height": 2796 }, "orientation": "portrait", "app": { "name": "Trailmate", "description": "Offline hiking maps" }, "caption": "Find every trail, even offline", "palette": [ { "hex": "#0F766E", "name": "Teal" } ], "logo": null, "metadata": { "campaign": "spring", "locale": "en-US" }, "creditsCharged": 1, "creditsRefunded": 0, "result": { "image": { "url": "https://storage.example.com/platform/users/5b0c7d2e-1f3a-4c5b-8d6e-7f8091a2b3c4/generations/job-9a8b7c6d-5e4f-4a3b-9c2d-1e0f9a8b7c6d-a1.png?X-Amz-Expires=900&X-Amz-Signature=abc123", "contentType": "image/png", "width": 1290, "height": 2796 }, "thumbnail": { "url": "https://storage.example.com/platform/users/5b0c7d2e-1f3a-4c5b-8d6e-7f8091a2b3c4/generations/job-9a8b7c6d-5e4f-4a3b-9c2d-1e0f9a8b7c6d-a1.png.thumb.webp?X-Amz-Expires=900&X-Amz-Signature=def456", "contentType": "image/webp" }, "urlsExpireAt": "2026-10-22T10:15:00.000Z", "caption": "Find every trail, even offline", "palette": [ { "hex": "#0F766E", "name": "Teal" } ] }, "error": null, "createdAt": "2026-10-22T10:00:00.000Z", "startedAt": "2026-10-22T10:00:01.000Z", "finishedAt": "2026-10-22T10:00:24.000Z", "deletedAt": null, "source": "cli", "clientName": "cli/storeshot@1.2.0", "requestId": "3f1c2b4a-5d6e-4f70-8a9b-0c1d2e3f4a5b", "project": { "id": "2a4b6c8d-0e1f-4a2b-8c3d-4e5f6a7b8c9d", "name": "Trailmate" } } ``` --- # Screenshot sets API reference > HTML version: https://storeshot.art/docs/api/screenshot-sets/ A whole store listing (every screen on every device) in one call. Contract 1.18.0; base URL `https://api.storeshot.art/v1`. Schemas: https://storeshot.art/docs/api/schemas.md ## GET /screenshot-sets **List screenshot sets** (operation `listScreenshotSets`). Auth: API key with scope `generations:read` or `generations:write`, or access token. Requires `generations:read` or `generations:write`. Screenshot sets of the account that are not deleted (every API key and environment), newest first. **Credits.** Free: no credits. **Parameters:** | Name | In | Type | Required | Description | |---|---|---|---|---| | `limit` | query | `integer` | no | 1–100, default 20. Default: 20 | | `cursor` | query | `string` | no | `nextCursor` of the previous page. | | `metadata` | query | `string` | no | `:`: only sets whose metadata has exactly this value. | | `environment` | query | `ApiKeyEnvironment` | no | | | `status` | query | `ScreenshotSetStatus` | no | | | `projectId` | query | `string` | no | Since 1.15.0: only sets of this project, or `none` for sets in no project. | **Responses:** | Status | Description | Schema | Headers | |---|---|---|---| | `200` | A page of screenshot sets. | `ScreenshotSetList` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `400` | `INVALID_INPUT`. A credential sent in the query string is refused before authentication, without RateLimit headers. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `401` | `UNAUTHORIZED`: missing, malformed, unknown, revoked or expired credential. | `ErrorEnvelope` | `X-Request-Id` | | `403` | `FORBIDDEN`: see `details.reason`. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `429` | `RATE_LIMITED`: the request rate of the API key (`details.scope` `key`) or of the account (`user`) is exhausted for this window, or too many failed authentication attempts came from this IP (no `details.scope` and no RateLimit headers). Retry after `Retry-After` seconds. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy`, `Retry-After` | | `default` | Error (see ErrorEnvelope and /v1/docs/errors). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | Example 200 response: ```json { "data": [ { "id": "7d0e1a52-3c4b-4f6e-9a8d-2b1c0f9e8a7d", "status": "queued", "environment": "live", "app": { "name": "string", "description": "string" }, "orientation": "portrait", "palette": [], "paletteSource": "given", "logo": { "uploadId": "7d0e1a52-3c4b-4f6e-9a8d-2b1c0f9e8a7d" }, "metadata": {}, "screens": [ {} ], "stores": [ {} ], "images": 1, "counts": { "queued": 0, "running": 0, "succeeded": 0, "failed": 0, "canceled": 0 }, "creditsCharged": 0, "creditsRefunded": 0, "children": [ {} ], "archiveUrl": "/v1/screenshot-sets/5c1e…/archive", "createdAt": "2026-10-21T12:00:00.000Z", "updatedAt": "2026-10-21T12:00:00.000Z", "finishedAt": "2026-10-21T12:00:00.000Z", "deletedAt": "2026-10-21T12:00:00.000Z", "preview": { "generationId": "9a8b7c6d-5e4f-4a3b-9c2d-1e0f9a8b7c6d", "store": "app_store", "device": "app_store_1290x2796", "thumbnail": { "url": "https://storage.example.com/platform/users/5b0c7d2e-1f3a-4c5b-8d6e-7f8091a2b3c4/generations/job-9a8b7c6d-5e4f-4a3b-9c2d-1e0f9a8b7c6d-a1.png.thumb.webp?X-Amz-Expires=900&X-Amz-Signature=def456", "contentType": "image/webp" }, "urlsExpireAt": "2026-10-24T12:15:00.000Z" } } ], "nextCursor": "string" } ``` ## POST /screenshot-sets **Create a screenshot set** (operation `createScreenshotSet`). Auth: API key with scope `generations:write`, or access token. Requires `generations:write`. Turns 1–10 screenshots into a full store listing: every screen on every requested (or default) device of one or both stores, plus an optional Google Play feature graphic, with one caption per screen and one palette and logo for all. Each image is one generation (`GET /generations?screenshotSetId=`) and costs 1 credit with a live key: the whole set is created and charged at once, or nothing is (`402` with `details.requiredCredits`). Failed images are refunded; retry them with `POST /screenshot-sets/{id}/retry`. Test keys are free. `?dryRun=true` validates the same way and answers `200` with a quote (images and credits) without creating or charging anything. Send an `Idempotency-Key` so a retried request never creates (or charges) twice. **When to use.** For a store listing: every screen on every device of one or both stores in one call. Quote it first with `?dryRun=true`, then create it with an `Idempotency-Key` and poll `GET /screenshot-sets/{id}`. **Credits.** 1 credit per image with a live key (screens × devices, +1 for a feature graphic), all charged at create or none (`402`); `?dryRun=true` quotes for free. Failed or canceled images are refunded; free with a test key (`ss_test_…`). **Parameters:** | Name | In | Type | Required | Description | |---|---|---|---|---| | `dryRun` | query | `string` | no | `true`: validate and quote only (`200 ScreenshotSetQuote`); nothing is created or charged. One of: "true", "false" | | `idempotency-key` | header | `string` | no | 1–255 printable ASCII characters. Retrying with the same key and body returns the original generation (`Idempotent-Replayed: true`) instead of creating and charging another. Keys are kept as long as the generation exists. | **Request body** (`application/json`, required, schema `CreateScreenshotSetRequest`): | Field | Type | Required | Description | |---|---|---|---| | `app` | `object` | yes | | | `app.name` | `string` | yes | | | `app.description` | `string` | no | Default: "" | | `screens` | `array of ScreenshotSetScreen` | yes | 1–10 screenshots, in listing order. | | `stores` | `array of ScreenshotSetTarget` | yes | | | `orientation` | `Orientation` | no | Of the screenshots (the feature graphic is always landscape). Default: "portrait" | | `palette` | `array of PaletteColor` | no | 1–8 colours. Absent: extracted once from screen 1 and used for every image. | | `logo` | `SetUploadRef` | no | Your app icon or logo, used on every image. | | `metadata` | `GenerationMetadata` | no | | | `projectId` | `string` | no | Since 1.15.0: put the set (and so every image of it, retries included) in this project of yours. Format: uuid | | `styleId` | `string` | no | Since 1.18.0: a style of yours (`GET /styles`). It fills `palette` and `logo` when you leave them out and adds its background mood, description and caption tone to every image. It is copied into the set and its retries: editing or deleting the style later changes nothing here. With `?dryRun=true` the style is checked; styles are free, so the quote is the same. Format: uuid | Example request body: ```json { "app": { "name": "Trailmate", "description": "Offline hiking maps" }, "screens": [ { "input": { "uploadId": "6f1c1f0e-8a5e-4b8e-9a51-0f0e1d2c3b4a" }, "caption": "Find every trail" }, { "input": { "uploadId": "7a2d3c4b-5e6f-4a7b-8c9d-0e1f2a3b4c5d" }, "caption": "Works offline" } ], "stores": [ { "store": "app_store" }, { "store": "google_play", "devices": [ "google_play_1080x1920", "google_play_1600x2560" ], "featureGraphic": true } ], "orientation": "portrait", "palette": [ { "hex": "#0F766E", "name": "Teal" } ], "metadata": { "release": "2.4" } } ``` **Responses:** | Status | Description | Schema | Headers | |---|---|---|---| | `200` | `?dryRun=true`: the quote (nothing created or charged). Or a replay of a finished set. | application/json | `X-Request-Id`, `RateLimit`, `RateLimit-Policy`, `Location`, `Idempotent-Replayed` | | `202` | Accepted: every image is queued. | `ScreenshotSet` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy`, `Location`, `Idempotent-Replayed` | | `400` | `INVALID_INPUT`. A credential sent in the query string is refused before authentication, without RateLimit headers. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `401` | `UNAUTHORIZED`: missing, malformed, unknown, revoked or expired credential. | `ErrorEnvelope` | `X-Request-Id` | | `402` | `INSUFFICIENT_CREDITS`: nothing was created or charged. See `details.availableCredits` and `details.topUpUrl`. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `403` | `FORBIDDEN`: see `details.reason`. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `409` | `IDEMPOTENCY_CONFLICT` (the `Idempotency-Key` was used for a different request; `details.screenshotSetId`) or `UPLOAD_NOT_READY` (an input upload is not usable; `details.reason`, `details.path`). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `413` | `PAYLOAD_TOO_LARGE`. | `ErrorEnvelope` | `X-Request-Id` | | `422` | `NOT_A_SCREENSHOT`: an input is not screenshot-sized (`details.path`). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `429` | `RATE_LIMITED`: the request rate of the API key (`details.scope` `key`) or of the account (`user`) is exhausted for this window, or too many failed authentication attempts came from this IP (no `details.scope` and no RateLimit headers). Retry after `Retry-After` seconds. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy`, `Retry-After` | | `503` | `SERVICE_UNAVAILABLE`: uploads are busy. Retry after `Retry-After` seconds. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy`, `Retry-After` | | `default` | Error (see ErrorEnvelope and /v1/docs/errors). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | Example 200 response: ```json { "dryRun": true, "environment": "live", "images": 1, "credits": 0, "availableCredits": 0, "sufficient": true, "screens": 1, "breakdown": [ { "store": "app_store", "device": "app_store_1290x2796", "size": { "width": 1, "height": 1 }, "orientation": "portrait", "images": 1, "defaulted": true } ] } ``` Example 202 response: ```json { "id": "7d0e1a52-3c4b-4f6e-9a8d-2b1c0f9e8a7d", "status": "queued", "environment": "live", "app": { "name": "string", "description": "string" }, "orientation": "portrait", "palette": [ { "hex": "#0F766E", "name": "Teal" } ], "paletteSource": "given", "logo": { "uploadId": "7d0e1a52-3c4b-4f6e-9a8d-2b1c0f9e8a7d" }, "metadata": {}, "screens": [ { "position": 1, "input": { "uploadId": "7d0e1a52-3c4b-4f6e-9a8d-2b1c0f9e8a7d" }, "caption": "string" } ], "stores": [ { "store": "app_store", "devices": [ "string" ], "featureGraphic": { "screen": 1, "caption": "string" } } ], "images": 1, "counts": { "queued": 0, "running": 0, "succeeded": 0, "failed": 0, "canceled": 0 }, "creditsCharged": 0, "creditsRefunded": 0, "children": [ { "position": 1, "store": "app_store", "device": "string", "size": { "width": 1, "height": 1 }, "featureGraphic": true, "generationId": "7d0e1a52-3c4b-4f6e-9a8d-2b1c0f9e8a7d", "attempt": 1, "status": "queued", "error": { "code": "GENERATION_FAILED", "message": "string" } } ], "archiveUrl": "/v1/screenshot-sets/5c1e…/archive", "createdAt": "2026-10-21T12:00:00.000Z", "updatedAt": "2026-10-21T12:00:00.000Z", "finishedAt": "2026-10-21T12:00:00.000Z", "deletedAt": "2026-10-21T12:00:00.000Z", "preview": { "generationId": "9a8b7c6d-5e4f-4a3b-9c2d-1e0f9a8b7c6d", "store": "app_store", "device": "app_store_1290x2796", "thumbnail": { "url": "https://storage.example.com/platform/users/5b0c7d2e-1f3a-4c5b-8d6e-7f8091a2b3c4/generations/job-9a8b7c6d-5e4f-4a3b-9c2d-1e0f9a8b7c6d-a1.png.thumb.webp?X-Amz-Expires=900&X-Amz-Signature=def456", "contentType": "image/webp" }, "urlsExpireAt": "2026-10-24T12:15:00.000Z" }, "source": "mobile", "project": { "id": "7d0e1a52-3c4b-4f6e-9a8d-2b1c0f9e8a7d", "name": "Trailmate" }, "style": { "id": "7d0e1a52-3c4b-4f6e-9a8d-2b1c0f9e8a7d", "name": "Trailmate brand", "applied": [] } } ``` ## GET /screenshot-sets/{id} **Get a screenshot set** (operation `getScreenshotSet`). Auth: API key with scope `generations:read` or `generations:write`, or access token. Requires `generations:read` or `generations:write`. The status is computed from the current image of every slot. Includes deleted sets (`deletedAt` set). Poll every few seconds until the status is final (`succeeded`, `partially_failed`, `failed` or `canceled`). **When to use.** To poll a set every 3–5 seconds until `status` is `succeeded`, `partially_failed`, `failed` or `canceled`. **Credits.** Free: no credits. **Parameters:** | Name | In | Type | Required | Description | |---|---|---|---|---| | `id` | path | `string` | yes | Screenshot set id. An unknown or malformed id is 404. | **Responses:** | Status | Description | Schema | Headers | |---|---|---|---| | `200` | The screenshot set. | `ScreenshotSet` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `400` | `INVALID_INPUT`. A credential sent in the query string is refused before authentication, without RateLimit headers. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `401` | `UNAUTHORIZED`: missing, malformed, unknown, revoked or expired credential. | `ErrorEnvelope` | `X-Request-Id` | | `403` | `FORBIDDEN`: see `details.reason`. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `404` | `NOT_FOUND`: no such resource for this caller. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `429` | `RATE_LIMITED`: the request rate of the API key (`details.scope` `key`) or of the account (`user`) is exhausted for this window, or too many failed authentication attempts came from this IP (no `details.scope` and no RateLimit headers). Retry after `Retry-After` seconds. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy`, `Retry-After` | | `default` | Error (see ErrorEnvelope and /v1/docs/errors). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | Example 200 response: ```json { "id": "7d0e1a52-3c4b-4f6e-9a8d-2b1c0f9e8a7d", "status": "queued", "environment": "live", "app": { "name": "string", "description": "string" }, "orientation": "portrait", "palette": [ { "hex": "#0F766E", "name": "Teal" } ], "paletteSource": "given", "logo": { "uploadId": "7d0e1a52-3c4b-4f6e-9a8d-2b1c0f9e8a7d" }, "metadata": {}, "screens": [ { "position": 1, "input": { "uploadId": "7d0e1a52-3c4b-4f6e-9a8d-2b1c0f9e8a7d" }, "caption": "string" } ], "stores": [ { "store": "app_store", "devices": [ "string" ], "featureGraphic": { "screen": 1, "caption": "string" } } ], "images": 1, "counts": { "queued": 0, "running": 0, "succeeded": 0, "failed": 0, "canceled": 0 }, "creditsCharged": 0, "creditsRefunded": 0, "children": [ { "position": 1, "store": "app_store", "device": "string", "size": { "width": 1, "height": 1 }, "featureGraphic": true, "generationId": "7d0e1a52-3c4b-4f6e-9a8d-2b1c0f9e8a7d", "attempt": 1, "status": "queued", "error": { "code": "GENERATION_FAILED", "message": "string" } } ], "archiveUrl": "/v1/screenshot-sets/5c1e…/archive", "createdAt": "2026-10-21T12:00:00.000Z", "updatedAt": "2026-10-21T12:00:00.000Z", "finishedAt": "2026-10-21T12:00:00.000Z", "deletedAt": "2026-10-21T12:00:00.000Z", "preview": { "generationId": "9a8b7c6d-5e4f-4a3b-9c2d-1e0f9a8b7c6d", "store": "app_store", "device": "app_store_1290x2796", "thumbnail": { "url": "https://storage.example.com/platform/users/5b0c7d2e-1f3a-4c5b-8d6e-7f8091a2b3c4/generations/job-9a8b7c6d-5e4f-4a3b-9c2d-1e0f9a8b7c6d-a1.png.thumb.webp?X-Amz-Expires=900&X-Amz-Signature=def456", "contentType": "image/webp" }, "urlsExpireAt": "2026-10-24T12:15:00.000Z" }, "source": "mobile", "project": { "id": "7d0e1a52-3c4b-4f6e-9a8d-2b1c0f9e8a7d", "name": "Trailmate" }, "style": { "id": "7d0e1a52-3c4b-4f6e-9a8d-2b1c0f9e8a7d", "name": "Trailmate brand", "applied": [] } } ``` ## DELETE /screenshot-sets/{id} **Delete a screenshot set** (operation `deleteScreenshotSet`). Auth: API key with scope `generations:write`, or access token. Requires `generations:write`. Deletes the result files of every image (retries included) and marks the set deleted; it keeps its record with `deletedAt` set and no longer appears in lists. Idempotent. Not possible while images are queued or running (`409 SCREENSHOT_SET_STATE_CONFLICT`): cancel the set or wait. **Credits.** Free: no credits and no refund (the images were made). **Parameters:** | Name | In | Type | Required | Description | |---|---|---|---|---| | `id` | path | `string` | yes | Screenshot set id. An unknown or malformed id is 404. | **Responses:** | Status | Description | Schema | Headers | |---|---|---|---| | `204` | Deleted. | — | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `400` | `INVALID_INPUT`. A credential sent in the query string is refused before authentication, without RateLimit headers. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `401` | `UNAUTHORIZED`: missing, malformed, unknown, revoked or expired credential. | `ErrorEnvelope` | `X-Request-Id` | | `403` | `FORBIDDEN`: see `details.reason`. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `404` | `NOT_FOUND`: no such resource for this caller. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `409` | `SCREENSHOT_SET_STATE_CONFLICT`: not possible in the current status (`details.status`, `details.action`). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `429` | `RATE_LIMITED`: the request rate of the API key (`details.scope` `key`) or of the account (`user`) is exhausted for this window, or too many failed authentication attempts came from this IP (no `details.scope` and no RateLimit headers). Retry after `Retry-After` seconds. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy`, `Retry-After` | | `default` | Error (see ErrorEnvelope and /v1/docs/errors). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | ## POST /screenshot-sets/{id}/retry **Retry failed images of a screenshot set** (operation `retryScreenshotSet`). Auth: API key with scope `generations:write`, or access token. Requires `generations:write` and a credential of the set’s environment. Creates a new image (attempt + 1, at most 5 per slot) for every failed or canceled image, or for the given `generationIds`, at 1 credit each with a live key, all at once or none (`402`). Images that are superseded, still running or succeeded are skipped (`retry.skipped`); if nothing is left to retry the answer is `409 SCREENSHOT_SET_STATE_CONFLICT` and nothing is charged, so repeating a retry never charges twice. **When to use.** After a set ends `partially_failed` or `failed`, to make new images for the failed slots only. **Credits.** 1 credit per new image with a live key, all or none (`402`); nothing when there is nothing to retry (`409`); free with a test key (`ss_test_…`). **Parameters:** | Name | In | Type | Required | Description | |---|---|---|---|---| | `id` | path | `string` | yes | Screenshot set id. An unknown or malformed id is 404. | **Request body** (`application/json`, optional, schema `RetryScreenshotSetRequest`): | Field | Type | Required | Description | |---|---|---|---| | `generationIds` | `array of string` | no | Images of this set to retry. Absent: every failed or canceled image. | Example request body: ```json { "generationIds": [ "7d0e1a52-3c4b-4f6e-9a8d-2b1c0f9e8a7d" ] } ``` **Responses:** | Status | Description | Schema | Headers | |---|---|---|---| | `202` | Accepted: the new images are queued. | `RetriedScreenshotSet` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `400` | `INVALID_INPUT`. A credential sent in the query string is refused before authentication, without RateLimit headers. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `401` | `UNAUTHORIZED`: missing, malformed, unknown, revoked or expired credential. | `ErrorEnvelope` | `X-Request-Id` | | `402` | `INSUFFICIENT_CREDITS`: nothing was created or charged. See `details.availableCredits` and `details.topUpUrl`. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `403` | `FORBIDDEN`: see `details.reason`. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `404` | `NOT_FOUND`: no such resource for this caller. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `409` | `SCREENSHOT_SET_STATE_CONFLICT` (nothing to retry; `details.status`) or `UPLOAD_NOT_READY` (an input upload expired or was deleted; `details.path`). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `429` | `RATE_LIMITED`: the request rate of the API key (`details.scope` `key`) or of the account (`user`) is exhausted for this window, or too many failed authentication attempts came from this IP (no `details.scope` and no RateLimit headers). Retry after `Retry-After` seconds. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy`, `Retry-After` | | `default` | Error (see ErrorEnvelope and /v1/docs/errors). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | Example 202 response: ```json { "id": "7d0e1a52-3c4b-4f6e-9a8d-2b1c0f9e8a7d", "status": "queued", "environment": "live", "app": { "name": "string", "description": "string" }, "orientation": "portrait", "palette": [ {} ], "paletteSource": "given", "logo": { "uploadId": "7d0e1a52-3c4b-4f6e-9a8d-2b1c0f9e8a7d" }, "metadata": {}, "screens": [ { "position": 1, "input": {}, "caption": "string" } ], "stores": [ { "devices": [], "featureGraphic": {} } ], "images": 1, "counts": { "queued": 0, "running": 0, "succeeded": 0, "failed": 0, "canceled": 0 }, "creditsCharged": 0, "creditsRefunded": 0, "children": [ { "position": 1, "device": "string", "size": {}, "featureGraphic": true, "generationId": "7d0e1a52-3c4b-4f6e-9a8d-2b1c0f9e8a7d", "attempt": 1, "error": {} } ], "archiveUrl": "/v1/screenshot-sets/5c1e…/archive", "createdAt": "2026-10-21T12:00:00.000Z", "updatedAt": "2026-10-21T12:00:00.000Z", "finishedAt": "2026-10-21T12:00:00.000Z", "deletedAt": "2026-10-21T12:00:00.000Z", "preview": { "generationId": "9a8b7c6d-5e4f-4a3b-9c2d-1e0f9a8b7c6d", "store": "app_store", "device": "app_store_1290x2796", "thumbnail": { "url": "https://storage.example.com/platform/users/5b0c7d2e-1f3a-4c5b-8d6e-7f8091a2b3c4/generations/job-9a8b7c6d-5e4f-4a3b-9c2d-1e0f9a8b7c6d-a1.png.thumb.webp?X-Amz-Expires=900&X-Amz-Signature=def456", "contentType": "image/webp" }, "urlsExpireAt": "2026-10-24T12:15:00.000Z" }, "source": "mobile", "project": {}, "style": {}, "retry": { "created": [ {} ], "skipped": [ {} ] } } ``` ## POST /screenshot-sets/{id}/cancel **Cancel a screenshot set** (operation `cancelScreenshotSet`). Auth: API key with scope `generations:write`, or access token. Requires `generations:write`. Cancels every image that has not started yet (each is refunded); running images finish normally. Canceling a canceled set returns it again; a set that already finished otherwise is `409 SCREENSHOT_SET_STATE_CONFLICT`. **Credits.** Free; refunds every image that had not started. **Parameters:** | Name | In | Type | Required | Description | |---|---|---|---|---| | `id` | path | `string` | yes | Screenshot set id. An unknown or malformed id is 404. | **Responses:** | Status | Description | Schema | Headers | |---|---|---|---| | `200` | The set after canceling its queued images. | `ScreenshotSet` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `400` | `INVALID_INPUT`. A credential sent in the query string is refused before authentication, without RateLimit headers. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `401` | `UNAUTHORIZED`: missing, malformed, unknown, revoked or expired credential. | `ErrorEnvelope` | `X-Request-Id` | | `403` | `FORBIDDEN`: see `details.reason`. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `404` | `NOT_FOUND`: no such resource for this caller. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `409` | `SCREENSHOT_SET_STATE_CONFLICT`: not possible in the current status (`details.status`, `details.action`). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `429` | `RATE_LIMITED`: the request rate of the API key (`details.scope` `key`) or of the account (`user`) is exhausted for this window, or too many failed authentication attempts came from this IP (no `details.scope` and no RateLimit headers). Retry after `Retry-After` seconds. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy`, `Retry-After` | | `default` | Error (see ErrorEnvelope and /v1/docs/errors). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | Example 200 response: ```json { "id": "7d0e1a52-3c4b-4f6e-9a8d-2b1c0f9e8a7d", "status": "queued", "environment": "live", "app": { "name": "string", "description": "string" }, "orientation": "portrait", "palette": [ { "hex": "#0F766E", "name": "Teal" } ], "paletteSource": "given", "logo": { "uploadId": "7d0e1a52-3c4b-4f6e-9a8d-2b1c0f9e8a7d" }, "metadata": {}, "screens": [ { "position": 1, "input": { "uploadId": "7d0e1a52-3c4b-4f6e-9a8d-2b1c0f9e8a7d" }, "caption": "string" } ], "stores": [ { "store": "app_store", "devices": [ "string" ], "featureGraphic": { "screen": 1, "caption": "string" } } ], "images": 1, "counts": { "queued": 0, "running": 0, "succeeded": 0, "failed": 0, "canceled": 0 }, "creditsCharged": 0, "creditsRefunded": 0, "children": [ { "position": 1, "store": "app_store", "device": "string", "size": { "width": 1, "height": 1 }, "featureGraphic": true, "generationId": "7d0e1a52-3c4b-4f6e-9a8d-2b1c0f9e8a7d", "attempt": 1, "status": "queued", "error": { "code": "GENERATION_FAILED", "message": "string" } } ], "archiveUrl": "/v1/screenshot-sets/5c1e…/archive", "createdAt": "2026-10-21T12:00:00.000Z", "updatedAt": "2026-10-21T12:00:00.000Z", "finishedAt": "2026-10-21T12:00:00.000Z", "deletedAt": "2026-10-21T12:00:00.000Z", "preview": { "generationId": "9a8b7c6d-5e4f-4a3b-9c2d-1e0f9a8b7c6d", "store": "app_store", "device": "app_store_1290x2796", "thumbnail": { "url": "https://storage.example.com/platform/users/5b0c7d2e-1f3a-4c5b-8d6e-7f8091a2b3c4/generations/job-9a8b7c6d-5e4f-4a3b-9c2d-1e0f9a8b7c6d-a1.png.thumb.webp?X-Amz-Expires=900&X-Amz-Signature=def456", "contentType": "image/webp" }, "urlsExpireAt": "2026-10-24T12:15:00.000Z" }, "source": "mobile", "project": { "id": "7d0e1a52-3c4b-4f6e-9a8d-2b1c0f9e8a7d", "name": "Trailmate" }, "style": { "id": "7d0e1a52-3c4b-4f6e-9a8d-2b1c0f9e8a7d", "name": "Trailmate brand", "applied": [] } } ``` ## GET /screenshot-sets/{id}/archive **Download a screenshot set as a ZIP** (operation `getScreenshotSetArchive`). Auth: API key with scope `generations:read` or `generations:write`, or access token. Requires `generations:read` or `generations:write`. Streams a ZIP of every succeeded image (the current attempt of each slot) and a `manifest.json` listing the files and the missing images. `layout=store` (default): `//.`. `layout=fastlane`: `fastlane/screenshots/en-US/_.png` for `deliver` and `fastlane/metadata/android/en-US/images/{phoneScreenshots,sevenInchScreenshots,tenInchScreenshots}/.png` + `featureGraphic.jpg` for `supply` (note: `supply` replaces the images already in your listing). `409 SCREENSHOT_SET_STATE_CONFLICT` when no image has succeeded yet; `503` with `Retry-After` when the server is busy building other archives. **When to use.** Once the set is final, to save every image: `?layout=fastlane` for the fastlane folders, or the default `//.png` layout. **Credits.** Free: no credits. **Parameters:** | Name | In | Type | Required | Description | |---|---|---|---|---| | `id` | path | `string` | yes | Screenshot set id. An unknown or malformed id is 404. | | `layout` | query | `string` | no | `store`: `//.`. `fastlane`: `fastlane/screenshots/en-US/` (deliver) and `fastlane/metadata/android/en-US/images/` (supply). One of: "store", "fastlane" Default: "store" | **Responses:** | Status | Description | Schema | Headers | |---|---|---|---| | `200` | The ZIP (streamed). | application/zip | `X-Request-Id`, `RateLimit`, `RateLimit-Policy`, `Content-Disposition` | | `400` | `INVALID_INPUT`. A credential sent in the query string is refused before authentication, without RateLimit headers. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `401` | `UNAUTHORIZED`: missing, malformed, unknown, revoked or expired credential. | `ErrorEnvelope` | `X-Request-Id` | | `403` | `FORBIDDEN`: see `details.reason`. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `404` | `NOT_FOUND`: no such resource for this caller. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `409` | `SCREENSHOT_SET_STATE_CONFLICT`: not possible in the current status (`details.status`, `details.action`). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `429` | `RATE_LIMITED`: the request rate of the API key (`details.scope` `key`) or of the account (`user`) is exhausted for this window, or too many failed authentication attempts came from this IP (no `details.scope` and no RateLimit headers). Retry after `Retry-After` seconds. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy`, `Retry-After` | | `503` | `SERVICE_UNAVAILABLE`: too many archives are being built. Retry after `Retry-After` seconds. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy`, `Retry-After` | | `default` | Error (see ErrorEnvelope and /v1/docs/errors). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | --- # Projects API reference > HTML version: https://storeshot.art/docs/api/projects/ Group generations and screenshot sets per app. Contract 1.18.0; base URL `https://api.storeshot.art/v1`. Schemas: https://storeshot.art/docs/api/schemas.md ## GET /projects **List projects** (operation `listProjects`). Auth: API key with scope `generations:read` or `generations:write`, or access token. Requires `generations:read` or `generations:write`. Projects of the account (every API key and environment), most recently updated first. **Credits.** Free: no credits. **Parameters:** | Name | In | Type | Required | Description | |---|---|---|---|---| | `limit` | query | `integer` | no | 1–100, default 20. Default: 20 | | `cursor` | query | `string` | no | `nextCursor` of the previous page. | **Responses:** | Status | Description | Schema | Headers | |---|---|---|---| | `200` | A page of projects. | `ProjectList` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `400` | `INVALID_INPUT`. A credential sent in the query string is refused before authentication, without RateLimit headers. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `401` | `UNAUTHORIZED`: missing, malformed, unknown, revoked or expired credential. | `ErrorEnvelope` | `X-Request-Id` | | `403` | `FORBIDDEN`: see `details.reason`. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `429` | `RATE_LIMITED`: the request rate of the API key (`details.scope` `key`) or of the account (`user`) is exhausted for this window, or too many failed authentication attempts came from this IP (no `details.scope` and no RateLimit headers). Retry after `Retry-After` seconds. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy`, `Retry-After` | | `default` | Error (see ErrorEnvelope and /v1/docs/errors). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | Example 200 response: ```json { "data": [ { "id": "2a4b6c8d-0e1f-4a2b-8c3d-4e5f6a7b8c9d", "name": "Trailmate", "description": "Offline hiking maps", "store": "app_store", "palette": [ { "hex": "#0F766E", "name": "Teal" } ], "logo": null, "counts": { "generations": 3, "screenshotSets": 1 }, "preview": null, "createdAt": "2026-10-29T10:00:00.000Z", "updatedAt": "2026-10-29T10:05:00.000Z" } ], "nextCursor": "string" } ``` ## POST /projects **Create a project** (operation `createProject`). Auth: API key with scope `generations:write`, or access token. Requires `generations:write`. A project groups the generations and screenshot sets of one app (name, store, palette, logo). Add items with `POST /projects/{id}/items` or `projectId` when creating them. At most 200 projects per account (`403 FORBIDDEN`, `details.reason` `project_limit_reached`). **Credits.** Free: no credits. **Request body** (`application/json`, required, schema `CreateProjectRequest`): | Field | Type | Required | Description | |---|---|---|---| | `name` | `string` | yes | | | `description` | `string` | no | | | `store` | `StoreTarget` | no | Default `google_play`. | | `palette` | `array of PaletteColor` | no | | | `logo` | `object` | no | | | `logo.uploadId` | `string` | yes | A `ready` upload of this account (PNG, JPEG or WebP; either environment). It is copied: deleting the upload later keeps the logo. Format: uuid | Example request body: ```json { "name": "Trailmate", "description": "Offline hiking maps", "store": "app_store", "palette": [ { "hex": "#0F766E", "name": "Teal" } ] } ``` **Responses:** | Status | Description | Schema | Headers | |---|---|---|---| | `201` | Created. | `Project` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `400` | `INVALID_INPUT`. A credential sent in the query string is refused before authentication, without RateLimit headers. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `401` | `UNAUTHORIZED`: missing, malformed, unknown, revoked or expired credential. | `ErrorEnvelope` | `X-Request-Id` | | `403` | `FORBIDDEN`: see `details.reason`. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `429` | `RATE_LIMITED`: the request rate of the API key (`details.scope` `key`) or of the account (`user`) is exhausted for this window, or too many failed authentication attempts came from this IP (no `details.scope` and no RateLimit headers). Retry after `Retry-After` seconds. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy`, `Retry-After` | | `default` | Error (see ErrorEnvelope and /v1/docs/errors). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | Example 201 response: ```json { "id": "2a4b6c8d-0e1f-4a2b-8c3d-4e5f6a7b8c9d", "name": "Trailmate", "description": "Offline hiking maps", "store": "app_store", "palette": [ { "hex": "#0F766E", "name": "Teal" } ], "logo": null, "counts": { "generations": 3, "screenshotSets": 1 }, "preview": null, "createdAt": "2026-10-29T10:00:00.000Z", "updatedAt": "2026-10-29T10:05:00.000Z" } ``` ## GET /projects/{id} **Get a project** (operation `getProject`). Auth: API key with scope `generations:read` or `generations:write`, or access token. Requires `generations:read` or `generations:write`. List its items with `GET /generations?projectId=` and `GET /screenshot-sets?projectId=`. **Credits.** Free: no credits. **Parameters:** | Name | In | Type | Required | Description | |---|---|---|---|---| | `id` | path | `string` | yes | Project id. An unknown or malformed id is 404. | **Responses:** | Status | Description | Schema | Headers | |---|---|---|---| | `200` | The project. | `Project` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `400` | `INVALID_INPUT`. A credential sent in the query string is refused before authentication, without RateLimit headers. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `401` | `UNAUTHORIZED`: missing, malformed, unknown, revoked or expired credential. | `ErrorEnvelope` | `X-Request-Id` | | `403` | `FORBIDDEN`: see `details.reason`. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `404` | `NOT_FOUND`: no such resource for this caller. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `429` | `RATE_LIMITED`: the request rate of the API key (`details.scope` `key`) or of the account (`user`) is exhausted for this window, or too many failed authentication attempts came from this IP (no `details.scope` and no RateLimit headers). Retry after `Retry-After` seconds. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy`, `Retry-After` | | `default` | Error (see ErrorEnvelope and /v1/docs/errors). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | Example 200 response: ```json { "id": "2a4b6c8d-0e1f-4a2b-8c3d-4e5f6a7b8c9d", "name": "Trailmate", "description": "Offline hiking maps", "store": "app_store", "palette": [ { "hex": "#0F766E", "name": "Teal" } ], "logo": null, "counts": { "generations": 3, "screenshotSets": 1 }, "preview": null, "createdAt": "2026-10-29T10:00:00.000Z", "updatedAt": "2026-10-29T10:05:00.000Z" } ``` ## DELETE /projects/{id} **Delete a project** (operation `deleteProject`). Auth: API key with scope `generations:write`, or access token. Requires `generations:write`. Its generations and screenshot sets are kept, in no project; its logo is deleted. **Credits.** Free: no credits. **Parameters:** | Name | In | Type | Required | Description | |---|---|---|---|---| | `id` | path | `string` | yes | Project id. An unknown or malformed id is 404. | **Responses:** | Status | Description | Schema | Headers | |---|---|---|---| | `204` | Deleted. | — | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `400` | `INVALID_INPUT`. A credential sent in the query string is refused before authentication, without RateLimit headers. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `401` | `UNAUTHORIZED`: missing, malformed, unknown, revoked or expired credential. | `ErrorEnvelope` | `X-Request-Id` | | `403` | `FORBIDDEN`: see `details.reason`. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `404` | `NOT_FOUND`: no such resource for this caller. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `429` | `RATE_LIMITED`: the request rate of the API key (`details.scope` `key`) or of the account (`user`) is exhausted for this window, or too many failed authentication attempts came from this IP (no `details.scope` and no RateLimit headers). Retry after `Retry-After` seconds. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy`, `Retry-After` | | `default` | Error (see ErrorEnvelope and /v1/docs/errors). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | ## PATCH /projects/{id} **Update a project** (operation `updateProject`). Auth: API key with scope `generations:write`, or access token. Requires `generations:write`. Changes only the fields sent: `palette: []` clears the palette, `logo: null` removes the logo. **Credits.** Free: no credits. **Parameters:** | Name | In | Type | Required | Description | |---|---|---|---|---| | `id` | path | `string` | yes | Project id. An unknown or malformed id is 404. | **Request body** (`application/json`, required, schema `UpdateProjectRequest`): | Field | Type | Required | Description | |---|---|---|---| | `name` | `string` | no | | | `description` | `string` | no | | | `store` | `StoreTarget` | no | | | `palette` | `array of PaletteColor` | no | Replaces the palette; `[]` clears it. | | `logo` | `object`, nullable | no | A new logo, or `null` to remove it. | | `logo.uploadId` | `string` | yes | A `ready` upload of this account (PNG, JPEG or WebP; either environment). It is copied: deleting the upload later keeps the logo. Format: uuid | Example request body: ```json { "name": "Trailmate 2", "logo": null } ``` **Responses:** | Status | Description | Schema | Headers | |---|---|---|---| | `200` | The updated project. | `Project` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `400` | `INVALID_INPUT`. A credential sent in the query string is refused before authentication, without RateLimit headers. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `401` | `UNAUTHORIZED`: missing, malformed, unknown, revoked or expired credential. | `ErrorEnvelope` | `X-Request-Id` | | `403` | `FORBIDDEN`: see `details.reason`. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `404` | `NOT_FOUND`: no such resource for this caller. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `429` | `RATE_LIMITED`: the request rate of the API key (`details.scope` `key`) or of the account (`user`) is exhausted for this window, or too many failed authentication attempts came from this IP (no `details.scope` and no RateLimit headers). Retry after `Retry-After` seconds. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy`, `Retry-After` | | `default` | Error (see ErrorEnvelope and /v1/docs/errors). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | Example 200 response: ```json { "id": "2a4b6c8d-0e1f-4a2b-8c3d-4e5f6a7b8c9d", "name": "Trailmate", "description": "Offline hiking maps", "store": "app_store", "palette": [ { "hex": "#0F766E", "name": "Teal" } ], "logo": null, "counts": { "generations": 3, "screenshotSets": 1 }, "preview": null, "createdAt": "2026-10-29T10:00:00.000Z", "updatedAt": "2026-10-29T10:05:00.000Z" } ``` ## POST /projects/{id}/items **Add items to a project** (operation `addProjectItems`). Auth: API key with scope `generations:write`, or access token. Requires `generations:write`. Puts standalone generations and whole screenshot sets in this project; an item in another project moves here (an item is in one project at most). Repeating it changes nothing. An image of a screenshot set is in the project of its set and cannot be added alone. **Credits.** Free: no credits. **Parameters:** | Name | In | Type | Required | Description | |---|---|---|---|---| | `id` | path | `string` | yes | Project id. An unknown or malformed id is 404. | **Request body** (`application/json`, required, schema `ProjectItemsRequest`): | Field | Type | Required | Description | |---|---|---|---| | `generationIds` | `array of string` | no | Ids of your standalone generations (an image of a set moves with its set). | | `screenshotSetIds` | `array of string` | no | Ids of your screenshot sets. | Example request body: ```json { "generationIds": [ "0b8e7a52-41c6-4f38-9d0e-7f6a1c2b3d4e" ] } ``` **Responses:** | Status | Description | Schema | Headers | |---|---|---|---| | `200` | The items are in the project. | `ProjectItemsAdded` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `400` | `INVALID_INPUT`. A credential sent in the query string is refused before authentication, without RateLimit headers. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `401` | `UNAUTHORIZED`: missing, malformed, unknown, revoked or expired credential. | `ErrorEnvelope` | `X-Request-Id` | | `403` | `FORBIDDEN`: see `details.reason`. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `404` | `NOT_FOUND`: no such resource for this caller. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `429` | `RATE_LIMITED`: the request rate of the API key (`details.scope` `key`) or of the account (`user`) is exhausted for this window, or too many failed authentication attempts came from this IP (no `details.scope` and no RateLimit headers). Retry after `Retry-After` seconds. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy`, `Retry-After` | | `default` | Error (see ErrorEnvelope and /v1/docs/errors). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | Example 200 response: ```json { "projectId": "7d0e1a52-3c4b-4f6e-9a8d-2b1c0f9e8a7d", "added": 0 } ``` ## POST /projects/{id}/items/remove **Remove items from a project** (operation `removeProjectItems`). Auth: API key with scope `generations:write`, or access token. Requires `generations:write`. Takes the items out of this project (they are kept, in no project). Items that are not in this project are left alone. **Credits.** Free: no credits. **Parameters:** | Name | In | Type | Required | Description | |---|---|---|---|---| | `id` | path | `string` | yes | Project id. An unknown or malformed id is 404. | **Request body** (`application/json`, required, schema `ProjectItemsRequest`): | Field | Type | Required | Description | |---|---|---|---| | `generationIds` | `array of string` | no | Ids of your standalone generations (an image of a set moves with its set). | | `screenshotSetIds` | `array of string` | no | Ids of your screenshot sets. | Example request body: ```json { "generationIds": [ "0b8e7a52-41c6-4f38-9d0e-7f6a1c2b3d4e" ] } ``` **Responses:** | Status | Description | Schema | Headers | |---|---|---|---| | `200` | The items left it. | `ProjectItemsRemoved` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `400` | `INVALID_INPUT`. A credential sent in the query string is refused before authentication, without RateLimit headers. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `401` | `UNAUTHORIZED`: missing, malformed, unknown, revoked or expired credential. | `ErrorEnvelope` | `X-Request-Id` | | `403` | `FORBIDDEN`: see `details.reason`. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `404` | `NOT_FOUND`: no such resource for this caller. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `429` | `RATE_LIMITED`: the request rate of the API key (`details.scope` `key`) or of the account (`user`) is exhausted for this window, or too many failed authentication attempts came from this IP (no `details.scope` and no RateLimit headers). Retry after `Retry-After` seconds. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy`, `Retry-After` | | `default` | Error (see ErrorEnvelope and /v1/docs/errors). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | Example 200 response: ```json { "projectId": "7d0e1a52-3c4b-4f6e-9a8d-2b1c0f9e8a7d", "removed": 0 } ``` --- # Styles API reference > HTML version: https://storeshot.art/docs/api/styles/ Brand kits: save a palette, logo, background and caption tone once and reuse them with `styleId`. Contract 1.18.0; base URL `https://api.storeshot.art/v1`. Schemas: https://storeshot.art/docs/api/schemas.md ## GET /styles **List styles** (operation `listStyles`). Auth: API key with scope `generations:read` or `generations:write`, or access token. Requires `generations:read` or `generations:write`. Styles of the account (every API key and environment), most recently updated first. **When to use.** To find the `styleId` your human means (match on `name`). **Credits.** Free: no credits. **Parameters:** | Name | In | Type | Required | Description | |---|---|---|---|---| | `limit` | query | `integer` | no | 1–100, default 20. Default: 20 | | `cursor` | query | `string` | no | `nextCursor` of the previous page. | **Responses:** | Status | Description | Schema | Headers | |---|---|---|---| | `200` | A page of styles. | `StyleList` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `400` | `INVALID_INPUT`. A credential sent in the query string is refused before authentication, without RateLimit headers. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `401` | `UNAUTHORIZED`: missing, malformed, unknown, revoked or expired credential. | `ErrorEnvelope` | `X-Request-Id` | | `403` | `FORBIDDEN`: see `details.reason`. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `429` | `RATE_LIMITED`: the request rate of the API key (`details.scope` `key`) or of the account (`user`) is exhausted for this window, or too many failed authentication attempts came from this IP (no `details.scope` and no RateLimit headers). Retry after `Retry-After` seconds. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy`, `Retry-After` | | `default` | Error (see ErrorEnvelope and /v1/docs/errors). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | Example 200 response: ```json { "data": [ { "id": "3b5d7f9a-1c2e-4d6f-8a0b-2c4e6f8a0b1c", "name": "Trailmate brand", "palette": [ { "hex": "#0F766E", "name": "Teal" }, { "hex": "#F8FAFC", "name": "Off White" } ], "logo": null, "backgroundMood": "dark", "backgroundPrompt": "soft paper texture with mountain line art", "captionTone": "friendly", "referenceGenerationId": null, "createdAt": "2026-11-03T10:00:00.000Z", "updatedAt": "2026-11-03T10:05:00.000Z" } ], "nextCursor": "string" } ``` ## POST /styles **Create a style (brand kit)** (operation `createStyle`). Auth: API key with scope `generations:write`, or access token. Requires `generations:write`. Saves a palette (up to 8 colours), a logo (copied from an upload), a background mood and description, and a caption tone once; send its `id` as `styleId` when you create a generation or a screenshot set. `referenceGenerationId` copies the colours (and style choices) of a succeeded generation into the fields you leave out. Send an `Idempotency-Key` so a retried request never creates twice; a repeat with the same body answers the style (`200`, `Idempotent-Replayed: true`). At most 50 styles per account (`403 FORBIDDEN`, `details.reason` `style_limit_reached`). Styles are free. **When to use.** Once per brand: save the palette, logo, background mood and caption tone, then send `styleId` with every generation or screenshot set instead of repeating them. **Credits.** Free: no credits. **Parameters:** | Name | In | Type | Required | Description | |---|---|---|---|---| | `idempotency-key` | header | `string` | no | 1–255 printable ASCII characters. Retrying with the same key and body returns the original generation (`Idempotent-Replayed: true`) instead of creating and charging another. Keys are kept as long as the generation exists. | **Request body** (`application/json`, required, schema `CreateStyleRequest`): | Field | Type | Required | Description | |---|---|---|---| | `name` | `string` | yes | | | `palette` | `array of PaletteColor` | no | 1–8 colours. | | `logo` | `object` | no | | | `logo.uploadId` | `string` | yes | A `ready` upload of this account (PNG, JPEG or WebP, at most 5 MB and 4096 × 4096 pixels; either environment). It is copied: deleting the upload later keeps the logo. Format: uuid | | `backgroundMood` | `BackgroundMood & string` | no | | | `backgroundPrompt` | `string` | no | At most 200 characters on one line: what the background looks like. It is passed to the model as a quoted description of the look, never as instructions. | | `captionTone` | `CaptionTone & string` | no | | | `referenceGenerationId` | `string` | no | A succeeded generation of yours: its colours (and, when it was made with a style, that style's background and caption choices) are copied into the fields you leave out, once. Its image and logo are not used. Format: uuid | Example request body: ```json { "name": "Trailmate brand", "palette": [ { "hex": "#0F766E", "name": "Teal" }, { "hex": "#F8FAFC", "name": "Off White" } ], "logo": { "uploadId": "6f1c1f0e-8a5e-4b8e-9a51-0f0e1d2c3b4a" }, "backgroundMood": "dark", "backgroundPrompt": "soft paper texture with mountain line art", "captionTone": "friendly" } ``` **Responses:** | Status | Description | Schema | Headers | |---|---|---|---| | `200` | A replay: the style created with this `Idempotency-Key`. | `Style` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy`, `Location`, `Idempotent-Replayed` | | `201` | Created. | `Style` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy`, `Location` | | `400` | `INVALID_INPUT`. A credential sent in the query string is refused before authentication, without RateLimit headers. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `401` | `UNAUTHORIZED`: missing, malformed, unknown, revoked or expired credential. | `ErrorEnvelope` | `X-Request-Id` | | `403` | `FORBIDDEN`: see `details.reason`. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `409` | `IDEMPOTENCY_CONFLICT`: the `Idempotency-Key` was used for a different request (`details.styleId`). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `429` | `RATE_LIMITED`: the request rate of the API key (`details.scope` `key`) or of the account (`user`) is exhausted for this window, or too many failed authentication attempts came from this IP (no `details.scope` and no RateLimit headers). Retry after `Retry-After` seconds. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy`, `Retry-After` | | `default` | Error (see ErrorEnvelope and /v1/docs/errors). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | Example 200 response: ```json { "id": "3b5d7f9a-1c2e-4d6f-8a0b-2c4e6f8a0b1c", "name": "Trailmate brand", "palette": [ { "hex": "#0F766E", "name": "Teal" }, { "hex": "#F8FAFC", "name": "Off White" } ], "logo": null, "backgroundMood": "dark", "backgroundPrompt": "soft paper texture with mountain line art", "captionTone": "friendly", "referenceGenerationId": null, "createdAt": "2026-11-03T10:00:00.000Z", "updatedAt": "2026-11-03T10:05:00.000Z" } ``` Example 201 response: ```json { "id": "3b5d7f9a-1c2e-4d6f-8a0b-2c4e6f8a0b1c", "name": "Trailmate brand", "palette": [ { "hex": "#0F766E", "name": "Teal" }, { "hex": "#F8FAFC", "name": "Off White" } ], "logo": null, "backgroundMood": "dark", "backgroundPrompt": "soft paper texture with mountain line art", "captionTone": "friendly", "referenceGenerationId": null, "createdAt": "2026-11-03T10:00:00.000Z", "updatedAt": "2026-11-03T10:05:00.000Z" } ``` ## GET /styles/{id} **Get a style** (operation `getStyle`). Auth: API key with scope `generations:read` or `generations:write`, or access token. Requires `generations:read` or `generations:write`. **Credits.** Free: no credits. **Parameters:** | Name | In | Type | Required | Description | |---|---|---|---|---| | `id` | path | `string` | yes | Style id. An unknown or malformed id is 404. | **Responses:** | Status | Description | Schema | Headers | |---|---|---|---| | `200` | The style. | `Style` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `400` | `INVALID_INPUT`. A credential sent in the query string is refused before authentication, without RateLimit headers. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `401` | `UNAUTHORIZED`: missing, malformed, unknown, revoked or expired credential. | `ErrorEnvelope` | `X-Request-Id` | | `403` | `FORBIDDEN`: see `details.reason`. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `404` | `NOT_FOUND`: no such resource for this caller. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `429` | `RATE_LIMITED`: the request rate of the API key (`details.scope` `key`) or of the account (`user`) is exhausted for this window, or too many failed authentication attempts came from this IP (no `details.scope` and no RateLimit headers). Retry after `Retry-After` seconds. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy`, `Retry-After` | | `default` | Error (see ErrorEnvelope and /v1/docs/errors). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | Example 200 response: ```json { "id": "3b5d7f9a-1c2e-4d6f-8a0b-2c4e6f8a0b1c", "name": "Trailmate brand", "palette": [ { "hex": "#0F766E", "name": "Teal" }, { "hex": "#F8FAFC", "name": "Off White" } ], "logo": null, "backgroundMood": "dark", "backgroundPrompt": "soft paper texture with mountain line art", "captionTone": "friendly", "referenceGenerationId": null, "createdAt": "2026-11-03T10:00:00.000Z", "updatedAt": "2026-11-03T10:05:00.000Z" } ``` ## DELETE /styles/{id} **Delete a style** (operation `deleteStyle`). Auth: API key with scope `generations:write`, or access token. Requires `generations:write`. Deletes the style and its logo. Generations and screenshot sets made with it keep what they used (their `style.id` becomes `null`), and so do their retries. **Credits.** Free: no credits. **Parameters:** | Name | In | Type | Required | Description | |---|---|---|---|---| | `id` | path | `string` | yes | Style id. An unknown or malformed id is 404. | **Responses:** | Status | Description | Schema | Headers | |---|---|---|---| | `204` | Deleted. | — | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `400` | `INVALID_INPUT`. A credential sent in the query string is refused before authentication, without RateLimit headers. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `401` | `UNAUTHORIZED`: missing, malformed, unknown, revoked or expired credential. | `ErrorEnvelope` | `X-Request-Id` | | `403` | `FORBIDDEN`: see `details.reason`. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `404` | `NOT_FOUND`: no such resource for this caller. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `429` | `RATE_LIMITED`: the request rate of the API key (`details.scope` `key`) or of the account (`user`) is exhausted for this window, or too many failed authentication attempts came from this IP (no `details.scope` and no RateLimit headers). Retry after `Retry-After` seconds. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy`, `Retry-After` | | `default` | Error (see ErrorEnvelope and /v1/docs/errors). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | ## PATCH /styles/{id} **Update a style** (operation `updateStyle`). Auth: API key with scope `generations:write`, or access token. Requires `generations:write`. Changes only the fields sent: `palette: []` clears the palette, `null` clears `logo`, `backgroundMood`, `backgroundPrompt` and `captionTone`. Generations and screenshot sets already made with the style, and their retries, keep what they used. **Credits.** Free: no credits. **Parameters:** | Name | In | Type | Required | Description | |---|---|---|---|---| | `id` | path | `string` | yes | Style id. An unknown or malformed id is 404. | **Request body** (`application/json`, required, schema `UpdateStyleRequest`): | Field | Type | Required | Description | |---|---|---|---| | `name` | `string` | no | | | `palette` | `array of PaletteColor` | no | Replaces the palette; `[]` clears it. | | `logo` | `object`, nullable | no | A new logo, or `null` to remove it. | | `logo.uploadId` | `string` | yes | A `ready` upload of this account (PNG, JPEG or WebP, at most 5 MB and 4096 × 4096 pixels; either environment). It is copied: deleting the upload later keeps the logo. Format: uuid | | `backgroundMood` | `BackgroundMood` | no | `null` clears it. | | `backgroundPrompt` | `string`, nullable | no | At most 200 characters on one line; `null` clears it. | | `captionTone` | `CaptionTone` | no | `null` clears it. | | `referenceGenerationId` | `string`, nullable | no | Copy from this generation again (into the fields you leave out), or `null` to forget where the values came from (the values stay). Format: uuid | Example request body: ```json { "captionTone": "bold", "logo": null } ``` **Responses:** | Status | Description | Schema | Headers | |---|---|---|---| | `200` | The updated style. | `Style` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `400` | `INVALID_INPUT`. A credential sent in the query string is refused before authentication, without RateLimit headers. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `401` | `UNAUTHORIZED`: missing, malformed, unknown, revoked or expired credential. | `ErrorEnvelope` | `X-Request-Id` | | `403` | `FORBIDDEN`: see `details.reason`. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `404` | `NOT_FOUND`: no such resource for this caller. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `429` | `RATE_LIMITED`: the request rate of the API key (`details.scope` `key`) or of the account (`user`) is exhausted for this window, or too many failed authentication attempts came from this IP (no `details.scope` and no RateLimit headers). Retry after `Retry-After` seconds. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy`, `Retry-After` | | `default` | Error (see ErrorEnvelope and /v1/docs/errors). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | Example 200 response: ```json { "id": "3b5d7f9a-1c2e-4d6f-8a0b-2c4e6f8a0b1c", "name": "Trailmate brand", "palette": [ { "hex": "#0F766E", "name": "Teal" }, { "hex": "#F8FAFC", "name": "Off White" } ], "logo": null, "backgroundMood": "dark", "backgroundPrompt": "soft paper texture with mountain line art", "captionTone": "friendly", "referenceGenerationId": null, "createdAt": "2026-11-03T10:00:00.000Z", "updatedAt": "2026-11-03T10:05:00.000Z" } ``` --- # Palettes API reference > HTML version: https://storeshot.art/docs/api/palettes/ Free brand palettes from your images. Contract 1.18.0; base URL `https://api.storeshot.art/v1`. Schemas: https://storeshot.art/docs/api/schemas.md ## POST /palettes **Extract a brand palette** (operation `createPalette`). Auth: API key with scope `generations:write`, or access token. Requires `generations:write`. Free: no credits, no AI model; the same result for the same image. `extract` (default) returns up to `maxColors` distinct colours of one image, exactly as the Storeshot app does; `dominant` returns the dominant colour of each image (an image that cannot be analysed is skipped). When no colour can be found, `source` is `default` and `colors` is the Storeshot default palette. URL inputs are fetched for this request only (same rules as `POST /uploads`) and never stored. **When to use.** To get brand colours from a screenshot or logo before a set or generation (pass them as `palette`); optional, because a set extracts one by itself. **Credits.** Free: no credits and no AI model (counts towards the request rate limit only). **Request body** (`application/json`, required, schema `CreatePaletteRequest`): | Field | Type | Required | Description | |---|---|---|---| | `inputs` | `array of PaletteInput` | yes | `extract`: exactly one image. `dominant`: 1–10 images. At most 3 URL inputs. | | `mode` | `string` | no | `extract`: up to `maxColors` distinct colours of one image (the Storeshot app’s palette). `dominant`: the dominant colour of each image. One of: "extract", "dominant" Default: "extract" | | `maxColors` | `integer` | no | `extract` only: 1–10, default 5. | Example request body: ```json { "inputs": [ { "uploadId": "6f1c1f0e-8a5e-4b8e-9a51-0f0e1d2c3b4a" } ], "mode": "extract", "maxColors": 5 } ``` **Responses:** | Status | Description | Schema | Headers | |---|---|---|---| | `200` | The palette. | `Palette` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `400` | `INVALID_INPUT`. A credential sent in the query string is refused before authentication, without RateLimit headers. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `401` | `UNAUTHORIZED`: missing, malformed, unknown, revoked or expired credential. | `ErrorEnvelope` | `X-Request-Id` | | `403` | `FORBIDDEN`: see `details.reason`. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `409` | `UPLOAD_NOT_READY`: see `details.reason`. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `413` | `PAYLOAD_TOO_LARGE` (the request body is over its limit) or `IMAGE_TOO_LARGE` (see `details.reason`). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `415` | `UNSUPPORTED_FORMAT`: not a PNG, JPEG or WebP image (see `details.reason`). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `422` | `URL_FETCH_FAILED`: the URL could not be fetched (see `details.reason`). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `429` | `RATE_LIMITED`: the request rate of the API key (`details.scope` `key`) or of the account (`user`) is exhausted for this window, or too many failed authentication attempts came from this IP (no `details.scope` and no RateLimit headers). Retry after `Retry-After` seconds. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy`, `Retry-After` | | `503` | `SERVICE_UNAVAILABLE`: uploads are busy. Retry after `Retry-After` seconds. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy`, `Retry-After` | | `default` | Error (see ErrorEnvelope and /v1/docs/errors). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | Example 200 response: ```json { "mode": "extract", "source": "extracted", "colors": [ { "hex": "#2040A0", "name": "Steel Blue" }, { "hex": "#E0E0E0", "name": "Silver" } ] } ``` --- # Webhooks API reference > HTML version: https://storeshot.art/docs/api/webhooks/ Signed HTTPS callbacks when generations and sets finish. Contract 1.18.0; base URL `https://api.storeshot.art/v1`. Schemas: https://storeshot.art/docs/api/schemas.md ## GET /webhooks **List webhooks** (operation `listWebhooks`). Auth: API key with scope `webhooks:read` or `webhooks:write`, or access token. Requires `webhooks:read` or `webhooks:write`. An API key sees the webhooks of its own environment; an access token sees both (or one with `?environment=`). Secrets are never returned. **Credits.** Free: no credits. **Parameters:** | Name | In | Type | Required | Description | |---|---|---|---|---| | `environment` | query | `ApiKeyEnvironment` | no | | **Responses:** | Status | Description | Schema | Headers | |---|---|---|---| | `200` | Webhooks, newest first. | `WebhookList` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `400` | `INVALID_INPUT`. A credential sent in the query string is refused before authentication, without RateLimit headers. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `401` | `UNAUTHORIZED`: missing, malformed, unknown, revoked or expired credential. | `ErrorEnvelope` | `X-Request-Id` | | `403` | `FORBIDDEN`: see `details.reason`. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `429` | `RATE_LIMITED`: the request rate of the API key (`details.scope` `key`) or of the account (`user`) is exhausted for this window, or too many failed authentication attempts came from this IP (no `details.scope` and no RateLimit headers). Retry after `Retry-After` seconds. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy`, `Retry-After` | | `default` | Error (see ErrorEnvelope and /v1/docs/errors). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | Example 200 response: ```json { "data": [ { "id": "7d0e1a52-3c4b-4f6e-9a8d-2b1c0f9e8a7d", "environment": "live", "url": "https://hooks.example.com/…shot", "events": [], "description": "string", "enabled": true, "creditsLowThreshold": 0, "secret": { "hint": "whsec_…Xy7Q", "rotatedAt": "2026-10-21T12:00:00.000Z", "previousExpiresAt": "2026-10-21T12:00:00.000Z" }, "createdAt": "2026-10-21T12:00:00.000Z", "updatedAt": "2026-10-21T12:00:00.000Z" } ] } ``` ## POST /webhooks **Create a webhook** (operation `createWebhook`). Auth: API key with scope `webhooks:write`, or access token. Requires `webhooks:write` (not a default scope). Registers an `https://` endpoint (port 443, public addresses only) for the given events of one environment; at most 10 per environment. The signing secret is in this response only. **When to use.** Instead of polling, when you run an https endpoint that can receive callbacks; agents in a terminal usually poll. **Credits.** Free: no credits. **Request body** (`application/json`, required, schema `CreateWebhookRequest`): | Field | Type | Required | Description | |---|---|---|---| | `url` | `string` | yes | An `https://` URL on port 443, without credentials, at most 2048 characters, whose host resolves only to public addresses (checked now and before every delivery). Shown masked afterwards. | | `events` | `array of WebhookEventType` | yes | The business events to deliver (1–4, unique). | | `description` | `string` | no | Free text, at most 200 characters. | | `enabled` | `boolean` | no | Default `true`. | | `creditsLowThreshold` | `integer` | no | Default 5. | | `environment` | `ApiKeyEnvironment` | no | Which events the webhook receives: `live` or `test` (test-key generations and sets). Default: the API key environment (access tokens: `live`). An API key can only create webhooks of its own environment. | Example request body: ```json { "url": "https://hooks.example.com/storeshot", "events": [ "generation.succeeded" ], "description": "string", "enabled": true, "creditsLowThreshold": 1, "environment": "live" } ``` **Responses:** | Status | Description | Schema | Headers | |---|---|---|---| | `201` | Created. Store `secret.value` now; it is never shown again. | `WebhookWithSecret` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `400` | `INVALID_INPUT`. A credential sent in the query string is refused before authentication, without RateLimit headers. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `401` | `UNAUTHORIZED`: missing, malformed, unknown, revoked or expired credential. | `ErrorEnvelope` | `X-Request-Id` | | `403` | `FORBIDDEN`: see `details.reason`. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `413` | `PAYLOAD_TOO_LARGE`. | `ErrorEnvelope` | `X-Request-Id` | | `422` | `WEBHOOK_URL_REJECTED`: the host is not public or does not resolve (`details.reason`). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `429` | `RATE_LIMITED`: the request rate of the API key (`details.scope` `key`) or of the account (`user`) is exhausted for this window, or too many failed authentication attempts came from this IP (no `details.scope` and no RateLimit headers). Retry after `Retry-After` seconds. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy`, `Retry-After` | | `503` | `SERVICE_UNAVAILABLE`: secrets cannot be issued right now. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `default` | Error (see ErrorEnvelope and /v1/docs/errors). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | Example 201 response: ```json { "id": "7d0e1a52-3c4b-4f6e-9a8d-2b1c0f9e8a7d", "environment": "live", "url": "https://hooks.example.com/…shot", "events": [ "generation.succeeded" ], "description": "string", "enabled": true, "creditsLowThreshold": 0, "secret": { "hint": "whsec_…Xy7Q", "rotatedAt": "2026-10-21T12:00:00.000Z", "previousExpiresAt": "2026-10-21T12:00:00.000Z", "value": "string" }, "createdAt": "2026-10-21T12:00:00.000Z", "updatedAt": "2026-10-21T12:00:00.000Z" } ``` ## GET /webhooks/{id} **Get a webhook** (operation `getWebhook`). Auth: API key with scope `webhooks:read` or `webhooks:write`, or access token. Requires `webhooks:read` or `webhooks:write`. The secret is never returned. **Credits.** Free: no credits. **Parameters:** | Name | In | Type | Required | Description | |---|---|---|---|---| | `id` | path | `string` | yes | Webhook id. An unknown or malformed id is 404. | **Responses:** | Status | Description | Schema | Headers | |---|---|---|---| | `200` | The webhook. | `Webhook` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `400` | `INVALID_INPUT`. A credential sent in the query string is refused before authentication, without RateLimit headers. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `401` | `UNAUTHORIZED`: missing, malformed, unknown, revoked or expired credential. | `ErrorEnvelope` | `X-Request-Id` | | `403` | `FORBIDDEN`: see `details.reason`. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `404` | `NOT_FOUND`: no such resource for this caller. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `429` | `RATE_LIMITED`: the request rate of the API key (`details.scope` `key`) or of the account (`user`) is exhausted for this window, or too many failed authentication attempts came from this IP (no `details.scope` and no RateLimit headers). Retry after `Retry-After` seconds. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy`, `Retry-After` | | `default` | Error (see ErrorEnvelope and /v1/docs/errors). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | Example 200 response: ```json { "id": "7d0e1a52-3c4b-4f6e-9a8d-2b1c0f9e8a7d", "environment": "live", "url": "https://hooks.example.com/…shot", "events": [ "generation.succeeded" ], "description": "string", "enabled": true, "creditsLowThreshold": 0, "secret": { "hint": "whsec_…Xy7Q", "rotatedAt": "2026-10-21T12:00:00.000Z", "previousExpiresAt": "2026-10-21T12:00:00.000Z" }, "createdAt": "2026-10-21T12:00:00.000Z", "updatedAt": "2026-10-21T12:00:00.000Z" } ``` ## DELETE /webhooks/{id} **Delete a webhook** (operation `deleteWebhook`). Auth: API key with scope `webhooks:write`, or access token. Requires `webhooks:write`. Deletes the webhook with its delivery log; pending deliveries are dropped. **Credits.** Free: no credits. **Parameters:** | Name | In | Type | Required | Description | |---|---|---|---|---| | `id` | path | `string` | yes | Webhook id. An unknown or malformed id is 404. | **Responses:** | Status | Description | Schema | Headers | |---|---|---|---| | `204` | Deleted. | — | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `400` | `INVALID_INPUT`. A credential sent in the query string is refused before authentication, without RateLimit headers. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `401` | `UNAUTHORIZED`: missing, malformed, unknown, revoked or expired credential. | `ErrorEnvelope` | `X-Request-Id` | | `403` | `FORBIDDEN`: see `details.reason`. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `404` | `NOT_FOUND`: no such resource for this caller. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `429` | `RATE_LIMITED`: the request rate of the API key (`details.scope` `key`) or of the account (`user`) is exhausted for this window, or too many failed authentication attempts came from this IP (no `details.scope` and no RateLimit headers). Retry after `Retry-After` seconds. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy`, `Retry-After` | | `default` | Error (see ErrorEnvelope and /v1/docs/errors). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | ## PATCH /webhooks/{id} **Update a webhook** (operation `updateWebhook`). Auth: API key with scope `webhooks:write`, or access token. Requires `webhooks:write`. Send any of `url`, `events`, `description`, `enabled`, `creditsLowThreshold`. A new `url` is checked like on create. While a webhook is disabled it gets no new events, and a pending delivery reaching its next attempt is canceled. **Credits.** Free: no credits. **Parameters:** | Name | In | Type | Required | Description | |---|---|---|---|---| | `id` | path | `string` | yes | Webhook id. An unknown or malformed id is 404. | **Request body** (`application/json`, required, schema `UpdateWebhookRequest`): | Field | Type | Required | Description | |---|---|---|---| | `url` | `string` | no | An `https://` URL on port 443, without credentials, at most 2048 characters, whose host resolves only to public addresses (checked now and before every delivery). Shown masked afterwards. | | `events` | `array of WebhookEventType` | no | The business events to deliver (1–4, unique). | | `description` | `string` | no | Free text, at most 200 characters. | | `enabled` | `boolean` | no | | | `creditsLowThreshold` | `integer` | no | `credits.low` fires when the live balance falls below this many credits (1–10000). | Example request body: ```json { "url": "https://hooks.example.com/storeshot", "events": [ "generation.succeeded" ], "description": "string", "enabled": true, "creditsLowThreshold": 1 } ``` **Responses:** | Status | Description | Schema | Headers | |---|---|---|---| | `200` | The updated webhook. | `Webhook` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `400` | `INVALID_INPUT`. A credential sent in the query string is refused before authentication, without RateLimit headers. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `401` | `UNAUTHORIZED`: missing, malformed, unknown, revoked or expired credential. | `ErrorEnvelope` | `X-Request-Id` | | `403` | `FORBIDDEN`: see `details.reason`. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `404` | `NOT_FOUND`: no such resource for this caller. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `413` | `PAYLOAD_TOO_LARGE`. | `ErrorEnvelope` | `X-Request-Id` | | `422` | `WEBHOOK_URL_REJECTED`: the host is not public or does not resolve (`details.reason`). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `429` | `RATE_LIMITED`: the request rate of the API key (`details.scope` `key`) or of the account (`user`) is exhausted for this window, or too many failed authentication attempts came from this IP (no `details.scope` and no RateLimit headers). Retry after `Retry-After` seconds. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy`, `Retry-After` | | `default` | Error (see ErrorEnvelope and /v1/docs/errors). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | Example 200 response: ```json { "id": "7d0e1a52-3c4b-4f6e-9a8d-2b1c0f9e8a7d", "environment": "live", "url": "https://hooks.example.com/…shot", "events": [ "generation.succeeded" ], "description": "string", "enabled": true, "creditsLowThreshold": 0, "secret": { "hint": "whsec_…Xy7Q", "rotatedAt": "2026-10-21T12:00:00.000Z", "previousExpiresAt": "2026-10-21T12:00:00.000Z" }, "createdAt": "2026-10-21T12:00:00.000Z", "updatedAt": "2026-10-21T12:00:00.000Z" } ``` ## POST /webhooks/{id}/rotate-secret **Rotate the signing secret** (operation `rotateWebhookSecret`). Auth: API key with scope `webhooks:write`, or access token. Requires `webhooks:write`. Issues a new secret (in this response only). For `gracePeriodSeconds` (default 24 hours) deliveries carry a signature with each secret, so receivers can switch without dropping events. **Credits.** Free: no credits. **Parameters:** | Name | In | Type | Required | Description | |---|---|---|---|---| | `id` | path | `string` | yes | Webhook id. An unknown or malformed id is 404. | **Request body** (`application/json`, optional, schema `RotateWebhookSecretRequest`): | Field | Type | Required | Description | |---|---|---|---| | `gracePeriodSeconds` | `integer` | no | How long the previous secret keeps signing deliveries next to the new one (0–604800, default 86400). 0 drops it at once. | Example request body: ```json { "gracePeriodSeconds": 0 } ``` **Responses:** | Status | Description | Schema | Headers | |---|---|---|---| | `200` | Rotated. Store `secret.value` now; it is never shown again. | `WebhookWithSecret` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `400` | `INVALID_INPUT`. A credential sent in the query string is refused before authentication, without RateLimit headers. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `401` | `UNAUTHORIZED`: missing, malformed, unknown, revoked or expired credential. | `ErrorEnvelope` | `X-Request-Id` | | `403` | `FORBIDDEN`: see `details.reason`. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `404` | `NOT_FOUND`: no such resource for this caller. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `413` | `PAYLOAD_TOO_LARGE`. | `ErrorEnvelope` | `X-Request-Id` | | `429` | `RATE_LIMITED`: the request rate of the API key (`details.scope` `key`) or of the account (`user`) is exhausted for this window, or too many failed authentication attempts came from this IP (no `details.scope` and no RateLimit headers). Retry after `Retry-After` seconds. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy`, `Retry-After` | | `503` | `SERVICE_UNAVAILABLE`: secrets cannot be issued right now. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `default` | Error (see ErrorEnvelope and /v1/docs/errors). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | Example 200 response: ```json { "id": "7d0e1a52-3c4b-4f6e-9a8d-2b1c0f9e8a7d", "environment": "live", "url": "https://hooks.example.com/…shot", "events": [ "generation.succeeded" ], "description": "string", "enabled": true, "creditsLowThreshold": 0, "secret": { "hint": "whsec_…Xy7Q", "rotatedAt": "2026-10-21T12:00:00.000Z", "previousExpiresAt": "2026-10-21T12:00:00.000Z", "value": "string" }, "createdAt": "2026-10-21T12:00:00.000Z", "updatedAt": "2026-10-21T12:00:00.000Z" } ``` ## POST /webhooks/{id}/test **Send a test event** (operation `testWebhook`). Auth: API key with scope `webhooks:write`, or access token. Requires `webhooks:write`. Queues a signed `webhook.test` event to this webhook (whatever its `events`); follow it in `GET /webhooks/{id}/deliveries`. At most 10 per webhook per hour. **Credits.** Free: no credits. **Parameters:** | Name | In | Type | Required | Description | |---|---|---|---|---| | `id` | path | `string` | yes | Webhook id. An unknown or malformed id is 404. | **Responses:** | Status | Description | Schema | Headers | |---|---|---|---| | `202` | Queued. | `WebhookTestResult` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `400` | `INVALID_INPUT`. A credential sent in the query string is refused before authentication, without RateLimit headers. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `401` | `UNAUTHORIZED`: missing, malformed, unknown, revoked or expired credential. | `ErrorEnvelope` | `X-Request-Id` | | `403` | `FORBIDDEN`: see `details.reason`. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `404` | `NOT_FOUND`: no such resource for this caller. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `409` | `WEBHOOK_STATE_CONFLICT`: the webhook is disabled (`details.action` `test`). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `429` | `RATE_LIMITED`: the request rate of the API key (`details.scope` `key`) or of the account (`user`) is exhausted for this window, or too many failed authentication attempts came from this IP (no `details.scope` and no RateLimit headers). Retry after `Retry-After` seconds. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy`, `Retry-After` | | `default` | Error (see ErrorEnvelope and /v1/docs/errors). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | Example 202 response: ```json { "eventId": "evt_3f2a…", "deliveryId": "7d0e1a52-3c4b-4f6e-9a8d-2b1c0f9e8a7d" } ``` ## GET /webhooks/{id}/deliveries **List deliveries** (operation `listWebhookDeliveries`). Auth: API key with scope `webhooks:read` or `webhooks:write`, or access token. Requires `webhooks:read` or `webhooks:write`. Deliveries of this webhook, newest first, with every attempt (status code, latency, error class and the start of the response body). Kept 30 days after they finish. **Credits.** Free: no credits. **Parameters:** | Name | In | Type | Required | Description | |---|---|---|---|---| | `id` | path | `string` | yes | Webhook id. An unknown or malformed id is 404. | | `limit` | query | `integer` | no | 1–100, default 20. Default: 20 | | `cursor` | query | `string` | no | `nextCursor` of the previous page. | | `status` | query | `WebhookDeliveryStatus` | no | | **Responses:** | Status | Description | Schema | Headers | |---|---|---|---| | `200` | A page of deliveries. | `WebhookDeliveryList` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `400` | `INVALID_INPUT`. A credential sent in the query string is refused before authentication, without RateLimit headers. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `401` | `UNAUTHORIZED`: missing, malformed, unknown, revoked or expired credential. | `ErrorEnvelope` | `X-Request-Id` | | `403` | `FORBIDDEN`: see `details.reason`. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `404` | `NOT_FOUND`: no such resource for this caller. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `429` | `RATE_LIMITED`: the request rate of the API key (`details.scope` `key`) or of the account (`user`) is exhausted for this window, or too many failed authentication attempts came from this IP (no `details.scope` and no RateLimit headers). Retry after `Retry-After` seconds. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy`, `Retry-After` | | `default` | Error (see ErrorEnvelope and /v1/docs/errors). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | Example 200 response: ```json { "data": [ { "id": "7d0e1a52-3c4b-4f6e-9a8d-2b1c0f9e8a7d", "eventId": "evt_3f2a…", "eventType": "generation.succeeded", "status": "pending", "attempts": 0, "nextAttemptAt": "2026-10-21T12:00:00.000Z", "createdAt": "2026-10-21T12:00:00.000Z", "finishedAt": "2026-10-21T12:00:00.000Z", "log": [ {} ] } ], "nextCursor": "string" } ``` ## Webhook events Sent as signed `POST` requests to your webhook URL. ### `generation.succeeded` **A generation succeeded** (`webhook_generation_succeeded`). Payload schema `GenerationSucceededEvent`. Sent as a signed `POST` to your webhook URL (at least once, unordered). Answer any 2xx within 10 seconds; anything else is retried 7 times over about 23 hours. Verify `Storeshot-Signature` over the raw body before parsing it. Headers: `storeshot-signature`, `storeshot-event-id`, `storeshot-event-type`, `storeshot-delivery-id`, `storeshot-delivery-attempt`. | Field | Type | Required | Description | |---|---|---|---| | `id` | `string` | yes | Event id (`evt_…`), identical on every attempt: deduplicate on it. | | `type` | `string` | yes | One of: "generation.succeeded" | | `created` | `string` | yes | RFC 3339 UTC timestamp. | | `environment` | `ApiKeyEnvironment` | yes | | | `apiVersion` | `string` | yes | | | `data` | `WebhookGenerationData` | yes | | Example payload: ```json { "id": "string", "type": "generation.succeeded", "created": "2026-10-21T12:00:00.000Z", "environment": "live", "apiVersion": "1.8.0", "data": { "id": "7d0e1a52-3c4b-4f6e-9a8d-2b1c0f9e8a7d", "status": "queued", "environment": "live", "inputs": [ { "uploadId": "7d0e1a52-3c4b-4f6e-9a8d-2b1c0f9e8a7d", "sourceUrl": "string" } ], "store": "app_store", "device": "app_store_1290x2796", "size": { "width": 1, "height": 1 }, "orientation": "portrait", "app": { "name": "string", "description": "string" }, "caption": "string", "palette": [ {} ], "logo": { "uploadId": "7d0e1a52-3c4b-4f6e-9a8d-2b1c0f9e8a7d" }, "metadata": {}, "creditsCharged": 0, "creditsRefunded": 0, "result": { "image": { "contentType": "image/png", "width": 1290, "height": 2796 }, "thumbnail": { "contentType": "image/webp" }, "caption": "string", "palette": [] }, "error": { "code": "GENERATION_FAILED", "message": "string" }, "createdAt": "2026-10-21T12:00:00.000Z", "startedAt": "2026-10-21T12:00:00.000Z", "finishedAt": "2026-10-21T12:00:00.000Z", "deletedAt": "2026-10-21T12:00:00.000Z", "screenshotSet": { "id": "7d0e1a52-3c4b-4f6e-9a8d-2b1c0f9e8a7d", "position": 1, "attempt": 1 } } } ``` ### `generation.failed` **A generation failed or was canceled** (`webhook_generation_failed`). Payload schema `GenerationFailedEvent`. Sent as a signed `POST` to your webhook URL (at least once, unordered). Answer any 2xx within 10 seconds; anything else is retried 7 times over about 23 hours. Verify `Storeshot-Signature` over the raw body before parsing it. Headers: `storeshot-signature`, `storeshot-event-id`, `storeshot-event-type`, `storeshot-delivery-id`, `storeshot-delivery-attempt`. | Field | Type | Required | Description | |---|---|---|---| | `id` | `string` | yes | Event id (`evt_…`), identical on every attempt: deduplicate on it. | | `type` | `string` | yes | One of: "generation.failed" | | `created` | `string` | yes | RFC 3339 UTC timestamp. | | `environment` | `ApiKeyEnvironment` | yes | | | `apiVersion` | `string` | yes | | | `data` | `WebhookGenerationData` | yes | | Example payload: ```json { "id": "string", "type": "generation.failed", "created": "2026-10-21T12:00:00.000Z", "environment": "live", "apiVersion": "1.8.0", "data": { "id": "7d0e1a52-3c4b-4f6e-9a8d-2b1c0f9e8a7d", "status": "queued", "environment": "live", "inputs": [ { "uploadId": "7d0e1a52-3c4b-4f6e-9a8d-2b1c0f9e8a7d", "sourceUrl": "string" } ], "store": "app_store", "device": "app_store_1290x2796", "size": { "width": 1, "height": 1 }, "orientation": "portrait", "app": { "name": "string", "description": "string" }, "caption": "string", "palette": [ {} ], "logo": { "uploadId": "7d0e1a52-3c4b-4f6e-9a8d-2b1c0f9e8a7d" }, "metadata": {}, "creditsCharged": 0, "creditsRefunded": 0, "result": { "image": { "contentType": "image/png", "width": 1290, "height": 2796 }, "thumbnail": { "contentType": "image/webp" }, "caption": "string", "palette": [] }, "error": { "code": "GENERATION_FAILED", "message": "string" }, "createdAt": "2026-10-21T12:00:00.000Z", "startedAt": "2026-10-21T12:00:00.000Z", "finishedAt": "2026-10-21T12:00:00.000Z", "deletedAt": "2026-10-21T12:00:00.000Z", "screenshotSet": { "id": "7d0e1a52-3c4b-4f6e-9a8d-2b1c0f9e8a7d", "position": 1, "attempt": 1 } } } ``` ### `screenshot_set.completed` **A screenshot set reached a final status** (`webhook_screenshot_set_completed`). Payload schema `ScreenshotSetCompletedEvent`. Sent as a signed `POST` to your webhook URL (at least once, unordered). Answer any 2xx within 10 seconds; anything else is retried 7 times over about 23 hours. Verify `Storeshot-Signature` over the raw body before parsing it. Headers: `storeshot-signature`, `storeshot-event-id`, `storeshot-event-type`, `storeshot-delivery-id`, `storeshot-delivery-attempt`. | Field | Type | Required | Description | |---|---|---|---| | `id` | `string` | yes | Event id (`evt_…`), identical on every attempt: deduplicate on it. | | `type` | `string` | yes | One of: "screenshot_set.completed" | | `created` | `string` | yes | RFC 3339 UTC timestamp. | | `environment` | `ApiKeyEnvironment` | yes | | | `apiVersion` | `string` | yes | | | `data` | `WebhookScreenshotSetData` | yes | | Example payload: ```json { "id": "string", "type": "screenshot_set.completed", "created": "2026-10-21T12:00:00.000Z", "environment": "live", "apiVersion": "1.8.0", "data": { "id": "7d0e1a52-3c4b-4f6e-9a8d-2b1c0f9e8a7d", "status": "queued", "environment": "live", "app": { "name": "string", "description": "string" }, "orientation": "portrait", "palette": [ {} ], "paletteSource": "given", "logo": { "uploadId": "7d0e1a52-3c4b-4f6e-9a8d-2b1c0f9e8a7d" }, "metadata": {}, "screens": [ { "position": 1, "input": {}, "caption": "string" } ], "stores": [ { "devices": [], "featureGraphic": {} } ], "images": 1, "counts": { "queued": 0, "running": 0, "succeeded": 0, "failed": 0, "canceled": 0 }, "creditsCharged": 0, "creditsRefunded": 0, "children": [ { "position": 1, "device": "string", "size": {}, "featureGraphic": true, "generationId": "7d0e1a52-3c4b-4f6e-9a8d-2b1c0f9e8a7d", "attempt": 1, "error": {} } ], "archiveUrl": "/v1/screenshot-sets/5c1e…/archive", "createdAt": "2026-10-21T12:00:00.000Z", "updatedAt": "2026-10-21T12:00:00.000Z", "finishedAt": "2026-10-21T12:00:00.000Z", "deletedAt": "2026-10-21T12:00:00.000Z", "preview": { "generationId": "9a8b7c6d-5e4f-4a3b-9c2d-1e0f9a8b7c6d", "store": "app_store", "device": "app_store_1290x2796", "thumbnail": { "url": "https://storage.example.com/platform/users/5b0c7d2e-1f3a-4c5b-8d6e-7f8091a2b3c4/generations/job-9a8b7c6d-5e4f-4a3b-9c2d-1e0f9a8b7c6d-a1.png.thumb.webp?X-Amz-Expires=900&X-Amz-Signature=def456", "contentType": "image/webp" }, "urlsExpireAt": "2026-10-24T12:15:00.000Z" } } } ``` ### `credits.low` **The live credit balance fell below the threshold** (`webhook_credits_low`). Payload schema `CreditsLowEvent`. Sent as a signed `POST` to your webhook URL (at least once, unordered). Answer any 2xx within 10 seconds; anything else is retried 7 times over about 23 hours. Verify `Storeshot-Signature` over the raw body before parsing it. Headers: `storeshot-signature`, `storeshot-event-id`, `storeshot-event-type`, `storeshot-delivery-id`, `storeshot-delivery-attempt`. | Field | Type | Required | Description | |---|---|---|---| | `id` | `string` | yes | Event id (`evt_…`), identical on every attempt: deduplicate on it. | | `type` | `string` | yes | One of: "credits.low" | | `created` | `string` | yes | RFC 3339 UTC timestamp. | | `environment` | `ApiKeyEnvironment` | yes | | | `apiVersion` | `string` | yes | | | `data` | `object` | yes | | | `data.balances` | `object` | yes | | | `data.balances.starter` | `integer` | yes | | | `data.balances.subscription` | `integer` | yes | | | `data.balances.purchased` | `integer` | yes | | | `data.total` | `integer` | yes | | | `data.threshold` | `integer` | yes | | Example payload: ```json { "id": "string", "type": "credits.low", "created": "2026-10-21T12:00:00.000Z", "environment": "live", "apiVersion": "1.8.0", "data": { "balances": { "starter": 0, "subscription": 0, "purchased": 0 }, "total": 0, "threshold": 0 } } ``` ### `webhook.test` **Test event (POST /webhooks/{id}/test)** (`webhook_webhook_test`). Payload schema `WebhookTestEvent`. Sent as a signed `POST` to your webhook URL (at least once, unordered). Answer any 2xx within 10 seconds; anything else is retried 7 times over about 23 hours. Verify `Storeshot-Signature` over the raw body before parsing it. Headers: `storeshot-signature`, `storeshot-event-id`, `storeshot-event-type`, `storeshot-delivery-id`, `storeshot-delivery-attempt`. | Field | Type | Required | Description | |---|---|---|---| | `id` | `string` | yes | Event id (`evt_…`), identical on every attempt: deduplicate on it. | | `type` | `string` | yes | One of: "webhook.test" | | `created` | `string` | yes | RFC 3339 UTC timestamp. | | `environment` | `ApiKeyEnvironment` | yes | | | `apiVersion` | `string` | yes | | | `data` | `object` | yes | | | `data.webhookId` | `string` | yes | Format: uuid | | `data.message` | `string` | yes | | Example payload: ```json { "id": "string", "type": "webhook.test", "created": "2026-10-21T12:00:00.000Z", "environment": "live", "apiVersion": "1.8.0", "data": { "webhookId": "7d0e1a52-3c4b-4f6e-9a8d-2b1c0f9e8a7d", "message": "string" } } ``` --- # Activity API reference > HTML version: https://storeshot.art/docs/api/activity/ Who changed what in the account. Contract 1.18.0; base URL `https://api.storeshot.art/v1`. Schemas: https://storeshot.art/docs/api/schemas.md ## GET /activity **List account activity** (operation `listActivity`). Auth: API key with scope `keys:manage`, or access token. Requires `keys:manage`. Changes to the account, newest first: API keys created and revoked, credits granted and removed (trial, packs, plans, refunds), webhook changes and console sign-ins, with who made them and the request id. Every environment; entries are kept 400 days. **Credits.** Free: no credits. **Parameters:** | Name | In | Type | Required | Description | |---|---|---|---|---| | `limit` | query | `integer` | no | 1–100, default 20. Default: 20 | | `cursor` | query | `string` | no | `nextCursor` of the previous page. | | `type` | query | `ActivityFamily` | no | | **Responses:** | Status | Description | Schema | Headers | |---|---|---|---| | `200` | A page of activity. | `ActivityList` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `400` | `INVALID_INPUT`. A credential sent in the query string is refused before authentication, without RateLimit headers. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `401` | `UNAUTHORIZED`: missing, malformed, unknown, revoked or expired credential. | `ErrorEnvelope` | `X-Request-Id` | | `403` | `FORBIDDEN`: see `details.reason`. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `429` | `RATE_LIMITED`: the request rate of the API key (`details.scope` `key`) or of the account (`user`) is exhausted for this window, or too many failed authentication attempts came from this IP (no `details.scope` and no RateLimit headers). Retry after `Retry-After` seconds. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy`, `Retry-After` | | `default` | Error (see ErrorEnvelope and /v1/docs/errors). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | Example 200 response: ```json { "data": [ { "id": "7d0e1a52-3c4b-4f6e-9a8d-2b1c0f9e8a7d", "type": "api_key.revoked", "createdAt": "2026-10-21T12:00:00.000Z", "actor": { "type": "user", "apiKeyId": "7d0e1a52-3c4b-4f6e-9a8d-2b1c0f9e8a7d", "apiKeyPrefix": "ss_live_AbCd" }, "target": { "type": "string", "id": "string" }, "details": { "name": "ci", "prefix": "ss_live_AbCd", "environment": "live" }, "requestId": "string" } ], "nextCursor": "string" } ``` --- # Billing API reference > HTML version: https://storeshot.art/docs/api/billing/ Buy credit packs (hosted checkout). Contract 1.18.0; base URL `https://api.storeshot.art/v1`. Schemas: https://storeshot.art/docs/api/schemas.md ## GET /billing/packs **List credit packs** (operation `listBillingPacks`). Auth: API key with scope `billing:read` or `billing:write`, or access token. Requires `billing:read` or `billing:write` (not default scopes). The one-time credit packs on sale, with list prices in cents; taxes are applied at checkout by the payment provider (merchant of record). Empty while web payments are switched off. Any key environment may list. **Credits.** Free: no credits. **Responses:** | Status | Description | Schema | Headers | |---|---|---|---| | `200` | The packs on sale. | `BillingPackList` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `400` | `INVALID_INPUT`. A credential sent in the query string is refused before authentication, without RateLimit headers. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `401` | `UNAUTHORIZED`: missing, malformed, unknown, revoked or expired credential. | `ErrorEnvelope` | `X-Request-Id` | | `403` | `FORBIDDEN`: see `details.reason`. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `429` | `RATE_LIMITED`: the request rate of the API key (`details.scope` `key`) or of the account (`user`) is exhausted for this window, or too many failed authentication attempts came from this IP (no `details.scope` and no RateLimit headers). Retry after `Retry-After` seconds. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy`, `Retry-After` | | `default` | Error (see ErrorEnvelope and /v1/docs/errors). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | Example 200 response: ```json { "data": [ { "id": "credits_15", "name": "Storeshot API: 15 credits", "credits": 15, "priceAmount": 399, "currency": "usd" } ] } ``` ## POST /billing/checkout **Start a checkout** (operation `createBillingCheckout`). Auth: API key with scope `billing:write`, or access token. Requires `billing:write` (not a default scope) and a live credential. Creates a hosted checkout for one pack and returns its `url`: send the buyer there. Credits are added to `purchased` once the payment succeeds (poll `GET /billing/checkouts/{id}`); a refund takes back what is still unused. Checkouts expire after 24 hours. At most 10 per account per hour. Send an `Idempotency-Key` so a retried request never opens two checkouts. **When to use.** Needs the non-default `billing:write` scope: to hand your human a payment `url` for a credit pack. Without it, give them the `details.topUpUrl` of the `402`. Never pay yourself. **Credits.** Free: no credits. Payments happen on the hosted page, never through the API. A paid checkout adds the pack's credits. **Parameters:** | Name | In | Type | Required | Description | |---|---|---|---|---| | `idempotency-key` | header | `string` | no | 1–255 printable ASCII characters. Retrying with the same key and body returns the original checkout (`Idempotent-Replayed: true`) instead of creating another. | **Request body** (`application/json`, required, schema `CreateBillingCheckoutRequest`): | Field | Type | Required | Description | |---|---|---|---| | `pack` | `string` | yes | | | `successUrl` | `string` | no | Where the buyer lands after paying. Default: a static confirmation page. `https://` on storeshot.art, a storeshot.art subdomain or this API host; at most 2048 characters. Format: uri | | `returnUrl` | `string` | no | Where "back" on the checkout page leads. Default: the credits page. `https://` on storeshot.art, a storeshot.art subdomain or this API host; at most 2048 characters. Format: uri | Example request body: ```json { "pack": "credits_15", "successUrl": "https://example.com", "returnUrl": "https://example.com" } ``` **Responses:** | Status | Description | Schema | Headers | |---|---|---|---| | `201` | Created (or, with `Idempotent-Replayed: true`, the checkout created earlier with this key and body). | `BillingCheckout` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy`, `Idempotent-Replayed` | | `400` | `INVALID_INPUT`. A credential sent in the query string is refused before authentication, without RateLimit headers. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `401` | `UNAUTHORIZED`: missing, malformed, unknown, revoked or expired credential. | `ErrorEnvelope` | `X-Request-Id` | | `403` | `FORBIDDEN`: `insufficient_scope`, `live_key_required` or `billing_account_flagged` (`details.reason`). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `409` | `IDEMPOTENCY_CONFLICT`: the key was used for a different body, or that checkout is still being created (`details.checkoutId`). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `413` | `PAYLOAD_TOO_LARGE`. | `ErrorEnvelope` | `X-Request-Id` | | `429` | `RATE_LIMITED`: request rate (see `details.scope`), or 10 checkouts in the last hour (`details.policy` `billing_checkouts`). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy`, `Retry-After` | | `503` | `BILLING_UNAVAILABLE`: `disabled`, `not_configured` or `provider_error` (`details.reason`). Nothing was charged. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy`, `Retry-After` | | `default` | Error (see ErrorEnvelope and /v1/docs/errors). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | Example 201 response: ```json { "id": "7d0e1a52-3c4b-4f6e-9a8d-2b1c0f9e8a7d", "status": "open", "pack": "credits_15", "credits": 0, "priceAmount": 399, "currency": "usd", "url": "https://buy.polar.sh/polar_c_abc123", "expiresAt": "2026-10-29T12:00:00.000Z", "createdAt": "2026-10-29T12:00:00.000Z", "paidAt": "2026-10-29T12:00:00.000Z", "creditsGranted": 0, "creditsReversed": 0, "kind": "subscription", "plan": "pro", "planName": "Storeshot API Pro", "subscriptionStatus": "active" } ``` ## GET /billing/checkouts/{id} **Get a checkout** (operation `getBillingCheckout`). Auth: API key with scope `billing:read` or `billing:write`, or access token. Requires `billing:read` or `billing:write` and a live credential. The checkout status, derived from the payment provider: `open`, `processing`, `paid`, `partially_refunded`, `refunded`, `expired` or `failed`, with the credits granted and reversed so far. Only the account that created it can read it. `{id}` is our checkout id or the payment provider's checkout id (the `{CHECKOUT_ID}` placeholder of `successUrl`), so a success page can look the checkout up straight from its URL. Plan checkouts also return `planName` and `subscriptionStatus`. **Credits.** Free: no credits. **Parameters:** | Name | In | Type | Required | Description | |---|---|---|---|---| | `id` | path | `string` | yes | Our checkout id (`id`) or the payment provider's checkout id (what `{CHECKOUT_ID}` in your `successUrl` becomes); our id wins if both match. An unknown, foreign or malformed id is 404. | **Responses:** | Status | Description | Schema | Headers | |---|---|---|---| | `200` | The checkout. | `BillingCheckout` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `400` | `INVALID_INPUT`. A credential sent in the query string is refused before authentication, without RateLimit headers. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `401` | `UNAUTHORIZED`: missing, malformed, unknown, revoked or expired credential. | `ErrorEnvelope` | `X-Request-Id` | | `403` | `FORBIDDEN`: see `details.reason`. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `404` | `NOT_FOUND`: no such resource for this caller. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `429` | `RATE_LIMITED`: the request rate of the API key (`details.scope` `key`) or of the account (`user`) is exhausted for this window, or too many failed authentication attempts came from this IP (no `details.scope` and no RateLimit headers). Retry after `Retry-After` seconds. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy`, `Retry-After` | | `default` | Error (see ErrorEnvelope and /v1/docs/errors). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | Example 200 response: ```json { "id": "7d0e1a52-3c4b-4f6e-9a8d-2b1c0f9e8a7d", "status": "open", "pack": "credits_15", "credits": 0, "priceAmount": 399, "currency": "usd", "url": "https://buy.polar.sh/polar_c_abc123", "expiresAt": "2026-10-29T12:00:00.000Z", "createdAt": "2026-10-29T12:00:00.000Z", "paidAt": "2026-10-29T12:00:00.000Z", "creditsGranted": 0, "creditsReversed": 0, "kind": "subscription", "plan": "pro", "planName": "Storeshot API Pro", "subscriptionStatus": "active" } ``` ## GET /billing/plans **List plans** (operation `listBillingPlans`). Auth: API key with scope `billing:read` or `billing:write`, or access token. Requires `billing:read` or `billing:write` (not default scopes). The monthly plans on sale, with list prices in cents (taxes are applied at checkout) and the limits each plan raises. Each paid month resets the `subscription` balance to the plan credits; subscription credits are used before starter and purchased credits and expire at renewal. Empty while plans are switched off. Any key environment may list. **Credits.** Free: no credits. **Responses:** | Status | Description | Schema | Headers | |---|---|---|---| | `200` | The plans on sale. | `BillingPlanList` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `400` | `INVALID_INPUT`. A credential sent in the query string is refused before authentication, without RateLimit headers. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `401` | `UNAUTHORIZED`: missing, malformed, unknown, revoked or expired credential. | `ErrorEnvelope` | `X-Request-Id` | | `403` | `FORBIDDEN`: see `details.reason`. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `429` | `RATE_LIMITED`: the request rate of the API key (`details.scope` `key`) or of the account (`user`) is exhausted for this window, or too many failed authentication attempts came from this IP (no `details.scope` and no RateLimit headers). Retry after `Retry-After` seconds. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy`, `Retry-After` | | `default` | Error (see ErrorEnvelope and /v1/docs/errors). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | Example 200 response: ```json { "data": [ { "id": "pro", "name": "Storeshot API Pro", "credits": 60, "priceAmount": 1200, "currency": "usd", "interval": "month", "limits": {} } ] } ``` ## GET /billing/subscription **Get the plan of this account** (operation `getBillingSubscription`). Auth: API key with scope `billing:read` or `billing:write`, or access token. Requires `billing:read` or `billing:write`. The tier whose limits apply now (`free` without a plan), those limits, and the latest subscription with its `subscription` balance. Any key environment may read. **Credits.** Free: no credits. **Responses:** | Status | Description | Schema | Headers | |---|---|---|---| | `200` | The plan state. | `BillingSubscriptionState` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `400` | `INVALID_INPUT`. A credential sent in the query string is refused before authentication, without RateLimit headers. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `401` | `UNAUTHORIZED`: missing, malformed, unknown, revoked or expired credential. | `ErrorEnvelope` | `X-Request-Id` | | `403` | `FORBIDDEN`: see `details.reason`. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `429` | `RATE_LIMITED`: the request rate of the API key (`details.scope` `key`) or of the account (`user`) is exhausted for this window, or too many failed authentication attempts came from this IP (no `details.scope` and no RateLimit headers). Retry after `Retry-After` seconds. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy`, `Retry-After` | | `default` | Error (see ErrorEnvelope and /v1/docs/errors). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | Example 200 response: ```json { "plan": "free", "limits": { "requestsPerMinutePerKey": 0, "requestsPerMinutePerUser": 0, "concurrentGenerations": 0, "dailyGenerations": 0, "maxStoredUploads": 0, "maxStoredUploadMegabytes": 0 }, "subscription": { "id": "7d0e1a52-3c4b-4f6e-9a8d-2b1c0f9e8a7d", "plan": "pro", "status": "active", "cancelAtPeriodEnd": true, "currentPeriodStart": "2026-10-29T12:00:00.000Z", "currentPeriodEnd": "2026-10-29T12:00:00.000Z", "pendingPlan": "pro", "creditsPerPeriod": 0, "subscriptionCredits": 0 } } ``` ## POST /billing/subscription/checkout **Start a plan checkout** (operation `createBillingSubscriptionCheckout`). Auth: API key with scope `billing:write`, or access token. Requires `billing:write` and a live credential. Creates a hosted checkout for a monthly plan and returns its `url`. Once the first payment succeeds the plan applies and the `subscription` balance is set to the plan credits (poll `GET /billing/subscription`). One plan per account: an account with a plan changes it instead. Asking again for the same plan within 15 minutes returns the open checkout; another plan replaces it. At most 10 checkouts per account per hour (packs included). Send an `Idempotency-Key` so a retried request never opens two checkouts. **Credits.** Free: no credits. Payments happen on the hosted page, never through the API. Each paid month resets the plan credits. **Parameters:** | Name | In | Type | Required | Description | |---|---|---|---|---| | `idempotency-key` | header | `string` | no | 1–255 printable ASCII characters. Retrying with the same key and body returns the original checkout (`Idempotent-Replayed: true`) instead of creating another. | **Request body** (`application/json`, required, schema `CreateSubscriptionCheckoutRequest`): | Field | Type | Required | Description | |---|---|---|---| | `plan` | `string` | yes | One of: "pro", "agency", "scale" | | `successUrl` | `string` | no | Where the buyer lands after paying. Default: a static confirmation page. `https://` on storeshot.art, a storeshot.art subdomain or this API host; at most 2048 characters. Format: uri | | `returnUrl` | `string` | no | Where "back" on the checkout page leads. Default: the credits page. `https://` on storeshot.art, a storeshot.art subdomain or this API host; at most 2048 characters. Format: uri | Example request body: ```json { "plan": "pro", "successUrl": "https://example.com", "returnUrl": "https://example.com" } ``` **Responses:** | Status | Description | Schema | Headers | |---|---|---|---| | `201` | Created (or, with `Idempotent-Replayed: true`, the open checkout created earlier for this plan or this key). | `BillingCheckout` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy`, `Idempotent-Replayed` | | `400` | `INVALID_INPUT`. A credential sent in the query string is refused before authentication, without RateLimit headers. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `401` | `UNAUTHORIZED`: missing, malformed, unknown, revoked or expired credential. | `ErrorEnvelope` | `X-Request-Id` | | `403` | `FORBIDDEN`: `insufficient_scope`, `live_key_required` or `billing_account_flagged` (`details.reason`). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `409` | `BILLING_STATE_CONFLICT` (`subscription_exists`, `checkout_pending`) or `IDEMPOTENCY_CONFLICT`. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `413` | `PAYLOAD_TOO_LARGE`. | `ErrorEnvelope` | `X-Request-Id` | | `429` | `RATE_LIMITED`: request rate (see `details.scope`), or 10 checkouts in the last hour (`details.policy` `billing_checkouts`). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy`, `Retry-After` | | `503` | `BILLING_UNAVAILABLE`: `disabled`, `not_configured` or `provider_error` (`details.reason`). Nothing changed. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy`, `Retry-After` | | `default` | Error (see ErrorEnvelope and /v1/docs/errors). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | Example 201 response: ```json { "id": "7d0e1a52-3c4b-4f6e-9a8d-2b1c0f9e8a7d", "status": "open", "pack": "credits_15", "credits": 0, "priceAmount": 399, "currency": "usd", "url": "https://buy.polar.sh/polar_c_abc123", "expiresAt": "2026-10-29T12:00:00.000Z", "createdAt": "2026-10-29T12:00:00.000Z", "paidAt": "2026-10-29T12:00:00.000Z", "creditsGranted": 0, "creditsReversed": 0, "kind": "subscription", "plan": "pro", "planName": "Storeshot API Pro", "subscriptionStatus": "active" } ``` ## POST /billing/subscription/change **Change the plan** (operation `changeBillingSubscription`). Auth: API key with scope `billing:write`, or access token. Requires `billing:write` and a live credential. A higher plan applies at once: the prorated difference is charged now and, once paid, the `subscription` balance grows by the extra credits for the rest of the period. A lower plan starts at the next renewal (`pendingPlan`). Asking for the current plan while a downgrade is pending cancels the downgrade. **Credits.** Free: no credits. Payments happen on the hosted page, never through the API. **Request body** (`application/json`, required, schema `ChangeSubscriptionRequest`): | Field | Type | Required | Description | |---|---|---|---| | `plan` | `string` | yes | One of: "pro", "agency", "scale" | Example request body: ```json { "plan": "agency" } ``` **Responses:** | Status | Description | Schema | Headers | |---|---|---|---| | `200` | The plan state after the change (credits move only when the provider reports payment). | `BillingSubscriptionState` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `400` | `INVALID_INPUT`. A credential sent in the query string is refused before authentication, without RateLimit headers. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `401` | `UNAUTHORIZED`: missing, malformed, unknown, revoked or expired credential. | `ErrorEnvelope` | `X-Request-Id` | | `403` | `FORBIDDEN`: `insufficient_scope` or `live_key_required` (`details.reason`). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `409` | `BILLING_STATE_CONFLICT`: `no_subscription`, `same_plan`, `canceling` or `past_due` (`details.reason`). Nothing was charged. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `413` | `PAYLOAD_TOO_LARGE`. | `ErrorEnvelope` | `X-Request-Id` | | `429` | `RATE_LIMITED`: the request rate of the API key (`details.scope` `key`) or of the account (`user`) is exhausted for this window, or too many failed authentication attempts came from this IP (no `details.scope` and no RateLimit headers). Retry after `Retry-After` seconds. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy`, `Retry-After` | | `503` | `BILLING_UNAVAILABLE`: `disabled`, `not_configured` or `provider_error` (`details.reason`). Nothing changed. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy`, `Retry-After` | | `default` | Error (see ErrorEnvelope and /v1/docs/errors). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | Example 200 response: ```json { "plan": "free", "limits": { "requestsPerMinutePerKey": 0, "requestsPerMinutePerUser": 0, "concurrentGenerations": 0, "dailyGenerations": 0, "maxStoredUploads": 0, "maxStoredUploadMegabytes": 0 }, "subscription": { "id": "7d0e1a52-3c4b-4f6e-9a8d-2b1c0f9e8a7d", "plan": "pro", "status": "active", "cancelAtPeriodEnd": true, "currentPeriodStart": "2026-10-29T12:00:00.000Z", "currentPeriodEnd": "2026-10-29T12:00:00.000Z", "pendingPlan": "pro", "creditsPerPeriod": 0, "subscriptionCredits": 0 } } ``` ## POST /billing/subscription/cancel **Cancel the plan at the period end** (operation `cancelBillingSubscription`). Auth: API key with scope `billing:write`, or access token. Requires `billing:write` and a live credential. The plan, its limits and its credits stay until the period ends; nothing renews after that. Works while plans are switched off, so a subscriber can always stop paying. **Credits.** Free: no credits. Payments happen on the hosted page, never through the API. **Responses:** | Status | Description | Schema | Headers | |---|---|---|---| | `200` | The plan state after the change (credits move only when the provider reports payment). | `BillingSubscriptionState` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `400` | `INVALID_INPUT`. A credential sent in the query string is refused before authentication, without RateLimit headers. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `401` | `UNAUTHORIZED`: missing, malformed, unknown, revoked or expired credential. | `ErrorEnvelope` | `X-Request-Id` | | `403` | `FORBIDDEN`: `insufficient_scope` or `live_key_required` (`details.reason`). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `409` | `BILLING_STATE_CONFLICT`: `no_subscription` (`details.reason`). Nothing was charged. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `429` | `RATE_LIMITED`: the request rate of the API key (`details.scope` `key`) or of the account (`user`) is exhausted for this window, or too many failed authentication attempts came from this IP (no `details.scope` and no RateLimit headers). Retry after `Retry-After` seconds. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy`, `Retry-After` | | `503` | `BILLING_UNAVAILABLE`: `disabled`, `not_configured` or `provider_error` (`details.reason`). Nothing changed. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy`, `Retry-After` | | `default` | Error (see ErrorEnvelope and /v1/docs/errors). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | Example 200 response: ```json { "plan": "free", "limits": { "requestsPerMinutePerKey": 0, "requestsPerMinutePerUser": 0, "concurrentGenerations": 0, "dailyGenerations": 0, "maxStoredUploads": 0, "maxStoredUploadMegabytes": 0 }, "subscription": { "id": "7d0e1a52-3c4b-4f6e-9a8d-2b1c0f9e8a7d", "plan": "pro", "status": "active", "cancelAtPeriodEnd": true, "currentPeriodStart": "2026-10-29T12:00:00.000Z", "currentPeriodEnd": "2026-10-29T12:00:00.000Z", "pendingPlan": "pro", "creditsPerPeriod": 0, "subscriptionCredits": 0 } } ``` ## POST /billing/subscription/uncancel **Keep the plan (undo a cancel)** (operation `uncancelBillingSubscription`). Auth: API key with scope `billing:write`, or access token. Requires `billing:write` and a live credential. Undoes a cancel before the period ends; the plan renews as before. **Credits.** Free: no credits. Payments happen on the hosted page, never through the API. **Responses:** | Status | Description | Schema | Headers | |---|---|---|---| | `200` | The plan state after the change (credits move only when the provider reports payment). | `BillingSubscriptionState` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `400` | `INVALID_INPUT`. A credential sent in the query string is refused before authentication, without RateLimit headers. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `401` | `UNAUTHORIZED`: missing, malformed, unknown, revoked or expired credential. | `ErrorEnvelope` | `X-Request-Id` | | `403` | `FORBIDDEN`: `insufficient_scope` or `live_key_required` (`details.reason`). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `409` | `BILLING_STATE_CONFLICT`: `no_subscription` (`details.reason`). Nothing was charged. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `429` | `RATE_LIMITED`: the request rate of the API key (`details.scope` `key`) or of the account (`user`) is exhausted for this window, or too many failed authentication attempts came from this IP (no `details.scope` and no RateLimit headers). Retry after `Retry-After` seconds. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy`, `Retry-After` | | `503` | `BILLING_UNAVAILABLE`: `disabled`, `not_configured` or `provider_error` (`details.reason`). Nothing changed. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy`, `Retry-After` | | `default` | Error (see ErrorEnvelope and /v1/docs/errors). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | Example 200 response: ```json { "plan": "free", "limits": { "requestsPerMinutePerKey": 0, "requestsPerMinutePerUser": 0, "concurrentGenerations": 0, "dailyGenerations": 0, "maxStoredUploads": 0, "maxStoredUploadMegabytes": 0 }, "subscription": { "id": "7d0e1a52-3c4b-4f6e-9a8d-2b1c0f9e8a7d", "plan": "pro", "status": "active", "cancelAtPeriodEnd": true, "currentPeriodStart": "2026-10-29T12:00:00.000Z", "currentPeriodEnd": "2026-10-29T12:00:00.000Z", "pendingPlan": "pro", "creditsPerPeriod": 0, "subscriptionCredits": 0 } } ``` ## POST /billing/portal **Open the billing portal** (operation `createBillingPortalSession`). Auth: API key with scope `billing:write`, or access token. Requires `billing:write` and a live credential. Returns a link to the payment provider portal (invoices, payment method, cancel). `mode: session` is signed in as this account; it is offered only when the payment account belongs to this account email. Otherwise `mode: email_login` asks for the billing email and sends a one-time code. **Credits.** Free: no credits. Payments happen on the hosted page, never through the API. **Request body** (`application/json`, optional, schema `CreatePortalSessionRequest`): | Field | Type | Required | Description | |---|---|---|---| | `returnUrl` | `string` | no | Where "back" in the portal leads. Default: the credits page. `https://` on storeshot.art, a storeshot.art subdomain or this API host; at most 2048 characters. Format: uri | Example request body: ```json { "returnUrl": "https://example.com" } ``` **Responses:** | Status | Description | Schema | Headers | |---|---|---|---| | `200` | The portal link. | `BillingPortalSession` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `400` | `INVALID_INPUT`. A credential sent in the query string is refused before authentication, without RateLimit headers. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `401` | `UNAUTHORIZED`: missing, malformed, unknown, revoked or expired credential. | `ErrorEnvelope` | `X-Request-Id` | | `403` | `FORBIDDEN`: `insufficient_scope` or `live_key_required` (`details.reason`). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `409` | `BILLING_STATE_CONFLICT`: `no_customer` (`details.reason`). Nothing was charged. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | | `413` | `PAYLOAD_TOO_LARGE`. | `ErrorEnvelope` | `X-Request-Id` | | `429` | `RATE_LIMITED`: the request rate of the API key (`details.scope` `key`) or of the account (`user`) is exhausted for this window, or too many failed authentication attempts came from this IP (no `details.scope` and no RateLimit headers). Retry after `Retry-After` seconds. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy`, `Retry-After` | | `503` | `BILLING_UNAVAILABLE`: `disabled`, `not_configured` or `provider_error` (`details.reason`). Nothing changed. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy`, `Retry-After` | | `default` | Error (see ErrorEnvelope and /v1/docs/errors). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` | Example 200 response: ```json { "url": "https://example.com", "expiresAt": "2026-10-29T12:00:00.000Z", "mode": "session" } ``` --- # StoreShot API schemas > HTML version: https://storeshot.art/docs/api/schemas/ The 117 objects of the StoreShot API 1.18.0 contract. ## ApiKeyEnvironment Type `string`. `test` keys never incur charges or call image providers. One of: `"live"`, `"test"`. ## GenerationStatus Type `string`. `queued`: waiting for a worker (can be canceled). `running`: being generated (retries keep it `running`). `succeeded`, `failed` and `canceled` are final. One of: `"queued"`, `"running"`, `"succeeded"`, `"failed"`, `"canceled"`. ## StoreTarget Type `string`. One of: `"app_store"`, `"google_play"`. ## Orientation Type `string`. One of: `"portrait"`, `"landscape"`. ## PaletteColor Type `object`. | Field | Type | Required | Description | |---|---|---|---| | `hex` | `string` | yes | | | `name` | `string` | yes | | ## WebhookGenerationData Type `object`. The `Generation` at the time of the event, without download URLs. | Field | Type | Required | Description | |---|---|---|---| | `id` | `string` | yes | Format: uuid | | `status` | `GenerationStatus` | yes | | | `environment` | `ApiKeyEnvironment` | yes | | | `inputs` | `array of object` | yes | | | `inputs[].uploadId` | `string` | yes | Format: uuid | | `inputs[].sourceUrl` | `string`, nullable | yes | For a URL input: origin and path of the URL (it was imported as `uploadId`). | | `store` | `StoreTarget` | yes | | | `device` | `string` | yes | Catalogue id of the size. | | `size` | `object` | yes | | | `size.width` | `integer` | yes | | | `size.height` | `integer` | yes | | | `orientation` | `Orientation` | yes | | | `app` | `object` | yes | | | `app.name` | `string` | yes | | | `app.description` | `string` | yes | | | `caption` | `string`, nullable | yes | As requested; null = derive. | | `palette` | `array of PaletteColor`, nullable | yes | As requested, or from the style (`style.applied` contains `palette`); null = extract from the screenshot. | | `logo` | `object`, nullable | yes | | | `logo.uploadId` | `string` | yes | Format: uuid | | `metadata` | `object` | yes | | | `creditsCharged` | `integer` | yes | 1 for live keys, 0 for test keys. | | `creditsRefunded` | `integer` | yes | Refunded when the generation failed or was canceled before it started. | | `result` | `object`, nullable | yes | As in `GET /generations/{id}` but without URLs (they expire): fetch the generation for fresh ones. | | `result.image` | `object` | yes | | | `result.image.contentType` | `string` | yes | PNG; JPEG for the Google Play feature graphic. One of: "image/png", "image/jpeg" | | `result.image.width` | `integer` | yes | | | `result.image.height` | `integer` | yes | | | `result.thumbnail` | `object`, nullable | yes | | | `result.thumbnail.contentType` | `string` | yes | One of: "image/webp" | | `result.caption` | `string` | yes | The caption used: yours, or the one derived from the screenshot (may be empty). | | `result.palette` | `array of PaletteColor` | yes | The colours used. | | `error` | `object`, nullable | yes | Only when `failed`. See /v1/docs/errors for each code. | | `error.code` | `string` | yes | One of: "GENERATION_FAILED", "GENERATION_TIMEOUT", "NOT_A_SCREENSHOT", "UPLOAD_NOT_READY", "INTERNAL_ERROR" | | `error.message` | `string` | yes | | | `createdAt` | `string` | yes | RFC 3339 UTC timestamp. | | `startedAt` | `string`, nullable | yes | RFC 3339 UTC timestamp. | | `finishedAt` | `string`, nullable | yes | RFC 3339 UTC timestamp. | | `deletedAt` | `string`, nullable | yes | Set by `DELETE`: the result files are gone, this record stays. | | `screenshotSet` | `object` | no | Present only when this generation is an image of a screenshot set (`POST /screenshot-sets`). | | `screenshotSet.id` | `string` | yes | Format: uuid | | `screenshotSet.position` | `integer` | yes | The screen (1-based). | | `screenshotSet.attempt` | `integer` | yes | 2 and up: a retry. | ## GenerationSucceededEvent Type `object`. | Field | Type | Required | Description | |---|---|---|---| | `id` | `string` | yes | Event id (`evt_…`), identical on every attempt: deduplicate on it. | | `type` | `string` | yes | One of: "generation.succeeded" | | `created` | `string` | yes | RFC 3339 UTC timestamp. | | `environment` | `ApiKeyEnvironment` | yes | | | `apiVersion` | `string` | yes | | | `data` | `WebhookGenerationData` | yes | | ## GenerationFailedEvent Type `object`. | Field | Type | Required | Description | |---|---|---|---| | `id` | `string` | yes | Event id (`evt_…`), identical on every attempt: deduplicate on it. | | `type` | `string` | yes | One of: "generation.failed" | | `created` | `string` | yes | RFC 3339 UTC timestamp. | | `environment` | `ApiKeyEnvironment` | yes | | | `apiVersion` | `string` | yes | | | `data` | `WebhookGenerationData` | yes | | ## ScreenshotSetStatus Type `string`. Computed from the current image of every slot: `queued` (all queued), `running` (any running, or some finished and some not), `succeeded` (all succeeded), `partially_failed` (all finished, some succeeded), `canceled` (none succeeded, all canceled), `failed` (none succeeded, otherwise). One of: `"queued"`, `"running"`, `"succeeded"`, `"partially_failed"`, `"failed"`, `"canceled"`. ## ScreenshotSetPreview Type `object`. A thumbnail for lists (since 1.12.0): the most recently finished succeeded image of the current attempts, a device screenshot before the Google Play feature graphic. Only on `GET /screenshot-sets` and `GET /screenshot-sets/{id}`; absent from create, retry and cancel answers and from `screenshot_set.completed` events. `null` while no image has succeeded, or for a deleted set. | Field | Type | Required | Description | |---|---|---|---| | `generationId` | `string` | yes | The image shown. Format: uuid | | `store` | `StoreTarget` | yes | | | `device` | `string` | yes | | | `thumbnail` | `object` | yes | | | `thumbnail.url` | `string` | yes | Signed URL of the 512 px WebP preview (expires at `urlsExpireAt`). Format: uri | | `thumbnail.contentType` | `string` | yes | One of: "image/webp" | | `urlsExpireAt` | `string` | yes | 15 minutes after this response. Fetch the set again for a fresh URL. | ## WebhookScreenshotSetData Type `object`. The `ScreenshotSet` at the time of the event, without `source`, `project` and `preview`. | Field | Type | Required | Description | |---|---|---|---| | `id` | `string` | yes | Format: uuid | | `status` | `ScreenshotSetStatus` | yes | | | `environment` | `ApiKeyEnvironment` | yes | | | `app` | `object` | yes | | | `app.name` | `string` | yes | | | `app.description` | `string` | yes | | | `orientation` | `Orientation` | yes | | | `palette` | `array of PaletteColor` | yes | The palette of every image: as requested, or from the style (`style.applied` contains `palette`), or extracted. | | `paletteSource` | `string` | yes | `auto`: extracted from screen 1 when the set was created. One of: "given", "auto" | | `logo` | `object`, nullable | yes | | | `logo.uploadId` | `string` | yes | Format: uuid | | `metadata` | `object` | yes | | | `screens` | `array of object` | yes | | | `screens[].position` | `integer` | yes | | | `screens[].input` | `object` | yes | | | `screens[].input.uploadId` | `string` | yes | Format: uuid | | `screens[].caption` | `string` | yes | | | `stores` | `array of object` | yes | | | `stores[].store` | `StoreTarget` | yes | | | `stores[].devices` | `array of string` | yes | | | `stores[].featureGraphic` | `object`, nullable | yes | | | `stores[].featureGraphic.screen` | `integer` | yes | | | `stores[].featureGraphic.caption` | `string` | yes | | | `images` | `integer` | yes | | | `counts` | `object` | yes | | | `counts.queued` | `integer` | yes | | | `counts.running` | `integer` | yes | | | `counts.succeeded` | `integer` | yes | | | `counts.failed` | `integer` | yes | | | `counts.canceled` | `integer` | yes | | | `creditsCharged` | `integer` | yes | Every image of the set, retries included (0 with a test key). | | `creditsRefunded` | `integer` | yes | | | `children` | `array of object` | yes | The current image of every slot (store, device, screen). Result URLs: `GET /generations?screenshotSetId=` or the archive. | | `children[].position` | `integer` | yes | The screen (1-based). | | `children[].store` | `StoreTarget` | yes | | | `children[].device` | `string` | yes | | | `children[].size` | `object` | yes | | | `children[].size.width` | `integer` | yes | | | `children[].size.height` | `integer` | yes | | | `children[].featureGraphic` | `boolean` | yes | | | `children[].generationId` | `string` | yes | Format: uuid | | `children[].attempt` | `integer` | yes | | | `children[].status` | `GenerationStatus` | yes | | | `children[].error` | `object`, nullable | yes | | | `children[].error.code` | `string` | yes | One of: "GENERATION_FAILED", "GENERATION_TIMEOUT", "NOT_A_SCREENSHOT", "UPLOAD_NOT_READY", "INTERNAL_ERROR" | | `children[].error.message` | `string` | yes | | | `archiveUrl` | `string` | yes | | | `createdAt` | `string` | yes | RFC 3339 UTC timestamp. | | `updatedAt` | `string` | yes | RFC 3339 UTC timestamp. | | `finishedAt` | `string`, nullable | yes | RFC 3339 UTC timestamp. | | `deletedAt` | `string`, nullable | yes | RFC 3339 UTC timestamp. | | `preview` | `ScreenshotSetPreview` | no | | ## ScreenshotSetCompletedEvent Type `object`. | Field | Type | Required | Description | |---|---|---|---| | `id` | `string` | yes | Event id (`evt_…`), identical on every attempt: deduplicate on it. | | `type` | `string` | yes | One of: "screenshot_set.completed" | | `created` | `string` | yes | RFC 3339 UTC timestamp. | | `environment` | `ApiKeyEnvironment` | yes | | | `apiVersion` | `string` | yes | | | `data` | `WebhookScreenshotSetData` | yes | | ## CreditsLowEvent Type `object`. | Field | Type | Required | Description | |---|---|---|---| | `id` | `string` | yes | Event id (`evt_…`), identical on every attempt: deduplicate on it. | | `type` | `string` | yes | One of: "credits.low" | | `created` | `string` | yes | RFC 3339 UTC timestamp. | | `environment` | `ApiKeyEnvironment` | yes | | | `apiVersion` | `string` | yes | | | `data` | `object` | yes | | | `data.balances` | `object` | yes | | | `data.balances.starter` | `integer` | yes | | | `data.balances.subscription` | `integer` | yes | | | `data.balances.purchased` | `integer` | yes | | | `data.total` | `integer` | yes | | | `data.threshold` | `integer` | yes | | ## WebhookTestEvent Type `object`. | Field | Type | Required | Description | |---|---|---|---| | `id` | `string` | yes | Event id (`evt_…`), identical on every attempt: deduplicate on it. | | `type` | `string` | yes | One of: "webhook.test" | | `created` | `string` | yes | RFC 3339 UTC timestamp. | | `environment` | `ApiKeyEnvironment` | yes | | | `apiVersion` | `string` | yes | | | `data` | `object` | yes | | | `data.webhookId` | `string` | yes | Format: uuid | | `data.message` | `string` | yes | | ## CheckStatus Type `string`. One of: `"up"`, `"down"`, `"not_configured"`. ## HealthReport Type `object`. | Field | Type | Required | Description | |---|---|---|---| | `status` | `string` | yes | One of: "healthy", "degraded" | | `version` | `string` | yes | Server release version. | | `commit` | `string` | yes | Deployed git commit (short SHA). | | `checks` | `object` | yes | | | `checks.database` | `CheckStatus` | yes | | | `checks.redis` | `CheckStatus` | yes | | ## ErrorCode Type `string`. Stable, machine-readable error code. See /v1/docs/errors. One of: `"INVALID_INPUT"`, `"UNAUTHORIZED"`, `"FORBIDDEN"`, `"NOT_FOUND"`, `"METHOD_NOT_ALLOWED"`, `"PAYLOAD_TOO_LARGE"`, `"UPLOAD_NOT_READY"`, `"IDEMPOTENCY_CONFLICT"`, `"GENERATION_STATE_CONFLICT"`, `"SCREENSHOT_SET_STATE_CONFLICT"`, `"WEBHOOK_STATE_CONFLICT"`, `"BILLING_STATE_CONFLICT"`, `"IMAGE_TOO_LARGE"`, `"UNSUPPORTED_FORMAT"`, `"NOT_A_SCREENSHOT"`, `"URL_FETCH_FAILED"`, `"WEBHOOK_URL_REJECTED"`, `"INSUFFICIENT_CREDITS"`, `"RATE_LIMITED"`, `"CONCURRENCY_LIMITED"`, `"INTERNAL_ERROR"`, `"GENERATION_FAILED"`, `"SERVICE_UNAVAILABLE"`, `"BILLING_UNAVAILABLE"`, `"GENERATION_TIMEOUT"`. ## ErrorEnvelope Type `object`. | Field | Type | Required | Description | |---|---|---|---| | `error` | `object` | yes | | | `error.code` | `ErrorCode` | yes | | | `error.message` | `string` | yes | Human-readable message; safe to display. | | `error.requestId` | `string` | yes | Equals the X-Request-Id response header. | | `error.docUrl` | `string` | yes | Documentation for this error code. Format: uri | | `error.details` | `object` | no | Code-specific structured details. | ## DeviceKind Type `string`. One of: `"screenshot"`, `"feature_graphic"`. ## Device Type `object`. | Field | Type | Required | Description | |---|---|---|---| | `id` | `string` | yes | Stable identifier: `_x`. | | `store` | `StoreTarget` | yes | | | `kind` | `DeviceKind` | yes | | | `label` | `string` | yes | Display name; may change. Do not parse. | | `deviceHint` | `string` | yes | Example devices; may change. Do not parse. | | `width` | `integer` | yes | Pixels. Portrait (canonical) for screenshots; landscape swaps width/height. | | `height` | `integer` | yes | Pixels. | | `orientations` | `array of Orientation` | yes | | | `deprecated` | `boolean` | no | `true`: still accepted (same output size) so existing requests keep working, but it breaks a store rule or is superseded. Never a default. Use `replacedBy`. Absent otherwise. | | `replacedBy` | `string` | no | Deprecated devices only: the `GET /devices` id to use instead. | ## DeviceList Type `object`. | Field | Type | Required | Description | |---|---|---|---| | `data` | `array of Device` | yes | | ## ComponentStatus Type `string`. The worst known component state (`unknown` only if every component is). One of: `"operational"`, `"degraded"`, `"outage"`, `"unknown"`. ## StatusReport Type `object`. | Field | Type | Required | Description | |---|---|---|---| | `status` | `ComponentStatus` | yes | | | `updatedAt` | `string` | yes | When the state was computed (RFC 3339 UTC). | | `windowSeconds` | `integer` | yes | The window judged, in seconds. | | `components` | `array of object` | yes | | | `components[].id` | `string` | yes | One of: "api", "generation", "image_provider", "vision_provider" | | `components[].name` | `string` | yes | | | `components[].status` | `ComponentStatus` | yes | `unknown`: no recent traffic to judge from, or the state could not be read. | ## ApiKeyScope Type `string`. Permission an API key carries. Access tokens implicitly hold every scope. One of: `"generations:write"`, `"generations:read"`, `"uploads:write"`, `"usage:read"`, `"keys:manage"`, `"billing:read"`, `"billing:write"`, `"webhooks:read"`, `"webhooks:write"`. ## Me Type `object`. | Field | Type | Required | Description | |---|---|---|---| | `user` | `object` | yes | | | `user.id` | `string` | yes | Format: uuid | | `user.displayName` | `string`, nullable | yes | | | `user.avatarUrl` | `string`, nullable | yes | | | `user.email` | `string` | no | Only when authenticated with an access token. | | `user.createdAt` | `string` | yes | RFC 3339 UTC timestamp. | | `credential` | `object` | yes | | | `credential.type` | `string` | yes | One of: "user_token", "api_key" | | `credential.environment` | `ApiKeyEnvironment` | yes | | | `credential.scopes` | `array of ApiKeyScope` | yes | | | `credential.apiKey` | `object` | no | Only when authenticated with an API key. Never the secret. | | `credential.apiKey.id` | `string` | yes | Format: uuid | | `credential.apiKey.name` | `string` | yes | | | `credential.apiKey.prefix` | `string` | yes | | | `credential.apiKey.expiresAt` | `string`, nullable | yes | RFC 3339 UTC timestamp. | ## ApiKey Type `object`. | Field | Type | Required | Description | |---|---|---|---| | `id` | `string` | yes | Format: uuid | | `name` | `string` | yes | | | `prefix` | `string` | yes | First characters of the key, for display. | | `environment` | `ApiKeyEnvironment` | yes | | | `scopes` | `array of ApiKeyScope` | yes | | | `status` | `string` | yes | One of: "active", "expired" | | `createdAt` | `string` | yes | RFC 3339 UTC timestamp. | | `lastUsedAt` | `string`, nullable | yes | RFC 3339 UTC timestamp. | | `expiresAt` | `string`, nullable | yes | RFC 3339 UTC timestamp. | ## CreatedApiKey Type `ApiKey & object`. | Field | Type | Required | Description | |---|---|---|---| | `id` | `string` | yes | Format: uuid | | `name` | `string` | yes | | | `prefix` | `string` | yes | First characters of the key, for display. | | `environment` | `ApiKeyEnvironment` | yes | | | `scopes` | `array of ApiKeyScope` | yes | | | `status` | `string` | yes | One of: "active", "expired" | | `createdAt` | `string` | yes | RFC 3339 UTC timestamp. | | `lastUsedAt` | `string`, nullable | yes | RFC 3339 UTC timestamp. | | `expiresAt` | `string`, nullable | yes | RFC 3339 UTC timestamp. | | `key` | `string` | yes | The full secret. Returned only in this response; store it now. | ## CreateApiKeyRequest Type `object`. | Field | Type | Required | Description | |---|---|---|---| | `name` | `string` | yes | 1–64 characters after trimming; need not be unique. | | `environment` | `ApiKeyEnvironment` | yes | | | `scopes` | `array of ApiKeyScope` | no | Defaults to every scope except `keys:manage`, `billing:read`, `billing:write`, `webhooks:read` and `webhooks:write`. | | `expiresAt` | `string` | no | Optional RFC 3339 expiry, in the future and at most 365 days ahead. Format: date-time | ## ApiKeyList Type `object`. Non-revoked keys, newest first (at most 100). | Field | Type | Required | Description | |---|---|---|---| | `data` | `array of ApiKey` | yes | | ## DeviceAuthorization Type `object`. | Field | Type | Required | Description | |---|---|---|---| | `device_code` | `string` | yes | Secret. Send it to `POST /auth/device/token`; never show it to anyone. | | `user_code` | `string` | yes | Show it to the human: they confirm it in the console. | | `verification_uri` | `string` | yes | Format: uri | | `verification_uri_complete` | `string` | yes | The verification page with the code filled in. Format: uri | | `expires_in` | `integer` | yes | Seconds until the codes expire. | | `interval` | `integer` | yes | Seconds to wait between polls of `POST /auth/device/token`. | ## DeviceAuthorizationRequest Type `object`. | Field | Type | Required | Description | |---|---|---|---| | `client_name` | `string` | no | Your tool's name, shown to the human as an unverified label. 1–64 printable ASCII characters. When absent, `client_id` is used instead (generic OAuth libraries send one). | | `scope` | `string` | no | Space-separated scopes for the new key. Default `generations:write generations:read uploads:write usage:read`. `keys:manage`, `billing:write` and `webhooks:write` cannot be requested. The human may narrow the list, or add `billing:read` and `webhooks:read`. | | `environment` | `ApiKeyEnvironment` | no | Environment of the new key. Default `live`. | ## DeviceToken Type `object`. | Field | Type | Required | Description | |---|---|---|---| | `access_token` | `string` | yes | The new API key (`ss_live_…` or `ss_test_…`). Returned once: store it now. | | `token_type` | `string` | yes | One of: "Bearer" | | `scope` | `string` | yes | Space-separated scopes the human approved (may be narrower than requested). | | `key_id` | `string` | yes | Id of the new API key. Format: uuid | | `environment` | `ApiKeyEnvironment` | yes | | ## DeviceTokenError Type `object`. OAuth 2.0 error body (RFC 6749 §5.2, RFC 8628 §3.5): the one deliberate exception to `ErrorEnvelope`. `X-Request-Id` is still in the response headers. | Field | Type | Required | Description | |---|---|---|---| | `error` | `string` | yes | One of: "authorization_pending", "slow_down", "access_denied", "expired_token", "invalid_grant", "invalid_request", "unsupported_grant_type" | | `error_description` | `string` | yes | | ## DeviceTokenRequest Type `object`. | Field | Type | Required | Description | |---|---|---|---| | `grant_type` | `string` | yes | One of: "urn:ietf:params:oauth:grant-type:device_code" | | `device_code` | `string` | yes | | ## CreditBalances Type `object`. Remaining credits. A caller with no credits reads all zeros. | Field | Type | Required | Description | |---|---|---|---| | `balances` | `object` | yes | | | `balances.starter` | `integer` | yes | | | `balances.subscription` | `integer` | yes | | | `balances.purchased` | `integer` | yes | | | `total` | `integer` | yes | Sum of the three balances. | ## UsageGroupBy Type `string`. `day`: one bucket per UTC day in the range, including empty days. `key`: per API key id (`none` for calls without a key). `source`: per surface (`mobile`, `api`, `web`, `mcp`, `cli`). One of: `"day"`, `"key"`, `"source"`. ## UsageCounts Type `object`. | Field | Type | Required | Description | |---|---|---|---| | `generations` | `integer` | yes | Generations started in the bucket. | | `succeeded` | `integer` | yes | Completed. | | `failed` | `integer` | yes | Failed before any credit was charged. | | `refunded` | `integer` | yes | Charged, then refunded. | | `pending` | `integer` | yes | Still running. | | `creditsUsed` | `integer` | yes | Credits charged minus credits refunded for these generations. One generation can use several credits. | ## UsageBucket Type `UsageCounts & object`. | Field | Type | Required | Description | |---|---|---|---| | `generations` | `integer` | yes | Generations started in the bucket. | | `succeeded` | `integer` | yes | Completed. | | `failed` | `integer` | yes | Failed before any credit was charged. | | `refunded` | `integer` | yes | Charged, then refunded. | | `pending` | `integer` | yes | Still running. | | `creditsUsed` | `integer` | yes | Credits charged minus credits refunded for these generations. One generation can use several credits. | | `key` | `string` | yes | The UTC day, API key id or source. | ## UsageReport Type `object`. Live usage of the whole account (every key and surface; test keys are unbilled and not counted), bucketed by when each generation started. Refunds always count against the generation they belong to. | Field | Type | Required | Description | |---|---|---|---| | `from` | `string` | yes | Resolved start (inclusive). | | `to` | `string` | yes | Resolved end (exclusive). | | `groupBy` | `UsageGroupBy` | yes | | | `data` | `array of UsageBucket` | yes | Buckets sorted by `key`. | | `totals` | `UsageCounts` | yes | | ## UploadStatus Type `string`. `pending`: presigned, waiting for the file and `complete`. `ready`: usable in generations. `failed`: the file was rejected (it is deleted). `deleted`: deleted by you or by retention (metadata only). One of: `"pending"`, `"ready"`, `"failed"`, `"deleted"`. ## UploadOrientation Type `string`. From the EXIF-oriented size; `square` when equal. One of: `"portrait"`, `"landscape"`, `"square"`. ## Upload Type `object`. | Field | Type | Required | Description | |---|---|---|---| | `id` | `string` | yes | Format: uuid | | `status` | `UploadStatus` | yes | | | `source` | `string` | yes | How the file arrived: a multipart upload, a presigned PUT, or a URL import. One of: "multipart", "presign", "url" | | `environment` | `ApiKeyEnvironment` | yes | | | `contentType` | `string` | yes | Detected from the file bytes (for a pending presigned upload: the declared type). One of: "image/png", "image/jpeg", "image/webp" | | `bytes` | `integer` | yes | File size (declared, while pending). | | `width` | `integer`, nullable | yes | Pixels, EXIF-oriented. Null unless ready. | | `height` | `integer`, nullable | yes | Pixels, EXIF-oriented. Null unless ready. | | `orientation` | `UploadOrientation` | yes | | | `sha256` | `string`, nullable | yes | Hex SHA-256 of the file. Null unless ready. | | `sourceUrl` | `string`, nullable | yes | URL imports only: origin and path of the URL you sent (query and fragment are never stored). | | `expiresAt` | `string` | yes | When the upload and its file are deleted: 30 days after creation (24 hours for pending or failed uploads). Generated results are kept until you delete them. | | `createdAt` | `string` | yes | RFC 3339 UTC timestamp. | ## CreateUploadFromUrl Type `object`. | Field | Type | Required | Description | |---|---|---|---| | `url` | `string` | yes | An `https://` URL (port 443, no credentials, at most 2048 characters) that resolves to a public address. Fetched once, server-side. | ## PresignedUpload Type `object`. | Field | Type | Required | Description | |---|---|---|---| | `upload` | `Upload` | yes | | | `uploadUrl` | `string` | yes | PUT the file here, from your server. Format: uri | | `method` | `string` | yes | One of: "PUT" | | `headers` | `object` | yes | Send exactly these headers with the PUT (they are signed). | | `uploadUrlExpiresAt` | `string` | yes | 15 minutes after this response. | ## PresignUploadRequest Type `object`. | Field | Type | Required | Description | |---|---|---|---| | `contentType` | `string` | yes | The type of the file you will PUT: `image/png`, `image/jpeg` or `image/webp`. | | `bytes` | `integer` | yes | Exact size of the file you will PUT, at most 20 MB (20971520). | ## UploadList Type `object`. Uploads that are not deleted, newest first. | Field | Type | Required | Description | |---|---|---|---| | `data` | `array of Upload` | yes | | | `nextCursor` | `string`, nullable | yes | Pass as `cursor` for the next page; null on the last page. | ## GenerationResult Type `object`. Only when `succeeded` and not deleted. | Field | Type | Required | Description | |---|---|---|---| | `image` | `object` | yes | | | `image.url` | `string` | yes | Signed download URL (expires at `urlsExpireAt`). Format: uri | | `image.contentType` | `string` | yes | PNG; JPEG for the Google Play feature graphic. One of: "image/png", "image/jpeg" | | `image.width` | `integer` | yes | | | `image.height` | `integer` | yes | | | `thumbnail` | `object`, nullable | yes | A 512 px preview. Best effort: its URL may return 404. | | `thumbnail.url` | `string` | yes | Format: uri | | `thumbnail.contentType` | `string` | yes | One of: "image/webp" | | `urlsExpireAt` | `string` | yes | 15 minutes after this response. Fetch the generation again for fresh URLs. | | `caption` | `string` | yes | The caption used: yours, or the one derived from the screenshot (may be empty). | | `palette` | `array of PaletteColor` | yes | The colours used. | ## GenerationSource Type `string`. Since 1.15.0 (always sent): where the request that created it came from. One of: `"mobile"`, `"api"`, `"web"`, `"mcp"`, `"cli"`. ## ProjectRef Type `object`. Since 1.15.0 (always sent): its project, directly or through its screenshot set (`POST /projects/{id}/items`). | Field | Type | Required | Description | |---|---|---|---| | `id` | `string` | yes | Format: uuid | | `name` | `string` | yes | | ## StyleField Type `string`. A field a style can supply to a generation or screenshot set. One of: `"palette"`, `"logo"`, `"backgroundMood"`, `"backgroundPrompt"`, `"captionTone"`. ## StyleRef Type `object`. Since 1.18.0 (always sent): the style (`styleId`) it was made with, for an image of a screenshot set its set's; `null` when none. | Field | Type | Required | Description | |---|---|---|---| | `id` | `string`, nullable | yes | `null` once the style was deleted (the generation keeps what it used). Format: uuid | | `name` | `string` | yes | | | `applied` | `array of StyleField` | yes | What the style supplied, as it was at creation. Fields you sent yourself are not listed. | ## Generation Type `object`. | Field | Type | Required | Description | |---|---|---|---| | `id` | `string` | yes | Format: uuid | | `status` | `GenerationStatus` | yes | | | `environment` | `ApiKeyEnvironment` | yes | | | `inputs` | `array of object` | yes | | | `inputs[].uploadId` | `string` | yes | Format: uuid | | `inputs[].sourceUrl` | `string`, nullable | yes | For a URL input: origin and path of the URL (it was imported as `uploadId`). | | `store` | `StoreTarget` | yes | | | `device` | `string` | yes | Catalogue id of the size. | | `size` | `object` | yes | | | `size.width` | `integer` | yes | | | `size.height` | `integer` | yes | | | `orientation` | `Orientation` | yes | | | `app` | `object` | yes | | | `app.name` | `string` | yes | | | `app.description` | `string` | yes | | | `caption` | `string`, nullable | yes | As requested; null = derive. | | `palette` | `array of PaletteColor`, nullable | yes | As requested, or from the style (`style.applied` contains `palette`); null = extract from the screenshot. | | `logo` | `object`, nullable | yes | | | `logo.uploadId` | `string` | yes | Format: uuid | | `metadata` | `object` | yes | | | `creditsCharged` | `integer` | yes | 1 for live keys, 0 for test keys. | | `creditsRefunded` | `integer` | yes | Refunded when the generation failed or was canceled before it started. | | `result` | `GenerationResult` | yes | | | `error` | `object`, nullable | yes | Only when `failed`. See /v1/docs/errors for each code. | | `error.code` | `string` | yes | One of: "GENERATION_FAILED", "GENERATION_TIMEOUT", "NOT_A_SCREENSHOT", "UPLOAD_NOT_READY", "INTERNAL_ERROR" | | `error.message` | `string` | yes | | | `createdAt` | `string` | yes | RFC 3339 UTC timestamp. | | `startedAt` | `string`, nullable | yes | RFC 3339 UTC timestamp. | | `finishedAt` | `string`, nullable | yes | RFC 3339 UTC timestamp. | | `deletedAt` | `string`, nullable | yes | Set by `DELETE`: the result files are gone, this record stays. | | `screenshotSet` | `object` | no | Present only when this generation is an image of a screenshot set (`POST /screenshot-sets`). | | `screenshotSet.id` | `string` | yes | Format: uuid | | `screenshotSet.position` | `integer` | yes | The screen (1-based). | | `screenshotSet.attempt` | `integer` | yes | 2 and up: a retry. | | `source` | `GenerationSource` | no | | | `clientName` | `string`, nullable | no | Since 1.15.0 (always sent): the `X-Storeshot-Client` header of the request (e.g. `cli/storeshot@1.2.0`), else its sanitised `User-Agent`. Self-asserted: for your records only. | | `requestId` | `string`, nullable | no | Since 1.15.0 (always sent): the `X-Request-Id` of the request that created it (quote it to support). Null for test generations made before 1.15.0. | | `project` | `ProjectRef` | no | | | `style` | `StyleRef` | no | | ## GenerationInput Type `object`. An image: an upload id or an https URL. ## GenerationSize Type `object`. Output pixels: a catalogue size of the store (portrait, or swapped for landscape; the Google Play feature graphic is 1024×500 only). | Field | Type | Required | Description | |---|---|---|---| | `width` | `integer` | yes | | | `height` | `integer` | yes | | ## GenerationMetadata Type `object`. Your own labels, echoed back and filterable (`GET /generations?metadata=key:value`): at most 20 keys of 1–40 characters (`A-Z a-z 0-9 _ . -`), string values of at most 500 characters, 4 KB in total. ## CreateGenerationRequest Type `object`. | Field | Type | Required | Description | |---|---|---|---| | `inputs` | `array of GenerationInput` | yes | Exactly one screenshot in this version. | | `store` | `StoreTarget` | yes | | | `device` | `string` | no | A `GET /devices` id of the same store. Exactly one of `device` and `size` is required. | | `size` | `GenerationSize` | no | | | `orientation` | `Orientation` | no | Default: from `size` (wider than tall is landscape), else portrait (the feature graphic: landscape). Must agree with the size and the device. | | `app` | `object` | yes | | | `app.name` | `string` | yes | | | `app.description` | `string` | no | Default: "" | | `caption` | `string` | no | The marketing caption. Absent or blank: derived from the screenshot. | | `palette` | `array of PaletteColor` | no | 1–8 colours. Absent: extracted from the screenshot. | | `logo` | `GenerationInput` | no | Your app icon or logo (any shape; PNG, JPEG or WebP). | | `metadata` | `GenerationMetadata` | no | | | `projectId` | `string` | no | Since 1.15.0: put the generation in this project of yours (`GET /projects`). The project does not change how it is generated. Format: uuid | | `styleId` | `string` | no | Since 1.18.0: a style of yours (`GET /styles`). It fills `palette` and `logo` when you leave them out and adds its background mood, description and caption tone. It is copied into the generation: editing or deleting the style later changes nothing here. Format: uuid | ## GenerationList Type `object`. Generations that are not deleted, newest first. | Field | Type | Required | Description | |---|---|---|---| | `data` | `array of Generation` | yes | | | `nextCursor` | `string`, nullable | yes | Pass as `cursor` for the next page; null on the last page. | ## ScreenshotSetQuote Type `object`. Nothing was created or charged. A quote is not a reservation. | Field | Type | Required | Description | |---|---|---|---| | `dryRun` | `boolean` | yes | One of: true | | `environment` | `ApiKeyEnvironment` | yes | | | `images` | `integer` | yes | | | `credits` | `integer` | yes | What creating this set charges now (1 per image with a live key, 0 with a test key). | | `availableCredits` | `integer` | yes | | | `sufficient` | `boolean` | yes | | | `screens` | `integer` | yes | | | `breakdown` | `array of object` | yes | | | `breakdown[].store` | `StoreTarget` | yes | | | `breakdown[].device` | `string` | yes | | | `breakdown[].size` | `object` | yes | | | `breakdown[].size.width` | `integer` | yes | | | `breakdown[].size.height` | `integer` | yes | | | `breakdown[].orientation` | `Orientation` | yes | | | `breakdown[].images` | `integer` | yes | | | `breakdown[].defaulted` | `boolean` | yes | The store default (no `devices` given). | ## ScreenshotSet Type `object`. | Field | Type | Required | Description | |---|---|---|---| | `id` | `string` | yes | Format: uuid | | `status` | `ScreenshotSetStatus` | yes | | | `environment` | `ApiKeyEnvironment` | yes | | | `app` | `object` | yes | | | `app.name` | `string` | yes | | | `app.description` | `string` | yes | | | `orientation` | `Orientation` | yes | | | `palette` | `array of PaletteColor` | yes | The palette of every image: as requested, or from the style (`style.applied` contains `palette`), or extracted. | | `paletteSource` | `string` | yes | `auto`: extracted from screen 1 when the set was created. One of: "given", "auto" | | `logo` | `object`, nullable | yes | | | `logo.uploadId` | `string` | yes | Format: uuid | | `metadata` | `object` | yes | | | `screens` | `array of object` | yes | | | `screens[].position` | `integer` | yes | | | `screens[].input` | `object` | yes | | | `screens[].input.uploadId` | `string` | yes | Format: uuid | | `screens[].caption` | `string` | yes | | | `stores` | `array of object` | yes | | | `stores[].store` | `StoreTarget` | yes | | | `stores[].devices` | `array of string` | yes | | | `stores[].featureGraphic` | `object`, nullable | yes | | | `stores[].featureGraphic.screen` | `integer` | yes | | | `stores[].featureGraphic.caption` | `string` | yes | | | `images` | `integer` | yes | | | `counts` | `object` | yes | | | `counts.queued` | `integer` | yes | | | `counts.running` | `integer` | yes | | | `counts.succeeded` | `integer` | yes | | | `counts.failed` | `integer` | yes | | | `counts.canceled` | `integer` | yes | | | `creditsCharged` | `integer` | yes | Every image of the set, retries included (0 with a test key). | | `creditsRefunded` | `integer` | yes | | | `children` | `array of object` | yes | The current image of every slot (store, device, screen). Result URLs: `GET /generations?screenshotSetId=` or the archive. | | `children[].position` | `integer` | yes | The screen (1-based). | | `children[].store` | `StoreTarget` | yes | | | `children[].device` | `string` | yes | | | `children[].size` | `object` | yes | | | `children[].size.width` | `integer` | yes | | | `children[].size.height` | `integer` | yes | | | `children[].featureGraphic` | `boolean` | yes | | | `children[].generationId` | `string` | yes | Format: uuid | | `children[].attempt` | `integer` | yes | | | `children[].status` | `GenerationStatus` | yes | | | `children[].error` | `object`, nullable | yes | | | `children[].error.code` | `string` | yes | One of: "GENERATION_FAILED", "GENERATION_TIMEOUT", "NOT_A_SCREENSHOT", "UPLOAD_NOT_READY", "INTERNAL_ERROR" | | `children[].error.message` | `string` | yes | | | `archiveUrl` | `string` | yes | | | `createdAt` | `string` | yes | RFC 3339 UTC timestamp. | | `updatedAt` | `string` | yes | RFC 3339 UTC timestamp. | | `finishedAt` | `string`, nullable | yes | RFC 3339 UTC timestamp. | | `deletedAt` | `string`, nullable | yes | RFC 3339 UTC timestamp. | | `preview` | `ScreenshotSetPreview` | no | | | `source` | `GenerationSource` | no | Since 1.15.0 (always sent; absent from `screenshot_set.completed` events): the source of the request that created the set. | | `project` | `ProjectRef` | no | Since 1.15.0 (always sent; absent from `screenshot_set.completed` events): its project (`POST /projects/{id}/items`). | | `style` | `StyleRef` | no | Since 1.18.0 (always sent; absent from `screenshot_set.completed` events): the style (`styleId`) it was made with; `null` when none. | ## SetUploadRef Type `object`. An upload id (URL inputs are not accepted in sets). | Field | Type | Required | Description | |---|---|---|---| | `uploadId` | `string` | yes | A `ready` upload of the same environment (test or live) as the credential. Format: uuid | ## ScreenshotSetScreen Type `object`. | Field | Type | Required | Description | |---|---|---|---| | `input` | `SetUploadRef` | yes | | | `caption` | `string` | yes | Required (1–1000 characters): the same caption is used on every device of this screen. | ## ScreenshotSetFeatureGraphic Type `boolean or object`. Google Play only. `true`: one 1024×500 feature graphic from screen 1 with its caption (+1 image). ## ScreenshotSetTarget Type `object`. | Field | Type | Required | Description | |---|---|---|---| | `store` | `StoreTarget` | yes | | | `devices` | `array of string` | no | `GET /devices` screenshot ids of this store that meet its rules (`GET /stores/{store}/requirements`). Absent: the store defaults (App Store: `app_store_1290x2796` and `app_store_1179x2556`, the 6.9" and 6.3" iPhone groups Apple asks for; Google Play: `google_play_1080x1920`). At most one size per device class (for a taller Play phone, `google_play_1080x2160` instead of `google_play_1080x1920`). Pass fewer devices to pay for fewer images. | | `featureGraphic` | `ScreenshotSetFeatureGraphic` | no | | ## CreateScreenshotSetRequest Type `object`. | Field | Type | Required | Description | |---|---|---|---| | `app` | `object` | yes | | | `app.name` | `string` | yes | | | `app.description` | `string` | no | Default: "" | | `screens` | `array of ScreenshotSetScreen` | yes | 1–10 screenshots, in listing order. | | `stores` | `array of ScreenshotSetTarget` | yes | | | `orientation` | `Orientation` | no | Of the screenshots (the feature graphic is always landscape). Default: "portrait" | | `palette` | `array of PaletteColor` | no | 1–8 colours. Absent: extracted once from screen 1 and used for every image. | | `logo` | `SetUploadRef` | no | Your app icon or logo, used on every image. | | `metadata` | `GenerationMetadata` | no | | | `projectId` | `string` | no | Since 1.15.0: put the set (and so every image of it, retries included) in this project of yours. Format: uuid | | `styleId` | `string` | no | Since 1.18.0: a style of yours (`GET /styles`). It fills `palette` and `logo` when you leave them out and adds its background mood, description and caption tone to every image. It is copied into the set and its retries: editing or deleting the style later changes nothing here. With `?dryRun=true` the style is checked; styles are free, so the quote is the same. Format: uuid | ## ScreenshotSetList Type `object`. Sets that are not deleted, newest first. | Field | Type | Required | Description | |---|---|---|---| | `data` | `array of ScreenshotSet` | yes | | | `nextCursor` | `string`, nullable | yes | | ## ScreenshotSetRetryResult Type `object`. | Field | Type | Required | Description | |---|---|---|---| | `created` | `array of object` | yes | | | `created[].generationId` | `string` | yes | Format: uuid | | `created[].position` | `integer` | yes | | | `created[].device` | `string` | yes | | | `created[].attempt` | `integer` | yes | | | `skipped` | `array of object` | yes | | | `skipped[].generationId` | `string` | yes | Format: uuid | | `skipped[].reason` | `string` | yes | One of: "superseded", "not_failed", "attempts_exhausted" | ## RetriedScreenshotSet Type `ScreenshotSet & object`. | Field | Type | Required | Description | |---|---|---|---| | `id` | `string` | yes | Format: uuid | | `status` | `ScreenshotSetStatus` | yes | | | `environment` | `ApiKeyEnvironment` | yes | | | `app` | `object` | yes | | | `app.name` | `string` | yes | | | `app.description` | `string` | yes | | | `orientation` | `Orientation` | yes | | | `palette` | `array of PaletteColor` | yes | The palette of every image: as requested, or from the style (`style.applied` contains `palette`), or extracted. | | `paletteSource` | `string` | yes | `auto`: extracted from screen 1 when the set was created. One of: "given", "auto" | | `logo` | `object`, nullable | yes | | | `logo.uploadId` | `string` | yes | Format: uuid | | `metadata` | `object` | yes | | | `screens` | `array of object` | yes | | | `screens[].position` | `integer` | yes | | | `screens[].input` | `object` | yes | | | `screens[].input.uploadId` | `string` | yes | Format: uuid | | `screens[].caption` | `string` | yes | | | `stores` | `array of object` | yes | | | `stores[].store` | `StoreTarget` | yes | | | `stores[].devices` | `array of string` | yes | | | `stores[].featureGraphic` | `object`, nullable | yes | | | `stores[].featureGraphic.screen` | `integer` | yes | | | `stores[].featureGraphic.caption` | `string` | yes | | | `images` | `integer` | yes | | | `counts` | `object` | yes | | | `counts.queued` | `integer` | yes | | | `counts.running` | `integer` | yes | | | `counts.succeeded` | `integer` | yes | | | `counts.failed` | `integer` | yes | | | `counts.canceled` | `integer` | yes | | | `creditsCharged` | `integer` | yes | Every image of the set, retries included (0 with a test key). | | `creditsRefunded` | `integer` | yes | | | `children` | `array of object` | yes | The current image of every slot (store, device, screen). Result URLs: `GET /generations?screenshotSetId=` or the archive. | | `children[].position` | `integer` | yes | The screen (1-based). | | `children[].store` | `StoreTarget` | yes | | | `children[].device` | `string` | yes | | | `children[].size` | `object` | yes | | | `children[].size.width` | `integer` | yes | | | `children[].size.height` | `integer` | yes | | | `children[].featureGraphic` | `boolean` | yes | | | `children[].generationId` | `string` | yes | Format: uuid | | `children[].attempt` | `integer` | yes | | | `children[].status` | `GenerationStatus` | yes | | | `children[].error` | `object`, nullable | yes | | | `children[].error.code` | `string` | yes | One of: "GENERATION_FAILED", "GENERATION_TIMEOUT", "NOT_A_SCREENSHOT", "UPLOAD_NOT_READY", "INTERNAL_ERROR" | | `children[].error.message` | `string` | yes | | | `archiveUrl` | `string` | yes | | | `createdAt` | `string` | yes | RFC 3339 UTC timestamp. | | `updatedAt` | `string` | yes | RFC 3339 UTC timestamp. | | `finishedAt` | `string`, nullable | yes | RFC 3339 UTC timestamp. | | `deletedAt` | `string`, nullable | yes | RFC 3339 UTC timestamp. | | `preview` | `ScreenshotSetPreview` | no | | | `source` | `GenerationSource` | no | Since 1.15.0 (always sent; absent from `screenshot_set.completed` events): the source of the request that created the set. | | `project` | `ProjectRef` | no | Since 1.15.0 (always sent; absent from `screenshot_set.completed` events): its project (`POST /projects/{id}/items`). | | `style` | `StyleRef` | no | Since 1.18.0 (always sent; absent from `screenshot_set.completed` events): the style (`styleId`) it was made with; `null` when none. | | `retry` | `ScreenshotSetRetryResult` | yes | | ## RetryScreenshotSetRequest Type `object`. | Field | Type | Required | Description | |---|---|---|---| | `generationIds` | `array of string` | no | Images of this set to retry. Absent: every failed or canceled image. | ## Project Type `object`. Groups generations and screenshot sets of one app. Its palette and logo are stored for you; generations do not inherit them (send them in each request). | Field | Type | Required | Description | |---|---|---|---| | `id` | `string` | yes | Format: uuid | | `name` | `string` | yes | | | `description` | `string` | yes | | | `store` | `StoreTarget` | yes | | | `palette` | `array of PaletteColor` | yes | | | `logo` | `object`, nullable | yes | | | `logo.url` | `string` | yes | Signed URL (expires at `expiresAt`). Format: uri | | `logo.expiresAt` | `string` | yes | RFC 3339 UTC timestamp. | | `counts` | `object` | yes | | | `counts.generations` | `integer` | yes | Standalone generations in the project (the images of a set count in it). | | `counts.screenshotSets` | `integer` | yes | | | `preview` | `ScreenshotSetPreview` | yes | The most recently finished succeeded image (with a thumbnail) of its generations and sets; `null` while there is none. | | `createdAt` | `string` | yes | RFC 3339 UTC timestamp. | | `updatedAt` | `string` | yes | RFC 3339 UTC timestamp. | ## CreateProjectRequest Type `object`. | Field | Type | Required | Description | |---|---|---|---| | `name` | `string` | yes | | | `description` | `string` | no | | | `store` | `StoreTarget` | no | Default `google_play`. | | `palette` | `array of PaletteColor` | no | | | `logo` | `object` | no | | | `logo.uploadId` | `string` | yes | A `ready` upload of this account (PNG, JPEG or WebP; either environment). It is copied: deleting the upload later keeps the logo. Format: uuid | ## ProjectList Type `object`. Projects, most recently updated first. | Field | Type | Required | Description | |---|---|---|---| | `data` | `array of Project` | yes | | | `nextCursor` | `string`, nullable | yes | Pass as `cursor` for the next page; null on the last page. | ## UpdateProjectRequest Type `object`. | Field | Type | Required | Description | |---|---|---|---| | `name` | `string` | no | | | `description` | `string` | no | | | `store` | `StoreTarget` | no | | | `palette` | `array of PaletteColor` | no | Replaces the palette; `[]` clears it. | | `logo` | `object`, nullable | no | A new logo, or `null` to remove it. | | `logo.uploadId` | `string` | yes | A `ready` upload of this account (PNG, JPEG or WebP; either environment). It is copied: deleting the upload later keeps the logo. Format: uuid | ## ProjectItemsAdded Type `object`. | Field | Type | Required | Description | |---|---|---|---| | `projectId` | `string` | yes | Format: uuid | | `added` | `integer` | yes | Items newly in this project (moved from another project or from none). | ## ProjectItemsRequest Type `object`. 1–100 ids in total. An API key can only use items of its own environment; an access token any. | Field | Type | Required | Description | |---|---|---|---| | `generationIds` | `array of string` | no | Ids of your standalone generations (an image of a set moves with its set). | | `screenshotSetIds` | `array of string` | no | Ids of your screenshot sets. | ## ProjectItemsRemoved Type `object`. | Field | Type | Required | Description | |---|---|---|---| | `projectId` | `string` | yes | Format: uuid | | `removed` | `integer` | yes | Items that left this project (items in another project are left alone). | ## BackgroundMood Type `string`. The look of the background: `minimal`, `light`, `dark`, `vibrant`, `gradient` or `textured`. One of: `"minimal"`, `"light"`, `"dark"`, `"vibrant"`, `"gradient"`, `"textured"`. ## CaptionTone Type `string`. The voice of the headline: `professional`, `friendly`, `playful`, `bold` or `premium`. A caption you send is kept word for word; the tone guides its typography and any wording the model writes. One of: `"professional"`, `"friendly"`, `"playful"`, `"bold"`, `"premium"`. ## Style Type `object`. A brand kit: send its `id` as `styleId` when you create a generation or a screenshot set. It fills the palette and logo you leave out and adds its background mood, description and caption tone; fields you send win. Generations and sets keep a copy of what they used, so editing or deleting the style never changes them or their retries. | Field | Type | Required | Description | |---|---|---|---| | `id` | `string` | yes | Format: uuid | | `name` | `string` | yes | | | `palette` | `array of PaletteColor` | yes | | | `logo` | `object`, nullable | yes | | | `logo.url` | `string` | yes | Signed URL (expires at `expiresAt`). Format: uri | | `logo.expiresAt` | `string` | yes | RFC 3339 UTC timestamp. | | `backgroundMood` | `BackgroundMood` | yes | | | `backgroundPrompt` | `string`, nullable | yes | | | `captionTone` | `CaptionTone` | yes | | | `referenceGenerationId` | `string`, nullable | yes | The generation the values were copied from, if any (provenance only). Format: uuid | | `createdAt` | `string` | yes | RFC 3339 UTC timestamp. | | `updatedAt` | `string` | yes | RFC 3339 UTC timestamp. | ## CreateStyleRequest Type `object`. | Field | Type | Required | Description | |---|---|---|---| | `name` | `string` | yes | | | `palette` | `array of PaletteColor` | no | 1–8 colours. | | `logo` | `object` | no | | | `logo.uploadId` | `string` | yes | A `ready` upload of this account (PNG, JPEG or WebP, at most 5 MB and 4096 × 4096 pixels; either environment). It is copied: deleting the upload later keeps the logo. Format: uuid | | `backgroundMood` | `BackgroundMood & string` | no | | | `backgroundPrompt` | `string` | no | At most 200 characters on one line: what the background looks like. It is passed to the model as a quoted description of the look, never as instructions. | | `captionTone` | `CaptionTone & string` | no | | | `referenceGenerationId` | `string` | no | A succeeded generation of yours: its colours (and, when it was made with a style, that style's background and caption choices) are copied into the fields you leave out, once. Its image and logo are not used. Format: uuid | ## StyleList Type `object`. Styles, most recently updated first. | Field | Type | Required | Description | |---|---|---|---| | `data` | `array of Style` | yes | | | `nextCursor` | `string`, nullable | yes | Pass as `cursor` for the next page; null on the last page. | ## UpdateStyleRequest Type `object`. | Field | Type | Required | Description | |---|---|---|---| | `name` | `string` | no | | | `palette` | `array of PaletteColor` | no | Replaces the palette; `[]` clears it. | | `logo` | `object`, nullable | no | A new logo, or `null` to remove it. | | `logo.uploadId` | `string` | yes | A `ready` upload of this account (PNG, JPEG or WebP, at most 5 MB and 4096 × 4096 pixels; either environment). It is copied: deleting the upload later keeps the logo. Format: uuid | | `backgroundMood` | `BackgroundMood` | no | `null` clears it. | | `backgroundPrompt` | `string`, nullable | no | At most 200 characters on one line; `null` clears it. | | `captionTone` | `CaptionTone` | no | `null` clears it. | | `referenceGenerationId` | `string`, nullable | no | Copy from this generation again (into the fields you leave out), or `null` to forget where the values came from (the values stay). Format: uuid | ## ExtractedColor Type `object`. | Field | Type | Required | Description | |---|---|---|---| | `hex` | `string` | yes | Uppercase `#RRGGBB`. | | `name` | `string` | yes | Nearest named colour. | | `inputIndex` | `integer` | no | `dominant` only: index in `inputs` of the image this colour came from. | ## Palette Type `object`. | Field | Type | Required | Description | |---|---|---|---| | `mode` | `string` | yes | One of: "extract", "dominant" | | `source` | `string` | yes | `default`: no colour could be extracted, so `colors` is the Storeshot default palette. One of: "extracted", "default" | | `colors` | `array of ExtractedColor` | yes | | ## PaletteInput Type `object`. An image: an upload id or an https URL. ## CreatePaletteRequest Type `object`. | Field | Type | Required | Description | |---|---|---|---| | `inputs` | `array of PaletteInput` | yes | `extract`: exactly one image. `dominant`: 1–10 images. At most 3 URL inputs. | | `mode` | `string` | no | `extract`: up to `maxColors` distinct colours of one image (the Storeshot app’s palette). `dominant`: the dominant colour of each image. One of: "extract", "dominant" Default: "extract" | | `maxColors` | `integer` | no | `extract` only: 1–10, default 5. | ## StoreSummary Type `object`. | Field | Type | Required | Description | |---|---|---|---| | `id` | `StoreTarget` | yes | | | `name` | `string` | yes | | | `requirementsUrl` | `string` | yes | | ## StoreList Type `object`. | Field | Type | Required | Description | |---|---|---|---| | `data` | `array of StoreSummary` | yes | | ## StoreRuleIssue Type `string`. One of: `"size_not_accepted"`, `"edge_below_min"`, `"edge_above_max"`, `"aspect_ratio_exceeds_2_1"`, `"aspect_ratio_not_exact"`. ## StoreDeviceClass Type `object`. | Field | Type | Required | Description | |---|---|---|---| | `id` | `string` | yes | | | `family` | `string` | yes | One of: "iphone", "ipad", "watch", "phone", "tablet", "chromebook", "xr" | | `label` | `string` | yes | | | `requirement` | `string` | yes | `conditional`: named by a `requiredGroups` entry (one of its classes is needed when its condition holds); `optional`: never needed. One of: "required", "conditional", "optional" | | `count` | `object` | yes | Screenshots per locale when this class is provided. | | `count.min` | `integer` | yes | | | `count.max` | `integer` | yes | | | `maxFileBytes` | `integer`, nullable | yes | Per-class file limit; null when the store publishes none. | | `acceptedSizes` | `array of object`, nullable | yes | App Store: every accepted pixel size (both orientations). Null when `rules` apply. | | `acceptedSizes[].width` | `integer` | yes | | | `acceptedSizes[].height` | `integer` | yes | | | `rules` | `object`, nullable | yes | Google Play: dimension rules. Null when `acceptedSizes` apply. | | `rules.minEdge` | `integer` | yes | Shorter side, at least. | | `rules.maxEdge` | `integer`, nullable | yes | Longer side, at most (null: no published maximum). | | `rules.maxAspectRatio` | `number` | yes | Longer side ÷ shorter side, at most. | | `rules.exactAspectRatio` | `string`, nullable | yes | `long:short` the image must match exactly, or null. | | `rules.recommendedAspectRatios` | `array of string` | yes | Advisory (store promotion), not enforced. | | `sources` | `array of string` | yes | | | `devices` | `array of object` | yes | `GET /devices` sizes of this class. `compliant: false` lists the hard store rules the size breaks (in portrait or landscape). A deprecated size (`deprecated: true`) names its `replacedBy`. | | `devices[].id` | `string` | yes | | | `devices[].compliant` | `boolean` | yes | | | `devices[].issues` | `array of StoreRuleIssue` | yes | | | `devices[].deprecated` | `boolean` | no | `true`: still accepted (same output size) so existing requests keep working, but it breaks a store rule or is superseded. Never a default. Use `replacedBy`. Absent otherwise. | | `devices[].replacedBy` | `string` | no | Deprecated devices only: the `GET /devices` id to use instead. | ## StoreRequirements Type `object`. What a store needs from screenshots, from the official pages in `sources` (checked on `checkedOn`). Stores change their rules; treat this as guidance. | Field | Type | Required | Description | |---|---|---|---| | `store` | `StoreTarget` | yes | | | `name` | `string` | yes | | | `screenshots` | `object` | yes | | | `screenshots.formats` | `array of string` | yes | | | `screenshots.alpha` | `boolean` | yes | false: no transparency (no alpha channel). | | `screenshots.maxFileBytes` | `integer`, nullable | yes | Null when the store publishes no limit (see a class’s own `maxFileBytes`). | | `screenshots.count` | `object` | yes | | | `screenshots.count.minTotal` | `integer` | yes | Across all classes. | | `screenshots.count.maxPerDeviceClass` | `integer` | yes | | | `screenshots.sources` | `array of string` | yes | | | `deviceClasses` | `array of StoreDeviceClass` | yes | | | `requiredGroups` | `array of object` | yes | | | `requiredGroups[].when` | `string` | yes | | | `requiredGroups[].anyOf` | `array of string` | yes | Provide at least one of these classes. | | `requiredGroups[].sources` | `array of string` | yes | | | `featureGraphic` | `object`, nullable | yes | Google Play only. | | `featureGraphic.width` | `integer` | yes | | | `featureGraphic.height` | `integer` | yes | | | `featureGraphic.formats` | `array of string` | yes | | | `featureGraphic.alpha` | `boolean` | yes | | | `featureGraphic.maxFileBytes` | `integer`, nullable | yes | | | `featureGraphic.required` | `boolean` | yes | | | `featureGraphic.sources` | `array of string` | yes | | | `featureGraphic.device` | `string` | yes | | | `sources` | `array of object` | yes | | | `sources[].id` | `string` | yes | One of: "apple_screenshot_specs", "play_preview_assets" | | `sources[].title` | `string` | yes | | | `sources[].url` | `string` | yes | Format: uri | | `sources[].checkedOn` | `string` | yes | Date the facts were last checked (UTC). | ## WebhookEventType Type `string`. `generation.succeeded`; `generation.failed` (a generation ended `failed` or `canceled`: see `data.status`); `screenshot_set.completed` (a set reached a final status, again after a retry); `credits.low` (live only: the balance fell below `creditsLowThreshold`). `webhook.test` is sent on demand by `POST /webhooks/{id}/test` and needs no subscription. One of: `"generation.succeeded"`, `"generation.failed"`, `"screenshot_set.completed"`, `"credits.low"`. ## Webhook Type `object`. | Field | Type | Required | Description | |---|---|---|---| | `id` | `string` | yes | Format: uuid | | `environment` | `ApiKeyEnvironment` | yes | | | `url` | `string` | yes | Masked: scheme, host and the last 4 characters of the path. | | `events` | `array of WebhookEventType` | yes | | | `description` | `string` | yes | | | `enabled` | `boolean` | yes | | | `creditsLowThreshold` | `integer` | yes | | | `secret` | `object` | yes | | | `secret.hint` | `string` | yes | The last 4 characters. | | `secret.rotatedAt` | `string`, nullable | yes | RFC 3339 UTC timestamp. | | `secret.previousExpiresAt` | `string`, nullable | yes | Until then deliveries are also signed with the previous secret. | | `createdAt` | `string` | yes | RFC 3339 UTC timestamp. | | `updatedAt` | `string` | yes | RFC 3339 UTC timestamp. | ## WebhookWithSecret Type `Webhook & object`. | Field | Type | Required | Description | |---|---|---|---| | `id` | `string` | yes | Format: uuid | | `environment` | `ApiKeyEnvironment` | yes | | | `url` | `string` | yes | Masked: scheme, host and the last 4 characters of the path. | | `events` | `array of WebhookEventType` | yes | | | `description` | `string` | yes | | | `enabled` | `boolean` | yes | | | `creditsLowThreshold` | `integer` | yes | | | `secret` | `object` | yes | | | `secret.hint` | `string` | yes | The last 4 characters. | | `secret.rotatedAt` | `string`, nullable | yes | RFC 3339 UTC timestamp. | | `secret.previousExpiresAt` | `string`, nullable | yes | Until then deliveries are also signed with the previous secret. | | `createdAt` | `string` | yes | RFC 3339 UTC timestamp. | | `updatedAt` | `string` | yes | RFC 3339 UTC timestamp. | | `secret` | `object` | yes | | | `secret.hint` | `string` | yes | The last 4 characters. | | `secret.rotatedAt` | `string`, nullable | yes | RFC 3339 UTC timestamp. | | `secret.previousExpiresAt` | `string`, nullable | yes | Until then deliveries are also signed with the previous secret. | | `secret.value` | `string` | yes | The signing secret (`whsec_…`). Returned only in this response; store it now. Verify `Storeshot-Signature` with it. | ## CreateWebhookRequest Type `object`. | Field | Type | Required | Description | |---|---|---|---| | `url` | `string` | yes | An `https://` URL on port 443, without credentials, at most 2048 characters, whose host resolves only to public addresses (checked now and before every delivery). Shown masked afterwards. | | `events` | `array of WebhookEventType` | yes | The business events to deliver (1–4, unique). | | `description` | `string` | no | Free text, at most 200 characters. | | `enabled` | `boolean` | no | Default `true`. | | `creditsLowThreshold` | `integer` | no | Default 5. | | `environment` | `ApiKeyEnvironment` | no | Which events the webhook receives: `live` or `test` (test-key generations and sets). Default: the API key environment (access tokens: `live`). An API key can only create webhooks of its own environment. | ## WebhookList Type `object`. Webhooks, newest first (at most 10 per environment). | Field | Type | Required | Description | |---|---|---|---| | `data` | `array of Webhook` | yes | | ## UpdateWebhookRequest Type `object`. | Field | Type | Required | Description | |---|---|---|---| | `url` | `string` | no | An `https://` URL on port 443, without credentials, at most 2048 characters, whose host resolves only to public addresses (checked now and before every delivery). Shown masked afterwards. | | `events` | `array of WebhookEventType` | no | The business events to deliver (1–4, unique). | | `description` | `string` | no | Free text, at most 200 characters. | | `enabled` | `boolean` | no | | | `creditsLowThreshold` | `integer` | no | `credits.low` fires when the live balance falls below this many credits (1–10000). | ## RotateWebhookSecretRequest Type `object`. | Field | Type | Required | Description | |---|---|---|---| | `gracePeriodSeconds` | `integer` | no | How long the previous secret keeps signing deliveries next to the new one (0–604800, default 86400). 0 drops it at once. | ## WebhookTestResult Type `object`. | Field | Type | Required | Description | |---|---|---|---| | `eventId` | `string` | yes | | | `deliveryId` | `string` | yes | Format: uuid | ## WebhookDeliveryStatus Type `string`. `pending`: queued or retrying. `succeeded`: a 2xx answer. `exhausted`: 8 attempts failed. `failed`: the destination was refused (`blocked_destination`, `invalid_url`) or the secret could not be read. `canceled`: the webhook was disabled before the attempt. One of: `"pending"`, `"succeeded"`, `"exhausted"`, `"failed"`, `"canceled"`. ## WebhookDelivery Type `object`. | Field | Type | Required | Description | |---|---|---|---| | `id` | `string` | yes | Format: uuid | | `eventId` | `string` | yes | | | `eventType` | `string` | yes | One of: "generation.succeeded", "generation.failed", "screenshot_set.completed", "credits.low", "webhook.test" | | `status` | `WebhookDeliveryStatus` | yes | | | `attempts` | `integer` | yes | | | `nextAttemptAt` | `string`, nullable | yes | When `pending`: the next attempt is due then (null while one is in flight). | | `createdAt` | `string` | yes | RFC 3339 UTC timestamp. | | `finishedAt` | `string`, nullable | yes | RFC 3339 UTC timestamp. | | `log` | `array of object` | yes | | | `log[].attempt` | `integer` | yes | | | `log[].at` | `string` | yes | RFC 3339 UTC timestamp. | | `log[].latencyMs` | `integer` | yes | | | `log[].statusCode` | `integer`, nullable | yes | | | `log[].errorClass` | `string`, nullable | yes | One of: "http_status", "timeout", "connect_failed", "tls_error", "dns_failure", "blocked_destination", "redirect", "invalid_url", "secret_unavailable" | | `log[].responseSnippet` | `string`, nullable | yes | First 256 characters of the response body, control characters removed. | ## WebhookDeliveryList Type `object`. Deliveries of this webhook, newest first. | Field | Type | Required | Description | |---|---|---|---| | `data` | `array of WebhookDelivery` | yes | | | `nextCursor` | `string`, nullable | yes | | ## ActivityEntry Type `object`. | Field | Type | Required | Description | |---|---|---|---| | `id` | `string` | yes | Format: uuid | | `type` | `string` | yes | `api_key.created`, `api_key.revoked`, `credits.granted`, `credits.removed`, `webhook.created`, `webhook.updated`, `webhook.deleted`, `webhook.secret_rotated`, `webhook.test_sent`, `console.signed_in`, `console.sessions_revoked`. New types may be added. | | `createdAt` | `string` | yes | RFC 3339 UTC timestamp. | | `actor` | `object` | yes | | | `actor.type` | `string` | yes | `user`: you (console or access token); `api_key`: one of your keys; `system`: Storeshot (payments, plan renewals). One of: "user", "api_key", "system" | | `actor.apiKeyId` | `string`, nullable | yes | Format: uuid | | `actor.apiKeyPrefix` | `string`, nullable | yes | | | `target` | `object`, nullable | yes | What changed (`api_key`, `webhook`, `polar_order`, …). | | `target.type` | `string` | yes | | | `target.id` | `string` | yes | | | `details` | `object` | yes | Type-specific, never secret: key name/prefix/environment, credits/bucket/reason, webhook host/events/changed fields, sign-in method. | | `requestId` | `string`, nullable | yes | The `X-Request-Id` of the request that made the change (null for Storeshot). | ## ActivityList Type `object`. Account activity, newest first (kept 400 days). | Field | Type | Required | Description | |---|---|---|---| | `data` | `array of ActivityEntry` | yes | | | `nextCursor` | `string`, nullable | yes | Pass as `cursor` for the next page; null on the last page. | ## ActivityFamily Type `string`. Only one family of types (`api_key`, `credits`, `webhook`, `console`). One of: `"api_key"`, `"credits"`, `"webhook"`, `"console"`. ## BillingPack Type `object`. | Field | Type | Required | Description | |---|---|---|---| | `id` | `string` | yes | Pack id to send as `pack`. | | `name` | `string` | yes | | | `credits` | `integer` | yes | Purchased credits (never expire). | | `priceAmount` | `integer` | yes | List price in minor units (cents). Taxes are applied at checkout. | | `currency` | `string` | yes | ISO 4217, lowercase. | ## BillingPackList Type `object`. Packs on sale, cheapest first. Empty while web payments are switched off. | Field | Type | Required | Description | |---|---|---|---| | `data` | `array of BillingPack` | yes | | ## BillingCheckout Type `object`. | Field | Type | Required | Description | |---|---|---|---| | `id` | `string` | yes | Format: uuid | | `status` | `string` | yes | `open` (send the buyer to `url`), `processing` (payment confirmed, credits on the way), `paid` (credits granted), `partially_refunded`, `refunded`, `expired` (not paid in time) or `failed`. One of: "open", "processing", "paid", "partially_refunded", "refunded", "expired", "failed" | | `pack` | `string` | yes | | | `credits` | `integer` | yes | | | `priceAmount` | `integer` | yes | Minor units (cents). | | `currency` | `string` | yes | | | `url` | `string`, nullable | yes | Hosted checkout page (Polar). Null unless `status` is `open`. Format: uri | | `expiresAt` | `string`, nullable | yes | Format: date-time | | `createdAt` | `string` | yes | Format: date-time | | `paidAt` | `string`, nullable | yes | Format: date-time | | `creditsGranted` | `integer` | yes | Credits added to `purchased`. | | `creditsReversed` | `integer` | yes | Credits taken back after a refund (spent credits are not taken twice). | | `kind` | `string` | no | SS-012: present (`subscription`) only on plan checkouts; then `pack` and `plan` hold the plan id and the credits are the monthly allotment, added to `subscription`. Absent on pack checkouts. One of: "subscription" | | `plan` | `string` | no | SS-012: the plan of a plan checkout (absent on pack checkouts). One of: "pro", "agency", "scale" | | `planName` | `string` | no | SS-013: the plan name, on `GET /billing/checkouts/{id}` of a plan checkout only (absent on pack checkouts). | | `subscriptionStatus` | `string`, nullable | no | SS-013: the payment provider's status of the subscription this plan checkout created (`active`, `trialing`, `past_due`, `canceled`, …), or null while none is linked yet. Only on `GET /billing/checkouts/{id}` of a plan checkout. | ## CreateBillingCheckoutRequest Type `object`. | Field | Type | Required | Description | |---|---|---|---| | `pack` | `string` | yes | | | `successUrl` | `string` | no | Where the buyer lands after paying. Default: a static confirmation page. `https://` on storeshot.art, a storeshot.art subdomain or this API host; at most 2048 characters. Format: uri | | `returnUrl` | `string` | no | Where "back" on the checkout page leads. Default: the credits page. `https://` on storeshot.art, a storeshot.art subdomain or this API host; at most 2048 characters. Format: uri | ## BillingPlanLimits Type `object`. The limits that apply on a plan. | Field | Type | Required | Description | |---|---|---|---| | `requestsPerMinutePerKey` | `integer` | yes | | | `requestsPerMinutePerUser` | `integer` | yes | | | `concurrentGenerations` | `integer` | yes | | | `dailyGenerations` | `integer` | yes | Credits per UTC day. | | `maxStoredUploads` | `integer` | yes | | | `maxStoredUploadMegabytes` | `integer` | yes | | ## BillingPlan Type `object`. | Field | Type | Required | Description | |---|---|---|---| | `id` | `string` | yes | Plan id to send as `plan`. One of: "pro", "agency", "scale" | | `name` | `string` | yes | | | `credits` | `integer` | yes | Credits added to `subscription` each paid month. They are used before any other credits and expire (reset) at renewal; pack credits never expire. | | `priceAmount` | `integer` | yes | Monthly list price in minor units (cents). Taxes are applied at checkout. | | `currency` | `string` | yes | ISO 4217, lowercase. | | `interval` | `string` | yes | One of: "month" | | `limits` | `BillingPlanLimits` | yes | | ## BillingPlanList Type `object`. Plans on sale, cheapest first. Empty while plans are switched off. | Field | Type | Required | Description | |---|---|---|---| | `data` | `array of BillingPlan` | yes | | ## BillingSubscription Type `object`. The latest subscription of the account, or null. | Field | Type | Required | Description | |---|---|---|---| | `id` | `string` | yes | Format: uuid | | `plan` | `string`, nullable | yes | Null when the subscription moved to a product that is not a Storeshot plan. One of: "pro", "agency", "scale" | | `status` | `string` | yes | The payment provider status: `active`, `trialing`, `past_due`, `incomplete`, `incomplete_expired`, `unpaid`, `paused` or `canceled`. | | `cancelAtPeriodEnd` | `boolean` | yes | | | `currentPeriodStart` | `string`, nullable | yes | Format: date-time | | `currentPeriodEnd` | `string`, nullable | yes | Format: date-time | | `pendingPlan` | `string`, nullable | yes | A downgrade that takes effect at the next renewal. One of: "pro", "agency", "scale" | | `creditsPerPeriod` | `integer` | yes | | | `subscriptionCredits` | `integer` | yes | The `subscription` balance now (expires at renewal). | ## BillingSubscriptionState Type `object`. | Field | Type | Required | Description | |---|---|---|---| | `plan` | `string` | yes | The tier whose limits apply now. A plan applies while its subscription is active (until one day after the period end) or past due (three days after the period end). One of: "free", "pro", "agency", "scale" | | `limits` | `BillingPlanLimits` | yes | | | `subscription` | `BillingSubscription` | yes | | ## CreateSubscriptionCheckoutRequest Type `object`. | Field | Type | Required | Description | |---|---|---|---| | `plan` | `string` | yes | One of: "pro", "agency", "scale" | | `successUrl` | `string` | no | Where the buyer lands after paying. Default: a static confirmation page. `https://` on storeshot.art, a storeshot.art subdomain or this API host; at most 2048 characters. Format: uri | | `returnUrl` | `string` | no | Where "back" on the checkout page leads. Default: the credits page. `https://` on storeshot.art, a storeshot.art subdomain or this API host; at most 2048 characters. Format: uri | ## ChangeSubscriptionRequest Type `object`. | Field | Type | Required | Description | |---|---|---|---| | `plan` | `string` | yes | One of: "pro", "agency", "scale" | ## BillingPortalSession Type `object`. | Field | Type | Required | Description | |---|---|---|---| | `url` | `string` | yes | Open it in the browser of the account owner. Format: uri | | `expiresAt` | `string`, nullable | yes | When a `session` link stops working; null for `email_login`. Format: date-time | | `mode` | `string` | yes | `session`: signed in as this account. `email_login`: the provider asks for the billing email and sends a one-time code. One of: "session", "email_login" | ## CreatePortalSessionRequest Type `object`. | Field | Type | Required | Description | |---|---|---|---| | `returnUrl` | `string` | no | Where "back" in the portal leads. Default: the credits page. `https://` on storeshot.art, a storeshot.art subdomain or this API host; at most 2048 characters. Format: uri | --- # Errors > HTML version: https://storeshot.art/docs/errors/ Every error of the API has the same JSON body, whatever the endpoint: ```json { "error": { "code": "INVALID_INPUT", "message": "Request validation failed.", "requestId": "1a1ae233-21d0-4d7a-9549-d4c6b482f2f3", "docUrl": "https://api.storeshot.art/v1/docs/errors#INVALID_INPUT", "details": { "issues": [{ "location": "body", "path": ["app"], "message": "Required" }] } } } ``` - `code` is stable (UPPER_SNAKE): branch on it, never on `message`, which may change. - `requestId` equals the `X-Request-Id` response header. Quote it when you contact support. - `details` is optional and specific to the code (validation issues, the required scope, the credits needed, the seconds to wait); the catalogue below says what each code carries. ## Handle an error This request leaves out required fields, so it answers `400 INVALID_INPUT` with one entry per problem in `details.issues`: ```sh curl -sS https://api.storeshot.art/v1/generations \ -H "Authorization: Bearer $STORESHOT_API_KEY" -H 'Content-Type: application/json' \ -d '{"inputs":[],"store":"app_store"}' \ | jq '{code: .error.code, requestId: .error.requestId, issues: [.error.details.issues[] | "\(.path | join(".")): \(.message)"]}' ``` ```js const res = await fetch('https://api.storeshot.art/v1/generations', { method: 'POST', headers: { Authorization: `Bearer ${process.env.STORESHOT_API_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ inputs: [], store: 'app_store' }), }); if (!res.ok) { const { error } = await res.json(); console.log(res.status, error.code, error.requestId); for (const issue of error.details?.issues ?? []) console.log(issue.path.join('.'), issue.message); } ``` ```python import os, requests res = requests.post( "https://api.storeshot.art/v1/generations", headers={"Authorization": f"Bearer {os.environ['STORESHOT_API_KEY']}"}, json={"inputs": [], "store": "app_store"}, ) if not res.ok: error = res.json()["error"] print(res.status_code, error["code"], error["requestId"]) for issue in error.get("details", {}).get("issues", []): print(".".join(str(p) for p in issue["path"]), issue["message"]) ``` ## What to retry | Status | Meaning | What to do | |---|---|---| | `400`, `404`, `405`, `413`, `415`, `422` | The request is wrong for this resource | Fix the request; retrying the same one fails the same way | | `401`, `403` | Credential missing or not allowed | Check the key, its environment and scopes (`details.reason`) | | `402` | Not enough credits; nothing was created | Top up (`details.topUpUrl`), then retry with the same `Idempotency-Key` | | `409` | Conflicts with the current state | Read the resource again (`details.status`, `details.action`) and decide | | `429` | Rate, concurrency or daily limit | Wait `Retry-After` seconds, then retry ([Rate limits](/docs/rate-limits/)) | | `500`, `503` | Server side or busy | Retry with exponential backoff (and `Retry-After` when sent), same `Idempotency-Key` | Requests that create something (`POST /generations`, `POST /screenshot-sets`) are safe to retry with the same `Idempotency-Key`: a request that already succeeded is answered again instead of running twice. A generation that ends `failed` reports its reason in its own `error.code` (`GENERATION_FAILED`, `GENERATION_TIMEOUT`, …) and, with a live key, is refunded; create a new generation to try again. ## Error codes Every code has its own anchor on this page, for example [`/docs/errors/#RATE_LIMITED`](/docs/errors/#RATE_LIMITED). Reserved codes are part of the contract but not sent yet. | Code | HTTP | State | Meaning | |---|---|---|---| | `INVALID_INPUT` | 400 | active | **Invalid input.** The request failed validation or its body could not be parsed. `details.issues` lists each problem as `{ location, path, message }`. | | `UNAUTHORIZED` | 401 | active | **Unauthorized.** No valid credential. Send `Authorization: Bearer `. Missing, malformed, unknown, revoked and expired credentials all return this same body. | | `FORBIDDEN` | 403 | active | **Forbidden.** The credential is valid but may not do this. `details.reason` is one of `insufficient_scope` (with `details.requiredScope`, and `details.requiredScopes` when any of several scopes is accepted), `api_key_limit_reached` (with `details.limit`), `scope_not_grantable`, `environment_not_grantable`, `account_conflict`, `upload_quota_exceeded` (with `details.kind` `count` or `bytes` and `details.limit`; delete uploads to free space), `webhook_limit_reached` (with `details.limit`, webhooks per environment), `project_limit_reached` (with `details.limit`, projects per account), `style_limit_reached` (with `details.limit`, styles per account), `live_key_required` (billing checkouts need a live key or an access token; test keys never buy) or `billing_account_flagged` (a refund left the account short of credits it had already used; contact support before buying again). | | `NOT_FOUND` | 404 | active | **Not found.** No `/v1` endpoint exists at this path (`details` echoes `{ method, path }`), or the addressed resource does not exist for this caller. | | `METHOD_NOT_ALLOWED` | 405 | active | **Method not allowed.** The path exists but does not support this HTTP method. See the `Allow` header and `details.allowedMethods`. | | `PAYLOAD_TOO_LARGE` | 413 | active | **Payload too large.** The request body exceeds the endpoint limit. `details.maxBytes` gives the limit. | | `UPLOAD_NOT_READY` | 409 | active | **Upload not ready.** The upload cannot be used or completed in its current state. `details.reason` is `object_missing` (no file was uploaded to the presigned URL yet: upload it, then retry; the upload stays pending), `expired` (the upload expired before it was completed), `failed` (with `details.errorCode`) or `deleted`. `details.path` names the field; for a screenshot set retry it can be `["styleId"]` (the set's copy of its brand kit logo has expired). | | `IDEMPOTENCY_CONFLICT` | 409 | active | **Idempotency conflict.** This `Idempotency-Key` was already used by this account for a different request (another body, or a credential of the other environment). `details.generationId` is the generation created with it (`details.screenshotSetId` for `POST /screenshot-sets`, `details.styleId` for `POST /styles`, `details.checkoutId` for `POST /billing/checkout`, also while that checkout is still being created). Use a new key for a new request. | | `GENERATION_STATE_CONFLICT` | 409 | active | **Generation state conflict.** The generation cannot do this in its current state. `details.status` is its status and `details.action` is `cancel` (only a generation that has not started yet can be canceled) or `delete` (only a finished generation can be deleted). | | `SCREENSHOT_SET_STATE_CONFLICT` | 409 | active | **Screenshot set state conflict.** The screenshot set cannot do this in its current state. `details.status` is the set status and `details.action` is `retry` (no image is failed or canceled, or every one used its 5 attempts; nothing was charged), `cancel` (the set already finished), `delete` (images are still queued or running: cancel the set or wait) or `archive` (no image has succeeded yet). | | `WEBHOOK_STATE_CONFLICT` | 409 | active | **Webhook state conflict.** The webhook cannot do this in its current state. `details.action` is `test` (a disabled webhook receives no test event: enable it first). | | `BILLING_STATE_CONFLICT` | 409 | active | **Billing state conflict.** The plan cannot do this in its current state; nothing was charged. `details.reason` is `subscription_exists` (the account already has a plan: change it instead), `checkout_pending` (a plan checkout is still open: finish it, or retry later), `no_subscription` (the account has no plan), `same_plan` (already on that plan), `canceling` (the plan ends at the period end: uncancel it first), `past_due` (a payment is overdue: update the payment method in the portal) or `no_customer` (no payment account is linked to this account yet). | | `IMAGE_TOO_LARGE` | 413 | active | **Image too large.** An uploaded image is too large. `details.reason` is `bytes` (the file exceeds `details.maxBytes`, 20 MB) or `pixels` (more than `details.maxPixels` pixels, 50 megapixels, or an edge longer than `details.maxEdge`, 16384 pixels). | | `UNSUPPORTED_FORMAT` | 415 | active | **Unsupported format.** An uploaded file is not a PNG, JPEG or WebP image (checked from its bytes, never from its name or declared type). `details.reason` is `unsupported_type`, `heic` (HEIC/HEIF photos are not supported; export as PNG or JPEG), `corrupt` (the image could not be decoded) or `type_mismatch` (the uploaded file is not the `contentType` declared when presigning). | | `NOT_A_SCREENSHOT` | 422 | active | **Not a screenshot.** The input image does not look like an app screenshot: its size or shape is not a screen (checked when you create the generation; `details.path` names the input), or it carries camera capture data such as an exposure time (checked before any image is generated; the generation fails with this `error.code` and its credit is refunded). Upload a screenshot taken on the device. | | `URL_FETCH_FAILED` | 422 | active | **URL fetch failed.** The image URL could not be fetched. Only `https://` URLs on port 443 that resolve to public addresses are fetched, with at most 3 redirects and 15 seconds in total. `details.reason` is `blocked_destination`, `dns_failure`, `connect_failed`, `tls_error`, `timeout`, `too_many_redirects`, `insecure_redirect` (a redirect to a non-https URL), `http_status` (with `details.status`) or `empty_body`. | | `WEBHOOK_URL_REJECTED` | 422 | active | **Webhook URL rejected.** The webhook URL is well formed but cannot be used. `details.reason` is `blocked_destination` (the host is or resolves to a private, loopback, link-local or otherwise non-public address; every DNS answer must be public) or `dns_failure` (the host does not resolve). The same check runs again before every delivery. | | `INSUFFICIENT_CREDITS` | 402 | active | **Insufficient credits.** The account does not have enough credits for this operation; nothing was created or charged. `details.requiredCredits` is the cost, `details.availableCredits` the current balance and `details.topUpUrl` where to get more credits. Test keys (`ss_test_…`) never need credits. | | `RATE_LIMITED` | 429 | active | **Rate limited.** Too many requests. Retry after the number of seconds in the `Retry-After` header (`details.retryAfterSeconds`). `details.scope` is `key` or `user` and `details.policy` is `requests` (per-minute request rate of the API key or of the whole account, with `details.limit` and `details.windowSeconds`; see the `RateLimit` headers) `daily_generations` (the account reached its generations for this UTC day, `details.limit`), `webhook_tests` (test events of one webhook per hour, `details.limit`) or `billing_checkouts` (checkouts created by the account in the last hour, `details.limit`). Without `details.scope`, too many failed authentication attempts came from this IP. | | `CONCURRENCY_LIMITED` | 429 | active | **Concurrency limited.** Too many generations of this account are in progress. Retry after the number of seconds in the `Retry-After` header (`details.retryAfterSeconds`); `details.limit` is the number allowed at once (`details.scope` `user`, `details.policy` `concurrent_generations`). | | `INTERNAL_ERROR` | 500 | active | **Internal error.** An unexpected error occurred. Quote the `requestId` when contacting support. | | `GENERATION_FAILED` | 502 | active | **Generation failed.** Appears as the `error.code` of a failed generation (never as an HTTP status): the image could not be generated. Its credit was refunded; create a new generation to try again. | | `SERVICE_UNAVAILABLE` | 503 | active | **Service unavailable.** A dependency is unavailable (on `/v1/health`, `details` contains the health report), or uploads are busy (`Retry-After`): retry after that many seconds. | | `BILLING_UNAVAILABLE` | 503 | active | **Billing unavailable.** Credits cannot be bought right now; nothing was charged. `details.reason` is `disabled` (web payments are switched off), `not_configured` (payments are not set up on this server), `provider_error` (the payment provider did not answer; retry later, with the same `Idempotency-Key` if you sent one) or `webhook_not_configured` (payment notifications are not accepted yet). | | `GENERATION_TIMEOUT` | 504 | active | **Generation timeout.** Appears as the `error.code` of a failed generation (never as an HTTP status): generating the image took too long. Its credit was refunded; create a new generation to try again. | --- # Rate limits > HTML version: https://storeshot.art/docs/rate-limits/ StoreShot limits how fast an account can call the API and how many live generations it can run. The numbers depend on your plan (table below); the headers of each response are always the authoritative values. ## Requests per minute Every authenticated request is counted per minute in two buckets: one per API key (`key`) and one per account (`user`, shared by all its keys and access tokens; access tokens use only this one). Test keys count like live keys. A request is counted only if every bucket allows it, so a refused request counts nowhere. `/health`, `/status`, `/devices` and the OpenAPI document are never limited. Every authenticated response carries two headers ([draft-ietf-httpapi-ratelimit-headers](https://datatracker.ietf.org/doc/draft-ietf-httpapi-ratelimit-headers/)): - `RateLimit-Policy`: each bucket as `"";q=;w=`. - `RateLimit`: the most restrictive bucket now, as `"";r=;t=`. ```sh curl -sS -D - -o /dev/null https://api.storeshot.art/v1/me \ -H "Authorization: Bearer $STORESHOT_API_KEY" | grep -i '^ratelimit' ``` Over a limit the answer is `429 RATE_LIMITED` with a `Retry-After` header (seconds) and `details.scope` (`key` or `user`). Wait that long, then retry: ```js // fetch() that waits out 429 answers (Retry-After seconds), up to 3 times. async function fetchWithRetry(url, options = {}, attempts = 3) { for (let attempt = 1; ; attempt += 1) { const res = await fetch(url, options); if (res.status !== 429 || attempt === attempts) return res; const seconds = Number(res.headers.get('retry-after') ?? '1'); await new Promise((resolve) => setTimeout(resolve, seconds * 1000)); } } const res = await fetchWithRetry('https://api.storeshot.art/v1/me', { headers: { Authorization: `Bearer ${process.env.STORESHOT_API_KEY}` }, }); console.log(res.status, res.headers.get('ratelimit')); ``` ```python import os, time, requests def request_with_retry(method, url, attempts=3, **kwargs): """requests.request() that waits out 429 answers (Retry-After seconds), up to 3 times.""" for attempt in range(1, attempts + 1): res = requests.request(method, url, **kwargs) if res.status_code != 429 or attempt == attempts: return res time.sleep(int(res.headers.get("Retry-After", "1"))) res = request_with_retry( "GET", "https://api.storeshot.art/v1/me", headers={"Authorization": f"Bearer {os.environ['STORESHOT_API_KEY']}"}, ) print(res.status_code, res.headers.get("RateLimit")) ``` ## Generations in progress and per day Live generations are also capped per account (test-key generations are not counted): - **In progress at once**: over it, a new generation or set is `429 CONCURRENCY_LIMITED` (`Retry-After: 10`). A screenshot set counts as one, whatever its size. - **Credits per UTC day**: over it, `429 RATE_LIMITED` with `details.policy: daily_generations`, until the next UTC midnight. Every image of a set counts. Webhook test events are limited to 10 per webhook per hour (`details.policy: webhook_tests`). ## Limits per plan | Plan | Requests / min per key | Requests / min per account | Generations in progress | Credits per UTC day | |---|---|---|---|---| | No plan | 60 | 120 | 3 | 100 | | Pro | 120 | 240 | 4 | 200 | | Agency | 120 | 240 | 6 | 500 | | Scale | 300 | 600 | 10 | 1000 | Plans and prices are on the [pricing page](/pricing/) and in [Credits](/docs/credits/). --- # API changelog > HTML version: https://storeshot.art/docs/changelog/ Versions of the StoreShot API contract (`https://api.storeshot.art/v1`, `info.version` of the OpenAPI document), newest first. Every version so far is additive: existing requests, fields and error codes keep working. ## 1.18.0 — 2026-11-03 - Added styles (brand kits): `POST /styles` (send an `Idempotency-Key`), `GET /styles`, `GET /styles/{id}`, `PATCH /styles/{id}` and `DELETE /styles/{id}`. A style holds a name, up to 8 colours, a logo (copied from an upload), a background mood, a short background description and a caption tone, and can copy its colours and choices from a succeeded generation (`referenceGenerationId`). Styles are free; at most 50 per account (`403 FORBIDDEN`, `details.reason` `style_limit_reached`). - Added optional `styleId` when creating a generation or a screenshot set (also with `?dryRun=true`): the style fills the palette and logo you leave out and adds its background mood, description and caption tone. Fields you send win. The style is copied into the generation or set when it is created, so editing or deleting the style later never changes it or its retries. - Generations and screenshot sets have an optional `style` (`{ id, name, applied }`; `null` when no style was used, `id` `null` once the style is deleted). ## 1.17.0 — 2026-11-02 - Added device authorization (RFC 8628) so an agent without a key can get one with its human's approval: `POST /auth/device` returns a user code and a console link, and `POST /auth/device/token` returns a new API key once the human approves it in the developer console. Neither needs a credential. - `POST /auth/device/token` answers refusals with the OAuth error body `{ "error", "error_description" }` (HTTP 400), not the usual error envelope. - Both answer 404 `NOT_FOUND` while device authorization is turned off on the server. ## 1.16.0 — 2026-11-02 - The API description now starts with a "For AI agents" section: the usual workflow (check the key, upload, quote, create, poll, download), what costs credits and how to handle errors. - Every operation description now ends with what it costs in credits and, for the main operations, when to use it. The code samples in the description use `STORESHOT_API_KEY`, like the guides. - Text only: no request, response, header or error code changed. ## 1.15.0 — 2026-10-29 - Added projects: `POST /projects`, `GET /projects`, `GET`, `PATCH` and `DELETE /projects/{id}`, and `POST /projects/{id}/items` and `/items/remove` to group generations and screenshot sets. - Added optional `projectId` when creating a generation or a screenshot set, and `?projectId=` (`none` for no project) and `?source=` filters on the lists. - Generations and screenshot sets now show `source` (`api`, `web`, `mcp`, `cli` or `mobile`); generations also show `clientName`, `requestId` and `project`. - Added `GET /activity`: who changed keys, credits and webhooks in the account. ## 1.14.0 — 2026-10-29 - Added `GET /status`: a public, cached summary of the API and generation pipeline status. ## 1.13.0 — 2026-10-29 - Added the Google Play device `google_play_1080x2160` (2:1). - Deprecated `google_play_1080x2400` (`deprecated: true`, `replacedBy`); it is still accepted but is never a default. ## 1.12.0 — 2026-10-24 - Added an optional `preview` thumbnail to screenshot sets in `GET /screenshot-sets` and `GET /screenshot-sets/{id}`. ## 1.11.0 — 2026-10-23 - `GET /billing/checkouts/{id}` also accepts the payment provider's checkout id; plan checkouts add optional `planName` and `subscriptionStatus`. - Added the `environment` filter to the generation and screenshot-set lists and the `status` filter to the screenshot-set list. ## 1.10.0 — 2026-10-22 - Added monthly plans: `GET /billing/plans`, `GET /billing/subscription`, `POST /billing/subscription/checkout`, `/change`, `/cancel` and `/uncancel`, and `POST /billing/portal`. - Added the error code `BILLING_STATE_CONFLICT`. Rate and generation limits now follow the plan. ## 1.9.0 — 2026-10-22 - Added credit packs: `GET /billing/packs`, `POST /billing/checkout` and `GET /billing/checkouts/{id}`. - Added the scopes `billing:read` and `billing:write` and the error code `BILLING_UNAVAILABLE`. ## 1.8.0 — 2026-10-22 - Added webhooks: `POST` and `GET /webhooks`, `GET`, `PATCH` and `DELETE /webhooks/{id}`, `POST /webhooks/{id}/rotate-secret`, `POST /webhooks/{id}/test` and `GET /webhooks/{id}/deliveries`. - Added the events `generation.succeeded`, `generation.failed`, `screenshot_set.completed`, `credits.low` and `webhook.test`, the scopes `webhooks:read` and `webhooks:write`, and the error codes `WEBHOOK_URL_REJECTED` and `WEBHOOK_STATE_CONFLICT`. ## 1.7.0 — 2026-10-22 - Added screenshot sets: `POST /screenshot-sets` (with `?dryRun=true` to quote it), `GET /screenshot-sets`, `GET` and `DELETE /screenshot-sets/{id}`, `POST /screenshot-sets/{id}/retry` and `/cancel`, and `GET /screenshot-sets/{id}/archive` (`?layout=fastlane`). - Generations of a set show `screenshotSet`; added the `?screenshotSetId=` filter and the error code `SCREENSHOT_SET_STATE_CONFLICT`. ## 1.6.0 — 2026-10-22 - Added `POST /palettes` (brand palettes from your images), `GET /stores` and `GET /stores/{store}/requirements` (official screenshot rules per store). ## 1.5.0 — 2026-10-22 - Added generations: `POST /generations`, `GET /generations`, `GET` and `DELETE /generations/{id}` and `POST /generations/{id}/cancel`, with `Prefer: wait` and `Idempotency-Key`. - Added the error codes `IDEMPOTENCY_CONFLICT` and `GENERATION_STATE_CONFLICT`; `402 INSUFFICIENT_CREDITS` carries `details.topUpUrl`. ## 1.4.0 — 2026-10-21 - Added uploads: `POST /uploads` (multipart or `{ "url" }`), `POST /uploads/presign` and `POST /uploads/{id}/complete`, `GET /uploads`, `GET` and `DELETE /uploads/{id}`. - Added the error codes `UPLOAD_NOT_READY` and `URL_FETCH_FAILED`; `UNSUPPORTED_FORMAT` and `IMAGE_TOO_LARGE` apply to uploads. ## 1.3.0 — 2026-10-21 - Added rate limits: `RateLimit` and `RateLimit-Policy` headers, `429 RATE_LIMITED` with `Retry-After`, and `429 CONCURRENCY_LIMITED` for too many generations in progress. ## 1.2.0 — 2026-10-21 - Added `GET /credits` and `GET /usage` (scope `usage:read`) and the optional `X-Storeshot-Client` header that labels your usage. ## 1.1.0 — 2026-10-21 - Added API keys (`ss_live_…`, `ss_test_…`) with scopes: `GET /me`, `POST` and `GET /api-keys`, and `DELETE /api-keys/{id}`. ## 1.0.0 — 2026-10-21 - First contract: `GET /health`, `GET /devices`, the OpenAPI document, the error envelope with its code catalogue and the `X-Request-Id` header.