Why Automate Screenshot Uploads?
Manually uploading screenshots through App Store Connect is tedious:
- 10 screenshots per device size
- Multiple device sizes (iPhone 6.7", 6.5", iPad Pro)
- 40+ localizations available
- Every app update requires fresh screenshots
For an app with 5 localizations and 3 device sizes, that's 150 individual screenshot uploads per update. At 30 seconds per upload, you're looking at 75 minutes of mind-numbing clicking.
The App Store Connect API lets you automate this completely. This guide shows you exactly how.
According to Apple's WWDC 2020 session, teams using the App Store Connect API reduce release preparation time by 60-80%.
What is the App Store Connect API?
The App Store Connect API is a REST API that lets you programmatically manage:
- App metadata (descriptions, keywords, categories)
- Screenshots and app previews
- Build uploads and TestFlight
- App Store review submissions
- Pricing and availability
- Sales and trends data
- User and role management
Why Use the API vs Manual Upload?
| Task | Manual (Web UI) | API Automation |
|---|---|---|
| Upload 150 screenshots | 75 minutes | 2-3 minutes |
| Update 40 localizations | 2+ hours | 5-10 minutes |
| Risk of human error | High | Low (validated) |
| Consistency across locales | Manual checking | Guaranteed |
| Integration with CI/CD | Not possible | Full support |
Prerequisites
Before you start, you'll need:
1. App Store Connect Access
- Account holder or Admin role
- Access to "Users and Access" in App Store Connect
2. Technical Setup
# Required tools - curl or HTTP client library - jwtRS256 token generator - Image processing tools (optional)
3. API Key (Generate in App Store Connect)
- Log into App Store Connect
- Go to "Users and Access" → "Keys"
- Click "+" to generate new API key
- Select access level: App Manager (minimum for screenshots)
- Download the
.p8private key file (SAVE THIS - you can't download it again) - Note your Issuer ID and Key ID
Store your .p8 private key securely. If lost, you must generate a new key. Never commit it to version control.
Authentication: Creating JWT Tokens
The App Store Connect API uses JWT (JSON Web Tokens) for authentication.
Understanding JWT Structure
Header.Payload.Signature
Header:
{ "alg": "ES256", "kid": "YOUR_KEY_ID", "typ": "JWT" }
Payload:
{ "iss": "YOUR_ISSUER_ID", "iat": 1640000000, "exp": 1640003600, "aud": "appstoreconnect-v1" }
Generating Tokens (Node.js Example)
const jwt = require('jsonwebtoken'); const fs = require('fs'); function generateToken(keyId, issuerId, privateKeyPath) { const privateKey = fs.readFileSync(privateKeyPath); const payload = { iss: issuerId, iat: Math.floor(Date.now() / 1000), exp: Math.floor(Date.now() / 1000) + (20 * 60), // 20 minutes aud: 'appstoreconnect-v1' }; const header = { alg: 'ES256', kid: keyId, typ: 'JWT' }; return jwt.sign(payload, privateKey, { algorithm: 'ES256', header: header }); } // Usage const token = generateToken( 'ABC123DEFG', // Your Key ID '12345678-1234-1234-1234-123456789012', // Your Issuer ID './AuthKey_ABC123DEFG.p8' // Path to .p8 file );
Python Implementation
import jwt import time from pathlib import Path def generate_token(key_id, issuer_id, private_key_path): with open(private_key_path, 'r') as f: private_key = f.read() headers = { 'alg': 'ES256', 'kid': key_id, 'typ': 'JWT' } payload = { 'iss': issuer_id, 'iat': int(time.time()), 'exp': int(time.time()) + 1200, # 20 minutes 'aud': 'appstoreconnect-v1' } return jwt.encode(payload, private_key, algorithm='ES256', headers=headers) # Usage token = generate_token( key_id='ABC123DEFG', issuer_id='12345678-1234-1234-1234-123456789012', private_key_path='./AuthKey_ABC123DEFG.p8' )
JWT tokens expire after 20 minutes maximum. Generate a fresh token for each automation run or implement token refresh logic.
The Screenshot Upload Process
Screenshot upload is a multi-step process:
- Reserve screenshot slot (POST to create placeholder)
- Upload image to reserved storage (PUT binary data)
- Commit the upload (PATCH to finalize)
- Verify upload success (GET to confirm)
This multi-step approach ensures reliable uploads even for large files.
Step 1: Get App Store Version ID
First, find your app's version ID:
curl -X GET \ "https://api.appstoreconnect.apple.com/v1/apps/{APP_ID}/appStoreVersions" \ -H "Authorization: Bearer YOUR_JWT_TOKEN"
Response includes data[0].id - save this as VERSION_ID.
Step 2: Get App Screenshot Set ID
Screenshots are grouped by device type and localization:
curl -X GET \ "https://api.appstoreconnect.apple.com/v1/appStoreVersions/{VERSION_ID}/appStoreVersionLocalizations" \ -H "Authorization: Bearer YOUR_JWT_TOKEN"
Find the localization (e.g., "en-US"), then get screenshot sets:
curl -X GET \ "https://api.appstoreconnect.apple.com/v1/appStoreVersionLocalizations/{LOCALIZATION_ID}/appScreenshotSets" \ -H "Authorization: Bearer YOUR_JWT_TOKEN"
Filter by screenshotDisplayType (e.g., "APP_IPHONE_67").
Step 3: Reserve Screenshot Slot
Create a placeholder for your screenshot:
curl -X POST \ "https://api.appstoreconnect.apple.com/v1/appScreenshots" \ -H "Authorization: Bearer YOUR_JWT_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "data": { "type": "appScreenshots", "attributes": { "fileName": "screenshot-1.png", "fileSize": 245680 }, "relationships": { "appScreenshotSet": { "data": { "type": "appScreenshotSets", "id": "SCREENSHOT_SET_ID" } } } } }'
Response includes:
data.id- Screenshot IDdata.attributes.uploadOperations- Upload URL and headers
Step 4: Upload Image Binary
Use the upload URL from Step 3:
const fs = require('fs'); const axios = require('axios'); async function uploadScreenshot(uploadOperation, filePath) { const fileBuffer = fs.readFileSync(filePath); await axios.put(uploadOperation.url, fileBuffer, { headers: uploadOperation.requestHeaders }); }
Step 5: Commit Upload
Finalize the upload:
curl -X PATCH \ "https://api.appstoreconnect.apple.com/v1/appScreenshots/{SCREENSHOT_ID}" \ -H "Authorization: Bearer YOUR_JWT_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "data": { "type": "appScreenshots", "id": "SCREENSHOT_ID", "attributes": { "uploaded": true } } }'
Step 6: Verify Upload
Check upload status:
curl -X GET \ "https://api.appstoreconnect.apple.com/v1/appScreenshots/{SCREENSHOT_ID}" \ -H "Authorization: Bearer YOUR_JWT_TOKEN"
Look for data.attributes.assetDeliveryState.state = "COMPLETE".
Complete Automation Script
Here's a production-ready Node.js script:
const fs = require('fs'); const axios = require('axios'); const jwt = require('jsonwebtoken'); class AppStoreConnectAPI { constructor(keyId, issuerId, privateKeyPath) { this.keyId = keyId; this.issuerId = issuerId; this.privateKey = fs.readFileSync(privateKeyPath); this.token = this.generateToken(); this.baseURL = 'https://api.appstoreconnect.apple.com/v1'; } generateToken() { const payload = { iss: this.issuerId, iat: Math.floor(Date.now() / 1000), exp: Math.floor(Date.now() / 1000) + 1200, aud: 'appstoreconnect-v1' }; return jwt.sign(payload, this.privateKey, { algorithm: 'ES256', header: { alg: 'ES256', kid: this.keyId, typ: 'JWT' } }); } async request(method, endpoint, data = null) { const config = { method, url: `${this.baseURL}${endpoint}`, headers: { 'Authorization': `Bearer ${this.token}`, 'Content-Type': 'application/json' } }; if (data) config.data = data; const response = await axios(config); return response.data; } async uploadScreenshot(screenshotSetId, filePath) { console.log(`Uploading ${filePath}...`); // Step 1: Reserve screenshot slot const fileStats = fs.statSync(filePath); const fileName = filePath.split('/').pop(); const reservation = await this.request('POST', '/appScreenshots', { data: { type: 'appScreenshots', attributes: { fileName, fileSize: fileStats.size }, relationships: { appScreenshotSet: { data: { type: 'appScreenshotSets', id: screenshotSetId } } } } }); const screenshotId = reservation.data.id; const uploadOps = reservation.data.attributes.uploadOperations; // Step 2: Upload binary for (const op of uploadOps) { const fileBuffer = fs.readFileSync(filePath); await axios.put(op.url, fileBuffer, { headers: op.requestHeaders }); } // Step 3: Commit upload await this.request('PATCH', `/appScreenshots/${screenshotId}`, { data: { type: 'appScreenshots', id: screenshotId, attributes: { uploaded: true } } }); console.log(`✓ Uploaded ${fileName}`); return screenshotId; } async uploadScreenshotsForLocale(appId, version, locale, deviceType, screenshots) { // Get version ID const versions = await this.request('GET', `/apps/${appId}/appStoreVersions`); const versionData = versions.data.find(v => v.attributes.versionString === version); // Get localization ID const localizations = await this.request( 'GET', `/appStoreVersions/${versionData.id}/appStoreVersionLocalizations` ); const localeData = localizations.data.find(l => l.attributes.locale === locale); // Get screenshot set ID const sets = await this.request( 'GET', `/appStoreVersionLocalizations/${localeData.id}/appScreenshotSets` ); const setData = sets.data.find(s => s.attributes.screenshotDisplayType === deviceType); // Upload all screenshots const uploadedIds = []; for (const screenshot of screenshots) { const id = await this.uploadScreenshot(setData.id, screenshot); uploadedIds.push(id); } return uploadedIds; } } // Usage (async () => { const api = new AppStoreConnectAPI( 'YOUR_KEY_ID', 'YOUR_ISSUER_ID', './AuthKey_YOUR_KEY_ID.p8' ); await api.uploadScreenshotsForLocale( 'YOUR_APP_ID', '1.2.0', 'en-US', 'APP_IPHONE_67', [ './screenshots/en-US/iphone67/1.png', './screenshots/en-US/iphone67/2.png', './screenshots/en-US/iphone67/3.png', './screenshots/en-US/iphone67/4.png', './screenshots/en-US/iphone67/5.png' ] ); console.log('All screenshots uploaded successfully!'); })();
Device Display Types Reference
Use these values for screenshotDisplayType:
iPhone
APP_IPHONE_67- iPhone 6.7" (15 Pro Max, 15 Plus, 14 Pro Max, 14 Plus)APP_IPHONE_65- iPhone 6.5" (11 Pro Max, XS Max)APP_IPHONE_61- iPhone 6.1" (14, 13, 12, 11)APP_IPHONE_58- iPhone 5.8" (XS, X)APP_IPHONE_55- iPhone 5.5" (8 Plus, 7 Plus, 6s Plus)
iPad
APP_IPAD_PRO_3GEN_129- iPad Pro 12.9" (6th gen)APP_IPAD_PRO_129- iPad Pro 12.9" (2nd gen)APP_IPAD_PRO_3GEN_11- iPad Pro 11" (4th gen)
Apple Watch
APP_WATCH_ULTRA- Apple Watch UltraAPP_WATCH_SERIES_7- Apple Watch Series 7+
Apple TV
APP_APPLE_TV- Apple TV
Always upload 6.7" iPhone screenshots at minimum. Apple automatically scales them for older devices.
Batch Upload Script for Multiple Locales
async function uploadAllScreenshots() { const api = new AppStoreConnectAPI( process.env.ASC_KEY_ID, process.env.ASC_ISSUER_ID, process.env.ASC_PRIVATE_KEY_PATH ); const config = { appId: 'YOUR_APP_ID', version: '1.2.0', locales: [ { code: 'en-US', path: './screenshots/en-US' }, { code: 'es-ES', path: './screenshots/es-ES' }, { code: 'fr-FR', path: './screenshots/fr-FR' }, { code: 'de-DE', path: './screenshots/de-DE' }, { code: 'ja', path: './screenshots/ja' } ], devices: [ { type: 'APP_IPHONE_67', folder: 'iphone67' }, { type: 'APP_IPAD_PRO_3GEN_129', folder: 'ipad129' } ] }; for (const locale of config.locales) { console.log(`\nProcessing ${locale.code}...`); for (const device of config.devices) { const screenshotPath = `${locale.path}/${device.folder}`; const screenshots = fs.readdirSync(screenshotPath) .filter(f => f.match(/\.(png|jpg|jpeg)$/i)) .map(f => `${screenshotPath}/${f}`) .slice(0, 10); // Max 10 screenshots console.log(` ${device.type}: ${screenshots.length} screenshots`); await api.uploadScreenshotsForLocale( config.appId, config.version, locale.code, device.type, screenshots ); } } console.log('\n✓ All uploads complete!'); } uploadAllScreenshots().catch(console.error);
Integration with CI/CD
GitHub Actions Example
name: Upload Screenshots to App Store Connect on: push: branches: [main] paths: - 'screenshots/**' jobs: upload-screenshots: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - name: Setup Node.js uses: actions/setup-node@v3 with: node-version: '18' - name: Install dependencies run: npm install axios jsonwebtoken - name: Upload screenshots env: ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }} ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }} ASC_PRIVATE_KEY: ${{ secrets.ASC_PRIVATE_KEY }} run: node upload-screenshots.js
Fastlane Integration
# Fastfile lane :upload_screenshots do api_key = app_store_connect_api_key( key_id: ENV['ASC_KEY_ID'], issuer_id: ENV['ASC_ISSUER_ID'], key_filepath: ENV['ASC_PRIVATE_KEY_PATH'] ) upload_to_app_store( api_key: api_key, skip_binary_upload: true, skip_metadata: true, overwrite_screenshots: true ) end
Error Handling Best Practices
Common Errors and Solutions
Error: ENTITY_ERROR.ATTRIBUTE.INVALID.SIZE
File size too large (> 500 KB for iOS)
Solution: Compress images before upload
const sharp = require('sharp'); async function compressImage(inputPath, outputPath) { await sharp(inputPath) .resize(1290, 2796, { fit: 'inside' }) .jpeg({ quality: 85 }) .toFile(outputPath); }
Error: AUTHENTICATION_ERROR
JWT token expired or invalid
Solution: Generate fresh token before each request
Error: ENTITY_ERROR.RELATIONSHIP.REQUIRED
Missing screenshot set ID
Solution: Ensure screenshot set exists for device type and locale
Retry Logic
async function uploadWithRetry(fn, maxRetries = 3) { for (let i = 0; i < maxRetries; i++) { try { return await fn(); } catch (error) { if (i === maxRetries - 1) throw error; console.log(`Retry ${i + 1}/${maxRetries}...`); await new Promise(r => setTimeout(r, 2000 * (i + 1))); } } }
Performance Optimization
Parallel Uploads
async function uploadScreenshotsParallel(screenshotSetId, files) { const promises = files.map(file => api.uploadScreenshot(screenshotSetId, file) ); return await Promise.all(promises); }
Note: Apple's API has rate limits. Recommended:
- Max 50 requests per second
- Max 5 concurrent uploads
- Add 100ms delay between requests
Rate Limiting
const pLimit = require('p-limit'); const limit = pLimit(5); // 5 concurrent uploads async function uploadWithRateLimit(files) { return Promise.all( files.map(file => limit(() => api.uploadScreenshot(screenshotSetId, file)) ) ); }
Skip the complexity - automate with AppShots
AppShots generates and uploads screenshots automatically. No API wrestling, no code, no hassle. Just beautiful screenshots in minutes.
Try AppShots FreeThe Reality: API Automation is Complex
While the App Store Connect API is powerful, it's also complex:
Initial setup time: 4-8 hours
- Understanding JWT authentication
- Learning the multi-step upload process
- Implementing error handling and retries
- Testing across all device types and locales
Maintenance burden:
- API changes require code updates
- Token expiration handling
- Error monitoring and debugging
- CI/CD integration maintenance
Development cost:
- $500-2,000 if hiring developer
- 8-16 hours of engineering time
- Ongoing maintenance (4-8 hours/year)
When to Use the API
Good fit:
- Large development teams with dedicated DevOps
- Apps with frequent updates (weekly+)
- Complex CI/CD requirements
- Screenshot generation already automated
Not ideal for:
- Solo developers or small teams
- Infrequent updates (monthly or less)
- Non-technical founders
- Apps with manual screenshot creation
Alternative: Modern Screenshot Tools
Tools like AppShots handle both screenshot creation and upload:
Traditional workflow:
- Design screenshots (4-8 hours)
- Export all device sizes (1 hour)
- Localize for multiple languages (2-4 hours per language)
- Write API upload script (4-8 hours)
- Upload via API (5-10 minutes)
Total: 12-30+ hours
Modern workflow with AppShots:
- Upload your app UI
- AI generates backgrounds and captions
- Localize with one click
- Auto-export and upload to App Store Connect
Total: 15-30 minutes
The API is powerful, but for most teams, the ROI isn't there unless you're updating screenshots weekly.
Resources and Documentation
Official Apple Resources:
- App Store Connect API Overview
- App Screenshots API Reference
- Uploading Assets Guide
- WWDC 2020: Expanding automation with the App Store Connect API
Third-Party Tools:
- Fastlane - Ruby-based automation (includes App Store Connect API)
- Runway - CI/CD for mobile apps
- AppShots - AI screenshot generation + upload
Libraries:
- Node.js:
@apple/app-store-connect-api - Python:
appstoreconnect - Ruby:
spaceship(part of Fastlane)
Conclusion
The App Store Connect API is a powerful tool for automating screenshot uploads, but it comes with significant complexity. For teams with dedicated engineering resources and frequent update cycles, the investment pays off. For most developers, modern tools that handle both creation and upload provide better ROI.
Key takeaways:
- API requires JWT authentication - Generate tokens with ES256 algorithm
- Screenshot upload is multi-step - Reserve, upload, commit, verify
- Device types are specific - Use correct
screenshotDisplayTypecodes - Error handling is critical - Implement retries and rate limiting
- Consider alternatives - Evaluate if API complexity is worth it for your use case
If you decide to go the API route, use this guide as your reference. If you want to skip the complexity, try AppShots for automated screenshot generation and upload.
Sources:
- How to upload assets using the App Store Connect API | Runway
- Upload app previews and screenshots - Apple Developer
- App Screenshots API Documentation
- Uploading Assets to App Store Connect
- WWDC20: Expanding automation with the App Store Connect API