Repository navigation
Adapter App Store Connect
The appstore adapter audits and (when you opt in) edits your App Store Connect listing text: description, promotional text, keywords, What's New, name and subtitle. It signs its own ES256 JWTs, finds the right app version, and knows which fields Apple lets you change on a live app and which need a new version. Screenshots and in-app purchase prices are deliberately out of scope. This page covers credentials, the JWT, bindings, version selection, supported fields, apply rules, revert, and what stays manual.
Status: verified live (2026-09-30). Besides the mocked test suite (
adapters/appstore.test.ts), the adapter has run against a real App Store Connect account: JWT auth,auditofname,subtitle,descriptionandpromotionalText, and a fullapply→revertofpromotionalTexton an unreleased version (see Live verification). Other writable fields (description,keywords,whatsNew) share the same PATCH path but haven't been written live yet. Audit first, use--dry-run, and try writes on an app you can afford to fumble.
Source: adapters/appstore.ts.
You need an App Store Connect API key (Users and Access → Integrations → App Store Connect API): a key id, the issuer id, and the .p8 private key file. Give the key a role that can edit app metadata if you plan to write.
Each value is read from adapter settings first, then the environment:
| Value | Setting (adapters.appstore.*) |
Env fallback |
|---|---|---|
| Key id | keyId |
ASC_KEY_ID |
| Issuer id | issuerId |
ASC_ISSUER_ID |
| Private key file |
keyPath (relative to the project root) |
ASC_PRIVATE_KEY_PATH |
| Private key PEM, inline | none |
ASC_PRIVATE_KEY (literal \n sequences become newlines) |
A key file path wins over ASC_PRIVATE_KEY. An unreadable file is reported as missing credentials: App Store Connect private key not readable at secrets/AuthKey.p8.
# .starchart/config.yaml
adapters:
appstore:
keyId: ABC123DEFG
issuerId: 00000000-0000-0000-0000-000000000000
keyPath: secrets/AuthKey_ABC123DEFG.p8 # keep this file out of gitOr keep config empty and export ASC_KEY_ID, ASC_ISSUER_ID and ASC_PRIVATE_KEY_PATH in CI.
Config values are not environment-interpolated.
keyId: ${ASC_KEY_ID}inconfig.yamlis the literal string${ASC_KEY_ID}, and because settings win over env vars, that string is sent as the key id and the realASC_KEY_IDis ignored. Put literal values in config, or leave the keys out and let the adapter read the environment. The demo has noappstoreblock at all; the adapter is still registered and reads the env vars.
Anything missing throws MissingCredentialsError and audit skips the artifact:
skip appstore:listing/description: App Store Connect credentials missing: ASC_KEY_ID, ASC_ISSUER_ID, ASC_PRIVATE_KEY_PATH or ASC_PRIVATE_KEY
That is the demo, run with no ASC variables set. Only the values still missing are listed, so with ASC_KEY_ID and ASC_ISSUER_ID exported the message shrinks to …missing: ASC_PRIVATE_KEY_PATH or ASC_PRIVATE_KEY.
createAscToken() builds a standard App Store Connect token, signed locally with Node's crypto:
| Part | Value |
|---|---|
| Header | { "alg": "ES256", "kid": <keyId>, "typ": "JWT" } |
| Payload | { "iss": <issuerId>, "iat": now, "exp": now + 1200, "aud": "appstoreconnect-v1" } |
| Signature | ECDSA P-256 over SHA-256, in raw r‖s (IEEE P1363) form, base64url |
The token lives 20 minutes, and a fresh one is minted for every audit, apply or revert call. It goes to https://api.appstoreconnect.apple.com as Authorization: Bearer <jwt>.
- id: appstore:listing/description
binding: { adapter: appstore, app: "6450000000", field: description, locale: en-US }
embeds: [addon:pro.name, addon:pro.price.usd]| Field | Required | Default | Meaning |
|---|---|---|---|
app |
yes | Numeric App Store Connect app id (the Apple ID of the app, not the bundle id). Quote it in YAML. | |
field |
yes, for audit and apply | One of description, promotionalText, keywords, whatsNew, name, subtitle
|
|
locale |
no | en-US |
Localization to read and write |
platform |
no | IOS |
Version platform filter (IOS, MAC_OS, TV_OS, VISION_OS) |
set |
Marks a screenshot binding. Out of scope (see below). | ||
iap |
Marks an in-app purchase price binding. Out of scope. (The demo binds its IAP with product: instead; any binding without field is treated the same way.) |
An unsupported field throws unsupported appstore field "…". A non-numeric app throws appstore binding needs "app" (numeric App Store Connect app id).
Bindings with set, with iap, or with no field get nothing from audit: it returns an empty list and the artifact is counted as checked. The demo's appstore:iap/pro-monthly (bound by product, no field) and appstore:screenshots/6.9/03 (bound by set and index) behave this way, which is why they appear under checked in audit -f json although nothing was compared. That is a known limitation: "checked" here means "the adapter ran", not "the listing was verified".
| Field | Lives on | Apple limit enforced on apply |
|---|---|---|
description |
App Store version localization | 4000 characters |
promotionalText |
App Store version localization | 170 |
keywords |
App Store version localization | 100 |
whatsNew |
App Store version localization | 4000 |
name |
App info localization | 30 |
subtitle |
App info localization | 30 |
Characters are counted by code point. An apply that would exceed the limit fails: promotionalText would be 181 characters; App Store Connect allows 170.
Version fields list the app's versions (GET /v1/apps/<app>/appStoreVersions?filter[platform]=IOS&limit=5). Info fields list app infos (GET /v1/apps/<app>/appInfos). The adapter picks a parent in this order:
- The first resource in an editable state, trying states in this order:
PREPARE_FOR_SUBMISSION,DEVELOPER_REJECTED,REJECTED,METADATA_REJECTED,INVALID_BINARY. - Otherwise the live one:
READY_FOR_SALEorREADY_FOR_DISTRIBUTION. - Otherwise the first one returned.
Then it loads that parent's localizations and picks the one matching locale (version … has no en-US localization if none).
So audit checks the version you're preparing when one exists, and the live listing otherwise. Only the 5 versions the API returns first are considered.
Collects leaf facts through embeds, mirrors and renders, reads the field's text and runs the shared text audit (same rules as Adapter fs). An old value from the lock still present is stale. A current value absent is missing. where looks like appstore:6450000000/en-US/description.
API errors (App Store Connect GET /v1/…: HTTP 401 …) are thrown and land under audit errors.
Only runs for auto steps, which requires adapters.appstore.write: true and a binding the adapter can write. The adapter implements canApply(node): it returns true only for bindings with a supported field and no set or iap. Everything else fails canApply, so canWrite says no for that artifact and the planner marks it manual whatever the config says. Then:
- Plan replacements from the lock's old values to current values. Ambiguous old values and unplaceable list items fail, as in fs:
ambiguous: old value 4.99 of … is also the current value of …,cannot place new item(s) … in description; edit it in App Store Connect. - Resolve the target version or app info for writing.
-
Editability rule:
-
promotionalTextcan be changed on a live version. If no editable version exists, apply targets the live (READY_FOR_SALE/READY_FOR_DISTRIBUTION) version. - Every other field needs an editable parent. Otherwise:
description can only be changed on an editable version (current: READY_FOR_SALE); create a new version in App Store Connect first.
-
- Replace old values in the current text in one pass. If nothing matched:
could not find old value … in description (en-US). - Enforce the length limit.
-
PATCH /v1/appStoreVersionLocalizations/<id>or/v1/appInfoLocalizations/<id>with just that attribute.
Changes read updated description (en-US): 4.99 → 5.99, with , live version added to the label when the write hit a live version. With --dry-run: would update ….
Apply never creates versions and never submits for review. A fixed description sits in your prepared version until you submit it.
Because of canApply, screenshot and IAP artifacts never reach apply, and a run with write: true no longer fails on them: they are listed under "Still needs a human" with the other manual items. If you call the adapter directly through the library with such a binding, it still refuses: screenshots and in-app purchase prices are updated manually.
Not audited, not written:
-
Screenshots (
setbindings) need re-capturing and re-uploading. The graph still tracks them: a screenshot thatcapturesa changed screen orembedsa changed price plans asmanual(value is burned into mediaorscreen changed; re-capture). -
In-app purchase and subscription prices (
iapbindings, or any binding withoutfield) go through Apple's price schedules and tiers. The demo'sappstore:iap/pro-monthlymirrorsaddon:pro.priceand plans asmanual:update in appstore (adapter is read-only). Withwrite: trueit staysmanualbecausecanApplyrejects the binding (the adapter has no price support), and the reason becomesappstore cannot update this binding; update it by hand.
Pixel-level drift against published store screenshots isn't built yet (see Roadmap).
The undo record holds the localization id, scope (version or info), field and the entire previous text:
{ "adapter": "appstore", "artifact": "appstore:listing/description",
"data": { "scope": "version", "localizationId": "…", "field": "description", "previous": "…full old text…" } }revert PATCHes that text back: restored description. Like fs, it restores a snapshot, so edits made in App Store Connect after the apply are overwritten. A version that has since gone live may reject the PATCH, in which case revert reports the failure and the lock is not restored (see Apply, Revert and Journals).
adapters:
appstore:
write: truestarchart adapters then shows appstore writes. That line is per adapter; per artifact, only field bindings become auto. From a copy of the demo with appstore.write: true and usd bumped to 5.99:
$ starchart plan
! manual appstore:iap/pro-monthly mirrors appstore cannot update this binding; update it by hand
! manual appstore:screenshots/6.9/03 embeds value is burned into media
~ auto appstore:listing/description embeds replace embedded value
…
``` Suggested order: audit with credentials, `apply --dry-run --only appstore:listing/description`, apply one artifact, check App Store Connect, keep the journal id handy. `write: false` also makes `apply` refuse directly: `writes to App Store Connect are disabled (adapters.appstore.write: false)`.
## Live verification
Run on 2026-09-30 against a real App Store Connect team (15 apps), on an app version in `PREPARE_FOR_SUBMISSION`, so customers never saw the test text:
| Step | Result |
|---|---|
| JWT (ES256) auth, `GET /v1/apps` | 200 with four of six team keys; the other two returned 401 |
| `audit` of `name`, `subtitle`, `description` (en-US) | name in sync; subtitle and description correctly reported `missing` for a fact they don't contain |
| `audit` after a fact change | `! stale … still shows old value "Orbit Alpha"; expected "Orbit Beta"` |
| `apply --yes` on `promotionalText` | `✓ updated promotionalText (en-US): "Orbit Alpha" → "Orbit Beta"`, confirmed at Apple, journal written, lock updated; `check` and `audit` clean afterwards |
| `revert <journal>` | text restored at Apple, `lockRestored: true` |
Two things the run surfaced:
- **Key roles matter.** App Store Connect API keys carry a team role. A key with a read-only role (e.g. Developer or Sales) audits fine but gets `403 FORBIDDEN_ERROR: The API key in use does not allow this request` on apply. Use an App Manager or Admin key when you set `write: true`.
- **STARCHART rewrites, it doesn't author.** `embeds` apply replaces the old fact value inside existing text. An empty field (promotional text is `null` until someone writes it) has nothing to replace, so apply refuses; write the first version by hand or bind the field to a template.
### Keeping credentials in Google Secret Manager
Config values aren't env-interpolated, so pass credentials through the environment. With the key stored as `ASC_ISSUER_ID`, `ASC_KEY_ID` and `ASC_PRIVATE_KEY` secrets:
```bash
for s in ASC_ISSUER_ID ASC_KEY_ID ASC_PRIVATE_KEY; do export $s="$(gcloud secrets versions access latest --secret=$s)"; done
npx @space-pirate-zero/starchart auditASC_PRIVATE_KEY holds the .p8 contents, so no key file needs to live on disk.
★ STARCHART
☠ a Space Pirate Zero joint
STARCHART Every dependency. Code to cosmos.
Guides
Concepts
- Three-Layer Model
- Facts and Entities
- Artifacts and Bindings
- Edge Types
- Node IDs
- Impact Analysis
- Lockfile and Drift
- Rollout Ordering
Authoring
Code layer
Commands & API
Adapters & engine
- Adapters Overview
- Adapter: fs
- Adapter: url
- Adapter: Stripe
- Adapter: App Store Connect
- Writing an Adapter
- Audit and Break Detection
- Apply, Revert and Journals
- Future Universe Preview
- Viewer and Serve
- Reality X-Ray
Rules & analysis
Integrations
Project