tutorial

Automate Screenshot Uploads with App Store Connect API: Complete Developer Guide

Learn how to automate app screenshot uploads to App Store Connect using Apple's API. Includes authentication, code examples, and best practices for 2026.

AppShots Team·Engineering
··13 min read
#api#automation#app-store-connect#development#workflow#screenshots

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?

TaskManual (Web UI)API Automation
Upload 150 screenshots75 minutes2-3 minutes
Update 40 localizations2+ hours5-10 minutes
Risk of human errorHighLow (validated)
Consistency across localesManual checkingGuaranteed
Integration with CI/CDNot possibleFull 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)

  1. Log into App Store Connect
  2. Go to "Users and Access" → "Keys"
  3. Click "+" to generate new API key
  4. Select access level: App Manager (minimum for screenshots)
  5. Download the .p8 private key file (SAVE THIS - you can't download it again)
  6. 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:

  1. Reserve screenshot slot (POST to create placeholder)
  2. Upload image to reserved storage (PUT binary data)
  3. Commit the upload (PATCH to finalize)
  4. 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 ID
  • data.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 Ultra
  • APP_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 Free

The 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:

  1. Design screenshots (4-8 hours)
  2. Export all device sizes (1 hour)
  3. Localize for multiple languages (2-4 hours per language)
  4. Write API upload script (4-8 hours)
  5. Upload via API (5-10 minutes)

Total: 12-30+ hours

Modern workflow with AppShots:

  1. Upload your app UI
  2. AI generates backgrounds and captions
  3. Localize with one click
  4. 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:

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:

  1. API requires JWT authentication - Generate tokens with ES256 algorithm
  2. Screenshot upload is multi-step - Reserve, upload, commit, verify
  3. Device types are specific - Use correct screenshotDisplayType codes
  4. Error handling is critical - Implement retries and rate limiting
  5. 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:

Ready to create stunning app screenshots?

AppShots makes it easy to generate professional app store screenshots with AI-powered backgrounds and captions.

Try AppShots Free