
App Store Connect
- 2 installs
- 4 repo stars
- Updated April 6, 2026
- 199-biotechnologies/app-store-connect-skill
app-store-connect is a Claude Code skill that manages App Store Connect operations (metadata, screenshots, reviews, TestFlight, IAP, releases) through the Apple App Store Connect REST API and xcodebuild.
About
app-store-connect is a Claude Code skill that drives the Apple App Store Connect REST API and xcodebuild CLI from natural-language requests. It handles metadata and localization, screenshots, customer reviews, TestFlight, in-app purchases, pricing, submissions, phased releases, and sales/finance report downloads. A developer uses it to update store listings and ship iOS builds without clicking through the App Store Connect web UI. It uses progressive disclosure with a core SKILL.md and nine reference files.
- Manages App Store Connect metadata, screenshots, keywords, reviews, TestFlight, IAP and releases via the Apple REST API
- Updates metadata across 30+ locales and downloads sales/finance reports from the terminal
- Progressive-disclosure design: SKILL.md core plus 9 reference files and a TestFlight-card script
App Store Connect by the numbers
- 2 all-time installs (skills.sh)
- Ranked #1,839 of 2,715 Automation & Workflows skills by installs in the Skillselion catalog
- Data as of Jul 28, 2026 (Skillselion catalog sync)
app-store-connect capabilities & compatibility
Free skill; requires a user-supplied App Store Connect API key (.p8). No subscription fees per the README.
- Capabilities
- app store metadata · testflight management · review responses · app submission · iap management · sales reports
- Use cases
- marketing · seo
- Pricing
- Bring your own API key
What app-store-connect says it does
Complete App Store Connect management via REST API and xcodebuild CLI.
Covers the full Apple App Store Connect REST API (v1/v2) plus xcodebuild CI/CD pipeline.
No subscription fees. No config files. No memorising API endpoints.
npx skills add https://github.com/199-biotechnologies/app-store-connect-skill --skill app-store-connectAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 2 |
|---|---|
| repo stars | ★ 4 |
| Last updated | April 6, 2026 |
| Repository | 199-biotechnologies/app-store-connect-skill ↗ |
What it does
A developer updates App Store listing metadata across locales, manages TestFlight testers, responds to reviews, and submits an iOS build for review from the terminal.
Who is it for?
iOS/macOS developers who want to update store metadata, manage TestFlight, respond to reviews, and submit builds without the App Store Connect web UI
Skip if: Operations Apple has no API for, which the skill documents as confirmed impossible via the REST API
When should I use this skill?
The user asks to upload to the App Store, update app metadata, manage TestFlight, respond to reviews, set pricing, or submit an app version for review
What you get
Store metadata, screenshots, reviews, TestFlight, IAP, and releases are managed through natural-language API calls instead of manual web-UI clicks.
- updated store metadata and localizations
- uploaded screenshots and app previews
- submitted app versions and phased releases
By the numbers
- 9-file reference set
- metadata across 30+ locales
- 4 TestFlight-card colour themes
Files
App Store Connect — Full Management Skill
Complete App Store Connect management via REST API and xcodebuild CLI.
Credentials
Load credentials from config/credentials.local.md (gitignored). That file contains:
- Key ID, Issuer ID, Private Key path, Team ID
- Default contact info (company, email, phone, copyright)
Authentication (Required for All API Calls)
import jwt, time, requests
# Load these values from config/credentials.local.md
KEY_ID = "YOUR_KEY_ID" # See config/credentials.local.md
ISSUER_ID = "YOUR_ISSUER_ID" # See config/credentials.local.md
KEY_PATH = "/path/to/AuthKey.p8" # See config/credentials.local.md
with open(KEY_PATH, 'r') as f:
private_key = f.read()
payload = {"iss": ISSUER_ID, "iat": int(time.time()), "exp": int(time.time()) + 1200, "aud": "appstoreconnect-v1"}
token = jwt.encode(payload, private_key, algorithm="ES256", headers={"kid": KEY_ID})
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}Core ID Resolution (Always Do First)
Most operations require these IDs. Resolve them before any update:
# 1. Get APP_ID
response = requests.get("https://api.appstoreconnect.apple.com/v1/apps", headers=headers)
for app in response.json()['data']:
print(f"{app['attributes']['name']} - {app['attributes']['bundleId']} (ID: {app['id']})")
APP_ID = "the-app-id"
# 2. Get VERSION_ID
response = requests.get(f"https://api.appstoreconnect.apple.com/v1/apps/{APP_ID}/appStoreVersions", headers=headers)
VERSION_ID = response.json()['data'][0]['id']
# 3. Get LOCALIZATION_ID (for version-level metadata)
response = requests.get(f"https://api.appstoreconnect.apple.com/v1/appStoreVersions/{VERSION_ID}/appStoreVersionLocalizations", headers=headers)
LOCALIZATION_ID = response.json()['data'][0]['id']
# 4. Get APP_INFO_ID and APP_INFO_LOC_ID (for app-level metadata)
response = requests.get(f"https://api.appstoreconnect.apple.com/v1/apps/{APP_ID}/appInfos", headers=headers)
APP_INFO_ID = response.json()['data'][0]['id']
response = requests.get(f"https://api.appstoreconnect.apple.com/v1/appInfos/{APP_INFO_ID}/appInfoLocalizations", headers=headers)
APP_INFO_LOC_ID = response.json()['data'][0]['id']Operations Index
| Area | Operations | Reference File |
|---|---|---|
| Metadata & Localization | Description, keywords, subtitle, copyright, category, promo text, multi-locale, AI translation | references/metadata-and-localization.md |
| Screenshots & Previews | Upload screenshots, app previews, simulator automation, screenshot sizes | references/screenshots-and-previews.md |
| Customer Reviews | List reviews, respond, delete responses, AI-assisted responses | references/reviews-and-ratings.md |
| TestFlight | Beta groups, testers, builds, beta review submission, invitations | references/testflight.md |
| Submissions & Releases | Submit for review, phased release, version increment, nominations | references/submissions-and-releases.md |
| IAP & Subscriptions | In-app purchases, subscription groups, promo offers, pricing | references/iap-and-subscriptions.md |
| Reports & Analytics | Sales reports, finance reports, download TSV data | references/reports-and-analytics.md |
| Advanced Features | In-app events, custom product pages, app clips | references/advanced-features.md |
| Build & Deploy | Xcode archive, upload, ExportOptions, one-line deploy | references/build-and-deploy.md |
API Permissions
- GET apps, versions, localizations, reviews, builds, reports
- UPDATE metadata, screenshots, categories, reviews, TestFlight, releases
- CREATE versions, localizations, screenshots, IAPs, subscriptions, events
Hard Limitations (Cannot Be Done via API)
These operations are confirmed impossible via the App Store Connect REST API as of April 2026. Do NOT attempt these programmatically — they will fail or have no endpoint.
| Operation | Why Not | What to Do Instead |
|---|---|---|
| Create a new app | No POST /v1/apps endpoint exists. API keys get 403 FORBIDDEN. Fastlane produce also cannot do this with API keys. | Create manually at https://appstoreconnect.apple.com -> Apps -> + -> New App |
| Configure App Privacy | No API endpoints exist for the privacy questionnaire (data collection types, tracking declarations). Apple provides only a web-based wizard. | Configure manually: ASC -> Apps -> [App] -> App Privacy -> Get Started |
| Upload or change app icon | Icons are embedded in the Xcode asset catalog and bundled into the binary. There is no separate icon upload endpoint. Changing an icon requires a new build. | Set icon in Xcode: Assets.xcassets -> AppIcon, then archive and upload a new build |
| Retrieve app icon image | Limited: iconAssetToken on build resources may return icon URLs, but there is no dedicated icon download endpoint. Results vary. | Best approach: extract from the Xcode project asset catalog, or use the App Store marketing artwork URL |
| Delete an app | Apps cannot be deleted via API. They can be removed from sale or fully removed (if never published). | Remove manually: ASC -> Apps -> [App] -> Remove App (see Apple docs) |
| Transfer an app | App transfers are a multi-step manual process between two developer accounts. No API support. | Initiate manually: ASC -> Apps -> [App] -> Transfer App |
| Manage agreements/contracts | Paid/free app agreements, tax forms, and banking details have no API. | Manage at https://appstoreconnect.apple.com/agreements/ |
| Configure Privacy Manifests | PrivacyInfo.xcprivacy files are part of the Xcode project, not ASC metadata. | Add PrivacyInfo.xcprivacy to Xcode project before building |
Note on age rating: Despite being listed as "manual" in some guides, age ratings CAN be configured via API using PATCH /v1/ageRatingDeclarations/{id}. This skill covers it in references/metadata-and-localization.md.
Common Errors
| Error | Solution |
|---|---|
whatsNew cannot be edited | Remove whatsNew for new apps (only available for updates) |
403 FORBIDDEN on CREATE apps | This is a hard API limitation, not a permissions issue. Create app manually in ASC portal |
Invalid bundle ID | Ensure bundle ID is registered in Developer portal first |
JWT expired | Tokens last 20 minutes max; regenerate before each session |
ENTITY_ERROR.RELATIONSHIP | Check that relationship IDs exist and are correct type |
Pending Agreements | Accept latest agreements at https://appstoreconnect.apple.com/agreements/ |
Full Workflow Checklist
New App Release
- [ ] MANUAL Create app in App Store Connect (no API — see Hard Limitations)
- [ ] MANUAL Configure App Privacy questionnaire (no API — see Hard Limitations)
- [ ] MANUAL Set app icon in Xcode asset catalog (icon is bundled in build, no API)
- [ ] Resolve IDs: APP_ID, VERSION_ID, LOCALIZATION_ID, APP_INFO_ID
- [ ] Update description, keywords, promotionalText, supportUrl
- [ ] Set subtitle (max 30 chars), copyright, category
- [ ] Set age rating declaration (API supported)
- [ ] Set content rights declaration
- [ ] Upload screenshots for each device size
- [ ] Set review contact info
- [ ] Archive and upload build via xcodebuild
- [ ] Set marketing/support/privacy URLs
- [ ] Submit for App Review
- [ ] Optionally enable phased release
App Update
- [ ] Create new version (or auto-increment)
- [ ] Update whatsNew (release notes) for all locales
- [ ] Update screenshots if UI changed
- [ ] Upload new build
- [ ] Submit for review
- [ ] Monitor phased release
Ongoing Management
- [ ] Monitor and respond to customer reviews
- [ ] Manage TestFlight beta testers and builds
- [ ] Download sales and finance reports
- [ ] Create in-app events for promotions
- [ ] Update pricing and availability as needed
App Privacy (Manual)
App Privacy must be configured manually in App Store Connect: 1. App Store Connect -> Apps -> [App] -> App Privacy 2. For apps with no data collection: select "Data Not Collected" 3. Privacy Policy URL: https://yourcompany.com/privacy
Creating a New App (Manual)
1. Go to https://appstoreconnect.apple.com 2. Apps -> + -> New App 3. Platform: iOS, Primary Language: English (U.S.) 4. Bundle ID: select from registered IDs (NOT widget extensions) 5. SKU: unique identifier (e.g., appname001)
# Private credentials - NEVER commit
config/credentials.local.md
# OS files
.DS_Store
# App Store Connect Credentials (PRIVATE - DO NOT COMMIT)
#
# Copy this file to credentials.local.md and fill in your values:
# cp credentials.local.md.example credentials.local.md
## API Key
```
Key ID: YOUR_KEY_ID
Issuer ID: YOUR_ISSUER_ID
Private Key: /path/to/AuthKey_YOURKEYID.p8
Team ID: YOUR_TEAM_ID
```
## Default Contact Info
```
Company: Your Company Name
Email: support@yourcompany.com
Phone: +1 000 000 0000
Copyright: 2026 Your Company Name
Privacy URL: https://yourcompany.com/privacy
```
## Python Auth Snippet
```python
KEY_ID = "YOUR_KEY_ID"
ISSUER_ID = "YOUR_ISSUER_ID"
KEY_PATH = "/path/to/AuthKey_YOURKEYID.p8"
```
## xcodebuild Auth Flags
```bash
-authenticationKeyPath /path/to/AuthKey_YOURKEYID.p8 \
-authenticationKeyID YOUR_KEY_ID \
-authenticationKeyIssuerID YOUR_ISSUER_ID
```
## ExportOptions.plist Team ID
```
YOUR_TEAM_ID
```
## Vendor Number (for Sales & Finance Reports)
```
VENDOR_NUMBER: (find in App Store Connect > Payments and Financial Reports)
```
MIT License
Copyright (c) 2026 199 Biotechnologies
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
<div align="center">
App Store Connect Skill for Claude Code
Manage your entire iOS app lifecycle from the terminal — metadata, screenshots, reviews, TestFlight, subscriptions, and releases.
<br />
 
<br />
  
---
A Claude Code skill that turns your terminal into a full App Store Connect dashboard. Update metadata across 30+ locales, upload screenshots, respond to reviews, manage TestFlight testers, create subscriptions, download sales reports, and submit for review — all through natural language.
Install | What You Can Do | How It Works | Hard Limitations | Contributing
</div>
The Problem
App Store Connect's web UI is slow and repetitive. Updating metadata across multiple locales means clicking through dozens of screens. Managing TestFlight testers is tedious. Downloading sales reports requires navigating a maze of dropdowns. And if you have multiple apps, multiply all of that.
How This Skill Fixes It
Tell Claude what you need in plain English. The skill gives Claude deep knowledge of every App Store Connect API endpoint, so it writes and executes the right API calls for you.
You: "Update the description for my app in English, French, and Japanese"
You: "Add these 5 screenshots to the 6.7-inch iPhone set"
You: "Respond to all 1-star reviews from the last week"
You: "Create a monthly subscription at $4.99 with a free trial"
You: "Submit version 2.1 for review with phased release enabled"No subscription fees. No config files. No memorising API endpoints.
What You Can Do
| Area | Operations |
|---|---|
| Metadata & Localization | Descriptions, keywords, subtitles, categories, copyright, promo text, multi-locale translation with AI |
| Screenshots & Previews | Upload, reorder, delete screenshots and app preview videos. Simulator automation |
| Customer Reviews | Read reviews, filter by rating, respond with AI-drafted replies, delete responses |
| TestFlight | Create beta groups, add testers, assign builds, submit for beta review, send invitations |
| Submissions & Releases | Submit for App Review, phased release (pause/resume/complete), version auto-increment, nominations |
| In-App Purchases | Create consumables, non-consumables, subscription groups, subscriptions, promotional offers |
| Pricing | Set prices per territory, manage availability, schedule price changes |
| Sales & Finance | Download daily/weekly/monthly sales reports, finance reports as TSV |
| In-App Events | Create challenges, competitions, premieres with scheduling and localization |
| Custom Product Pages | Create campaign-specific store listings with unique URLs |
| App Clips | Configure default and advanced App Clip card experiences |
| Build & Deploy | Archive, sign, and upload builds via xcodebuild CLI |
| Export Compliance | Set encryption declarations, bypass with Info.plist flag |
| Cancel Submissions | Remove a version from App Review programmatically |
| TestFlight Cards | Generate stylish invitation cards with artistic QR codes (4 colour themes) |
Install
Three commands:
# 1. Clone the skill
git clone https://github.com/199-biotechnologies/app-store-connect-skill.git \
~/.claude/skills/app-store-connect
# 2. Set up your credentials
cp ~/.claude/skills/app-store-connect/config/credentials.local.md.example \
~/.claude/skills/app-store-connect/config/credentials.local.md
# 3. Edit with your API key details
$EDITOR ~/.claude/skills/app-store-connect/config/credentials.local.mdNeed an API key? Go to App Store Connect > Users and Access > Integrations > App Store Connect API. Generate a key, download the .p8 file (available once only), and note your Key ID and Issuer ID.
How It Works
The skill uses progressive disclosure to stay lightweight:
app-store-connect/
├── SKILL.md # Core skill — auth, ID resolution, operations index
├── config/
│ └── credentials.local.md # Your API credentials (gitignored)
├── references/
│ ├── metadata-and-localization.md
│ ├── screenshots-and-previews.md
│ ├── reviews-and-ratings.md
│ ├── testflight.md
│ ├── submissions-and-releases.md
│ ├── iap-and-subscriptions.md
│ ├── reports-and-analytics.md
│ ├── advanced-features.md
│ └── build-and-deploy.md
└── scripts/
└── testflight_card.py # Stylish QR code invitation cardsSKILL.md loads when Claude detects an App Store task. It contains auth setup, ID resolution patterns, and an index pointing to the 9 reference files. Claude only reads the specific reference file it needs for your request — keeping context lean and responses fast.
Hard Limitations
These operations are confirmed impossible via the App Store Connect REST API as of April 2026. The skill documents these clearly so Claude won't waste time attempting them.
| Operation | Why | What to Do Instead |
|---|---|---|
| Create a new app | No POST /v1/apps endpoint exists | Create manually in the ASC portal |
| Configure App Privacy | No API endpoints for privacy questionnaire | Manual: ASC > App Privacy wizard |
| Upload/change app icon | Icons are embedded in the Xcode binary | Set in Xcode asset catalog, then upload a new build |
| Delete an app | No delete endpoint | Remove App in ASC portal |
| Transfer an app | Multi-step manual process | Initiate in ASC portal |
| Manage agreements | Tax, banking, contracts have no API | Handle at appstoreconnect.apple.com/agreements/ |
| Privacy manifests | PrivacyInfo.xcprivacy is an Xcode project file | Add to your Xcode project before building |
Requirements
- Python 3 with
PyJWTandrequests—pip install PyJWT requests - For QR cards:
pip install "qrcode[pil]" Pillow - Xcode — for build archiving and simulator screenshots
- App Store Connect API key — with appropriate permissions
Contributing
PRs are welcome. If you find a missing API endpoint or an outdated limitation, open an issue or submit a fix.
1. Fork the repo 2. Make your changes 3. Submit a PR with a clear description
License
MIT
---
<div align="center">
Built by Boris Djordjevic at Paperfoot AI
<br />
If this saves you time:
 
</div>
Advanced Features
In-app events, custom product pages, and app clips.
In-App Events
Promote time-sensitive content on the App Store (challenges, competitions, premieres).
Create an Event
event_data = {
"data": {
"type": "appEvents",
"attributes": {
"referenceName": "Summer Challenge 2026",
"badge": "CHALLENGE",
"deepLink": "myapp://events/summer2026",
"purchaseRequirement": "NO_COST_ASSOCIATED",
"purpose": "APPROPRIATE_FOR_ALL_USERS",
"primaryLocale": "en-US",
"priority": "NORMAL",
"territorySchedules": [{
"territories": ["USA", "GBR", "CAN"],
"publishStart": "2026-06-01T00:00:00Z",
"eventStart": "2026-06-01T08:00:00Z",
"eventEnd": "2026-08-31T23:59:00Z"
}]
},
"relationships": {
"app": {"data": {"type": "apps", "id": APP_ID}}
}
}
}
response = requests.post(
"https://api.appstoreconnect.apple.com/v1/appEvents",
headers=headers, json=event_data
)
EVENT_ID = response.json()['data']['id']Badge values: LIVE_EVENT, PREMIERE, CHALLENGE, COMPETITION, NEW_SEASON, MAJOR_UPDATE, SPECIAL_EVENT.
Add Event Localization
loc_data = {
"data": {
"type": "appEventLocalizations",
"attributes": {
"locale": "en-US",
"name": "Summer Fitness Challenge",
"shortDescription": "30 days of daily workouts",
"longDescription": "Join thousands of users in our annual summer challenge..."
},
"relationships": {
"appEvent": {"data": {"type": "appEvents", "id": EVENT_ID}}
}
}
}
requests.post(
"https://api.appstoreconnect.apple.com/v1/appEventLocalizations",
headers=headers, json=loc_data
)Limits: 10 published events at a time, 15 approved events total.
List Events
response = requests.get(
f"https://api.appstoreconnect.apple.com/v1/apps/{APP_ID}/appEvents",
headers=headers
)Custom Product Pages
Create different store listings for different ad campaigns. Each gets a unique App Store URL.
Create a Custom Product Page
cpp_data = {
"data": {
"type": "appCustomProductPages",
"attributes": {"name": "Holiday Campaign 2026"},
"relationships": {
"app": {"data": {"type": "apps", "id": APP_ID}}
}
}
}
response = requests.post(
"https://api.appstoreconnect.apple.com/v1/appCustomProductPages",
headers=headers, json=cpp_data
)
CPP_ID = response.json()['data']['id']Create a Version for the Custom Page
version_data = {
"data": {
"type": "appCustomProductPageVersions",
"relationships": {
"appCustomProductPage": {
"data": {"type": "appCustomProductPages", "id": CPP_ID}
}
}
}
}
response = requests.post(
"https://api.appstoreconnect.apple.com/v1/appCustomProductPageVersions",
headers=headers, json=version_data
)
CPP_VERSION_ID = response.json()['data']['id']Add Localization to Custom Page
loc_data = {
"data": {
"type": "appCustomProductPageLocalizations",
"attributes": {
"locale": "en-US",
"promotionalText": "Special holiday offer!"
},
"relationships": {
"appCustomProductPageVersion": {
"data": {"type": "appCustomProductPageVersions", "id": CPP_VERSION_ID}
}
}
}
}
requests.post(
"https://api.appstoreconnect.apple.com/v1/appCustomProductPageLocalizations",
headers=headers, json=loc_data
)List Custom Pages
response = requests.get(
f"https://api.appstoreconnect.apple.com/v1/apps/{APP_ID}/appCustomProductPages",
headers=headers
)Maximum 35 custom product pages per app.
App Clips
Configure App Clip card experiences for NFC/QR code invocations.
Create Default Experience
# Get app clip ID
response = requests.get(
f"https://api.appstoreconnect.apple.com/v1/apps/{APP_ID}/appClips",
headers=headers
)
APP_CLIP_ID = response.json()['data'][0]['id']
experience_data = {
"data": {
"type": "appClipDefaultExperiences",
"attributes": {"action": "OPEN"},
"relationships": {
"appClip": {"data": {"type": "appClips", "id": APP_CLIP_ID}},
"releaseWithAppStoreVersion": {
"data": {"type": "appStoreVersions", "id": VERSION_ID}
}
}
}
}
response = requests.post(
"https://api.appstoreconnect.apple.com/v1/appClipDefaultExperiences",
headers=headers, json=experience_data
)
EXPERIENCE_ID = response.json()['data']['id']Actions: OPEN, VIEW, PLAY.
Add Localization to App Clip Card
loc_data = {
"data": {
"type": "appClipDefaultExperienceLocalizations",
"attributes": {
"locale": "en-US",
"subtitle": "Track your health instantly"
},
"relationships": {
"appClipDefaultExperience": {
"data": {"type": "appClipDefaultExperiences", "id": EXPERIENCE_ID}
}
}
}
}
requests.post(
"https://api.appstoreconnect.apple.com/v1/appClipDefaultExperienceLocalizations",
headers=headers, json=loc_data
)Build & Deploy
All operations require AUTH credentials from config/credentials.local.md (Key ID, Issuer ID, key path, Team ID).
Archive, sign, and upload builds to App Store Connect via xcodebuild CLI.
Archive
# For .xcodeproj
xcodebuild -project AppName.xcodeproj -scheme AppName \
-configuration Release \
-archivePath /tmp/AppName.xcarchive \
-destination 'generic/platform=iOS' \
archive
# For .xcworkspace (CocoaPods, etc.)
xcodebuild -workspace AppName.xcworkspace -scheme AppName \
-configuration Release \
-archivePath /tmp/AppName.xcarchive \
-destination 'generic/platform=iOS' \
archiveExport Options Plist
Create /tmp/ExportOptions.plist (adjust teamID per your account):
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>method</key>
<string>app-store-connect</string>
<key>destination</key>
<string>upload</string>
<key>teamID</key>
<string>TEAM_ID_HERE</string><!-- See config/credentials.local.md -->
<key>signingStyle</key>
<string>automatic</string>
<key>manageAppVersionAndBuildNumber</key>
<true/>
</dict>
</plist>Export and Upload
Use API key authentication for non-interactive (CI/CD) uploads. Provide the key path, key ID, and issuer ID from the credentials config:
xcodebuild -exportArchive \
-archivePath /tmp/AppName.xcarchive \
-exportOptionsPlist /tmp/ExportOptions.plist \
-exportPath /tmp/AppExport \
-allowProvisioningUpdates \
-authenticationKeyPath PATH_TO_P8_KEY \ # See config/credentials.local.md
-authenticationKeyID KEY_ID \ # See config/credentials.local.md
-authenticationKeyIssuerID ISSUER_ID # See config/credentials.local.mdOne-Line Build + Upload
xcodebuild -project AppName.xcodeproj -scheme AppName -configuration Release \
-archivePath /tmp/AppName.xcarchive -destination 'generic/platform=iOS' archive && \
xcodebuild -exportArchive -archivePath /tmp/AppName.xcarchive \
-exportOptionsPlist /tmp/ExportOptions.plist -exportPath /tmp/AppExport \
-allowProvisioningUpdates \
-authenticationKeyPath PATH_TO_P8_KEY \ # See config/credentials.local.md
-authenticationKeyID KEY_ID \ # See config/credentials.local.md
-authenticationKeyIssuerID ISSUER_ID # See config/credentials.local.mdCommon Build Errors
| Error | Solution |
|---|---|
No profiles for 'bundle.id' were found | Use API key auth flags |
The certificate has expired | Renew in Apple Developer portal |
Provisioning profile doesn't include signing certificate | Use signingStyle: automatic |
App Store Connect Operation Error | Check bundle ID matches registered app |
exportArchive requires -archivePath | Ensure archive step completed successfully |
In-App Purchases & Subscriptions
Create and manage IAPs, subscription groups, subscriptions, promotional offers, and pricing.
Create a Non-Consumable In-App Purchase
iap_data = {
"data": {
"type": "inAppPurchases",
"attributes": {
"name": "Remove Ads",
"productId": "com.company.app.removeads",
"inAppPurchaseType": "NON_CONSUMABLE",
"reviewNote": "Removes all banner and interstitial ads"
},
"relationships": {
"app": {"data": {"type": "apps", "id": APP_ID}}
}
}
}
response = requests.post(
"https://api.appstoreconnect.apple.com/v2/inAppPurchases",
headers=headers, json=iap_data
)
IAP_ID = response.json()['data']['id']IAP types: CONSUMABLE, NON_CONSUMABLE, NON_RENEWING_SUBSCRIPTION.
List All In-App Purchases
response = requests.get(
f"https://api.appstoreconnect.apple.com/v1/apps/{APP_ID}/inAppPurchasesV2",
headers=headers
)
for iap in response.json()['data']:
attrs = iap['attributes']
print(f"{attrs['name']} ({attrs['productId']}) - {attrs['inAppPurchaseType']} - {attrs['state']}")Create a Subscription Group
group_data = {
"data": {
"type": "subscriptionGroups",
"attributes": {"referenceName": "Premium Plans"},
"relationships": {
"app": {"data": {"type": "apps", "id": APP_ID}}
}
}
}
response = requests.post(
"https://api.appstoreconnect.apple.com/v1/subscriptionGroups",
headers=headers, json=group_data
)
SUB_GROUP_ID = response.json()['data']['id']Create a Subscription
sub_data = {
"data": {
"type": "subscriptions",
"attributes": {
"name": "Monthly Premium",
"productId": "com.company.app.monthly",
"subscriptionPeriod": "ONE_MONTH",
"groupLevel": 1,
"reviewNote": "Auto-renewable monthly subscription",
"familySharable": True
},
"relationships": {
"group": {"data": {"type": "subscriptionGroups", "id": SUB_GROUP_ID}}
}
}
}
response = requests.post(
"https://api.appstoreconnect.apple.com/v1/subscriptions",
headers=headers, json=sub_data
)
SUBSCRIPTION_ID = response.json()['data']['id']Subscription periods: ONE_WEEK, ONE_MONTH, TWO_MONTHS, THREE_MONTHS, SIX_MONTHS, ONE_YEAR.
Set Subscription Pricing
# Get available price points
response = requests.get(
f"https://api.appstoreconnect.apple.com/v1/subscriptions/{SUBSCRIPTION_ID}/pricePoints",
headers=headers,
params={"filter[territory]": "USA"}
)
PRICE_POINT_ID = response.json()['data'][0]['id']Promotional Offers
Win back lapsed subscribers:
offer_data = {
"data": {
"type": "subscriptionPromotionalOffers",
"attributes": {
"name": "Winback30",
"offerCode": "WINBACK30",
"duration": "ONE_MONTH",
"offerMode": "FREE_TRIAL",
"numberOfPeriods": 1
},
"relationships": {
"subscription": {"data": {"type": "subscriptions", "id": SUBSCRIPTION_ID}},
"prices": {
"data": [{"type": "subscriptionPromotionalOfferPrices", "id": "PLACEHOLDER_PRICE_ID"}]
}
}
},
"included": [{
"type": "subscriptionPromotionalOfferPrices",
"id": "PLACEHOLDER_PRICE_ID",
"relationships": {
"subscriptionPricePoint": {"data": {"type": "subscriptionPricePoints", "id": PRICE_POINT_ID}},
"territory": {"data": {"type": "territories", "id": "USA"}}
}
}]
}
requests.post(
"https://api.appstoreconnect.apple.com/v1/subscriptionPromotionalOffers",
headers=headers, json=offer_data
)Offer modes: PAY_AS_YOU_GO, PAY_UP_FRONT, FREE_TRIAL. Durations: ONE_DAY, THREE_DAYS, ONE_WEEK, TWO_WEEKS, ONE_MONTH, TWO_MONTHS, THREE_MONTHS, SIX_MONTHS, ONE_YEAR.
App Pricing & Availability
# Set app pricing schedule
price_data = {
"data": {
"type": "appPriceSchedules",
"relationships": {
"app": {"data": {"type": "apps", "id": APP_ID}},
"baseTerritory": {"data": {"type": "territories", "id": "USA"}},
"manualPrices": {"data": [{"type": "appPrices", "id": "PLACEHOLDER_PRICE_ID"}]}
}
},
"included": [{
"type": "appPrices",
"id": "PLACEHOLDER_PRICE_ID",
"attributes": {"startDate": None},
"relationships": {
"appPricePoint": {"data": {"type": "appPricePoints", "id": "PRICE_POINT_ID"}}
}
}]
}
requests.post(
"https://api.appstoreconnect.apple.com/v1/appPriceSchedules",
headers=headers, json=price_data
)Update Territory Availability
First, get the territory availability IDs:
response = requests.get(
f"https://api.appstoreconnect.apple.com/v1/apps/{APP_ID}/relationships/appAvailabilityV2",
headers=headers
)
APP_AVAILABILITY_ID = response.json()['data']['id']
# Get territory availabilities (v2 endpoint)
response = requests.get(
f"https://api.appstoreconnect.apple.com/v2/appAvailabilities/{APP_AVAILABILITY_ID}/territoryAvailabilities",
headers=headers
)
for territory in response.json()['data']:
print(f"{territory['id']} - available: {territory['attributes']['available']}")
TERRITORY_ID = "the-territory-id"Then update:
requests.patch(
f"https://api.appstoreconnect.apple.com/v1/territoryAvailabilities/{TERRITORY_ID}",
headers=headers,
json={"data": {"type": "territoryAvailabilities", "id": TERRITORY_ID,
"attributes": {"available": True, "preOrderEnabled": False}}}
)Metadata & Localization
All operations require AUTH headers and resolved IDs from SKILL.md.
Update Description, Keywords & Promo Text
update_data = {
"data": {
"type": "appStoreVersionLocalizations",
"id": LOCALIZATION_ID,
"attributes": {
"description": "Your app description...",
"keywords": "keyword1,keyword2,keyword3", # Max 100 chars total
"promotionalText": "Short promo text (170 chars max)",
"supportUrl": "https://yourcompany.com/{slug}/support",
"marketingUrl": "https://yourcompany.com/{slug}",
"whatsNew": "Bug fixes and improvements" # Only for updates, not new apps
}
}
}
requests.patch(
f"https://api.appstoreconnect.apple.com/v1/appStoreVersionLocalizations/{LOCALIZATION_ID}",
headers=headers, json=update_data
)Note: whatsNew cannot be set for new apps (only for version updates).
Set Subtitle (Max 30 Characters)
Subtitle is at the appInfoLocalizations level, not version level:
update_data = {
"data": {
"type": "appInfoLocalizations",
"id": APP_INFO_LOC_ID,
"attributes": {
"subtitle": "Your subtitle here"
}
}
}
requests.patch(
f"https://api.appstoreconnect.apple.com/v1/appInfoLocalizations/{APP_INFO_LOC_ID}",
headers=headers, json=update_data
)Set Copyright
update_data = {
"data": {
"type": "appStoreVersions",
"id": VERSION_ID,
"attributes": {"copyright": "2026 Your Company"}
}
}
requests.patch(
f"https://api.appstoreconnect.apple.com/v1/appStoreVersions/{VERSION_ID}",
headers=headers, json=update_data
)Set Category
update_data = {
"data": {
"type": "appInfos",
"id": APP_INFO_ID,
"relationships": {
"primaryCategory": {
"data": {"type": "appCategories", "id": "HEALTH_AND_FITNESS"}
}
}
}
}
requests.patch(
f"https://api.appstoreconnect.apple.com/v1/appInfos/{APP_INFO_ID}",
headers=headers, json=update_data
)Common category IDs: HEALTH_AND_FITNESS, MEDICAL, LIFESTYLE, PRODUCTIVITY, UTILITIES, EDUCATION, BUSINESS, FINANCE, SOCIAL_NETWORKING, ENTERTAINMENT, GAMES, SPORTS, TRAVEL, FOOD_AND_DRINK, WEATHER, MUSIC, PHOTO_AND_VIDEO, NAVIGATION, REFERENCE, NEWS, BOOKS.
Set Privacy Policy URL
update_data = {
"data": {
"type": "appInfoLocalizations",
"id": APP_INFO_LOC_ID,
"attributes": {
"privacyPolicyUrl": "https://yourcompany.com/privacy"
}
}
}
requests.patch(
f"https://api.appstoreconnect.apple.com/v1/appInfoLocalizations/{APP_INFO_LOC_ID}",
headers=headers, json=update_data
)Content Rights Declaration
update_data = {
"data": {
"type": "apps",
"id": APP_ID,
"attributes": {
"contentRightsDeclaration": "DOES_NOT_USE_THIRD_PARTY_CONTENT"
}
}
}
requests.patch(
f"https://api.appstoreconnect.apple.com/v1/apps/{APP_ID}",
headers=headers, json=update_data
)Values: DOES_NOT_USE_THIRD_PARTY_CONTENT, USES_THIRD_PARTY_CONTENT.
Age Rating
response = requests.get(
f"https://api.appstoreconnect.apple.com/v1/appInfos/{APP_INFO_ID}/ageRatingDeclaration",
headers=headers
)
AGE_RATING_ID = response.json()['data']['id']
# All NONE for 4+ rating
update_data = {
"data": {
"type": "ageRatingDeclarations",
"id": AGE_RATING_ID,
"attributes": {
"alcoholTobaccoOrDrugUseOrReferences": "NONE",
"contests": "NONE",
"gamblingSimulated": "NONE",
"horrorOrFearThemes": "NONE",
"matureOrSuggestiveThemes": "NONE",
"medicalOrTreatmentInformation": "NONE",
"profanityOrCrudeHumor": "NONE",
"sexualContentGraphicAndNudity": "NONE",
"sexualContentOrNudity": "NONE",
"violenceCartoonOrFantasy": "NONE",
"violenceRealistic": "NONE",
"violenceRealisticProlongedGraphicOrSadistic": "NONE",
"gambling": False,
"unrestrictedWebAccess": False,
"ageRatingOverride": "NONE"
}
}
}
requests.patch(
f"https://api.appstoreconnect.apple.com/v1/ageRatingDeclarations/{AGE_RATING_ID}",
headers=headers, json=update_data
)For health apps with medical info, set medicalOrTreatmentInformation to INFREQUENT_OR_MILD or FREQUENT_OR_INTENSE.
Review Contact Info
create_data = {
"data": {
"type": "appStoreReviewDetails",
"attributes": {
"contactFirstName": "Your Company",
"contactLastName": "Support",
"contactPhone": "+44 20 8191 3199",
"contactEmail": "support@yourcompany.com",
"demoAccountRequired": False
},
"relationships": {
"appStoreVersion": {
"data": {"type": "appStoreVersions", "id": VERSION_ID}
}
}
}
}
requests.post(
"https://api.appstoreconnect.apple.com/v1/appStoreReviewDetails",
headers=headers, json=create_data
)Multi-Locale Translation Workflow
To translate metadata to multiple locales:
Step 1: List existing localizations
response = requests.get(
f"https://api.appstoreconnect.apple.com/v1/appStoreVersions/{VERSION_ID}/appStoreVersionLocalizations",
headers=headers
)
existing_locales = {loc['attributes']['locale']: loc['id'] for loc in response.json()['data']}Step 2: Create missing localizations
TARGET_LOCALES = ["fr-FR", "de-DE", "es-ES", "ja", "zh-Hans", "pt-BR", "it", "ko", "ar-SA", "nl-NL"]
for locale in TARGET_LOCALES:
if locale not in existing_locales:
create_data = {
"data": {
"type": "appStoreVersionLocalizations",
"attributes": {"locale": locale},
"relationships": {
"appStoreVersion": {
"data": {"type": "appStoreVersions", "id": VERSION_ID}
}
}
}
}
response = requests.post(
"https://api.appstoreconnect.apple.com/v1/appStoreVersionLocalizations",
headers=headers, json=create_data
)
existing_locales[locale] = response.json()['data']['id']Step 3: Translate with Claude and update each locale
For each locale, use Claude to translate the English description, keywords, and promo text, then PATCH each localization with the translated content. Also create appInfoLocalizations for subtitle translation:
# Create app-info localization for subtitle in each locale
response = requests.get(
f"https://api.appstoreconnect.apple.com/v1/appInfos/{APP_INFO_ID}/appInfoLocalizations",
headers=headers
)
existing_info_locales = {loc['attributes']['locale']: loc['id'] for loc in response.json()['data']}
for locale in TARGET_LOCALES:
if locale not in existing_info_locales:
create_data = {
"data": {
"type": "appInfoLocalizations",
"attributes": {"locale": locale},
"relationships": {
"appInfo": {"data": {"type": "appInfos", "id": APP_INFO_ID}}
}
}
}
response = requests.post(
"https://api.appstoreconnect.apple.com/v1/appInfoLocalizations",
headers=headers, json=create_data
)AI-Assisted Keyword Generation
Use Claude to generate optimized keywords: 1. Provide the app description, category, and current keywords 2. Ask Claude to generate keyword variations optimized for App Store search 3. Ensure total keyword string stays within 100 characters 4. Focus on high-volume, low-competition terms relevant to the app 5. PATCH the keywords field on each localization
Sales & Finance Reports
Download sales, subscription, and financial reports as gzip-compressed TSV files.
Sales Reports
import gzip # Additional imports required for report decompression
from io import BytesIO
response = requests.get(
"https://api.appstoreconnect.apple.com/v1/salesReports",
headers=headers,
params={
"filter[frequency]": "MONTHLY",
"filter[reportType]": "SALES",
"filter[reportSubType]": "SUMMARY",
"filter[vendorNumber]": "YOUR_VENDOR_NUMBER",
"filter[reportDate]": "2026-03"
}
)
if response.status_code == 200:
decompressed = gzip.GzipFile(fileobj=BytesIO(response.content)).read()
tsv_data = decompressed.decode('utf-8')
lines = tsv_data.strip().split('\n')
header = lines[0].split('\t')
for line in lines[1:]:
fields = line.split('\t')
print(f"{fields[4]}: {fields[7]} units, ${fields[8]} proceeds")
with open("sales_report.tsv", "w") as f:
f.write(tsv_data)Report Parameters
Frequency: DAILY, WEEKLY, MONTHLY, YEARLY
Report Types:
SALES— Unit sales, proceeds, refundsSUBSCRIPTION— Subscription activitySUBSCRIPTION_EVENT— Subscription lifecycle eventsSUBSCRIBER— Active subscribersSUBSCRIPTION_OFFER_CODE_REDEMPTION— Offer code usagePRE_ORDER— Pre-order dataINSTALLS— Install countsFIRST_ANNUAL— First year activity
Report Sub-Types: SUMMARY, DETAILED, SUMMARY_INSTALL_TYPE, SUMMARY_TERRITORY, SUMMARY_CHANNEL
Vendor Number: Find in App Store Connect under Payments and Financial Reports.
Report Date Format:
- Daily:
YYYY-MM-DD - Weekly:
YYYY-MM-DD(use Sunday date) - Monthly:
YYYY-MM - Yearly:
YYYY
Finance Reports
response = requests.get(
"https://api.appstoreconnect.apple.com/v1/financeReports",
headers=headers,
params={
"filter[regionCode]": "US",
"filter[reportDate]": "2026-03",
"filter[reportType]": "FINANCIAL",
"filter[vendorNumber]": "YOUR_VENDOR_NUMBER"
}
)
if response.status_code == 200:
decompressed = gzip.GzipFile(fileobj=BytesIO(response.content)).read()
with open("finance_report.tsv", "w") as f:
f.write(decompressed.decode('utf-8'))Region codes: US, EU, GB, AU, CA, JP, CN, etc.
Common Response Codes
200— Report data returned (gzip)404— No report available for the requested date (data not yet generated)400— Invalid filter parameters
Customer Reviews & Ratings
Manage customer reviews: read, respond, delete responses, and use AI-assisted drafting.
List Customer Reviews
# List all reviews for an app (newest first, filter by rating)
response = requests.get(
f"https://api.appstoreconnect.apple.com/v1/apps/{APP_ID}/customerReviews",
headers=headers,
params={
"sort": "-createdDate",
"limit": 50
}
)
reviews = response.json()['data']
for review in reviews:
attrs = review['attributes']
print(f"[{attrs['rating']}*] {attrs.get('title', 'No title')}: {attrs['body'][:100]}")Filter by Rating
# Only 1-2 star reviews (critical to respond to)
params = {"sort": "-createdDate", "filter[rating]": "1,2", "limit": 50}
# Only 4-5 star reviews
params = {"sort": "-createdDate", "filter[rating]": "4,5", "limit": 50}Reviews for a Specific Version
response = requests.get(
f"https://api.appstoreconnect.apple.com/v1/appStoreVersions/{VERSION_ID}/customerReviews",
headers=headers,
params={"sort": "-createdDate", "limit": 20}
)Respond to a Review
review_id = "the-review-id"
response_data = {
"data": {
"type": "customerReviewResponses",
"attributes": {
"responseBody": "Thank you for your feedback. We've addressed this in our latest update."
},
"relationships": {
"review": {
"data": {"type": "customerReviews", "id": review_id}
}
}
}
}
requests.post(
"https://api.appstoreconnect.apple.com/v1/customerReviewResponses",
headers=headers, json=response_data
)Note: Each review can only have one developer response. Posting a new response replaces any existing one.
Get Existing Response
response = requests.get(
f"https://api.appstoreconnect.apple.com/v1/customerReviews/{review_id}/response",
headers=headers
)
RESPONSE_ID = response.json()['data']['id']Delete a Response
# Use RESPONSE_ID from "Get Existing Response" above
requests.delete(
f"https://api.appstoreconnect.apple.com/v1/customerReviewResponses/{RESPONSE_ID}",
headers=headers
)AI-Assisted Review Summarization
Apple provides AI-generated review summaries:
response = requests.get(
f"https://api.appstoreconnect.apple.com/v1/apps/{APP_ID}/customerReviewSummarizations",
headers=headers
)AI-Assisted Review Response Workflow
For responding to reviews using Claude:
1. Fetch all unresponded reviews (filter by low ratings first) 2. For each review, ask Claude to:
- Translate the review if not in English
- Identify the core issue or compliment
- Generate a professional, empathetic response
- Keep response under 5970 characters (API limit)
3. POST the response via the customerReviewResponses endpoint
Response guidelines:
- Professional and empathetic tone
- Acknowledge the specific issue mentioned
- Provide a concrete solution or timeline if possible
- Direct to support@yourcompany.com for complex issues
- Thank positive reviewers and mention upcoming features
Screenshots & App Previews
Screenshot Upload Workflow
Step 1: Create Screenshot Set
Display types:
APP_IPHONE_67— iPhone 6.7" (Pro Max) — 1320x2868 or 1290x2796APP_IPHONE_65— iPhone 6.5"APP_IPHONE_61— iPhone 6.1"APP_IPHONE_55— iPhone 5.5"APP_IPAD_PRO_129— iPad Pro 12.9" — 2048x2732APP_APPLE_VISION_PRO— Apple Vision Pro
create_set_data = {
"data": {
"type": "appScreenshotSets",
"attributes": {"screenshotDisplayType": "APP_IPHONE_67"},
"relationships": {
"appStoreVersionLocalization": {
"data": {"type": "appStoreVersionLocalizations", "id": LOCALIZATION_ID}
}
}
}
}
response = requests.post(
"https://api.appstoreconnect.apple.com/v1/appScreenshotSets",
headers=headers, json=create_set_data
)
SCREENSHOT_SET_ID = response.json()['data']['id']Step 2: Reserve, Upload, and Commit Each Screenshot
import os, hashlib # Additional imports required for screenshot upload
filepath = "/path/to/screenshot.png"
file_size = os.path.getsize(filepath)
with open(filepath, 'rb') as f:
file_data = f.read()
checksum = hashlib.md5(file_data).hexdigest()
# Reserve slot
reserve_data = {
"data": {
"type": "appScreenshots",
"attributes": {"fileName": os.path.basename(filepath), "fileSize": file_size},
"relationships": {
"appScreenshotSet": {
"data": {"type": "appScreenshotSets", "id": SCREENSHOT_SET_ID}
}
}
}
}
response = requests.post(
"https://api.appstoreconnect.apple.com/v1/appScreenshots",
headers=headers, json=reserve_data
)
screenshot_data = response.json()['data']
screenshot_id = screenshot_data['id']
upload_ops = screenshot_data['attributes']['uploadOperations']
# Upload binary chunks
for op in upload_ops:
op_headers = {h['name']: h['value'] for h in op.get('requestHeaders', [])}
offset = op.get('offset', 0)
length = op.get('length', file_size)
requests.put(op['url'], headers=op_headers, data=file_data[offset:offset + length])
# Commit
commit_data = {
"data": {
"type": "appScreenshots", "id": screenshot_id,
"attributes": {"uploaded": True, "sourceFileChecksum": checksum}
}
}
requests.patch(
f"https://api.appstoreconnect.apple.com/v1/appScreenshots/{screenshot_id}",
headers=headers, json=commit_data
)Delete a Screenshot
requests.delete(
f"https://api.appstoreconnect.apple.com/v1/appScreenshots/{screenshot_id}",
headers=headers
)Reorder Screenshots
reorder_data = {
"data": [
{"type": "appScreenshots", "id": "screenshot-id-3"},
{"type": "appScreenshots", "id": "screenshot-id-1"},
{"type": "appScreenshots", "id": "screenshot-id-2"},
]
}
requests.patch(
f"https://api.appstoreconnect.apple.com/v1/appScreenshotSets/{SCREENSHOT_SET_ID}/relationships/appScreenshots",
headers=headers, json=reorder_data
)App Preview (Video) Upload
Same pattern as screenshots but with appPreviewSets and appPreviews:
# Create preview set
preview_set_data = {
"data": {
"type": "appPreviewSets",
"attributes": {"previewType": "APP_IPHONE_67"},
"relationships": {
"appStoreVersionLocalization": {
"data": {"type": "appStoreVersionLocalizations", "id": LOCALIZATION_ID}
}
}
}
}
response = requests.post(
"https://api.appstoreconnect.apple.com/v1/appPreviewSets",
headers=headers, json=preview_set_data
)
PREVIEW_SET_ID = response.json()['data']['id']
# Reserve, upload, commit (same flow as screenshots)
# Use type "appPreviews" and endpoint /v1/appPreviewsSimulator Screenshots
# Boot simulator
xcrun simctl boot "iPhone 16 Pro Max"
open -a Simulator
# Build and install app
xcodebuild -project App.xcodeproj -scheme App \
-destination 'platform=iOS Simulator,name=iPhone 16 Pro Max' \
-derivedDataPath build build
xcrun simctl install booted "build/Build/Products/Debug-iphonesimulator/App Name.app"
# Launch app
xcrun simctl launch booted com.company.bundleid
# Take screenshot
xcrun simctl io booted screenshot ~/Pictures/screenshot.pngRequired Screenshot Sizes
| Device | Display Type | Resolution |
|---|---|---|
| iPhone 16 Pro Max | APP_IPHONE_67 | 1320 x 2868 |
| iPhone 15 Pro Max | APP_IPHONE_67 | 1290 x 2796 |
| iPhone 15 Plus | APP_IPHONE_67 | 1290 x 2796 |
| iPad Pro 12.9" | APP_IPAD_PRO_129 | 2048 x 2732 |
| Apple Vision Pro | APP_APPLE_VISION_PRO | 3840 x 2160 |
Minimum 2 screenshots per device type, maximum 10. App previews: 15-30 seconds, up to 3 per locale per device.
Submissions & Releases
Submit for review, manage phased releases, auto-increment versions, and nominate for featuring.
Submit for App Review
submission_data = {
"data": {
"type": "appStoreVersionSubmissions",
"relationships": {
"appStoreVersion": {
"data": {"type": "appStoreVersions", "id": VERSION_ID}
}
}
}
}
requests.post(
"https://api.appstoreconnect.apple.com/v1/appStoreVersionSubmissions",
headers=headers, json=submission_data
)Cancel a Submission (Remove from Review)
# Get the submission ID first
response = requests.get(
f"https://api.appstoreconnect.apple.com/v1/appStoreVersions/{VERSION_ID}/appStoreVersionSubmission",
headers=headers
)
SUBMISSION_ID = response.json()['data']['id']
# Cancel (sets status to Developer Rejected — review restarts if resubmitted)
requests.delete(
f"https://api.appstoreconnect.apple.com/v1/appStoreVersionSubmissions/{SUBMISSION_ID}",
headers=headers
)Export Compliance / Encryption Declaration
Required for every build. Avoids the "Missing Compliance" warning that blocks TestFlight distribution.
# List encryption declarations for a build
response = requests.get(
f"https://api.appstoreconnect.apple.com/v1/builds/{BUILD_ID}/appEncryptionDeclaration",
headers=headers
)
ENCRYPTION_ID = response.json()['data']['id']
# Set export compliance (most apps that only use HTTPS)
update_data = {
"data": {
"type": "appEncryptionDeclarations",
"id": ENCRYPTION_ID,
"attributes": {
"usesEncryption": True,
"isExempt": True # HTTPS-only apps are typically exempt
}
}
}
requests.patch(
f"https://api.appstoreconnect.apple.com/v1/appEncryptionDeclarations/{ENCRYPTION_ID}",
headers=headers, json=update_data
)Tip: To skip this entirely for future builds, add to Info.plist:
<key>ITSAppUsesNonExemptEncryption</key>
<false/>Prerequisites before submission:
- All required metadata fields populated (description, keywords, screenshots)
- Build uploaded and processed
- Age rating configured
- Review contact info set
- Content rights declared
- App privacy configured (manual in ASC portal)
Release an Approved Version
After Apple approves a version, manually release it:
release_data = {
"data": {
"type": "appStoreVersionReleaseRequests",
"relationships": {
"appStoreVersion": {
"data": {"type": "appStoreVersions", "id": VERSION_ID}
}
}
}
}
requests.post(
"https://api.appstoreconnect.apple.com/v1/appStoreVersionReleaseRequests",
headers=headers, json=release_data
)Phased Release
Gradual rollout over 7 days: 1% -> 2% -> 5% -> 10% -> 20% -> 50% -> 100%.
Enable Phased Release
phased_data = {
"data": {
"type": "appStoreVersionPhasedReleases",
"attributes": {"phasedReleaseState": "INACTIVE"},
"relationships": {
"appStoreVersion": {
"data": {"type": "appStoreVersions", "id": VERSION_ID}
}
}
}
}
response = requests.post(
"https://api.appstoreconnect.apple.com/v1/appStoreVersionPhasedReleases",
headers=headers, json=phased_data
)
PHASED_RELEASE_ID = response.json()['data']['id']Check Phased Release Status
response = requests.get(
f"https://api.appstoreconnect.apple.com/v1/appStoreVersions/{VERSION_ID}/appStoreVersionPhasedRelease",
headers=headers
)
state = response.json()['data']['attributes']
print(f"State: {state['phasedReleaseState']}, Day: {state.get('currentDayNumber')}")Pause Phased Release
Use when crash reports spike:
requests.patch(
f"https://api.appstoreconnect.apple.com/v1/appStoreVersionPhasedReleases/{PHASED_RELEASE_ID}",
headers=headers,
json={"data": {"type": "appStoreVersionPhasedReleases", "id": PHASED_RELEASE_ID,
"attributes": {"phasedReleaseState": "PAUSED"}}}
)Resume Phased Release
requests.patch(
f"https://api.appstoreconnect.apple.com/v1/appStoreVersionPhasedReleases/{PHASED_RELEASE_ID}",
headers=headers,
json={"data": {"type": "appStoreVersionPhasedReleases", "id": PHASED_RELEASE_ID,
"attributes": {"phasedReleaseState": "ACTIVE"}}}
)Immediately Release to All Users
requests.patch(
f"https://api.appstoreconnect.apple.com/v1/appStoreVersionPhasedReleases/{PHASED_RELEASE_ID}",
headers=headers,
json={"data": {"type": "appStoreVersionPhasedReleases", "id": PHASED_RELEASE_ID,
"attributes": {"phasedReleaseState": "COMPLETE"}}}
)States: INACTIVE, ACTIVE, PAUSED, COMPLETE.
Version Auto-Increment
Create a New Version
# Get current latest version number
response = requests.get(
f"https://api.appstoreconnect.apple.com/v1/apps/{APP_ID}/appStoreVersions",
headers=headers,
params={"sort": "-createdDate", "limit": 1}
)
current_version = response.json()['data'][0]['attributes']['versionString']
# Compute next version (e.g., "1.2.0" -> "1.3.0")
parts = current_version.split('.')
while len(parts) < 3:
parts.append('0') # Ensure 3-part version
parts[1] = str(int(parts[1]) + 1)
parts[2] = '0'
next_version = '.'.join(parts)
# Create new version
version_data = {
"data": {
"type": "appStoreVersions",
"attributes": {
"versionString": next_version,
"platform": "IOS"
},
"relationships": {
"app": {"data": {"type": "apps", "id": APP_ID}}
}
}
}
response = requests.post(
"https://api.appstoreconnect.apple.com/v1/appStoreVersions",
headers=headers, json=version_data
)
NEW_VERSION_ID = response.json()['data']['id']Note: This creates the ASC version entry. The actual build version (CFBundleShortVersionString) must match and is set in Xcode/Info.plist.
Featured App Nomination
Nominate an app for editorial featuring:
nomination_data = {
"data": {
"type": "nominations",
"attributes": {
"description": "Brief description of why this app deserves featuring...",
"releaseDate": "2026-05-01"
},
"relationships": {
"app": {"data": {"type": "apps", "id": APP_ID}}
}
}
}
requests.post(
"https://api.appstoreconnect.apple.com/v1/nominations",
headers=headers, json=nomination_data
)Launch Readiness Check
Aggregate multiple API calls to verify an app is ready for submission:
def check_launch_readiness(APP_ID, VERSION_ID, headers):
checks = []
# Check version exists and has a build
r = requests.get(f"https://api.appstoreconnect.apple.com/v1/appStoreVersions/{VERSION_ID}",
headers=headers, params={"include": "build"})
version = r.json()
has_build = version.get('included') and len(version['included']) > 0
checks.append(("Build attached", has_build))
# Check localizations have required fields
r = requests.get(f"https://api.appstoreconnect.apple.com/v1/appStoreVersions/{VERSION_ID}/appStoreVersionLocalizations",
headers=headers)
locs = r.json()['data']
for loc in locs:
attrs = loc['attributes']
has_desc = bool(attrs.get('description'))
has_keywords = bool(attrs.get('keywords'))
checks.append((f"Description ({attrs['locale']})", has_desc))
checks.append((f"Keywords ({attrs['locale']})", has_keywords))
# Check screenshots exist
for loc in locs:
r = requests.get(f"https://api.appstoreconnect.apple.com/v1/appStoreVersionLocalizations/{loc['id']}/appScreenshotSets",
headers=headers)
has_screenshots = len(r.json()['data']) > 0
checks.append((f"Screenshots ({loc['attributes']['locale']})", has_screenshots))
# Check review detail
r = requests.get(f"https://api.appstoreconnect.apple.com/v1/appStoreVersions/{VERSION_ID}/appStoreReviewDetail",
headers=headers)
has_review_detail = r.status_code == 200 and r.json().get('data')
checks.append(("Review contact info", has_review_detail))
# Print results
all_pass = True
for name, passed in checks:
status = "PASS" if passed else "FAIL"
if not passed: all_pass = False
print(f" [{status}] {name}")
return all_passTestFlight Management
Manage beta testers, groups, builds, and beta review submissions.
List Builds
response = requests.get(
"https://api.appstoreconnect.apple.com/v1/builds",
headers=headers,
params={
"filter[app]": APP_ID,
"sort": "-uploadedDate",
"limit": 10,
"fields[builds]": "version,uploadedDate,processingState,buildAudienceType"
}
)
for build in response.json()['data']:
attrs = build['attributes']
print(f"Build {attrs['version']} - {attrs['processingState']} ({attrs['uploadedDate']})")
BUILD_ID = response.json()['data'][0]['id'] # Latest buildCreate a Beta Group
group_data = {
"data": {
"type": "betaGroups",
"attributes": {
"name": "External Testers",
"isInternalGroup": False,
"hasAccessToAllBuilds": False,
"publicLinkEnabled": True,
"publicLinkLimit": 100
},
"relationships": {
"app": {"data": {"type": "apps", "id": APP_ID}}
}
}
}
response = requests.post(
"https://api.appstoreconnect.apple.com/v1/betaGroups",
headers=headers, json=group_data
)
GROUP_ID = response.json()['data']['id']List Beta Groups
response = requests.get(
f"https://api.appstoreconnect.apple.com/v1/apps/{APP_ID}/betaGroups",
headers=headers
)
for group in response.json()['data']:
print(f"{group['attributes']['name']} (ID: {group['id']})")Add a Beta Tester
tester_data = {
"data": {
"type": "betaTesters",
"attributes": {
"firstName": "Jane",
"lastName": "Doe",
"email": "jane.doe@example.com"
},
"relationships": {
"betaGroups": {
"data": [{"type": "betaGroups", "id": GROUP_ID}]
}
}
}
}
requests.post(
"https://api.appstoreconnect.apple.com/v1/betaTesters",
headers=headers, json=tester_data
)Add Existing Testers to a Group
tester_ids = ["tester-id-1", "tester-id-2"]
requests.post(
f"https://api.appstoreconnect.apple.com/v1/betaGroups/{GROUP_ID}/relationships/betaTesters",
headers=headers,
json={"data": [{"type": "betaTesters", "id": tid} for tid in tester_ids]}
)Remove Testers from a Group
requests.delete(
f"https://api.appstoreconnect.apple.com/v1/betaGroups/{GROUP_ID}/relationships/betaTesters",
headers=headers,
json={"data": [{"type": "betaTesters", "id": tid} for tid in tester_ids]}
)Assign Build to a Beta Group
requests.post(
f"https://api.appstoreconnect.apple.com/v1/betaGroups/{GROUP_ID}/relationships/builds",
headers=headers,
json={"data": [{"type": "builds", "id": BUILD_ID}]}
)Submit Build for Beta Review
Required for external testers (not needed for internal testers):
submission_data = {
"data": {
"type": "betaAppReviewSubmissions",
"relationships": {
"build": {"data": {"type": "builds", "id": BUILD_ID}}
}
}
}
requests.post(
"https://api.appstoreconnect.apple.com/v1/betaAppReviewSubmissions",
headers=headers, json=submission_data
)Send TestFlight Invitation
invite_data = {
"data": {
"type": "betaTesterInvitations",
"relationships": {
"betaTester": {"data": {"type": "betaTesters", "id": "tester-id"}}
}
}
}
requests.post(
"https://api.appstoreconnect.apple.com/v1/betaTesterInvitations",
headers=headers, json=invite_data
)Update Beta App Localization (TestFlight Description)
response = requests.get(
f"https://api.appstoreconnect.apple.com/v1/apps/{APP_ID}/betaAppLocalizations",
headers=headers
)
BETA_LOC_ID = response.json()['data'][0]['id']
update_data = {
"data": {
"type": "betaAppLocalizations",
"id": BETA_LOC_ID,
"attributes": {
"description": "Thanks for testing! This build includes...",
"feedbackEmail": "support@yourcompany.com"
}
}
}
requests.patch(
f"https://api.appstoreconnect.apple.com/v1/betaAppLocalizations/{BETA_LOC_ID}",
headers=headers, json=update_data
)List All Beta Testers
response = requests.get(
"https://api.appstoreconnect.apple.com/v1/betaTesters",
headers=headers,
params={"filter[apps]": APP_ID, "limit": 100}
)
for tester in response.json()['data']:
attrs = tester['attributes']
print(f"{attrs.get('firstName', '')} {attrs.get('lastName', '')} - {attrs['email']}")#!/usr/bin/env python3
"""
TestFlight Invitation Card Generator
Generates a stylish, editorial-quality card with an artistic QR code
for sharing TestFlight beta links. Output is a high-res PNG.
Usage:
python testflight_card.py <url> <app_name> [--subtitle "Join the beta"] [--style dark|light|electric|sunset] [--output card.png]
Examples:
python testflight_card.py "https://testflight.apple.com/join/AbCdEf" "Simple Blood Pressure Log"
python testflight_card.py "https://testflight.apple.com/join/AbCdEf" "Dream Journal" --style sunset
python testflight_card.py "https://apps.apple.com/app/id123" "My App" --subtitle "Download now"
Requirements:
pip install qrcode[pil] Pillow
"""
import argparse
import sys
from pathlib import Path
try:
import qrcode
from qrcode.image.styledpil import StyledPilImage
from qrcode.image.styles.moduledrawers.pil import RoundedModuleDrawer, CircleModuleDrawer
from qrcode.image.styles.colormasks import RadialGradiantColorMask
except ImportError:
# Older versions of qrcode have different import paths
import qrcode
from qrcode.image.styledpil import StyledPilImage
from qrcode.image.styles.moduledrawers import RoundedModuleDrawer, CircleModuleDrawer
from qrcode.image.styles.colormasks import RadialGradiantColorMask
from PIL import Image, ImageDraw, ImageFont, ImageFilter
# ── Colour Palettes ──────────────────────────────────────────────────
STYLES = {
"dark": {
"bg": (15, 15, 20),
"card_bg": (25, 25, 35),
"accent": (99, 102, 241), # Indigo
"text": (255, 255, 255),
"subtext": (160, 163, 175),
"qr_center": (99, 102, 241),
"qr_edge": (236, 72, 153), # Pink
"qr_bg": (255, 255, 255),
"badge_bg": (99, 102, 241),
"badge_text": (255, 255, 255),
},
"light": {
"bg": (250, 250, 252),
"card_bg": (255, 255, 255),
"accent": (15, 15, 20),
"text": (15, 15, 20),
"subtext": (107, 114, 128),
"qr_center": (15, 15, 20),
"qr_edge": (75, 85, 99),
"qr_bg": (255, 255, 255),
"badge_bg": (15, 15, 20),
"badge_text": (255, 255, 255),
},
"electric": {
"bg": (0, 0, 0),
"card_bg": (10, 10, 18),
"accent": (0, 255, 136), # Neon green
"text": (255, 255, 255),
"subtext": (0, 255, 136),
"qr_center": (0, 255, 136),
"qr_edge": (0, 180, 255), # Cyan
"qr_bg": (255, 255, 255),
"badge_bg": (0, 255, 136),
"badge_text": (0, 0, 0),
},
"sunset": {
"bg": (30, 10, 40),
"card_bg": (45, 15, 55),
"accent": (255, 140, 50), # Orange
"text": (255, 255, 255),
"subtext": (255, 180, 120),
"qr_center": (255, 100, 50),
"qr_edge": (200, 50, 180), # Magenta
"qr_bg": (255, 255, 255),
"badge_bg": (255, 140, 50),
"badge_text": (30, 10, 40),
},
}
def get_font(size, bold=False):
"""Get the best available font."""
font_paths = [
# macOS system fonts
"/System/Library/Fonts/SFProDisplay-Bold.otf" if bold else "/System/Library/Fonts/SFProDisplay-Regular.otf",
"/System/Library/Fonts/SFProDisplay-Heavy.otf" if bold else "/System/Library/Fonts/SFProDisplay-Medium.otf",
"/System/Library/Fonts/Supplemental/Arial Bold.ttf" if bold else "/System/Library/Fonts/Supplemental/Arial.ttf",
"/System/Library/Fonts/Helvetica.ttc",
]
for path in font_paths:
try:
return ImageFont.truetype(path, size)
except (OSError, IOError):
continue
return ImageFont.load_default()
def generate_qr(url, style, size=600):
"""Generate an artistic QR code with rounded dots and gradient."""
palette = STYLES[style]
qr = qrcode.QRCode(
version=None,
error_correction=qrcode.constants.ERROR_CORRECT_H, # High correction for style overlay
box_size=12,
border=2,
)
qr.add_data(url)
qr.make(fit=True)
img = qr.make_image(
image_factory=StyledPilImage,
module_drawer=RoundedModuleDrawer(radius_ratio=1.0), # Fully rounded = dots
color_mask=RadialGradiantColorMask(
back_color=palette["qr_bg"],
center_color=palette["qr_center"],
edge_color=palette["qr_edge"],
),
)
# Convert and resize
img = img.convert("RGBA")
img = img.resize((size, size), Image.LANCZOS)
return img
def draw_rounded_rect(draw, xy, radius, fill):
"""Draw a rounded rectangle."""
x0, y0, x1, y1 = xy
draw.rounded_rectangle(xy, radius=radius, fill=fill)
def generate_card(url, app_name, subtitle="Scan to join the beta", style="dark", output="testflight_card.png"):
"""Generate a complete TestFlight invitation card."""
palette = STYLES[style]
# Card dimensions (2:3 ratio, high-res)
W, H = 1200, 1800
MARGIN = 80
CARD_RADIUS = 40
# Create canvas
canvas = Image.new("RGB", (W, H), palette["bg"])
draw = ImageDraw.Draw(canvas)
# ── Card background ──
draw_rounded_rect(draw, (MARGIN, MARGIN, W - MARGIN, H - MARGIN), CARD_RADIUS, palette["card_bg"])
# ── Accent line at top ──
accent_y = MARGIN + 8
draw_rounded_rect(
draw,
(MARGIN + 200, accent_y, W - MARGIN - 200, accent_y + 6),
3,
palette["accent"],
)
# ── Badge: "TestFlight" or "App Store" ──
badge_font = get_font(28, bold=True)
is_testflight = "testflight" in url.lower()
badge_text = "TESTFLIGHT BETA" if is_testflight else "APP STORE"
badge_y = MARGIN + 80
bbox = draw.textbbox((0, 0), badge_text, font=badge_font)
badge_w = bbox[2] - bbox[0] + 48
badge_h = bbox[3] - bbox[1] + 24
badge_x = (W - badge_w) // 2
draw_rounded_rect(
draw,
(badge_x, badge_y, badge_x + badge_w, badge_y + badge_h),
badge_h // 2,
palette["badge_bg"],
)
draw.text(
(badge_x + 24, badge_y + 10),
badge_text,
fill=palette["badge_text"],
font=badge_font,
)
# ── App name (large, bold, centered, multi-line if needed) ──
title_font = get_font(72, bold=True)
title_y = badge_y + badge_h + 60
# Word-wrap the app name
words = app_name.split()
lines = []
current_line = ""
max_w = W - MARGIN * 2 - 80
for word in words:
test = f"{current_line} {word}".strip()
bbox = draw.textbbox((0, 0), test, font=title_font)
if bbox[2] - bbox[0] <= max_w:
current_line = test
else:
if current_line:
lines.append(current_line)
current_line = word
if current_line:
lines.append(current_line)
for line in lines:
bbox = draw.textbbox((0, 0), line, font=title_font)
lw = bbox[2] - bbox[0]
draw.text(((W - lw) // 2, title_y), line, fill=palette["text"], font=title_font)
title_y += bbox[3] - bbox[1] + 16
# ── Subtitle ──
sub_font = get_font(32, bold=False)
sub_y = title_y + 20
bbox = draw.textbbox((0, 0), subtitle, font=sub_font)
sw = bbox[2] - bbox[0]
draw.text(((W - sw) // 2, sub_y), subtitle, fill=palette["subtext"], font=sub_font)
# ── QR Code ──
qr_size = 560
qr_img = generate_qr(url, style, qr_size)
# White rounded background for QR
qr_bg_size = qr_size + 60
qr_bg = Image.new("RGBA", (qr_bg_size, qr_bg_size), (0, 0, 0, 0))
qr_bg_draw = ImageDraw.Draw(qr_bg)
draw_rounded_rect(qr_bg_draw, (0, 0, qr_bg_size, qr_bg_size), 30, (255, 255, 255, 255))
qr_total_y = sub_y + 80
qr_bg_x = (W - qr_bg_size) // 2
canvas.paste(Image.new("RGB", (qr_bg_size, qr_bg_size), (255, 255, 255)), (qr_bg_x, qr_total_y))
# Paste QR on top
qr_x = (W - qr_size) // 2
qr_y = qr_total_y + 30
canvas.paste(qr_img, (qr_x, qr_y), qr_img)
# ── URL hint at bottom ──
url_font = get_font(22, bold=False)
# Show shortened URL
display_url = url.replace("https://", "").replace("http://", "")
if len(display_url) > 50:
display_url = display_url[:47] + "..."
url_y = qr_total_y + qr_bg_size + 40
bbox = draw.textbbox((0, 0), display_url, font=url_font)
uw = bbox[2] - bbox[0]
draw.text(((W - uw) // 2, url_y), display_url, fill=palette["subtext"], font=url_font)
# ── Bottom accent line ──
bottom_y = H - MARGIN - 8
draw_rounded_rect(
draw,
(MARGIN + 200, bottom_y - 6, W - MARGIN - 200, bottom_y),
3,
palette["accent"],
)
# ── Save ──
canvas.save(output, "PNG")
print(f"Card saved: {output}")
print(f" Style: {style}")
print(f" Size: {W}x{H}")
print(f" App: {app_name}")
print(f" URL: {url}")
return output
def main():
parser = argparse.ArgumentParser(
description="Generate a stylish TestFlight/App Store invitation card with artistic QR code"
)
parser.add_argument("url", help="TestFlight or App Store URL")
parser.add_argument("app_name", help="App name to display on the card")
parser.add_argument("--subtitle", default=None, help="Subtitle text (auto-detected if not set)")
parser.add_argument("--style", choices=STYLES.keys(), default="dark", help="Visual style (default: dark)")
parser.add_argument("--output", default=None, help="Output file path (default: <app_name>_card.png)")
args = parser.parse_args()
# Auto-detect subtitle
if args.subtitle is None:
if "testflight" in args.url.lower():
args.subtitle = "Scan to join the beta"
else:
args.subtitle = "Scan to download"
# Auto-generate output filename
if args.output is None:
safe_name = "".join(c if c.isalnum() or c in "._-" else "_" for c in args.app_name.lower())
args.output = f"{safe_name}_card.png"
generate_card(
url=args.url,
app_name=args.app_name,
subtitle=args.subtitle,
style=args.style,
output=args.output,
)
if __name__ == "__main__":
main()
Related skills
FAQ
What credentials does this skill need?
An App Store Connect API key: a Key ID, Issuer ID, and a downloaded .p8 private key, loaded from a gitignored config/credentials.local.md file.
Can it do everything App Store Connect can do?
No. The skill documents operations that are confirmed impossible via the App Store Connect REST API and tells Claude not to attempt them programmatically.