| name | asc-api-automation |
| description | Use when automating App Store Connect from a CLI or CI — mint the ES256 JWT from an ASC API `.p8` key (`kid`, `iss` = Issuer ID, `aud` `appstoreconnect-v1`, exp ≤ 20 min), then drive `api.appstoreconnect.apple.com` with curl for TestFlight `betaGroups` / `betaBuildLocalizations`, `appStoreVersions` metadata, `reviewSubmissions` submit-for-review, `salesReports` / `analyticsReportRequests`. No fastlane, no Ruby — a CryptoKit token script + curl. Invoke when asked "automate TestFlight / submission / release notes", writing release tooling, or debugging 401 NOT_AUTHORIZED / 429 RATE_LIMIT_EXCEEDED. API-side ops only: build & upload → xcode-cloud-single-track-ci; `.p8` storage / leak prevention → build-time-secret-injection + apple-public-repo-security. |
App 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 — there is no REST endpoint for
.ipa upload; builds arrive in ASC via Xcode Cloud (→ [[xcode-cloud-single-track-ci]]), Xcode Organizer, or Transporter. This skill picks up after the build exists in ASC.
.p8 key storage & leak prevention → [[build-time-secret-injection]] (Layer 2 secrets/.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:
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), aud: "appstoreconnect-v1", optional scope (array of allowed requests like "GET /v1/apps" — pin single-purpose tokens to single endpoints).
scripts/mint-asc-token.swift:
#!/usr/bin/env swift
import CryptoKit
import Foundation
let env = ProcessInfo.processInfo.environment
guard let keyID = env["ASC_KEY_ID"], let issuerID = env["ASC_ISSUER_ID"],
let keyPath = env["ASC_KEY_PATH"] else {
FileHandle.standardError.write(Data("Set ASC_KEY_ID / ASC_ISSUER_ID / ASC_KEY_PATH (source secrets/.env)\n".utf8))
exit(1)
}
func b64url(_ data: Data) -> String {
data.base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
}
let now = Int(Date().timeIntervalSince1970)
let header = #"{"alg":"ES256","kid":"\#(keyID)","typ":"JWT"}"#
let payload = #"{"iss":"\#(issuerID)","iat":\#(now),"exp":\#(now + 600),"aud":"appstoreconnect-v1"}"#
let signingInput = b64url(Data(header.utf8)) + "." + b64url(Data(payload.utf8))
let pem = try String(contentsOfFile: keyPath, encoding: .utf8)
let key = try P256.Signing.PrivateKey(pemRepresentation: pem)
let signature = try key.signature(for: Data(signingInput.utf8))
print(signingInput + "." + b64url(signature.rawRepresentation))
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/json with the body wrapped as {"data": {"type": ..., "id": ..., "attributes": {...}, "relationships": {...}}}.
- Pagination — pass
limit=200 (the max) and follow links.next until 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 429 RATE_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 or scope problem, 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 2022+ reviewSubmissions model, which replaced the deprecated one-shot appStoreVersionSubmissions.
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's MARKETING_VERSION (CFBundleShortVersionString) — bump that once at the Xcode-project level (→ [[xcode-cloud-single-track-ci]] build-number & version automation), then read it back for the appStoreVersions call instead of maintaining a second version counter in release tooling.
- Changelog →
whatsNew, generated once, written twice. Build the "What's New" text from git log <last-tag>..HEAD --oneline (or your conventional-commit tooling) in the release job, then PATCH it into both betaBuildLocalizations (TestFlight) and appStoreVersionLocalizations (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); skipping releaseType in 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 to MANUAL in 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
scope claim 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
iss set to Team ID — ASC API wants the Issuer ID (UUID); Team ID belongs to other Apple JWTs (e.g. APNs). Symptom: 401 NOT_AUTHORIZED with a well-formed token.
exp more than 20 minutes ahead — token rejected outright; also watch local clock skew on iat.
- openssl-signed tokens failing —
openssl dgst emits a DER-encoded signature; JWT ES256 requires the raw 64-byte r‖s form. CryptoKit's rawRepresentation is already correct.
- Uploading the binary via REST — no such endpoint exists; route builds through Xcode Cloud / Organizer / Transporter.
- Ignoring pagination — the default page size silently truncates; always
limit=200 + follow links.next.
- Parsing
salesReports as 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.
.p8 committed to the repo — stop and run the rotate-first SOP in [[apple-public-repo-security]]; storage layout per [[build-time-secret-injection]].
- Omitting
releaseType on an appStoreVersions create/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. Set releaseType: "MANUAL" explicitly unless auto-release is intended.
Review Checklist
Related skills
- [[xcode-cloud-single-track-ci]] — build & upload side; this skill starts after the build exists in ASC
- [[build-time-secret-injection]] — where
ASC_KEY_ID / ASC_ISSUER_ID / the .p8 live (Layer 2 secrets/.env)
- [[apple-public-repo-security]] —
.p8 leak prevention and the rotate-first SOP
- [[app-store-review-rejections]] — what to submit so review passes; this skill is how to submit