← Engineering blog

Apple's App Store Connect API Facelift

You want the French release notes for an iOS app. You know the app, the version, and the locale. Apple knows the release notes. Somewhere between those two facts, your little script has acquired a JWT signer, a pagination loop, and a working knowledge of appStoreVersionLocalizations.

App Store Connect already has a REST API that returns JSON. The awkward part is how much of Apple's resource model you have to absorb before doing ordinary work. A localized listing spans several resources, each with its own ID. Fetching the first version or the first localization can produce a perfectly valid response for the wrong thing. That's a lousy default for release tooling.

We're building Parsequerade, an open-source JSON facade over that API. Clients use app IDs, explicit version or app-info IDs, and locale strings; the service handles Apple authentication and the resource traversal. The current Worker implements 12 resource GET routes, plus hosted connection setup and revocation. Writes, sales reports, and copy workflows are still planned. The interesting work starts with making those reads mean what the caller asked for.

Start with the list of apps

Apple's List apps documentation makes the problem visible. The fields[apps] parameter puts ordinary attributes beside relationships to versions, builds, subscriptions, review submissions, and plenty more. Those are optional choices, not requirements for listing apps. But a newcomer has to sort through the graph to find the three fields their screen needs.

Apple's List apps documentation shows fields[apps] mixing name and bundleId with a long list of relationships, including appStoreVersions, builds, and reviewSubmissions.

Even the small response uses a typed resource envelope. Here are selected fields from a fictional app, followed by the equivalent Parsequerade collection:

Apple

{
  "data": [{
    "type": "apps",
    "id": "1234567890",
    "attributes": {
      "name": "Example Notes",
      "bundleId": "com.example.notes"
    }
  }]
}

Parsequerade

{
  "items": [{
    "id": "1234567890",
    "name": "Example Notes",
    "bundleId": "com.example.notes"
  }],
  "nextCursor": null
}

The facade translates bundleId=com.example.notes into Apple's filter[bundleId]. Single-resource reads return an object; collections consistently return items and nextCursor. The adapter copies reviewed attributes into the object and turns supported relationship linkage into fields such as primaryCategoryId. It checks resource types and value types, rather than blindly spreading whatever arrived upstream.

Removing data.attributes is pleasant, but you could do that in a helper function. Resolving the right child resource is where a shared service earns its keep.

Keep the version; hide the localization ID

An app's name, subtitle, and privacy URL belong to an app-info localization. Its description, keywords, and release notes belong to a version localization. They can all appear on one App Store page, which makes it tempting to stuff them into one metadata object. Their ownership still matters when you select or change them.

One app branches into app information and a selected version. App-info localizations own name, subtitle, and privacy URL. Version localizations separately own description, keywords, and release notes. Both branches have an en-US and a fr-FR locale.

For the French release notes, the facade route is relative to your connection:

GET /apps/1234567890/versions/version_123/localizations/fr-FR

You still choose version_123. The version list accepts filters such as platform=IOS and state=PREPARE_FOR_SUBMISSION, then returns IDs you can inspect. An API that silently chooses “latest” has to guess whether you mean the live release, an editable draft, or another platform. Keeping that choice visible prevents a shorter URL from becoming a less predictable operation.

The Worker first proves that the selected version belongs to the app by finding it in the app's scoped version collection. Nesting two IDs in a URL doesn't prove that relationship. This check also works when Apple omits relationship linkage from the response.

Next it lists that version's localizations, asks Apple to filter by fr-FR, and verifies exact locale matches itself. It follows every returned page to establish uniqueness. Zero matches produces localization_not_found; two matches produces 409 ambiguous_selection. There is no fallback to English and no first-item lottery.

Once it has exactly one localization ID, it fetches the resource and checks the returned ID, locale, and any supplied parent linkage before flattening the fields. The client gets the localization's data with its app and version context. This can take several Apple requests, so the gain is simpler client logic, not a claim of lower upstream latency.

One place to hold the Apple key

That script also shouldn't need to know when an Apple token expires. Apple requires signed JWTs. Parsequerade accepts an Apple team key's issuer ID, key ID, and .p8 private key during connection setup, then issues a separate facade bearer key. Subsequent resource requests use the connection's base URL and that bearer key.

During hosted setup, the Worker imports and validates the P-256 private key, then probes GET /v1/apps?limit=1. It checks the read access it will issue without requiring an administrator-only users request. Successful setup stores the Apple credentials encrypted with AES-GCM in a SQLite Durable Object, one per connection. Only a SHA-256 hash of the facade key is stored.

The Apple transport signs ES256 JWTs using native Web Crypto. Tokens expire ten minutes after generation and are reused until they're within a minute of expiry. Concurrent requests share an in-progress signing operation. If Apple rejects a cached token, the transport can refresh it once within the read's retry budget.

Hosted facade keys currently grant read access to every app accessible through the supplied Apple team key. Operator-configured deployments can restrict grants to explicit app IDs; the app-list adapter pushes that restriction into Apple's filter[id] instead of crawling the account and filtering afterward. Facade access cannot add permissions that Apple denied.

There is a cost to this arrangement: the hosted operator holds a credential capable of signing Apple requests. Encryption at rest doesn't remove that trust. The GPL-licensed Worker can be self-hosted if you want the translation without handing the private key to another operator. Deleting a hosted connection removes its stored credentials and revokes its facade key; revoking the Apple key is a separate action.

Pagination has to preserve the question

Suppose page one lists only the apps your key may read, but Apple's next-page link omits the original filter. Following that link verbatim would change the question halfway through the list. A convenient pagination helper can become a permission bug.

Parsequerade validates the next link's Apple origin and resource path, rejects changed query values, then reconstructs the next request with the original filters, field selection, and page size. It carries only Apple's cursor forward. Redirects are rejected, and callers cannot supply an arbitrary upstream URL.

The client receives an HMAC-signed cursor bound to the connection, Apple credential fingerprint, caller's grant, resource path, and query. Changing credentials, permissions, filters, or page size invalidates it. Cursors expire after fifteen minutes, and repeated pages fail explicitly. A cursor continues a particular authorized read; it isn't a portable bookmark for any nearby list.

All the internal reads share a budget: 25 seconds, at most 32 Apple attempts, and bounded response bodies. Locale and ownership searches allow at most 20 pages. Exhaustion returns an error rather than a partial list dressed up as a complete result. The four-read concurrency limit is local to each Worker isolate, so it doesn't yet enforce a global quota across the deployment.

Errors preserve that honesty too. A bad facade key produces 401. Valid facade access paired with Apple rejecting its credentials produces 502 upstream_authentication_failed, because regenerating the caller's facade key won't fix the connection. API responses use private, no-store, and logs omit tokens, private keys, queries, and Apple response bodies.

Keep the reference tied to the runtime

A facade takes on maintenance that an SDK can often delegate to generated types. Apple's schema changes; our smaller contract needs deliberate mappings. The repository pins an Apple documentation snapshot and OpenAPI specification, currently version 4.5.1. An explicit sync command refreshes the source; ordinary builds work offline.

The same reviewed mappings generate the runtime query and response contracts and the facade OpenAPI document. A separate manifest selects implemented adapters. That separation matters: the reference covers 457 GET endpoint pages found through Apple's App Store topic tree, while the runtime implements twelve resource routes. Reference pages label implementation status. A documented design shouldn't masquerade as a working endpoint.

The deployed service combines Workers Static Assets for the site with small JavaScript modules for the API. There are no runtime packages. A generated SDK could make Apple's payloads easier to type, but it would still leave each client deciding how to resolve locales, prove ownership, and recover from incomplete traversal. Those decisions belong in the shared implementation.

A write needs a record of what happened

Writes make the case for centralizing this work stronger, and they make the implementation harder. The planned mutation contract would let a client send an ordinary description update to the locale route. The service would resolve the localization ID and assemble Apple's typed update body. Today's resource routes reject mutation methods.

Resolving an ID isn't enough. Apple might apply an update just before its response disappears. Retrying because the caller saw a timeout can send the same write again. For a create, that can be much worse than a duplicated log line.

Planned write handling, with time moving downward: Parsequerade saves an intent and sends a write to Apple. Apple applies it, but the reply is lost. Parsequerade records the outcome as unknown and uses a fresh read to reconcile it.

The plan is to persist a write intent before dispatch and require an Idempotency-Key. The key is scoped to the caller and operation, with a body hash so reuse for different content fails. If a dispatched write can't be confirmed, the service records an unknown outcome and attempts reconciliation with a fresh read. If that read can't establish what happened, the outcome stays unresolved. Its idempotency record prevents duplicate facade execution; it cannot manufacture an idempotency guarantee from Apple.

A metadata copy would build on those records: preview the selected changes, check that the destination hasn't changed, then execute in a known order. Completed changes stay completed if a later step fails. Cancellation stops future dispatches; it doesn't undo earlier edits. The client should be able to inspect partial progress without pretending several Apple resources share one transaction.

Sales reports need a different adapter again. Apple returns compressed tabular reports. The proposed route would parse bounded downloads into typed JSON rows, preserve monetary precision with decimal strings, and keep currencies separate. An unavailable report must stay an error rather than quietly becoming zero revenue. Those are useful decisions to implement once instead of rediscovering in every reporting app.

You can try the read slice through the quickstart: connect a team key, find an app, list its versions, and fetch an exact locale. Check the returned parent IDs and release notes against the version you selected. Your script should finish with the French text it asked for, without knowing the localization ID Apple used to store it.