Overview
TestFlight is Apple’s official beta testing service for distributing pre-release versions of iOS, iPadOS, macOS, watchOS, tvOS, and visionOS applications to testers before App Store release. It allows developers to invite up to 100 internal testers (team members) and up to 10,000 external testers per app to install and test builds. TestFlight automatically collects crash reports, allows testers to submit feedback with screenshots, and provides build management through App Store Connect.
TestFlight handles the complexity of beta distribution by managing provisioning, installation, and update notifications. Internal testing builds are available immediately after processing, while external testing builds require a brief Beta App Review before distribution. Builds remain active for 90 days, and the TestFlight app on testers’ devices automatically notifies them of new builds. The service integrates with Xcode, fastlane, and CI/CD pipelines through the App Store Connect API.
Installation and Setup
# TestFlight is managed through App Store Connect
# https://appstoreconnect.apple.com
# Prerequisites:
# - Apple Developer Program membership ($99/year)
# - App registered in App Store Connect
# - Xcode with valid signing certificates
# Testers install the TestFlight app:
# iOS/iPadOS: App Store > TestFlight
# macOS: App Store > TestFlight (macOS 12+)
# Upload builds via Xcode
# Product > Archive > Distribute App > App Store Connect
# Upload via command line (altool - legacy)
xcrun altool --upload-app \
--type ios \
--file MyApp.ipa \
--apiKey "YOUR_KEY_ID" \
--apiIssuer "YOUR_ISSUER_ID"
# Upload via command line (notarytool for newer uploads)
xcrun notarytool submit MyApp.ipa \
--apple-id developer@example.com \
--team-id ABCDEF1234 \
--password "@keychain:AC_PASSWORD"
# Upload via Transporter app (GUI)
# Download from Mac App Store
# Upload via fastlane (recommended for CI/CD)
fastlane pilot upload --ipa ./build/MyApp.ipa
App Store Connect API Setup
# Generate API key in App Store Connect:
# Users and Access > Integrations > App Store Connect API
# Download .p8 key file
# Set environment variables
export APP_STORE_CONNECT_API_KEY_ID="YOUR_KEY_ID"
export APP_STORE_CONNECT_ISSUER_ID="YOUR_ISSUER_ID"
export APP_STORE_CONNECT_API_KEY_PATH="/path/to/AuthKey_KEYID.p8"
# Using App Store Connect API with Python
import jwt
import time
import requests
# Generate JWT token
def generate_token(key_id, issuer_id, key_path):
with open(key_path, 'r') as f:
private_key = f.read()
payload = {
'iss': issuer_id,
'iat': int(time.time()),
'exp': int(time.time()) + 1200, # 20 minutes
'aud': 'appstoreconnect-v1'
}
headers = {
'alg': 'ES256',
'kid': key_id,
'typ': 'JWT'
}
return jwt.encode(payload, private_key, algorithm='ES256', headers=headers)
token = generate_token(key_id, issuer_id, key_path)
headers = {
'Authorization': f'Bearer {token}',
'Content-Type': 'application/json'
}
# List builds
response = requests.get(
'https://api.appstoreconnect.apple.com/v1/builds',
headers=headers,
params={'filter[app]': 'APP_ID', 'limit': 10}
)
Build Upload Methods
# Method 1: Xcode (GUI)
# Product > Archive > Distribute App > App Store Connect > Upload
# Method 2: xcodebuild + altool
# Step 1: Archive
xcodebuild archive \
-workspace MyApp.xcworkspace \
-scheme MyApp \
-archivePath build/MyApp.xcarchive \
-allowProvisioningUpdates
# Step 2: Export
xcodebuild -exportArchive \
-archivePath build/MyApp.xcarchive \
-exportPath build/export \
-exportOptionsPlist ExportOptions.plist
# Step 3: Upload
xcrun altool --upload-app \
--type ios \
--file build/export/MyApp.ipa \
--apiKey "$API_KEY_ID" \
--apiIssuer "$ISSUER_ID"
# Method 3: fastlane
# Fastfile
lane :beta do
build_app(
workspace: "MyApp.xcworkspace",
scheme: "MyApp",
export_method: "app-store"
)
upload_to_testflight(
api_key_path: "fastlane/api_key.json",
skip_waiting_for_build_processing: false,
distribute_external: true,
groups: ["Beta Testers"],
changelog: "Bug fixes and improvements"
)
end
Tester Management
# Using fastlane pilot
# List testers
fastlane pilot list
# Add internal tester
fastlane pilot add \
email:tester@example.com \
first_name:John \
last_name:Doe
# Add external tester to group
fastlane pilot add \
email:external@example.com \
first_name:Jane \
last_name:Smith \
group:"Beta Testers"
# Remove tester
fastlane pilot remove email:tester@example.com
# Export tester list
fastlane pilot export
# Create tester group
# Done via App Store Connect web interface or API
Fastlane Pilot Commands
| Command | Description |
|---|
pilot upload | Upload IPA to TestFlight |
pilot builds | List builds |
pilot list | List all testers |
pilot add | Add new tester |
pilot remove | Remove tester |
pilot find | Find tester by email |
pilot import | Import testers from CSV |
pilot export | Export testers to CSV |
# Upload with options
fastlane pilot upload \
--ipa ./build/MyApp.ipa \
--changelog "Version 2.1 - New features and bug fixes" \
--distribute_external true \
--groups "Beta Testers" \
--notify_external_testers true \
--beta_app_review_info '{
"contact_email": "dev@example.com",
"contact_first_name": "John",
"contact_last_name": "Doe",
"contact_phone": "555-0100",
"demo_account_name": "demo@example.com",
"demo_account_password": "demo123"
}'
Build Management
# List recent builds
fastlane pilot builds
# Check build processing status
fastlane pilot builds --filter-build-number 42
# Expire a build
# Via App Store Connect: TestFlight > Build > Expire Build
# Or via API
# Set build metadata
# In upload:
fastlane pilot upload \
--ipa ./MyApp.ipa \
--changelog "What's new in this build:\n- Fixed login bug\n- Improved performance\n- New onboarding flow" \
--beta_app_description "Beta version of MyApp for testing" \
--beta_app_feedback_email "feedback@example.com"
Configuration
// fastlane/api_key.json
{
"key_id": "YOUR_KEY_ID",
"issuer_id": "YOUR_ISSUER_ID",
"key": "-----BEGIN EC PRIVATE KEY-----\nMIGTAg...\n-----END EC PRIVATE KEY-----",
"in_house": false
}
# Fastfile - Complete beta workflow
platform :ios do
desc "Push a new beta build to TestFlight"
lane :beta do
# Ensure clean git state
ensure_git_clean
# Increment build number
increment_build_number(xcodeproj: "MyApp.xcodeproj")
# Sync code signing
match(type: "appstore", readonly: true)
# Build
build_app(
workspace: "MyApp.xcworkspace",
scheme: "MyApp",
configuration: "Release",
export_method: "app-store",
output_directory: "./build",
output_name: "MyApp.ipa",
include_bitcode: false,
include_symbols: true
)
# Upload to TestFlight
upload_to_testflight(
api_key_path: "fastlane/api_key.json",
skip_submission: false,
skip_waiting_for_build_processing: false,
distribute_external: true,
groups: ["Internal Team", "Beta Testers"],
changelog: changelog_from_git_commits(
commits_count: 10,
merge_commit_filtering: "exclude_merges"
),
beta_app_review_info: {
contact_email: "dev@example.com",
contact_first_name: "John",
contact_last_name: "Doe",
contact_phone: "+15550100"
}
)
# Commit and tag
commit_version_bump(
message: "Bump build number for TestFlight [skip ci]",
xcodeproj: "MyApp.xcodeproj"
)
add_git_tag(tag: "beta/#{get_build_number}")
push_to_git_remote
# Notify team
slack(
message: "New beta build uploaded to TestFlight!",
channel: "#mobile-releases",
payload: {
"Build Number" => get_build_number,
"Version" => get_version_number
}
)
end
end
Advanced Usage
# Distribute to specific group programmatically
# Using App Store Connect API
curl -X POST \
"https://api.appstoreconnect.apple.com/v1/betaGroups/GROUP_ID/relationships/builds" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"data": [
{ "type": "builds", "id": "BUILD_ID" }
]
}'
# Submit for external beta review
curl -X POST \
"https://api.appstoreconnect.apple.com/v1/betaBuildLocalizations" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"data": {
"type": "betaBuildLocalizations",
"attributes": {
"whatsNew": "Bug fixes and performance improvements",
"locale": "en-US"
},
"relationships": {
"build": {
"data": { "type": "builds", "id": "BUILD_ID" }
}
}
}
}'
# CI/CD GitHub Actions workflow
# .github/workflows/testflight.yml
name: TestFlight Deploy
on:
push:
branches: [develop]
jobs:
deploy:
runs-on: macos-14
steps:
- uses: actions/checkout@v4
- name: Setup Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: '3.2'
bundler-cache: true
- name: Install certificates
env:
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
MATCH_GIT_URL: ${{ secrets.MATCH_GIT_URL }}
run: bundle exec fastlane match appstore --readonly
- name: Build and upload
env:
APP_STORE_CONNECT_API_KEY: ${{ secrets.ASC_API_KEY }}
run: bundle exec fastlane beta
TestFlight Limits
| Limit | Value |
|---|
| Internal testers | 100 per app |
| External testers | 10,000 per app |
| Tester groups | 200 per app |
| Build expiration | 90 days |
| Build size limit | 4 GB (iOS), 35 GB (tvOS) |
| Apps per account | Unlimited |
| Concurrent builds | 10 per app |
| Beta review time | Usually < 24 hours |
Troubleshooting
| Issue | Solution |
|---|
| Build stuck “Processing” | Wait up to 30 minutes; re-upload if longer; check email for errors |
| ”Missing compliance” | Add export compliance info in App Store Connect or Info.plist |
| Beta review rejected | Check review notes; ensure demo account works; verify app doesn’t crash |
| ”Invalid IPA” upload error | Verify code signing; check entitlements match provisioning profile |
| Tester can’t install | Check device compatibility; verify tester accepted invite; check build not expired |
| ”No eligible devices” | Tester needs matching OS version; check minimum deployment target |
| Public link not working | Ensure external testing is enabled and build passed beta review |
| Crash reports missing | Ensure dSYM symbols are uploaded; check symbols in Xcode Organizer |
| Build number conflict | Increment build number; each upload must have unique build number |
| Notification not sent | Check TestFlight notification settings; testers must have TestFlight app |