App Store Connect API and Fastlane: Automating iOS App Distribution


You just shipped a hotfix at 11 PM and now you are clicking through App Store Connect’s web UI to submit the build for review, update the release notes, and notify your beta testers. Three manual steps that should be zero. The App Store Connect API turns that entire workflow into a single script, and Fastlane wraps it in a developer-friendly DSL that your CI server already speaks.

This guide covers JWT authentication, the most useful REST endpoints for day-to-day distribution, Fastlane lane configuration, and the sharp edges that only surface in production. We will not cover Xcode Cloud (that deserves its own post) or StoreKit testing, which is handled in a separate article.

Contents

The Problem

Consider a typical release workflow for a Pixar-themed movie catalog app. You have a CI pipeline that builds your app, but everything downstream is manual:

// What your release day actually looks like:
// 1. Archive in Xcode                    (automated)
// 2. Upload via Xcode Organizer          (manual, 5 min wait)
// 3. Wait for processing                 (manual refresh, 10-20 min)
// 4. Assign build to beta group          (manual, App Store Connect UI)
// 5. Update release notes                (manual, copy-paste from Notion)
// 6. Submit for review                   (manual, 3 clicks)
// 7. Notify the team on Slack            (manual, "build is out")
//
// Total human time: ~30 minutes of clicking and waiting
// Frequency: Every sprint, sometimes more for hotfixes

Multiply that by two or three apps (maybe your main Pixar catalog app, a companion widget extension, and an internal tooling app) and you are spending hours each month on ceremony instead of code. The App Store Connect API lets you script every one of those steps. Fastlane gives you a battle-tested runner for those scripts.

Understanding the App Store Connect API

The App Store Connect API is a REST API that exposes nearly everything you can do in the App Store Connect web interface. Apple first introduced it at WWDC 2018 (“Automating App Store Connect” — WWDC18, session 303) and has expanded it significantly each year.

The API follows the JSON:API specification, which means:

  • Resources have a type, id, and attributes structure
  • Relationships between resources are explicit
  • You can include related resources in a single request using the include parameter
  • Pagination uses limit and offset query parameters

Key resource types you will interact with most often:

  • builds — uploaded binaries and their processing status
  • betaGroups — TestFlight beta groups
  • appStoreVersions — version metadata, screenshots, and review status
  • appStoreVersionSubmissions — the act of submitting a version for review
  • apps — your app records, including bundle IDs and pricing

Apple Docs: App Store Connect API — Apple Developer Documentation

JWT Authentication with .p8 Keys

Every API request requires a JSON Web Token (JWT) signed with an API key you create in App Store Connect. This is the foundational step, and getting it wrong means nothing else works.

First, generate an API key in App Store Connect under Users and Access > Integrations > App Store Connect API. You will download a .p8 file (an ES256 private key) that you can only download once. Store it securely — this is not something that belongs in your repository.

Here is a Swift implementation of JWT generation using the token signing approach:

import Foundation
import Crypto // swift-crypto (or use CryptoKit on Apple platforms)

struct AppStoreConnectAuth {
    let issuerID: String   // Your team's issuer ID from App Store Connect
    let keyID: String      // The Key ID shown next to the .p8 download
    let privateKey: P256.Signing.PrivateKey

    /// Generates a JWT valid for 20 minutes (Apple's maximum)
    func generateToken() throws -> String {
        let header = Header(alg: "ES256", kid: keyID, typ: "JWT")
        let now = Date()
        let payload = Payload(
            iss: issuerID,
            iat: Int(now.timeIntervalSince1970),
            exp: Int(now.addingTimeInterval(20 * 60).timeIntervalSince1970),
            aud: "appstoreconnect-v1"
        )

        let headerData = try JSONEncoder().encode(header)
        let payloadData = try JSONEncoder().encode(payload)

        let signingInput = headerData.base64URLEncoded()
            + "." + payloadData.base64URLEncoded()

        let signature = try privateKey.signature(
            for: Data(signingInput.utf8)
        )
        return signingInput
            + "." + signature.rawRepresentation.base64URLEncoded()
    }
}

The aud claim must be exactly "appstoreconnect-v1". The exp claim cannot exceed 20 minutes from iat — Apple rejects tokens with longer lifetimes. This is not configurable.

Warning: Never commit your .p8 key to version control. Store it in your CI provider’s secrets manager (GitHub Actions secrets, Bitrise secrets, etc.) and load it at build time. A leaked key grants full API access to your App Store Connect account.

Here are the supporting types for the JWT structure:

private struct Header: Encodable {
    let alg: String
    let kid: String
    let typ: String
}

private struct Payload: Encodable {
    let iss: String
    let iat: Int
    let exp: Int
    let aud: String
}

extension Data {
    func base64URLEncoded() -> String {
        base64EncodedString()
            .replacingOccurrences(of: "+", with: "-")
            .replacingOccurrences(of: "/", with: "_")
            .replacingOccurrences(of: "=", with: "")
    }
}

Base64URL encoding is not the same as standard Base64. The +, /, and = characters are replaced to make the token URL-safe. This is a common source of “401 Unauthorized” errors when developers use standard Base64 encoding.

Working with Builds and Beta Groups

Once authenticated, the most common workflow is checking build processing status and assigning builds to TestFlight beta groups. Here is a client that wraps these operations:

actor AppStoreConnectClient {
    private let baseURL =
        URL(string: "https://api.appstoreconnect.apple.com/v1")!
    private let auth: AppStoreConnectAuth
    private var cachedToken: String?
    private var tokenExpiry: Date = .distantPast

    init(auth: AppStoreConnectAuth) {
        self.auth = auth
    }

    /// Fetches the latest build for a given app, filtering by version
    func latestBuild(
        appID: String,
        version: String
    ) async throws -> Build {
        let url = baseURL.appending(path: "builds")
        var components = URLComponents(
            url: url,
            resolvingAgainstBaseURL: false
        )!
        components.queryItems = [
            URLQueryItem(name: "filter[app]", value: appID),
            URLQueryItem(name: "filter[version]", value: version),
            URLQueryItem(name: "sort", value: "-uploadedDate"),
            URLQueryItem(name: "limit", value: "1")
        ]

        let response: BuildsResponse =
            try await request(components.url!)
        guard let build = response.data.first else {
            throw AppStoreConnectError
                .buildNotFound(version: version)
        }
        return build
    }
}

Notice the use of actor — we are dealing with mutable state (the cached token) and this client will be called from multiple concurrent contexts in any real CI script. The actor isolation eliminates data races without manual locking.

To assign a build to a beta group (for example, your “Pixar Internal Testers” group), you send a POST to the beta group’s relationship endpoint:

extension AppStoreConnectClient {
    /// Adds a build to a TestFlight beta group
    func addBuildToBetaGroup(
        buildID: String,
        betaGroupID: String
    ) async throws {
        let url = baseURL
            .appending(path: "betaGroups")
            .appending(path: betaGroupID)
            .appending(path: "relationships/builds")

        let body = RelationshipRequest(
            data: [.init(type: "builds", id: buildID)]
        )

        try await post(url, body: body)
    }
}

Tip: You can find your beta group’s ID by calling GET /v1/betaGroups?filter[app]=YOUR_APP_ID and matching on the group name. Hardcoding the ID in your CI config is fine for stable groups — it does not change unless you delete and recreate the group.

Metadata Updates and Review Submission

Updating localized metadata (release notes, description, keywords) and submitting for review are the operations that save the most manual time. Here is how to update release notes for a specific version:

extension AppStoreConnectClient {
    /// Updates the "What's New" text for a localized version
    func updateWhatsNew(
        localizationID: String,
        whatsNew: String
    ) async throws {
        let url = baseURL
            .appending(path: "appStoreVersionLocalizations")
            .appending(path: localizationID)

        let body = LocalizationPatch(
            data: .init(
                type: "appStoreVersionLocalizations",
                id: localizationID,
                attributes: .init(whatsNew: whatsNew)
            )
        )

        try await patch(url, body: body)
    }

    /// Submits an app store version for review
    func submitForReview(versionID: String) async throws {
        let url = baseURL
            .appending(path: "appStoreVersionSubmissions")

        let body = SubmissionRequest(
            data: .init(
                type: "appStoreVersionSubmissions",
                relationships: .init(
                    appStoreVersion: .init(
                        data: .init(
                            type: "appStoreVersions",
                            id: versionID
                        )
                    )
                )
            )
        )

        try await post(url, body: body)
    }
}

The submission endpoint is deceptively simple — a single POST creates the review submission. But there are preconditions: the version must have at least one build assigned, all required metadata must be present, and the build must have finished processing. If any precondition fails, the API returns a 409 Conflict with a detailed error telling you what is missing.

Note: As of iOS 17, Apple also supports App Store submission via Xcode Cloud. If you are already using Xcode Cloud for builds, the API approach described here is complementary — you can use the API for metadata management while Xcode Cloud handles the build and upload pipeline.

Fastlane Integration

Fastlane is the de facto standard for iOS CI/CD automation. It wraps the App Store Connect API (and altool/notarytool for uploads) behind a Ruby DSL that reads like pseudocode. If you are not ready to build and maintain a custom Swift client, Fastlane is the pragmatic choice.

Here is a Fastfile for a Pixar movie catalog app that automates the full release pipeline:

# fastlane/Fastfile
default_platform(:ios)

platform :ios do
  desc "Build, upload, and submit the Pixar Catalog app"
  lane :release do
    # Increment the build number based on TestFlight's latest
    increment_build_number(
      build_number: latest_testflight_build_number + 1
    )

    # Build the archive
    build_app(
      scheme: "PixarCatalog",
      export_method: "app-store",
      output_directory: "./build"
    )

    # Upload to App Store Connect
    upload_to_app_store(
      skip_metadata: true,
      skip_screenshots: true,
      precheck_include_in_app_purchases: false
    )

    # Distribute to TestFlight beta testers
    upload_to_testflight(
      distribute_external: true,
      groups: ["Pixar Internal Testers", "External Beta"],
      changelog: "Bug fixes and performance improvements."
    )
  end
end

Fastlane authenticates using the same .p8 API key. Create an api_key JSON file or set environment variables:

# fastlane/Fastfile — authentication block
lane :release do
  api_key = app_store_connect_api_key(
    key_id: ENV["ASC_KEY_ID"],
    issuer_id: ENV["ASC_ISSUER_ID"],
    key_filepath: ENV["ASC_KEY_PATH"],  # .p8 file path
    duration: 1200,                      # Max 1200 seconds
    in_house: false
  )

  upload_to_testflight(
    api_key: api_key,
    # ... other options
  )
end

Tip: Prefer API key authentication over Apple ID + app-specific password. The API key approach does not require two-factor authentication handling, never expires (unless you revoke it), and works reliably in headless CI environments. Apple deprecated the session-based approach, and it frequently breaks with cookie expiration.

Fastlane Metadata Management

Fastlane’s deliver action can sync metadata from local text files to App Store Connect. The directory structure maps directly to localization fields:

fastlane/metadata/en-US/
├── description.txt
├── keywords.txt
├── release_notes.txt
├── name.txt
├── subtitle.txt
├── privacy_url.txt
└── support_url.txt

Running fastlane deliver uploads these files to App Store Connect, replacing whatever is currently there. This means you can version-control your metadata alongside your code — review release notes in pull requests, diff keyword changes, and roll back if needed.

lane :update_metadata do
  deliver(
    api_key: api_key,
    skip_binary_upload: true,
    skip_screenshots: true,
    force: true,                  # Skip HTML preview
    submit_for_review: false,     # Update without submitting
    automatic_release: false
  )
end

Advanced Usage

Polling for Build Processing Status

After uploading a build, it enters a processing state that can take anywhere from 5 to 30 minutes. You need to poll for completion before assigning the build to a beta group or submitting for review:

extension AppStoreConnectClient {
    /// Polls until a build finishes processing or times out
    func waitForProcessing(
        buildID: String,
        timeout: TimeInterval = 1800, // 30 minutes
        interval: TimeInterval = 30
    ) async throws -> Build {
        let deadline = Date().addingTimeInterval(timeout)

        while Date() < deadline {
            let build = try await fetchBuild(id: buildID)

            switch build.attributes.processingState {
            case "VALID":
                return build
            case "FAILED", "INVALID":
                throw AppStoreConnectError.processingFailed(
                    state: build.attributes.processingState
                )
            default:
                // Still processing — wait before retrying
                try await Task.sleep(for: .seconds(interval))
            }
        }

        throw AppStoreConnectError.processingTimeout
    }
}

Warning: Do not poll more frequently than every 30 seconds. Apple does not document rate limits for the App Store Connect API, but aggressive polling can result in 429 Too Many Requests responses. In practice, 30-second intervals strike a good balance between responsiveness and politeness.

Reading Sales and Analytics Reports

The API also exposes analytics endpoints for download counts, sales, and subscription metrics. These are useful for building internal dashboards:

extension AppStoreConnectClient {
    /// Fetches a sales report for a specific date range
    func salesReport(
        vendorNumber: String,
        frequency: String = "DAILY",
        reportDate: String  // Format: "2026-03-12"
    ) async throws -> Data {
        let url = baseURL.appending(path: "salesReports")
        var components = URLComponents(
            url: url,
            resolvingAgainstBaseURL: false
        )!
        components.queryItems = [
            URLQueryItem(
                name: "filter[vendorNumber]",
                value: vendorNumber
            ),
            URLQueryItem(
                name: "filter[frequency]",
                value: frequency
            ),
            URLQueryItem(
                name: "filter[reportDate]",
                value: reportDate
            ),
            URLQueryItem(
                name: "filter[reportType]",
                value: "SALES"
            ),
            URLQueryItem(
                name: "filter[reportSubType]",
                value: "SUMMARY"
            )
        ]

        // Sales reports return gzip-compressed TSV, not JSON
        return try await requestRaw(components.url!)
    }
}

Sales reports are returned as gzip-compressed tab-separated values, not JSON. This catches many developers off guard when they try to decode the response as JSON:API and get a decoding error. You need to decompress the response body first, then parse the TSV data.

Subscription Management

For apps with auto-renewable subscriptions (perhaps a “Pixar Premium” tier that unlocks exclusive behind-the-scenes content), the API lets you manage subscription groups, pricing, and offer codes programmatically:

// Fetch all subscription groups for your app
// GET /v1/apps/{appID}/subscriptionGroups?include=subscriptions

// Create a promotional offer code
// POST /v1/subscriptionOfferCodes
// Includes subscription ID, offer type, duration, code count

Apple Docs: Managing Subscriptions — App Store Connect API

Performance Considerations

The App Store Connect API is not designed for high-throughput use. Keep these characteristics in mind:

Rate limiting: Apple does not publish official rate limits, but community testing suggests roughly 200 requests per minute per API key. Batch operations using the include parameter to reduce total request count.

Response times: Expect 200-800ms per request. List endpoints with large datasets (all builds for an app with years of history) can take several seconds. Always use filter and limit parameters to scope your queries.

Token caching: Generating a new JWT for every request is wasteful. Cache the token and reuse it until it approaches expiry:

extension AppStoreConnectClient {
    private func validToken() throws -> String {
        if let token = cachedToken,
           tokenExpiry > Date().addingTimeInterval(60) {
            return token
        }

        let token = try auth.generateToken()
        cachedToken = token
        tokenExpiry = Date().addingTimeInterval(20 * 60)
        return token
    }
}

Payload size: When fetching resources with many relationships (an app version with all its localizations, screenshots, and builds), use the fields parameter to request only the attributes you need. A full app version response with all included relationships can exceed 100KB.

Parallel requests: When you need to update metadata for multiple localizations, fire the PATCH requests concurrently with a TaskGroup. The API handles concurrent requests well as long as you stay under the rate limit:

func updateAllLocalizations(
    _ updates: [(localizationID: String, whatsNew: String)]
) async throws {
    try await withThrowingTaskGroup(of: Void.self) { group in
        for update in updates {
            group.addTask {
                try await self.updateWhatsNew(
                    localizationID: update.localizationID,
                    whatsNew: update.whatsNew
                )
            }
        }
        try await group.waitForAll()
    }
}

When to Use (and When Not To)

ScenarioRecommendation
Single app, infrequent releasesFastlane lanes are sufficient. A custom Swift client is overkill.
Multiple apps, shared cadenceCustom client or shared Fastlane config pays for itself quickly.
Build status in Slack/DiscordThe API is the only programmatic way to check processing state.
20+ localization metadata updatesAPI is far faster than the web UI. Use Fastlane deliver.
Screenshot managementFastlane snapshot + deliver is more ergonomic than raw API.
Subscription pricing changesAPI is essential for complex pricing matrices across regions.
One-off manual releaseJust use the web UI. Automation has overhead.
Xcode Cloud is already your CIUse API for metadata and reporting; Xcode Cloud for builds.

Summary

  • The App Store Connect API is a JSON:API-compliant REST service that programmatically exposes nearly every action available in the App Store Connect web UI.
  • JWT authentication with ES256-signed tokens is the only supported auth method. Tokens expire after 20 minutes. Cache and rotate them.
  • Core workflows — build polling, beta group assignment, metadata updates, and review submission — each map to straightforward REST operations.
  • Fastlane wraps the API in a developer-friendly DSL and is the right starting point for most teams. Graduate to a custom client only when Fastlane’s abstractions get in the way.
  • Rate limits are undocumented but real. Use filter, limit, fields, and include parameters to minimize request count and payload size.

If you are already managing TestFlight distribution manually, start with the Fastlane integration described here — you can automate your entire beta workflow in an afternoon. For the other side of the distribution coin, check out TestFlight Beyond the Basics to learn about beta group strategies and crash report aggregation.