Uploading App Store screenshots with the App Store Connect API
appScreenshotSet for the right locale and device,
reserve an appScreenshot (Apple hands you pre-signed chunk URLs), PUT the bytes,
then PATCH the reservation with uploaded: true and the file's MD5. Auth is a
20-minute JWT signed with your .p8 key. None of it is hard; all of it is fiddly.
Here is the real sequence — and the shortcut if you would rather not build it.
Apple's documentation for this is accurate and almost unreadable — the flow is spread across a dozen reference pages with no single worked example. This post is the one walkthrough, in order, with the exact endpoints and field names. It is written for someone deciding whether to build the integration or reach for a tool that already has.
The object model you are uploading into
Screenshots do not attach to your app directly. They hang off a chain of objects, and you have to walk it top-down:
- appStoreVersion — the specific version you are editing (must be in an editable state).
- appStoreVersionLocalization — one per App Store locale (en-US, de-DE, ja, and so on).
- appScreenshotSet — one per display type inside a localization (6.7" iPhone, 13" iPad, etc.).
- appScreenshot — the individual image, up to 10 per set.
So a fully localized listing in 50 languages across two iPhone sizes and one iPad size is 50 × 3 sets, each holding up to 10 screenshots. That multiplication is the whole reason people automate this instead of clicking through the web UI.
Step 0 — the 20-minute JWT
Every request except the byte uploads themselves needs an Authorization: Bearer
header carrying a JSON Web Token you sign yourself. You need three things from App Store Connect:
your issuer ID, a key ID, and the .p8 private key
file you download once when you create the key (Apple never shows it again).
The token is signed with ES256 — ECDSA on the P-256 curve with SHA-256 — using
that .p8 key. The header carries the kid; the payload carries the
issuer as iss, the audience as the literal string appstoreconnect-v1,
and an exp no more than 20 minutes past iat. Apple
rejects anything with a longer lifetime, so you regenerate often — mint a fresh token per batch
rather than trying to hold one open.
// JWT header
{
"alg": "ES256",
"kid": "2X9R4HXF34",
"typ": "JWT"
}
// JWT payload
{
"iss": "57246542-96fe-1a63-e053-0824d011072a",
"iat": 1767225600,
"exp": 1767226800,
"aud": "appstoreconnect-v1"
}
Sign that with any ES256-capable JWT library (jsonwebtoken, PyJWT, whatever your stack uses),
pass the .p8 contents as the key, and put the result in the
Authorization header on every call below.
Step 1 — create the screenshot set
A set is scoped to one localization and one display type. You POST to
/v1/appScreenshotSets with the screenshotDisplayType in attributes and
a relationship pointing at the appStoreVersionLocalization you want to fill.
POST /v1/appScreenshotSets
{
"data": {
"type": "appScreenshotSets",
"attributes": { "screenshotDisplayType": "APP_IPHONE_67" },
"relationships": {
"appStoreVersionLocalization": {
"data": {
"type": "appStoreVersionLocalizations",
"id": "VERSION_LOCALIZATION_ID"
}
}
}
}
}
Common display types: APP_IPHONE_67 (6.7", 1290 × 2796), the current large-iPhone
size, and APP_IPAD_PRO_3GEN_129 (12.9", 2048 × 2732). One useful thing Apple now
does: screenshots for the largest iPhone size are reused down the smaller iPhone classes, and the
largest iPad size down the smaller iPads — so in practice you often only need one iPhone set and
one iPad set per locale, not one per physical device. The response gives you a set id
you carry into the next call. If a set already exists for that locale/type, reuse it instead of
creating a duplicate.
Step 2 — reserve the screenshot
You do not upload the file in one shot. First you reserve it: tell Apple the file name
and exact byte size, and it hands back an upload plan. POST to /v1/appScreenshots
with the fileName and fileSize attributes and a relationship to the
set from step 1.
POST /v1/appScreenshots
{
"data": {
"type": "appScreenshots",
"attributes": {
"fileName": "01-home.png",
"fileSize": 2847123
},
"relationships": {
"appScreenshotSet": {
"data": { "type": "appScreenshotSets", "id": "SCREENSHOT_SET_ID" }
}
}
}
}
The response is the interesting part. Inside the new screenshot's attributes you get
uploadOperations — an array describing exactly how to push the bytes. For a small
file it is one operation; for a large one Apple splits it into several. Each entry gives you a
method (PUT), a pre-signed url, the byte offset and
length to send, and the requestHeaders to attach.
"uploadOperations": [
{
"method": "PUT",
"url": "https://ul.itunes.apple.com/upload/...",
"offset": 0,
"length": 2847123,
"requestHeaders": [
{ "name": "Content-Type", "value": "image/png" }
]
}
] Step 3 — PUT the bytes
For each operation, read the slice of your file from offset for length
bytes, and PUT it to the given url with exactly the requestHeaders Apple
supplied. These URLs are pre-signed, so you do not send your JWT here — adding
the Authorization header can actually break the signed request. Multiple operations
can run in parallel, but Apple rate-limits aggressive uploads, so wrap them in a retry-with-backoff
rather than firing everything at once.
Step 4 — commit with uploaded: true and the MD5
Uploading the bytes does nothing until you tell Apple the reservation is complete. PATCH the
screenshot back with uploaded: true and a sourceFileChecksum — the MD5
of the whole file as a lowercase hex string. This is Apple's integrity check: if the
checksum does not match what landed, processing fails.
PATCH /v1/appScreenshots/SCREENSHOT_ID
{
"data": {
"type": "appScreenshots",
"id": "SCREENSHOT_ID",
"attributes": {
"uploaded": true,
"sourceFileChecksum": "9f86d081884c7d659a2feaa0c55ad015"
}
}
}
After the PATCH, the asset goes into processing on Apple's side. Poll the screenshot's
assetDeliveryState until it reaches a completed state — and read its
errors if it fails, because that is where wrong-dimension and alpha-channel problems
surface. The PUT and PATCH can both succeed and the screenshot can still be rejected minutes later
during processing. Do not assume a 2xx on the commit means you are done; watch the delivery state.
Step 5 — set the display order
Screenshots come back in whatever order they were created, which is rarely the order you want
shown. The set's order is a separate relationship. PATCH
/v1/appScreenshotSets/{id}/relationships/appScreenshots with an array of
screenshot IDs — the array order is the on-store display order.
PATCH /v1/appScreenshotSets/SCREENSHOT_SET_ID/relationships/appScreenshots
{
"data": [
{ "type": "appScreenshots", "id": "SCREENSHOT_ID_1" },
{ "type": "appScreenshots", "id": "SCREENSHOT_ID_2" },
{ "type": "appScreenshots", "id": "SCREENSHOT_ID_3" }
]
} The constraints Apple actually enforces
- PNG or JPEG only. No HEIC, no WebP. If your renderer outputs anything else, convert first.
- No alpha channel. Flatten transparency to a solid background and export RGB. A stray alpha channel is one of the most common silent rejections at processing time.
- Exact dimensions per display type. The image has to match the display type's pixel size precisely — no off-by-a-few, no upscaling. A 1290 × 2796 file goes in an
APP_IPHONE_67set and nowhere else. - Up to 10 per set. Ten screenshots maximum per localization per display type.
- Editable version only. You can only mutate sets on a version that is in an editable state; a version already in review is locked.
Why this is more work than it looks
Each individual call is simple. The cost is in everything around them: signing and rotating a
20-minute token, computing MD5s, slicing files to match uploadOperations, handling
partial-upload retries, polling delivery state, mapping your locales to Apple's locale codes,
and doing all of it 150-plus times for a properly localized listing. Getting it reliable is a
weekend of auth, chunked uploads, and error handling — and then it is yours to maintain every
time Apple adds a display type.
If you want the sequence without writing it, Fastlane's deliver
wraps this same API and is the standard open-source path. It solves the upload; it does not draw
the screenshots or write the captions.
…or skip all of it
The API only moves finished images. Something still has to design the carousel, write the captions, and translate them per locale — which is the part that actually takes time. Mokbi does that today: it designs the screenshots, drafts the listing copy, and translates the whole thing into 50 languages, then exports every image at the exact per-display-type dimensions this API demands — PNG, RGB, no alpha, right size — so an upload does not bounce at the delivery-state step.
Then it publishes. For the App Store, Mokbi runs this exact reserve-and-commit sequence under the hood — uploading your screenshots and metadata and staging the version ready for you to submit, so you never touch a JWT or a chunked PUT. Apple still requires the final Submit and its review; everything up to that point is handled for you. For Google Play it pushes straight to the store. Either way you get the API result without writing the code, with the design, the copy, and the 50-language translation already done.