August 2, 2026 · Lazare Kolebka

How to Upload App Store Screenshots with the API

Dragging ten screenshots into App Store Connect, per locale, every release, is the part of shipping that nobody budgets for. The API can do it instead. What makes it awkward is that there is no POST /screenshots that takes a file.

Get an API key first

App Store Connect, then Users and Access, then Integrations, then App Store Connect API. Generate a key with the App Manager or Admin role. You get a Key ID, an Issuer ID, and a .p8 private key file that downloads exactly once. Lose it and you revoke and start over.

Keep the .p8 out of your repo. It is a signing key for your whole account, not a per-app token.

Sign a JWT

Every request carries a bearer token you generate yourself. It is ES256, signed with the .p8, and Apple rejects anything with a lifetime over 20 minutes.

import time, jwt # pyjwt def generate_token(key_id, issuer_id, key_path): now = int(time.time()) payload = { "iss": issuer_id, "iat": now, "exp": now + 1200, # 20 minutes, the maximum Apple allows "aud": "appstoreconnect-v1", } headers = {"alg": "ES256", "kid": key_id, "typ": "JWT"} return jwt.encode(payload, open(key_path).read(), algorithm="ES256", headers=headers)

Two mistakes account for most 401s here: putting the Key ID in iss instead of the Issuer ID, and setting exp further out than 20 minutes because the token felt short.

Find the localization you are uploading into

Screenshots do not attach to an app. They attach to a screenshot set, which attaches to a version localization. So the chain is app, then version, then localization, then set.

GET /apps/{id}/appStoreVersions GET /appStoreVersions/{id}/appStoreVersionLocalizations GET /appStoreVersionLocalizations/{id}/appScreenshotSets?include=appScreenshots&limit=40

That last response is keyed by screenshotDisplayType. If the set you want does not exist yet, create it:

POST /appScreenshotSets { "data": { "type": "appScreenshotSets", "attributes": { "screenshotDisplayType": "APP_IPHONE_69" }, "relationships": { "appStoreVersionLocalization": { "data": { "type": "appStoreVersionLocalizations", "id": "<localization-id>" } } } } }

The display types you will actually use: APP_IPHONE_69, APP_IPHONE_67, APP_IPHONE_65, APP_IPHONE_61, and APP_IPAD_PRO_3GEN_129 for iPad. Since App Store Connect scales the largest set down, APP_IPHONE_69 on its own is usually the whole iPhone job. The sizes those display types expect are worth checking before you upload rather than after.

Step 1: reserve the slot

You tell Apple the filename and the byte count. You do not send the file.

POST /appScreenshots { "data": { "type": "appScreenshots", "attributes": { "fileName": "01-timeline.png", "fileSize": 2847362 }, "relationships": { "appScreenshotSet": { "data": { "type": "appScreenshotSets", "id": "<set-id>" } } } } }

The response carries the new screenshot’s id and an uploadOperations array. That array is the actual upload plan.

Step 2: run the upload operations

uploadOperations is a list of chunks, not a single URL. Each entry gives you a method, a url, an offset, a length, and the requestHeaders that request needs. Slice the file accordingly and send each piece.

for op in operations: chunk = file_bytes[op["offset"] : op["offset"] + op["length"]] headers = {h["name"]: h["value"] for h in op.get("requestHeaders", [])} response = client.request(op["method"], op["url"], content=chunk, headers=headers) response.raise_for_status()

Small screenshots usually come back as one operation, so it is easy to write code that assumes exactly one and works fine until a larger file arrives and silently uploads only its first chunk. Loop over the array even when it has one element in it.

These URLs are pre-signed and point at Apple’s storage, not at api.appstoreconnect.apple.com. Do not attach your bearer token to them.

Step 3: commit with a checksum

Nothing has told App Store Connect the upload finished. This call does, and it has to carry an MD5 hex digest of the file you sent.

PATCH /appScreenshots/{screenshot-id} { "data": { "type": "appScreenshots", "id": "<screenshot-id>", "attributes": { "uploaded": true, "sourceFileChecksum": "<md5-hex>" } } }

Yes, MD5, in 2026. Apple uses it as a transfer integrity check, not a security one.

Miss this call and the screenshot exists in the API and renders in App Store Connect as a failed asset. That is the single most common way this flow goes wrong, because steps 1 and 2 both returned success.

Replacing a set, not appending to it

There is no “replace” operation. Uploading into a set that already has screenshots adds to it, so a re-run gives you twenty screenshots in a set that allows ten. DELETE /appScreenshots/{id} each existing screenshot in the set first, then upload.

Ordering follows upload order, which is why filenames like 01-timeline.png and 02-search.png are worth the two extra characters.

Retry on 429 and 5xx, not on 4xx

Apple’s endpoints time out and rate-limit under normal use, particularly during a multi-locale push. Retry 429 and 5xx with exponential backoff. Everything else in the 4xx range is a bug in your request and retrying it just makes the same mistake five more times.

_RETRYABLE = {429, 500, 502, 503, 504}

Connection errors and read timeouts belong in the retry path too. A screenshot push that runs for several minutes will hit at least one.

What the API will not do for you

The API uploads whatever PNG you hand it. It does not frame your capture, clean the status bar, or make the ten screens in a set look like they belong together, and a raw capture at the right resolution is still a raw capture.

That part happens before the upload. Screeny frames and exports the set on the phone that took the screenshots, or in batches from the Mac app, and the files land in a folder ready for the flow above. If you would rather script the framing too, Screeny for Mac exposes it as MCP tools an agent can call, so the framing and the upload can be one prompt.

Frequently asked questions

Can I upload app previews the same way? Yes. appPreviews and appPreviewSets follow the same reserve, upload, commit sequence, with a previewType in place of screenshotDisplayType.

Do I need a separate upload per locale? Yes. Screenshot sets hang off a version localization, so each locale gets its own set and its own uploads, even when the images are identical.

Does this work on an app version that is already live? No. You upload into an editable version, one in a Prepare for Submission state. Screenshots on a released version cannot be changed without a new version.

Can I A/B test screenshots through the API? You can create the experiment and its treatments, and upload screenshots to treatment localizations. Apple does not expose the results, so you read those in App Store Connect under Product Page Optimization.

Is there an official CLI? No. Apple ships the API and an OpenAPI spec. Fastlane’s deliver is the best-known third-party wrapper, and everything above is what a wrapper is doing underneath.