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

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

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

<!-- tabs -->
```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=<id>`.

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

<!-- tabs -->
```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`:

<!-- doctest: expect=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'
```
