Screenshot sets
A whole store listing in one call - quote it, create it, poll it and download every image as a ZIP, also in fastlane folders.
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, 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 first).
curl
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"}
}'Node.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 acaption(1–1000 characters) used on every device.stores: one or both, each once. Withoutdevicesa store gets the sizes its required groups ask for: the App Storeapp_store_1290x2796andapp_store_1179x2556, Google Playgoogle_play_1080x1920. Passdevices(at most one per device class) to choose, and pay for, fewer images.featureGraphic(Google Play):truemakes one 1024 × 500 image from screen 1, or{ "screen": 2, "caption": "…" }.paletteandlogoapply 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.
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.
curl
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)"]}'Node.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.
curl
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}'Node.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.
curl
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.zipNode.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:
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).
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'