App Store Connect API for Node
A library to support Apple's App Store Connect API.
See the WWDC video introducing the API.
Requires Node 18+. (This package uses ES Modules.)
API Keys Required
You'll have to start by creating an API key on the App Store Connect site.
When you're done, you'll have an issuer ID (like xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
), an API key (like XXXXXXXXXX
) and a private key file.
Keep your private keys private. Store them securely, outside of your git repository.
A common location for the key is in your user's home directory, in ~/.appstoreconnect/private_keys/AuthKey_XXXXXXXXXX.p8
(where that XXXXXXXXXX
is your API key). You can load the private key from there like this:
async function loadPrivateKey(apiKey) {
const fs = await import('node:fs/promises');
const { homedir } = await import ('node:os');
const privateKey = await fs.readFile(`${homedir()}/.appstoreconnect/private_keys/AuthKey_${apiKey}.p8`, 'utf8');
return privateKey;
}
Usage
import { api } from `node-app-store-connect-api`;
const { read, readAll, create, update, remove } = await api({issuerId, apiKey, privateKey});
const { data: apps } = await readAll('https://api.appstoreconnect.apple.com/v1/apps'));
console.log(apps);
The readAll()
function returns a Promise
for an object containing data
(returned by the request) and an optional included
object. (See the "Data and Inclusions" section below for more details on that.)
When using this API, you'll want to make heavy use of destructuring assignment. In the example above, we're declaring a variable apps
and setting it to the value of the data
object in the response.
You can use an absolute URL, or, at your convenience, you can omit the https://api.appstoreconnect.apple.com/v1/
part of the URL.
const { data: apps } = await readAll('apps');
console.log(apps);
You'll need your app's numeric "app ID" to use the API; the apps
endpoint will provide it, or you can get it directly from the App Store Connect web site.
Note that some APIs require you to pass an unusual version number, e.g. v2/inAppPurchases
; you can specify a version number like this:
const { data: inAppPurchase } = await readAll('inAppPurchases/12345678', { version: 2 });
console.log(inAppPurchase);
The App Store Connect API can return data in multiple pages. You're meant to request each page one at a time, using the data from the links
section.
readAll()
automatically crawls all pages in a response by default. If you'd like that not to happen, you can use the read()
function instead:
const { data: apps } = await read('apps');
When using read()
, consider using a limit
parameter to limit the number of results:
const { data: [app] } = await read('apps?limit=1');
When making requests that return a single object, e.g. 'apps/123456789'
, read()
and readAll()
do the same thing. It might be more readable to use read()
in that case, but that's up to you.
App Store Connect API read()
responses may also include a links
section and a meta
section used mostly for pagination, if you're interested in those. (You typically don't need them, because readAll()
will paginate for you.)
readAll()
and ?limit=N
affects performance, not total results
Most App Store Connect APIs allow a limit
parameter, allowing you to restrict the number of results returned. But note that readAll()
and ?limit=1
will not do what you might think.
limit
means something like "number of results per request." If you have 500 apps, readAll('apps?limit=1')
will dutifully crawl all 500 apps in 500 requests, one at a time. 😳
If you want to limit the total number of results, use read('apps?limit=1')
instead of readAll()
.
On the other hand, if you do have 500 apps, well, the default limit
is usually 50, so readAll('apps')
will perform ten requests, one at a time. You can greatly improve the performance of readAll()
by passing in the maximum limit, like this:
const { data: apps } = readAll('apps?limit=200');
Instead of performing ten requests for 500 apps, 50 apps at a time, this will make readAll()
make just three requests for 500 apps, 200 at a time.
When debugging performance, you might want to pass in an options object, { logRequests: true }
. That will show you all the requests we're making.
const { data: apps } = readAll('apps?limit=200', { logRequests: true });
Data and Inclusions
The App Store Connect API endpoint returns all data in a data
key, like this:
{
data: [ ]
}
But there can be information outside the data
key that you might want/need. If you choose to use an include
query parameter, like apps?include=appStoreVersions
, then the App Store Connect API will return that data as a separate included
key, outside the data
response:
{
data: [
{
type: "apps",
id: 123,
attributes: { },
relationships: {
appStoreVersions: { data: [
{ type: "appStoreVersions", id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" }
]}
}
}
],
included: [
{
type: "appStoreVersions",
id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
attributes: { },
relationships: { }
}
]
}
This library will transform the 'included' data by default.
Apple's default behavior is to return all included
data in a flat array of objects. Even if you include
multiple types of data, like this: readAll('apps?include=builds,appStoreVersions')
, all of them will be shuffled together into one giant array.
In our experience, having inclusions be one giant array is not ideal, so we decided to transform the included
data automagically.
Both read()
and readAll()
will return the inclusions as a nested JSON object, where the top-level keys are type names (like builds
and appStoreVersions
), mapping to another JSON object, mapping IDs to objects.
The result of readAll('apps?include=builds,appStoreVersions')
would look like this:
{
data: [ ],
included: {
builds: {
"xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx": {
type: "builds",
id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
attributes: { },
relationships: { }
}
},
appStoreVersions: {
"yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy": {
type: "appStoreVersions",
id: "yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy"
attributes: { },
relationships: { }
}
}
}
}
Handling inclusions as a tree makes it easier to map from relationships
to the included
objects.
const {data: apps, included: { builds, appStoreVersions } =
await readAll('apps?include=builds,appStoreVersions');
for (const app of apps) {
const versionStrings = app.relationships.appStoreVersions.data
.map(rel => appStoreVersions[rel.id])
.map(appStoreVersion => appStoreVersion.attributes.versionString);
const buildVersions = app.relationships.builds.data
.map(rel => builds[rel.id])
.map(build => build.attributes.version);
console.log({name: app.attributes.name, versionStrings, buildVersions});
}
URL Parameters
Sometimes, you'll find that you have a lot of URL parameters:
const { data: appPricePoints } = await readAll(`apps/${appId}/pricePoints?include=priceTier&filter[territory]=USA&limit=200`);
You can pass a params
object to read
and readAll
instead of including parameters in the URL string.
This code does the same thing, but is more readable:
const { data: appPricePoints } = await readAll(`apps/${appId}/pricePoints`,
{ params: {
include: "priceTier",
"filter[territory]": "USA",
limit: 200,
}}
);
Creating, updating, and removing a new App Store version
We create objects with the create
function. It constructs a create request (a POST
). It uses the "type" as the URL, and it allows you to pass in a data object as a relationship.
const { read, create, update, remove } = await api({issuerId, apiKey, privateKey});
const { data: [app] } = await read('apps?limit=1');
const appStoreVersion = await create({
type: 'appStoreVersions',
attributes: { platform: 'IOS', versionString: '1.0.1' },
relationships: { app }
);
(Officially, the relationships
that we submit to Apple aren't supposed to be entire fetched objects; they're supposed to contain just a data
object containing only the type
and id
of the related object. This API takes care of that detail for you, because writing out relationships the official way is much wordier, but you're allowed to write out relationships by hand, if you prefer.)
const appStoreVersion = await create({
type: 'appStoreVersions',
attributes: { platform: 'IOS', versionString: '1.0.1' },
relationships: { app: { data: { type: "apps", id: app.id } } }
);
Note that some APIs require you to pass an unusual version number, e.g. v2/inAppPurchases
; you must do that by adding version number as the version
.
const inAppPurchase = await create({
type: 'inAppPurchases',
attributes: { name: "test", productId: "com.example.test", inAppPurchaseType: "CONSUMABLE"},
relationships: { app },
version: 2
});
We can also update objects with the update
function.
await update(appStoreVersion, { attributes: { versionString: '1.0.2' }});
Or you can delete objects with the remove
function. (delete
is a language keyword in JavaScript!)
await remove(appStoreVersion);
await remove({ type: 'appStoreVersions', id: appStoreVersionId });
When updating or removing objects with unusual version numbers, e.g. v2/inAppPurchases
, you must pass the version
number in options.
await update(inAppPurchase, { attributes: { name: "Test 2" }, version: 2});
await remove(inAppPurchase, { version: 2 });
Uploading an asset (screenshots, app previews)
Uploading an asset (a screenshot or a preview) is a multi-step process.
- Assets are linked to sets (App Screenshot Sets, App Preview Sets)
- Sets have a type, corresponding to the screen size of the device. These are defined as strings given by Apple (screenshot display types and preview types).
- Different versions of an app can have different screenshots, and each app version can have multiple "localizations," allowing you to show different screenshots to users in different countries/languages.
So, to upload a screenshot, you must start by fetching or creating:
- App
- App Store Version
- App Store Version Localization
- App Screenshot Set (with the selected
screenshotDisplayType
) - App Screenshot
But the "App Screenshot" object is just a "reservation" object, allowing you to do the upload. Apple's documentation explains how to upload assets using the App Screenshot reservation, in a series of "upload operations." This API will take care of that for you.
import { stat, readFile } from 'fs/promises';
import { api } from `node-app-store-connect-api`;
const { read, create, uploadAsset, pollForUploadSuccess } =
await api({issuerId, apiKey, privateKey});
const { data: [app] } = await read('apps?limit=1');
const { data: [version] } = await read(
app.relationships.appStoreVersions.links.related);
const { data: [l10n] } = await read(
version.relationships.appStoreVersionLocalizations.links.related);
const { data: [appScreenshotSet] } = await (read(
l10n.relationships.appScreenshots.links.related);
const filePath = '/path/to/myScreenshot.png';
const fileSize = await stat(filePath)).size;
const appScreenshot = await create({
type: 'appScreenshots',
attributes: {
fileName: 'myScreenshot.png',
fileSize: fileSize,
},
relationships: { appScreenshotSet }
});
await uploadAsset(appScreenshot, await readFile(filePath));
await pollForUploadSuccess(appScreenshot.links.self);
That's a lot of work. Check out the working samples in the samples
directory of this repository.
Managing App Prices
Apple manages prices in "tiers."
Each price tier ID is a number; most of them are equal to the rounded price in US dollars. The free price tier is 0
. The $5.99 price tier is 6
, the $6.99 price tier is 7
, the $7.99 price tier is 8
, and so on.
But there are also a handful of "alternate price tiers" for low-priced tiers. As of Jan 2023, the alternate price tiers are:
- Alternate Tier 1: 550
- Alternate Tier 2: 560
- Alternate Tier 3: 570
- Alternate Tier 4: 580
- Alternate Tier 5: 590
- Alternate Tier A: 510
- Alternate Tier B: 530
Get current app price
Get the current price tier for your app like this:
async function getPriceTierForApp(appId) {
const { data: [appPrice] } =
await readAll(`apps/${appId}/prices?include=priceTier`);
const tier = appPrice.relationships.priceTier.data.id;
return tier;
}
(As of Jan 2023, the apps/${appId}/prices
endpoint doesn't include a startDate
. I think this is a bug on Apple's end.)
Note that appPrices
objects are related to appPriceTiers
objects, but the price tiers are hidden unless you explicitly include=priceTier
.
If you need to get the price in an actual currency, you can do it like this. (Consider aggressively caching the priceTiersInDollars
object, or even hardcoding it. It doesn't appear to vary from app to app.)
async function getAllPriceTiersInDollars(appId) {
const { data: appPricePoints } = await readAll(`apps/${appId}/pricePoints`,
{ params: {
include: "priceTier",
"filter[territory]": "USA",
limit: 200,
}}
);
const priceTiersInDollars = {};
for (const appPricePoint of appPricePoints) {
const tier = appPricePoint.relationships.priceTier.data.id;
priceTiersInDollars[tier] = appPricePoint.attributes.customerPrice;
}
return priceTiersInDollars;
}
const priceTiersInDollars = await getAllPriceTiersInDollars(appId);
console.log(priceTiersInDollars[await getPriceTierForApp(appId)]);
Set your app's price schedule
Let's say you'd like to set a price schedule for your app, where the current price will be price tier 6 ($5.99) and on January 31, 2050, the price tier will change to be price tier 7 ($6.99).
You can write that code like this:
await update({ type: 'apps', id: appId }, {
relationships: {
prices: [
{ type: "appPrices", id: "${price0}" },
{ type: "appPrices", id: "${price1}" }
]
},
included: [
{
type: "appPrices",
id: "${price0}",
attributes: { startDate: null },
relationships: {
priceTier: {
type: "appPriceTiers",
id: "5"
}
}
},
{
type: "appPrices",
id: "${price1}",
attributes: { startDate: '2050-01-31' },
relationships: {
priceTier: {
type: "appPriceTiers",
id: "6"
}
}
},
]
});
Note the use of an included
array in the call to update()
. We're defining a relationship to new appPrices
objects that we're creating in the included
array. In the id
strings, e.g. "${price1}"
we're literally passing the string "${price1}
" to Apple, including the dollar sign and the brackets. By using the same id
string in the initial relationships
object and in the included
array, Apple understands that we're creating an object and using it immediately as a relationship.
Also note that you must set the entire price schedule at once; you can't append an upcoming price change to start after the current price. And, therefore, at least one of the prices that you set must have startDate: null
.
You might prefer to use this convenience function, which does the same thing:
async function setPricesForApp(appId, newPrices) {
await update({ type: 'apps', id: appId }, {
relationships: {
prices: newPrices.map((price, i) =>
({ type: "appPrices", id: `\${price${i}}` })
)
},
included: newPrices.map((price, i) => ({
type: "appPrices",
id: `\${price${i}}`,
attributes: { startDate: price.startDate ?? null },
relationships: {
priceTier: {
type: "appPriceTiers",
id: String(price.tier)
}
}
})),
});
}
await setPricesForApp(appId, [
{tier: 5},
{tier: 6, startDate: '2050-01-31'},
]);
Working with In-App Purchases
In-app purchases are really tough to work with. They sometimes (but not always) require using /v2
URLs, and the way they manage prices is quite confusing, and different from the way apps manage prices. Worst of all, there's no sample code in the WWDC video!
Query for all IAPs for your app like this:
const { data: inAppPurchases } = await readAll(`apps/${app.id}/inAppPurchasesV2`);
IAP Price Information
Getting all prices is a pain in the butt. inAppPurchases
objects are related to iapPriceSchedule
objects, but those are nothing but relationships
links to manualPrices
.
And each "manual price" inAppPurchasePrices
object has its own territory (there are 175 territories), so if you query for all prices for an IAP, you're going to get at least 175 price objects. Worse, the territory and the "price point" of inAppPurchasePrices
objects is hidden unless you explicitly include
them with inclusions. So, to explore all manual prices for a given IAP, you'll do it like this:
const { data: [inAppPurchase] } = await read(`apps/${app.id}/inAppPurchasesV2`);
const { data: inAppPurchasePrices, included: { inAppPurchasePricePoint, territory } } =
await readAll(`inAppPurchasePriceSchedules/${inAppPurchase.id}/manualPrices`,
{ params: { include: "inAppPurchasePricePoint,territory" } } );
But it's very unlikely that you actually want to explore all manual prices for a given IAP; you probably just want price tiers. (See "Managing App Prices" above for more information about price tiers.)
The easiest way to get the price tiers for your IAP is to filter to a single territory (e.g. USA
).
const { data: [inAppPurchase] } = await read(`apps/${app.id}/inAppPurchasesV2`);
const { data: inAppPurchasePrices, included: { inAppPurchasePricePoints } } =
await readAll(`inAppPurchasePriceSchedules/${inAppPurchase.id}/manualPrices`,
{ params: {
include: "inAppPurchasePricePoint",
"filter[territory]": "USA",
}}
);
That's supposed to return one price object per entry on the price schedule. The one with startDate: null
represents the current live price.
So, if you want the current latest price for an IAP, you'd do it like this:
async function readPriceForIap(inAppPurchase) {
const { data: inAppPurchasePrices, included: { inAppPurchasePricePoints } } =
await readAll(`inAppPurchasePriceSchedules/${inAppPurchase.id}/manualPrices`,
{ params: {
include: "inAppPurchasePricePoint",
"filter[territory]": "USA",
}}
);
const currentPriceObject = inAppPurchasePrices.find(
price => price.attributes.startDate === null
);
const currentPricePoint = inAppPurchasePricePoints[
currentPriceObject.relationships.inAppPurchasePricePoint.data.id
];
return currentPricePoint;
}
const { attributes: { priceTier, customerPrice } } =
await readPriceForIap(inAppPurchase);
Creating an IAP
Creating an IAP requires a /v2
URL, so you'd do it like this, with version: 2
:
const inAppPurchase = await create({
type: 'inAppPurchases',
attributes: { name: "test", productId: "com.example.test", inAppPurchaseType: "CONSUMABLE"},
relationships: { app },
version: 2
});
Setting an IAP's price
First, you'll have to determine the price tier ID you want. (See "Manage App Prices" above for details about price tiers.)
To get a list of all possible price tiers with their prices in a single territory, you do it like this:
const {data: pricePoints} =
await readAll(`inAppPurchases/${inAppPurchase.id}/pricePoints`,
{ version: 2, params: {
"filter[territory]": "USA",
limit: 200,
}}
);
Each price point has a customerPrice
, e.g. 4.99
, and a priceTier
, e.g. 5
.
Once you know which tier ID you want, you can query for the desired price point by price tier ID, and create an entry on the price schedule like this:
const priceTier = 5;
const {data: [inAppPurchasePricePoint]} = await read(
`inAppPurchases/${inAppPurchase.id}/pricePoints`,
{version: 2, params: {
"filter[priceTier]": priceTier,
"filter[territory]": "USA",
limit: 1,
}}
);
await create({
type: 'inAppPurchasePriceSchedules',
relationships: {
inAppPurchase,
manualPrices: [{
type: "inAppPurchasePrices",
id: "${price1}"
}]
},
included: [{
type: "inAppPurchasePrices",
id: "${price1}",
attributes: {
startDate: null,
},
relationships: {
inAppPurchaseV2: inAppPurchase,
inAppPurchasePricePoint
}
}]
});
Note the use of an included
array in the call to create()
. We're defining a relationship to a new inAppPurchasePrices
object that we're creating in the included
array. The id
string "${price1}"
isn't a backtick JavaScript template string; we're literally passing the string "${price1}
" to Apple, including the dollar sign and the brackets. By using the same id
string in the initial relationships
object and in the included
array, Apple understands that we're creating an object and using it immediately as a relationship.
Also note that you must set the entire price schedule at once; you can't append an upcoming price change to start after the current price. And, therefore, at least one of the prices that you set must have startDate: null
.
If you're setting a price schedule with multiple price changes, you might prefer to use this convenience function, which does the same thing:
async function setPricesForIap(inAppPurchase, newPrices) {
const {data: pricePoints} = await readAll(
`inAppPurchases/${inAppPurchase.id}/pricePoints`,
{version: 2, params: {
"filter[territory]"="USA",
limit: 200,
}}
);
const pricePointsByTierId = {};
for (const pricePoint of pricePoints) {
pricePointsByTierId[pricePoint.attributes.priceTier] = pricePoint;
}
await create({
type: 'inAppPurchasePriceSchedules',
relationships: {
inAppPurchase,
manualPrices: newPrices.map((price, i) =>
({ type: "inAppPurchasePrices", id: `\${price${i}}` })
)
},
included: newPrices.map((price, i) => ({
type: "inAppPurchasePrices",
id: `\${price${i}}`,
attributes: {
startDate: price.startDate ?? null,
},
relationships: {
inAppPurchaseV2: inAppPurchase,
inAppPurchasePricePoint: pricePointsByTierId[price.tier];
}
}]
});
}
await setPricesForIap(inAppPurchase, [
{tier: 5},
{tier: 6, startDate: '2050-01-31'},
]);
Raw Requests and Responses
If you want access to the data exactly as App Store Connect provided it, circumventing all of our "helpful" conveniences, (like transforming the included
results,) the API provides a raw fetch
function. The fetch
function follows the rules of the standard fetch
API, but we automatically add the Authorization
header, and prepend https://api.appstoreconnect.apple.com/v1
on relative URLs.
import { api } from `node-app-store-connect-api`;
const { fetch } = await api({issuerId, apiKey, privateKey});
const { data: [app], included: appStoreVersions, links, meta } =
await fetch('apps?include=appStoreVersions&limit=1').then(r=>r.json());
const { appStoreVersion } = await fetch('appStoreVersions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ data: {
type: 'appStoreVersions',
attributes: { platform: 'IOS', versionString: '1.0.1' },
relationships: { app: { data: { type: "apps", id: app.id } } }
}})
}).then(r=>r.json());