App Store Connect API: 350 Writes Per Product
In App Store Connect, an auto-renewable subscription looks like one row. One product ID, one price, one duration. You fill in a form and it exists.
Underneath, that row is not one record. It is a graph — and creating it through the App Store Connect API takes roughly 350 writes per product.
I know the number because I built a tool that does it and then ran it against a throwaway app to see what actually happened. Fresh app, two products, eleven logical actions: four minutes and eleven seconds of API calls.
Here is where they go, and which parts of it will bite you.
The form is hiding a record graph
Creating one subscription means creating, in this order:
- The subscription group
- A localization for the group — one per language
- The subscription itself (product ID, reference name, duration)
- A localization for the subscription — display name and description, one per language
- Its availability — the list of territories it sells in
- Its price — one row per territory
- Its introductory offer, if it has a trial
Steps 1 through 4 are cheap: a handful of calls. Steps 5 and 6 are where the 350 comes from.
Availability and price are per-territory
Apple sells in 175 territories. “Available everywhere at $4.99” is not one fact stored once. It is an availability record covering 175 entries and a price ladder with a row for each one.
That is the whole story of the number. Two products, both worldwide, both priced: the eleven actions you asked for turn into ~350 records, and there is no bulk endpoint that collapses them. You are making the calls one way or another — through the API, or through a browser form that is making them for you while you wait.
This is also why the operation is slow in a way that feels wrong for what you asked. Four minutes for two products is not the API being sluggish. It is 350 round trips being honest about what you requested.
The chicken-and-egg on price
Here is the constraint that breaks the obvious mental model.
Apple does not let you set an arbitrary decimal price. You pick from a price point ladder — a fixed set of rungs, with an equalized value per territory. Fine. Except: the API lists price points only for products that already exist.
So on a brand-new product you cannot validate 4.99 against the ladder before creating it, because the ladder you would validate against does not exist yet. You create the product, and only then can you ask what prices it is allowed to have.
Any tool that promises to tell you exactly what a fresh product will cost before it creates it is either wrong or quietly rounding. The honest move is to say so — which is why asc-subs prints a warning on that one line of a plan and vouches for every other row:
⚠ "com.example.app.weekly" does not exist in App Store Connect yet, so it has
no price point ladder to check 4.99 against
Free trials are per-territory too, and that one cost me
An introductory offer is stored as one record per territory, exactly like price.
I got this wrong. My own code passed a single base territory when creating a trial, with a confident comment claiming Apple “requires an explicit territory even for an offer meant to apply everywhere.” It does not. The result was a trial that existed in the USA and nowhere else:
GET /v1/subscriptions/{id}/introductoryOffers?filter[territory]=USA → 1 row
…filter[territory]=TUR → 0 rows
…filter[territory]=DEU → 0 rows
The push had printed ✓ create-intro-offer … P3D and exited zero. Every unit test passed. The calls were shaped correctly; they were just shaped correctly around a wrong assumption, and no test that mocks Apple can tell you that.
This is the argument for running a tool against the real thing before you ship it, and for writing down what you found rather than what you hoped. The full run — seven probes, five passes, one failure, one path still unexercised — is in the repo as docs/verification.md. A tool’s test count tells you how much code executed. A verification table tells you how much of it met Apple.
Three things that are permanent
Worth knowing before your first API call, because none of them are reversible:
Product IDs are burned forever. Create com.example.app.wekly with a typo and that string is gone. Apple never frees a product ID for reuse. The same is true of bundle IDs. Namespace anything experimental so a mistake costs you nothing.
There is no POST /v1/apps. You cannot create the app record through the API. That step is a browser, every time — the same screen that holds the 160 characters Apple actually indexes, which are not automatable either and matter considerably more to whether anyone finds the thing you are pricing.
Nothing here has a delete. Removing a subscription is a decision with subscribers attached to it, and the API treats it accordingly. Whatever you create, you live with.
There is a fourth constraint that catches people at the end rather than the start: your first subscription group has to be submitted with a new app version. You cannot ship subscriptions onto an app that is already live without shipping a version alongside them. App Store Connect will tell you this — after you have built everything.
What this means if you ship more than one app
For a single app, once, all of this is an afternoon. Annoying, survivable.
The cost is not the first app. It is the fourth. Every new app repeats the entire sequence from scratch, and every new language multiplies the localization screens across every product you already have. “Add German to all three apps” is not a small task in a browser — it is the same six screens, nine times, with no diff and no review.
That asymmetry is the actual problem. Not that the work is hard, but that it is unrepeatable: you cannot copy an afternoon of clicking to the next app, and you cannot review it before it happens.
The fix is the obvious one once you see the shape: put the desired state in a file, diff it against what Apple holds, and apply the difference. That is what asc-subs does — plan shows you the diff, push applies exactly what plan printed, pull turns an app that already shipped into the file that would produce it. Seventeen lines of JSON, MIT, zero runtime dependencies.
It exists because I ship several apps and did this by hand too many times. The 350 writes do not go away. They just stop being an afternoon of your attention.