Skip to content

Latest commit

 

History

History
665 lines (515 loc) · 24.7 KB

README.md

File metadata and controls

665 lines (515 loc) · 24.7 KB

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});

// log all apps
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);

Pagination: read() vs. readAll()

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: [ /* actual data here */ ]
}

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});

// let's use the first app ID, for example
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);

// or, delete by ID, if you don't have the entire object:

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});

// in real life, you might have to create your
// own app, version, localization, and screenshot set
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;

// create the screenshot reservation
const appScreenshot = await create({
  type: 'appScreenshots',
  attributes: {
    fileName: 'myScreenshot.png',
    fileSize: fileSize,
  },
  relationships: { appScreenshotSet }
});

// upload the asset
await uploadAsset(appScreenshot, await readFile(filePath));
// poll the API for upload success/failure
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});

// read the raw JSON from a fetch request
const { data: [app], included: appStoreVersions, links, meta } =
  await fetch('apps?include=appStoreVersions&limit=1').then(r=>r.json());

// create an appStoreVersion the long way
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());