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
- Understanding the App Store Connect API
- JWT Authentication with .p8 Keys
- Working with Builds and Beta Groups
- Metadata Updates and Review Submission
- Fastlane Integration
- Advanced Usage
- Performance Considerations
- When to Use (and When Not To)
- Summary
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, andattributesstructure - Relationships between resources are explicit
- You can include related resources in a single request using the
includeparameter - Pagination uses
limitandoffsetquery parameters
Key resource types you will interact with most often:
builds— uploaded binaries and their processing statusbetaGroups— TestFlight beta groupsappStoreVersions— version metadata, screenshots, and review statusappStoreVersionSubmissions— the act of submitting a version for reviewapps— 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
.p8key 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_IDand 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 Requestsresponses. 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)
| Scenario | Recommendation |
|---|---|
| Single app, infrequent releases | Fastlane lanes are sufficient. A custom Swift client is overkill. |
| Multiple apps, shared cadence | Custom client or shared Fastlane config pays for itself quickly. |
| Build status in Slack/Discord | The API is the only programmatic way to check processing state. |
| 20+ localization metadata updates | API is far faster than the web UI. Use Fastlane deliver. |
| Screenshot management | Fastlane snapshot + deliver is more ergonomic than raw API. |
| Subscription pricing changes | API is essential for complex pricing matrices across regions. |
| One-off manual release | Just use the web UI. Automation has overhead. |
| Xcode Cloud is already your CI | Use 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, andincludeparameters 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.