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), a local xcodebuild -exportArchive / xcrun altool run (→ local-archive-export-upload), 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:
# 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), 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"}"#
// Apple rejects exp > 20 min ahead; 10 min leaves slack for clock skew.
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)) // ECDSA + SHA-256 = ES256
print(signingInput + "." + b64url(signature.rawRepresentation)) // rawRepresentation = r‖s, the JWT wire format
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.
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:
- App pricing must be set first. Apple's ASC Help confirms pricing has to be configured before submission (Set a price / Overview of submitting for review, help.apple.com/app-store-connect). A pricing write endpoint does exist —
POST /v1/appPriceSchedules ("Add a Scheduled Price Change to an App") — but it doesn't help until the Paid Apps Agreement is accepted, which is web-UI-only (see "Steps with no ASC API at all" below). Observed in practice (exact error-code string not found in Apple's published API reference): a POST reviewSubmissions on an app with no price configured fails with a pricing-state error. Set the price once — via the API or the web UI — before automating the rest.
- An empty
copyright attribute blocks submission. Apple's error-code family for a missing required attribute is documented on the developer forums as ENTITY_ERROR.ATTRIBUTE.REQUIRED for other required fields (e.g. companyName); the same shape is observed in practice for a blank copyright on the app/version resource — not confirmed against Apple's official attribute reference, so treat the exact code as observed-in-practice, not documented fact. Fix: always send a non-empty copyright string.
- A leftover non-
COMPLETE reviewSubmissions blocks a new POST, and there is no DELETE. Apple's API reference publishes DELETE /v1/appStoreVersionSubmissions/{id} only for the deprecated pre-2022 model; the current reviewSubmissions resource has no DELETE operation. To clear a stuck submission, PATCH /v1/reviewSubmissions/<id> with {"data":{"type":"reviewSubmissions","id":"<id>","attributes":{"canceled":true}}} — state is a read-only response attribute (ReviewSubmissionUpdateRequest.Data.Attributes accepts only canceled / submitted / platform); after the PATCH the state transitions to CANCELING, one of the documented ReviewSubmission.Attributes.state values (alongside READY_FOR_REVIEW, WAITING_FOR_REVIEW, IN_REVIEW, UNRESOLVED_ISSUES, COMPLETING, COMPLETE). During WAITING_FOR_REVIEW, canceling returns the version to an editable state (observed as DEVELOPER_REJECTED, a documented AppVersionState value) without leaving an Apple rejection record — this is the withdrawal recipe when a submission needs correcting before Apple starts review.
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.
- Verified ✓ — Agreements, Tax, and Banking (including accepting the Paid Apps Agreement).
Apple's App Store Connect API topic index (
developer.apple.com/tutorials/data/documentation/AppStoreConnectAPI.md,
checked 2026-09) lists every automatable area — App Store, TestFlight, Game Center,
Provisioning, Xcode Cloud, Webhooks, Reporting, Users and Access, Alternative App
Distribution — and "Agreements, Tax, and Banking" is absent from all of them; no
agreements/taxForms/bankAccounts-shaped resource exists anywhere in the reference.
ASC Help's Schedule price changes for apps page confirms the practical consequence for
this skill's pricing prerequisite above: "If you've accepted the Paid Apps Agreement and
submitted your app for review, you can schedule price changes for your app" — the
Agreement is accepted only in ASC's Manage Agreements section, by the Account Holder, in
the browser. This is the actual gate behind the "app pricing must be set first" prerequisite
above, not a scriptable pricing endpoint being missing (as of 2026, POST /v1/appPriceSchedules
does exist for pricing itself — but it 404s/403s until the Agreement is accepted, and the
Agreement has no API path).
- Verified ✓ — App promo codes (whole-app free-download codes, Apps → <App> → Promo Codes).
ASC Help's Request and manage promo codes page (
developer.apple.com/help/app-store-connect/offer-promo-codes/request-and-manage-promo-codes)
documents only the web click-path ("In Apps, select the app you want to view. In the
sidebar, click Promo Codes. The Promo Code page opens with Generate selected.") with no
REST alternative mentioned; no promoCodes-shaped resource appears in the API topic index
either. Don't confuse this with Subscription Offer Codes, which do have a documented API
(POST /v1/subscriptionOfferCodeCustomCodes and siblings under Subscription Offer Codes)
— the API gap is specific to whole-app promo codes, not offer codes in general.
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,
local-archive-export-upload, Organizer, or 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
local-archive-export-upload — the local xcodebuild -exportArchive / altool upload path; 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
1---2name: asc-api-automation3description: 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.4---56# App Store Connect API Automation78This 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.910## When to invoke1112- Writing release tooling: distribute a build to TestFlight groups, set what's-new, create the next App Store version, submit for review13- Pulling sales or App Store analytics reports on a schedule14- Debugging ASC API auth failures (401 `NOT_AUTHORIZED`) or rate limiting (429)15- Asked "should I use fastlane for this?"1617## Scope1819Owns: token minting, curl conventions, and the endpoint cookbook below. Does NOT own:2021- **Building / uploading the binary** — there is no REST endpoint for `.ipa` upload; builds arrive in ASC via Xcode Cloud (→ `xcode-cloud-single-track-ci`), a local `xcodebuild -exportArchive` / `xcrun altool` run (→ `local-archive-export-upload`), Xcode Organizer, or Transporter. This skill picks up *after* the build exists in ASC.22- **`.p8` key storage & leak prevention** → `build-time-secret-injection` (Layer 2 `secrets/.env`) + `apple-public-repo-security` (rotate-first SOP).23- **What metadata will pass review** → `app-store-review-rejections`; this skill is *how* to submit, not *what*.2425## Keys: create, scope, store2627| Key kind | Where | JWT identity claim | Use for |28|---|---|---|---|29| **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) |30| **Individual key** | Your user profile → Individual API Key | `sub: "user"` (no `iss`) | Personal one-off scripts; inherits *your* permissions |3132Store per `build-time-secret-injection` Layer 2:3334```bash35# secrets/.env (gitignored; .env.example committed)36ASC_KEY_ID=2X9R4HXF3437ASC_ISSUER_ID=57246542-96fe-1a63-e053-0824d011072a38ASC_KEY_PATH=secrets/AuthKey_2X9R4HXF34.p839```4041## Mint the token (CryptoKit, zero dependencies)4243Claims (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).4445`scripts/mint-asc-token.swift`:4647```swift48#!/usr/bin/env swift49import CryptoKit50import Foundation5152let env = ProcessInfo.processInfo.environment53guard let keyID = env["ASC_KEY_ID"], let issuerID = env["ASC_ISSUER_ID"],54 let keyPath = env["ASC_KEY_PATH"] else {55 FileHandle.standardError.write(Data("Set ASC_KEY_ID / ASC_ISSUER_ID / ASC_KEY_PATH (source secrets/.env)\n".utf8))56 exit(1)57}5859func b64url(_ data: Data) -> String {60 data.base64EncodedString()61 .replacingOccurrences(of: "+", with: "-")62 .replacingOccurrences(of: "/", with: "_")63 .replacingOccurrences(of: "=", with: "")64}6566let now = Int(Date().timeIntervalSince1970)67let header = #"{"alg":"ES256","kid":"\#(keyID)","typ":"JWT"}"#68// Apple rejects exp > 20 min ahead; 10 min leaves slack for clock skew.69let payload = #"{"iss":"\#(issuerID)","iat":\#(now),"exp":\#(now + 600),"aud":"appstoreconnect-v1"}"#70let signingInput = b64url(Data(header.utf8)) + "." + b64url(Data(payload.utf8))7172let pem = try String(contentsOfFile: keyPath, encoding: .utf8)73let key = try P256.Signing.PrivateKey(pemRepresentation: pem)74let signature = try key.signature(for: Data(signingInput.utf8)) // ECDSA + SHA-256 = ES25675print(signingInput + "." + b64url(signature.rawRepresentation)) // rawRepresentation = r‖s, the JWT wire format76```7778```bash79source secrets/.env80ASC_TOKEN=$(swift scripts/mint-asc-token.swift)81curl -sf -H "Authorization: Bearer $ASC_TOKEN" "https://api.appstoreconnect.apple.com/v1/apps?limit=200"82```8384Mint fresh per run; a job that outlives the token re-mints instead of extending `exp`.8586## curl conventions8788- **JSON:API shape** — write calls send `Content-Type: application/json` with the body wrapped as `{"data": {"type": ..., "id": ..., "attributes": {...}, "relationships": {...}}}`.89- **Pagination** — pass `limit=200` (the max) and follow `links.next` until absent; the small default page size silently truncates lists otherwise.90- **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.91- **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).9293## Endpoint cookbook9495| Goal | Call |96|---|---|97| App's ASC id | `GET /v1/apps?filter[bundleId]=<bundle-id>` |98| Latest processed builds | `GET /v1/builds?filter[app]=<appId>&sort=-uploadedDate&limit=5` |99| TestFlight what's-new | `GET /v1/builds/<id>/betaBuildLocalizations` → `PATCH /v1/betaBuildLocalizations/<locId>` with `attributes.whatsNew` |100| Add build to a beta group | `POST /v1/betaGroups/<groupId>/relationships/builds` with `{"data":[{"type":"builds","id":"<buildId>"}]}` |101| Create next App Store version | `POST /v1/appStoreVersions` — relationship `app`, attributes `platform: "IOS"`, `versionString`, `releaseType` (see Release automation below — don't omit) |102| Set description / what's-new | `GET /v1/appStoreVersions/<id>/appStoreVersionLocalizations` → `PATCH /v1/appStoreVersionLocalizations/<locId>` |103| Attach build to version | `PATCH /v1/appStoreVersions/<id>/relationships/build` |104| 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) |105| Submit for review | `POST /v1/reviewSubmissions` (app + platform) → `POST /v1/reviewSubmissionItems` (reviewSubmission + appStoreVersion) → `PATCH /v1/reviewSubmissions/<id>` with `attributes.submitted: true` |106| 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` |107| App Store analytics | `POST /v1/analyticsReportRequests` (`accessType: "ONGOING"`) once per app, then poll its `reports` → instances → segments for download URLs |108109The review-submission flow is the 2022+ `reviewSubmissions` model, which replaced the deprecated one-shot `appStoreVersionSubmissions`.110111## Pre-submission prerequisites the three-call flow doesn't mention112113The `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:114115- **App pricing must be set first.** Apple's ASC Help confirms pricing has to be configured before submission (*Set a price* / *Overview of submitting for review*, help.apple.com/app-store-connect). A pricing write endpoint does exist — `POST /v1/appPriceSchedules` ("Add a Scheduled Price Change to an App") — but it doesn't help until the Paid Apps Agreement is accepted, which is web-UI-only (see "Steps with no ASC API at all" below). Observed in practice (exact error-code string not found in Apple's published API reference): a `POST reviewSubmissions` on an app with no price configured fails with a pricing-state error. Set the price once — via the API or the web UI — before automating the rest.116- **An empty `copyright` attribute blocks submission.** Apple's error-code family for a missing required attribute is documented on the developer forums as `ENTITY_ERROR.ATTRIBUTE.REQUIRED` for other required fields (e.g. `companyName`); the same shape is observed in practice for a blank `copyright` on the app/version resource — not confirmed against Apple's official attribute reference, so treat the exact code as observed-in-practice, not documented fact. Fix: always send a non-empty `copyright` string.117- **A leftover non-`COMPLETE` `reviewSubmissions` blocks a new `POST`, and there is no DELETE.** Apple's API reference publishes `DELETE /v1/appStoreVersionSubmissions/{id}` only for the deprecated pre-2022 model; the current `reviewSubmissions` resource has no DELETE operation. To clear a stuck submission, `PATCH /v1/reviewSubmissions/<id>` with `{"data":{"type":"reviewSubmissions","id":"<id>","attributes":{"canceled":true}}}` — `state` is a read-only response attribute (`ReviewSubmissionUpdateRequest.Data.Attributes` accepts only `canceled` / `submitted` / `platform`); after the PATCH the `state` transitions to `CANCELING`, one of the documented `ReviewSubmission.Attributes.state` values (alongside `READY_FOR_REVIEW`, `WAITING_FOR_REVIEW`, `IN_REVIEW`, `UNRESOLVED_ISSUES`, `COMPLETING`, `COMPLETE`). During `WAITING_FOR_REVIEW`, canceling returns the version to an editable state (observed as `DEVELOPER_REJECTED`, a documented `AppVersionState` value) without leaving an Apple rejection record — this is the withdrawal recipe when a submission needs correcting before Apple starts review.118119## Steps with no ASC API at all — must be clicked by a human120121The three prerequisites above are gaps in an otherwise-scriptable flow. These are different:122Apple publishes no REST resource for them at all, so no amount of scripting closes the gap —123budget a manual, one-time (or rarely-repeated) click in the ASC web UI.124125- **Verified ✓ — Agreements, Tax, and Banking (including accepting the Paid Apps Agreement).**126 Apple's App Store Connect API topic index (`developer.apple.com/tutorials/data/documentation/AppStoreConnectAPI.md`,127 checked 2026-09) lists every automatable area — App Store, TestFlight, Game Center,128 Provisioning, Xcode Cloud, Webhooks, Reporting, Users and Access, Alternative App129 Distribution — and "Agreements, Tax, and Banking" is absent from all of them; no130 `agreements`/`taxForms`/`bankAccounts`-shaped resource exists anywhere in the reference.131 ASC Help's *Schedule price changes for apps* page confirms the practical consequence for132 this skill's pricing prerequisite above: "If you've accepted the Paid Apps Agreement and133 submitted your app for review, you can schedule price changes for your app" — the134 Agreement is accepted only in ASC's *Manage Agreements* section, by the Account Holder, in135 the browser. This is the actual gate behind the "app pricing must be set first" prerequisite136 above, not a scriptable pricing endpoint being missing (as of 2026, `POST /v1/appPriceSchedules`137 does exist for pricing itself — but it 404s/403s until the Agreement is accepted, and the138 Agreement has no API path).139- **Verified ✓ — App promo codes (whole-app free-download codes, Apps → \<App\> → Promo Codes).**140 ASC Help's *Request and manage promo codes* page (`developer.apple.com/help/app-store-connect/offer-promo-codes/request-and-manage-promo-codes`)141 documents only the web click-path ("In Apps, select the app you want to view. In the142 sidebar, click Promo Codes. The Promo Code page opens with Generate selected.") with no143 REST alternative mentioned; no `promoCodes`-shaped resource appears in the API topic index144 either. Don't confuse this with **Subscription Offer Codes**, which do have a documented API145 (`POST /v1/subscriptionOfferCodeCustomCodes` and siblings under *Subscription Offer Codes*)146 — the API gap is specific to whole-app promo codes, not offer codes in general.147148## Release automation: SemVer, changelog, and explicit releaseType149150- **`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.151- **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.152- **`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.153154## Rationale155156- **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.157- **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.158- **Least privilege**: token-leak blast radius scales with the key's role; the `scope` claim can pin a single script to a single request.159160## Deviation considerations161162- **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.163- **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.164- **One-off manual task** → the ASC web UI is faster; scripting has a floor cost.165166## Common Mistakes1671681. **`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.1692. **`exp` more than 20 minutes ahead** — token rejected outright; also watch local clock skew on `iat`.1703. **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.1714. **Uploading the binary via REST** — no such endpoint exists; route builds through Xcode Cloud, `local-archive-export-upload`, Organizer, or Transporter.1725. **Ignoring pagination** — the default page size silently truncates; always `limit=200` + follow `links.next`.1736. **Parsing `salesReports` as JSON** — it's a gzipped TSV file.1747. **Tight-polling build processing or analytics** without reading `X-Rate-Limit` — 429 locks out every consumer of the key.1758. **Admin-role key in CI** when App Manager or a `scope`d token suffices.1769. **`.p8` committed to the repo** — stop and run the rotate-first SOP in `apple-public-repo-security`; storage layout per `build-time-secret-injection`.17710. **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.178179## Review Checklist180181- [ ] Team key with least-privilege role; `.p8` + IDs stored per build-time-secret-injection Layer 2, nothing tracked by git182- [ ] Token claims: ES256, `kid` header, `iss` = Issuer ID (or `sub: "user"` for individual keys), `exp` ≤ 20 min, `aud` `appstoreconnect-v1`183- [ ] Every collection call sets `limit` and follows `links.next`184- [ ] 429 handled with backoff; no tight polling loops185- [ ] Write calls use the JSON:API `{"data": {...}}` wrapper186- [ ] No REST binary-upload attempt; build side delegated to xcode-cloud-single-track-ci187- [ ] `releaseType` explicitly set on every `appStoreVersions` create/update (`MANUAL` unless auto-release is intended) — never left to ASC's undocumented default188- [ ] `grep` for key IDs / issuer ID / `.p8` contents across tracked files returns zero hits189190## Related skills191192- `xcode-cloud-single-track-ci` — build & upload side; this skill starts after the build exists in ASC193- `local-archive-export-upload` — the local `xcodebuild -exportArchive` / `altool` upload path; this skill starts after the build exists in ASC194- `build-time-secret-injection` — where `ASC_KEY_ID` / `ASC_ISSUER_ID` / the `.p8` live (Layer 2 `secrets/.env`)195- `apple-public-repo-security` — `.p8` leak prevention and the rotate-first SOP196- `app-store-review-rejections` — *what* to submit so review passes; this skill is *how* to submit