asc-api-automation
Use when automating App Store Connect from a script or CI via the ASC REST API (`api.appstoreconnect.apple.com`) — ASC API `.p8` key / JWT auth, TestFlight `betaGroups` / `betaBuildLocalizations`, `ap
SKILL.mdapple-dev-skills/skills/asc-api-automation/SKILL.md
GitHubApp Store Connect API Automation
This catalog's default way to drive App Store Connect from scripts and CI: an Apple-native toolchain — a ~30-line Swift/CryptoKit script mints the JWT, plain curl calls the REST API. No fastlane/spaceship, no Ruby gems, no Homebrew, consistent with the catalog's mise + no-third-party baselines.
When to invoke
- Writing release tooling: distribute a build to TestFlight groups, set what's-new, create the next App Store version, submit for review
- Pulling sales or App Store analytics reports on a schedule
- Debugging ASC API auth failures (401
NOT_AUTHORIZED) or rate limiting (429) - Asked "should I use fastlane for this?"
Scope
Owns: token minting, curl conventions, and the endpoint cookbook below. Does NOT own:
- Building / uploading the binary — builds arrive in ASC via Xcode Cloud (→
xcode-cloud-single-track-ci), a localxcodebuild -exportArchive/xcrun altoolrun (→local-archive-export-upload), Xcode Organizer, Transporter, or the ASC API's Build Uploads resources (POST /v1/buildUploads+buildUploadFilesreserve / commit — not part of this cookbook). This skill picks up after the build exists in ASC. .p8key storage & leak prevention →build-time-secret-injection(Layer 2secrets/.env) +apple-public-repo-security(rotate-first SOP).- What metadata will pass review →
app-store-review-rejections; this skill is how to submit, not what.
Keys: create, scope, store
| Key kind | Where | JWT identity claim | Use for |
|---|---|---|---|
| Team key (default) | Users and Access → Integrations; role-scoped | iss = Issuer ID |
CI and shared tooling — pick the least-privilege role that works (App Manager covers release ops; avoid Admin) |
| Individual key | Your user profile → Individual API Key | sub: "user" (no iss) |
Personal one-off scripts; inherits your permissions |
Store per build-time-secret-injection Layer 2:
# secrets/.env (gitignored; .env.example committed)
ASC_KEY_ID=2X9R4HXF34
ASC_ISSUER_ID=57246542-96fe-1a63-e053-0824d011072a
ASC_KEY_PATH=secrets/AuthKey_2X9R4HXF34.p8
Mint the token (CryptoKit, zero dependencies)
Claims (verified against Apple's Generating tokens for API requests, 2026-07): header alg: ES256 (the only accepted algorithm), kid, typ: JWT; payload iss (Issuer ID, the UUID from Users and Access → Integrations — not your Team ID), iat, exp (invalid if more than 20 minutes ahead — exception: a token carrying scope and restricted to GET requests on allow-listed resources can live up to 6 months, per Apple's Determine the Appropriate Token Lifetime — but that allow-list is Xcode Cloud / Power-and-Performance resources only (Build Actions, Build Runs, Git References, Issues, macOS Versions, Products, Providers, Power and Performance Metrics and Logs, Pull Requests, Repositories, Test Results, Workflows, Xcode Versions); none of this cookbook's endpoints qualify, so release tooling always mints ≤ 20-minute tokens), aud: "appstoreconnect-v1", optional scope (array of allowed requests like "GET /v1/apps" — pin single-purpose tokens to single endpoints).
The script is bundled at ${CLAUDE_SKILL_DIR}/scripts/mint-asc-token.swift (the scripts/ folder next to this SKILL.md; CryptoKit, zero dependencies) — run it by that path, or copy it into your repo's scripts/ first, which the example below assumes.
source secrets/.env
ASC_TOKEN=$(swift scripts/mint-asc-token.swift)
curl -sf -H "Authorization: Bearer $ASC_TOKEN" "https://api.appstoreconnect.apple.com/v1/apps?limit=200"
Mint fresh per run; a job that outlives the token re-mints instead of extending exp.
curl conventions
- JSON:API shape — write calls send
Content-Type: application/jsonwith the body wrapped as{"data": {"type": ..., "id": ..., "attributes": {...}, "relationships": {...}}}. - Pagination — pass
limit=200(the max) and followlinks.nextuntil absent; the small default page size silently truncates lists otherwise. - Rate limit — every response carries
X-Rate-Limit: user-hour-lim:3500; user-hour-rem:…(rolling hour, per key; Apple says actual limits vary). Exceeding returns 429RATE_LIMIT_EXCEEDED— back off and re-queue; a 429 lockout hits everything sharing that key, including CI. - Errors are structured — read
.errors[].detail. Triage: 401 = token/claims problem, 403 = key role orscopeproblem, 409 = resource state problem (e.g. version not in a submittable state).
Endpoint cookbook
| Goal | Call |
|---|---|
| App's ASC id | GET /v1/apps?filter[bundleId]=<bundle-id> |
| Latest processed builds | GET /v1/builds?filter[app]=<appId>&sort=-uploadedDate&limit=5 |
| TestFlight what's-new | GET /v1/builds/<id>/betaBuildLocalizations → PATCH /v1/betaBuildLocalizations/<locId> with attributes.whatsNew |
| Add build to a beta group | POST /v1/betaGroups/<groupId>/relationships/builds with {"data":[{"type":"builds","id":"<buildId>"}]} |
| Create next App Store version | POST /v1/appStoreVersions — relationship app, attributes platform: "IOS", versionString, releaseType (see Release automation below — don't omit) |
| Set description / what's-new | GET /v1/appStoreVersions/<id>/appStoreVersionLocalizations → PATCH /v1/appStoreVersionLocalizations/<locId> |
| Attach build to version | PATCH /v1/appStoreVersions/<id>/relationships/build |
| Set release behavior | PATCH /v1/appStoreVersions/<id> with attributes.releaseType — MANUAL (release yourself), AFTER_APPROVAL (auto-release the instant Apple approves), or SCHEDULED (auto-release at attributes.earliestReleaseDate, ISO 8601) |
| Submit for review | POST /v1/reviewSubmissions (app + platform) → POST /v1/reviewSubmissionItems (reviewSubmission + appStoreVersion) → PATCH /v1/reviewSubmissions/<id> with attributes.submitted: true |
| Daily sales report | GET /v1/salesReports?filter[frequency]=DAILY&filter[reportType]=SALES&filter[reportSubType]=SUMMARY&filter[vendorNumber]=<n> (+ filter[reportDate]=YYYY-MM-DD for a specific day) — response is a gzipped TSV, not JSON: curl -o report.gz then gunzip |
| App Store analytics | POST /v1/analyticsReportRequests (accessType: "ONGOING") once per app, then poll its reports → instances → segments for download URLs |
The review-submission flow is the reviewSubmissions model introduced in App Store Connect API 1.7, which replaced the deprecated one-shot appStoreVersionSubmissions.
Pre-submission prerequisites the three-call flow doesn't mention
The POST reviewSubmissions → POST reviewSubmissionItems → PATCH submitted: true sequence above assumes the app is already submittable; in practice three preconditions block it and have no REST fix:
| Blocker | Symptom | Fix | Where |
|---|---|---|---|
| App pricing not set | POST reviewSubmissions fails with a pricing-state error (exact error-code string not found in Apple's published API reference — observed in practice) |
Set the price once, via the API (POST /v1/appPriceSchedules, "Add a Scheduled Price Change to an App") or the web UI |
Needs the Paid Apps Agreement accepted first — web-UI-only (see "Steps with no ASC API at all" below) |
Empty copyright attribute |
An ENTITY_ERROR.ATTRIBUTE.REQUIRED-shaped error — documented for other required fields (e.g. companyName); the same shape is observed in practice for a blank copyright, not confirmed against Apple's official attribute reference |
Always send a non-empty copyright string |
API |
Leftover non-COMPLETE reviewSubmissions |
A new POST /v1/reviewSubmissions is refused; the resource has no DELETE (unlike the deprecated pre-2022 appStoreVersionSubmissions, which did) |
PATCH /v1/reviewSubmissions/<id> with {"data":{"type":"reviewSubmissions","id":"<id>","attributes":{"canceled":true}}} — state is read-only and transitions to CANCELING in response; during WAITING_FOR_REVIEW, canceling returns the version to an editable state (observed as DEVELOPER_REJECTED) without leaving an Apple rejection record |
API |
Steps with no ASC API at all — must be clicked by a human
The three prerequisites above are gaps in an otherwise-scriptable flow. These are different:
Apple publishes no REST resource for them at all, so no amount of scripting closes the gap —
budget a manual, one-time (or rarely-repeated) click in the ASC web UI. For the two verified
cases (Agreements/Tax/Banking, and whole-app Promo Codes vs Subscription Offer Codes) and their
sourcing, read references/no-api-steps.md.
Release automation: SemVer, changelog, and explicit releaseType
versionString= SemVer, sourced from the build, not reinvented in CI. Set it to the same value as the archived build'sMARKETING_VERSION(CFBundleShortVersionString) — bump that once at the Xcode-project level (→xcode-cloud-single-track-cibuild-number & version automation), then read it back for theappStoreVersionscall instead of maintaining a second version counter in release tooling.- Changelog →
whatsNew, generated once, written twice. Build the "What's New" text fromgit log <last-tag>..HEAD --oneline(or your conventional-commit tooling) in the release job, thenPATCHit into bothbetaBuildLocalizations(TestFlight) andappStoreVersionLocalizations(App Store) per locale — one generated string, two writes, so testers and reviewers see the same notes. releaseType— set it explicitly, every time. The attribute is optional and Apple's schema documents no default value. The ASC web UI backs the same behavior with an explicit 3-way choice (Manually release this version / Automatically release this version / Automatically release this version after App Review, no earlier than); skippingreleaseTypein a scripted create/update leaves the version's release behavior to an undocumented default — in practice new versions show up as auto-release — instead of a decision your pipeline made on purpose. Default toMANUALin automation unless auto-release is the deliberate intent.
Rationale
- No fastlane by default: spaceship drags a Ruby toolchain into a repo whose only Ruby consumer would be release tooling — against the catalog's mise/no-Homebrew baseline — and inserts a drift layer that breaks whenever ASC changes ahead of a spaceship release. Direct REST against Apple's own docs has zero intermediary.
- CryptoKit mint script: token minting is the only genuinely fiddly step (ES256, raw-signature format); everything after is plain curl. A 30-line Apple-native script beats installing PyJWT or hand-rolling openssl DER conversion.
- Least privilege: token-leak blast radius scales with the key's role; the
scopeclaim can pin a single script to a single request.
Deviation considerations
- Already on fastlane with maintained lanes and a Gemfile → keep it; this skill is the default for repos without a Ruby toolchain, not a migration mandate.
- Heavy tooling (dozens of endpoints, typed models, retries) → generate a client from Apple's published App Store Connect OpenAPI spec instead of hand-rolled curl; still no fastlane required.
- One-off manual task → the ASC web UI is faster; scripting has a floor cost.
Common Mistakes
issset to Team ID — ASC API wants the Issuer ID (UUID); Team ID belongs to other Apple JWTs (e.g. APNs). Symptom: 401NOT_AUTHORIZEDwith a well-formed token.expmore than 20 minutes ahead — token rejected outright (unless it's ascoped, GET-only token on allow-listed resources, which can live up to 6 months); also watch local clock skew oniat.- openssl-signed tokens failing —
openssl dgstemits a DER-encoded signature; JWT ES256 requires the raw 64-byte r‖s form. CryptoKit'srawRepresentationis already correct. - Uploading the binary via REST — no such endpoint exists; route builds through Xcode Cloud,
local-archive-export-upload, Organizer, or Transporter. - Ignoring pagination — the default page size silently truncates; always
limit=200+ followlinks.next. - Parsing
salesReportsas JSON — it's a gzipped TSV file. - Tight-polling build processing or analytics without reading
X-Rate-Limit— 429 locks out every consumer of the key. - Admin-role key in CI when App Manager or a
scoped token suffices. .p8committed to the repo — stop and run the rotate-first SOP inapple-public-repo-security; storage layout perbuild-time-secret-injection.- Omitting
releaseTypeon anappStoreVersionscreate/update — the attribute has no documented default, so an unset value can leave a version on automatic release; it goes live the instant Apple approves it instead of waiting for a deliberate manual release. SetreleaseType: "MANUAL"explicitly unless auto-release is intended.
Review Checklist
- Team key with least-privilege role;
.p8+ IDs stored per build-time-secret-injection Layer 2, nothing tracked by git - Token claims: ES256,
kidheader,iss= Issuer ID (orsub: "user"for individual keys),exp≤ 20 min (or ascoped GET-only token, up to 6 months),audappstoreconnect-v1 - Every collection call sets
limitand followslinks.next - 429 handled with backoff; no tight polling loops
- Write calls use the JSON:API
{"data": {...}}wrapper - No REST binary-upload attempt; build side delegated to xcode-cloud-single-track-ci
-
releaseTypeexplicitly set on everyappStoreVersionscreate/update (MANUALunless auto-release is intended) — never left to ASC's undocumented default -
grepfor key IDs / issuer ID /.p8contents across tracked files returns zero hits
Related skills
xcode-cloud-single-track-ci— build & upload side; this skill starts after the build exists in ASClocal-archive-export-upload— the localxcodebuild -exportArchive/altoolupload path; this skill starts after the build exists in ASCbuild-time-secret-injection— whereASC_KEY_ID/ASC_ISSUER_ID/ the.p8live (Layer 2secrets/.env)apple-public-repo-security—.p8leak prevention and the rotate-first SOPapp-store-review-rejections— what to submit so review passes; this skill is how to submit- Official sources: when verifying or updating a factual or version-sensitive claim, read
references/official-docs.md.