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=40That 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.