├── .gitignore ├── CHANGELOG.md ├── LICENSE.txt ├── README.md ├── index.mjs ├── package-lock.json ├── package.json └── samples ├── upload-preview.mjs └── upload-screenshot.mjs /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Microbundle cache 58 | .rpt2_cache/ 59 | .rts2_cache_cjs/ 60 | .rts2_cache_es/ 61 | .rts2_cache_umd/ 62 | 63 | # Optional REPL history 64 | .node_repl_history 65 | 66 | # Output of 'npm pack' 67 | *.tgz 68 | 69 | # Yarn Integrity file 70 | .yarn-integrity 71 | 72 | # dotenv environment variables file 73 | .env 74 | .env.test 75 | .env.production 76 | 77 | # parcel-bundler cache (https://parceljs.org/) 78 | .cache 79 | .parcel-cache 80 | 81 | # Next.js build output 82 | .next 83 | out 84 | 85 | # Nuxt.js build / generate output 86 | .nuxt 87 | dist 88 | 89 | # Gatsby files 90 | .cache/ 91 | # Comment in the public line in if your project uses Gatsby and not Next.js 92 | # https://nextjs.org/blog/next-9-1#public-directory-support 93 | # public 94 | 95 | # vuepress build output 96 | .vuepress/dist 97 | 98 | # Serverless directories 99 | .serverless/ 100 | 101 | # FuseBox cache 102 | .fusebox/ 103 | 104 | # DynamoDB Local files 105 | .dynamodb/ 106 | 107 | # TernJS port file 108 | .tern-port 109 | 110 | # Stores VSCode versions used for testing VSCode extensions 111 | .vscode-test 112 | 113 | # yarn v2 114 | .yarn/cache 115 | .yarn/unplugged 116 | .yarn/build-state.yml 117 | .yarn/install-state.gz 118 | .pnp.* 119 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | v2: Initial public release 2 | 3 | v3: 4 | * Added support for `version` parameter, to handle `/v2` URLs 5 | * Retry 500 errors 6 | * Add documentation for working with IAPs 7 | 8 | v4: 9 | * Added support for `read()` and `readAll()`, a clearer API than `fetchJson` and its `options` object 10 | 11 | v4.1: 12 | * Fixed array relationships 13 | * Add support for `included` array in `create()` and `update()` 14 | * Add documentation for setting IAP prices 15 | 16 | v4.2: 17 | * Added `params` object 18 | 19 | v5.0: 20 | * The library now works in browsers and in Node.js 18+ 21 | * Increased minimum required Node version to v18 22 | * Removed convenience to automatically read the private key from `~/.appstoreconnect` (not compatible with browsers); documented alternative 23 | * Removed dependency on `node-fetch` (now using `fetch` from global, available in browsers and Node.js 18+) 24 | * Replaced dependency on `jsonwebtoken` with `jose`, because `jsonwebtoken` doesn't work in browsers and `jose` does 25 | 26 | v5.0.3 27 | * We no longer set `sourceFileChecksum` when uploading files. 28 | 29 | `sourceFileChecksum` is never required, but it is sometimes forbidden. For example, App Previews and App Screenshots allow `sourceFileChecksum`, but Game Center Achievement Images and App Event Screenshots do not. 30 | 31 | v5.0.4 32 | * Bring your own `fetch` implementation by passing a `fetch` parameter to the `api` function. -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright 2021 Dan Fabulich 2 | 3 | Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. 4 | 5 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # App Store Connect API for Node 2 | 3 | A library to support [Apple's App Store Connect API](https://developer.apple.com/documentation/appstoreconnectapi). 4 | 5 | See the [WWDC video introducing the API](https://developer.apple.com/videos/play/wwdc2020/10004/). 6 | 7 | Requires Node 18+. (This package uses ES Modules.) 8 | 9 | # API Keys Required 10 | 11 | You'll have to start by [creating an API key](https://developer.apple.com/documentation/appstoreconnectapi/creating_api_keys_for_app_store_connect_api) on the App Store Connect site. 12 | 13 | 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. 14 | 15 | **Keep your private keys private.** Store them securely, outside of your git repository. 16 | 17 | 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: 18 | 19 | ```js 20 | async function loadPrivateKey(apiKey) { 21 | const fs = await import('node:fs/promises'); 22 | const { homedir } = await import ('node:os'); 23 | const privateKey = await fs.readFile(`${homedir()}/.appstoreconnect/private_keys/AuthKey_${apiKey}.p8`, 'utf8'); 24 | return privateKey; 25 | } 26 | ``` 27 | 28 | # Usage 29 | 30 | ```js 31 | import { api } from `node-app-store-connect-api`; 32 | 33 | const { read, readAll, create, update, remove } = await api({issuerId, apiKey, privateKey}); 34 | 35 | // log all apps 36 | const { data: apps } = await readAll('https://api.appstoreconnect.apple.com/v1/apps')); 37 | console.log(apps); 38 | ``` 39 | 40 | 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.) 41 | 42 | When using this API, you'll want to make heavy use of [destructuring assignment](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/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. 43 | 44 | You can use an absolute URL, or, at your convenience, you can omit the `https://api.appstoreconnect.apple.com/v1/` part of the URL. 45 | 46 | ```js 47 | const { data: apps } = await readAll('apps'); 48 | console.log(apps); 49 | ``` 50 | 51 | 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. 52 | 53 | Note that some APIs require you to pass an unusual version number, e.g. `v2/inAppPurchases`; you can specify a version number like this: 54 | 55 | ```js 56 | const { data: inAppPurchase } = await readAll('inAppPurchases/12345678', { version: 2 }); 57 | console.log(inAppPurchase); 58 | ``` 59 | 60 | ## Pagination: `read()` vs. `readAll()` 61 | 62 | 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. 63 | 64 | `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: 65 | 66 | ```js 67 | const { data: apps } = await read('apps'); 68 | ``` 69 | 70 | When using `read()`, consider using a `limit` parameter to limit the number of results: 71 | 72 | ```js 73 | const { data: [app] } = await read('apps?limit=1'); 74 | ``` 75 | 76 | 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. 77 | 78 | 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.) 79 | 80 | ### `readAll()` and `?limit=N` affects performance, _not_ total results 81 | 82 | 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. 83 | 84 | `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. 😳 85 | 86 | If you want to limit the total number of results, use `read('apps?limit=1')` instead of `readAll()`. 87 | 88 | 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: 89 | 90 | ```js 91 | const { data: apps } = readAll('apps?limit=200'); 92 | ``` 93 | 94 | 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. 95 | 96 | When debugging performance, you might want to pass in an options object, `{ logRequests: true }`. That will show you all the requests we're making. 97 | 98 | ```js 99 | const { data: apps } = readAll('apps?limit=200', { logRequests: true }); 100 | ``` 101 | 102 | ## Data and Inclusions 103 | 104 | The App Store Connect API endpoint returns all data in a `data` key, like this: 105 | 106 | ```js 107 | { 108 | data: [ /* actual data here */ ] 109 | } 110 | ``` 111 | 112 | 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: 113 | 114 | ```js 115 | { 116 | data: [ 117 | { 118 | type: "apps", 119 | id: 123, 120 | attributes: { /* ... */ }, 121 | relationships: { 122 | appStoreVersions: { data: [ 123 | { type: "appStoreVersions", id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" } 124 | ]} 125 | } 126 | } 127 | ], 128 | included: [ 129 | { 130 | type: "appStoreVersions", 131 | id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" 132 | attributes: { /* ... */ }, 133 | relationships: { /* ... */ } 134 | } 135 | ] 136 | } 137 | ``` 138 | 139 | **This library will transform the 'included' data by default.** 140 | 141 | 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. 142 | 143 | In our experience, having inclusions be one giant array is not ideal, so we decided to transform the `included` data automagically. 144 | 145 | 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. 146 | 147 | The result of `readAll('apps?include=builds,appStoreVersions')` would look like this: 148 | 149 | ```js 150 | { 151 | data: [ /* ... */ ], 152 | included: { 153 | builds: { 154 | "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx": { 155 | type: "builds", 156 | id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" 157 | attributes: { /* ... */ }, 158 | relationships: { /* ... */ } 159 | } 160 | }, 161 | appStoreVersions: { 162 | "yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy": { 163 | type: "appStoreVersions", 164 | id: "yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy" 165 | attributes: { /* ... */ }, 166 | relationships: { /* ... */ } 167 | } 168 | } 169 | } 170 | } 171 | ``` 172 | 173 | Handling inclusions as a tree makes it easier to map from `relationships` to the `included` objects. 174 | 175 | ```js 176 | const {data: apps, included: { builds, appStoreVersions } = 177 | await readAll('apps?include=builds,appStoreVersions'); 178 | for (const app of apps) { 179 | const versionStrings = app.relationships.appStoreVersions.data 180 | .map(rel => appStoreVersions[rel.id]) 181 | .map(appStoreVersion => appStoreVersion.attributes.versionString); 182 | const buildVersions = app.relationships.builds.data 183 | .map(rel => builds[rel.id]) 184 | .map(build => build.attributes.version); 185 | console.log({name: app.attributes.name, versionStrings, buildVersions}); 186 | } 187 | ``` 188 | 189 | ## URL Parameters 190 | 191 | Sometimes, you'll find that you have a lot of URL parameters: 192 | 193 | ``` 194 | const { data: appPricePoints } = await readAll(`apps/${appId}/pricePoints?include=priceTier&filter[territory]=USA&limit=200`); 195 | ``` 196 | 197 | You can pass a `params` object to `read` and `readAll` instead of including parameters in the URL string. 198 | 199 | This code does the same thing, but is more readable: 200 | 201 | ```js 202 | const { data: appPricePoints } = await readAll(`apps/${appId}/pricePoints`, 203 | { params: { 204 | include: "priceTier", 205 | "filter[territory]": "USA", 206 | limit: 200, 207 | }} 208 | ); 209 | ``` 210 | 211 | ## Creating, updating, and removing a new App Store version 212 | 213 | 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. 214 | 215 | ```js 216 | const { read, create, update, remove } = await api({issuerId, apiKey, privateKey}); 217 | 218 | // let's use the first app ID, for example 219 | const { data: [app] } = await read('apps?limit=1'); 220 | 221 | const appStoreVersion = await create({ 222 | type: 'appStoreVersions', 223 | attributes: { platform: 'IOS', versionString: '1.0.1' }, 224 | relationships: { app } 225 | ); 226 | ``` 227 | 228 | (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.) 229 | 230 | ```js 231 | const appStoreVersion = await create({ 232 | type: 'appStoreVersions', 233 | attributes: { platform: 'IOS', versionString: '1.0.1' }, 234 | relationships: { app: { data: { type: "apps", id: app.id } } } 235 | ); 236 | ``` 237 | 238 | 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`. 239 | 240 | ```js 241 | const inAppPurchase = await create({ 242 | type: 'inAppPurchases', 243 | attributes: { name: "test", productId: "com.example.test", inAppPurchaseType: "CONSUMABLE"}, 244 | relationships: { app }, 245 | version: 2 246 | }); 247 | ``` 248 | 249 | We can also update objects with the `update` function. 250 | 251 | ```js 252 | await update(appStoreVersion, { attributes: { versionString: '1.0.2' }}); 253 | ``` 254 | 255 | Or you can delete objects with the `remove` function. (`delete` is a language keyword in JavaScript!) 256 | 257 | ```js 258 | await remove(appStoreVersion); 259 | 260 | // or, delete by ID, if you don't have the entire object: 261 | 262 | await remove({ type: 'appStoreVersions', id: appStoreVersionId }); 263 | ``` 264 | 265 | When updating or removing objects with unusual version numbers, e.g. `v2/inAppPurchases`, you must pass the `version` number in options. 266 | 267 | ```js 268 | await update(inAppPurchase, { attributes: { name: "Test 2" }, version: 2}); 269 | await remove(inAppPurchase, { version: 2 }); 270 | ``` 271 | 272 | ## Uploading an asset (screenshots, app previews) 273 | 274 | Uploading an asset (a screenshot or a preview) is a multi-step process. 275 | 276 | * Assets are linked to sets (App Screenshot Sets, App Preview Sets) 277 | * Sets have a type, corresponding to the screen size of the device. These are defined as strings given by Apple ([screenshot display types](https://developer.apple.com/documentation/appstoreconnectapi/screenshotdisplaytype) and [preview types](https://developer.apple.com/documentation/appstoreconnectapi/previewtype)). 278 | * 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. 279 | 280 | So, to upload a screenshot, you must start by fetching or creating: 281 | 282 | * App 283 | * App Store Version 284 | * App Store Version Localization 285 | * App Screenshot Set (with the selected `screenshotDisplayType`) 286 | * App Screenshot 287 | 288 | 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. 289 | 290 | ```js 291 | import { stat, readFile } from 'fs/promises'; 292 | import { api } from `node-app-store-connect-api`; 293 | 294 | const { read, create, uploadAsset, pollForUploadSuccess } = 295 | await api({issuerId, apiKey, privateKey}); 296 | 297 | // in real life, you might have to create your 298 | // own app, version, localization, and screenshot set 299 | const { data: [app] } = await read('apps?limit=1'); 300 | const { data: [version] } = await read( 301 | app.relationships.appStoreVersions.links.related); 302 | const { data: [l10n] } = await read( 303 | version.relationships.appStoreVersionLocalizations.links.related); 304 | const { data: [appScreenshotSet] } = await (read( 305 | l10n.relationships.appScreenshots.links.related); 306 | 307 | const filePath = '/path/to/myScreenshot.png'; 308 | const fileSize = await stat(filePath)).size; 309 | 310 | // create the screenshot reservation 311 | const appScreenshot = await create({ 312 | type: 'appScreenshots', 313 | attributes: { 314 | fileName: 'myScreenshot.png', 315 | fileSize: fileSize, 316 | }, 317 | relationships: { appScreenshotSet } 318 | }); 319 | 320 | // upload the asset 321 | await uploadAsset(appScreenshot, await readFile(filePath)); 322 | // poll the API for upload success/failure 323 | await pollForUploadSuccess(appScreenshot.links.self); 324 | ``` 325 | 326 | **That's a lot of work.** Check out the working samples in the [`samples`](https://github.com/dfabulich/node-app-store-connect-api/tree/main/samples) directory of this repository. 327 | 328 | ## Managing App Prices 329 | 330 | Apple manages prices in "tiers." 331 | 332 | 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. 333 | 334 | But there are also a handful of "alternate price tiers" for low-priced tiers. As of Jan 2023, the alternate price tiers are: 335 | 336 | * Alternate Tier 1: 550 337 | * Alternate Tier 2: 560 338 | * Alternate Tier 3: 570 339 | * Alternate Tier 4: 580 340 | * Alternate Tier 5: 590 341 | * Alternate Tier A: 510 342 | * Alternate Tier B: 530 343 | 344 | ### Get current app price 345 | 346 | Get the current price tier for your app like this: 347 | 348 | ``` 349 | async function getPriceTierForApp(appId) { 350 | const { data: [appPrice] } = 351 | await readAll(`apps/${appId}/prices?include=priceTier`); 352 | const tier = appPrice.relationships.priceTier.data.id; 353 | return tier; 354 | } 355 | ``` 356 | 357 | (As of Jan 2023, the `apps/${appId}/prices` endpoint doesn't include a `startDate`. I think this is a bug on Apple's end.) 358 | 359 | Note that `appPrices` objects are related to `appPriceTiers` objects, but the price tiers are _hidden_ unless you explicitly `include=priceTier`. 360 | 361 | 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.) 362 | 363 | ```js 364 | async function getAllPriceTiersInDollars(appId) { 365 | const { data: appPricePoints } = await readAll(`apps/${appId}/pricePoints`, 366 | { params: { 367 | include: "priceTier", 368 | "filter[territory]": "USA", 369 | limit: 200, 370 | }} 371 | ); 372 | const priceTiersInDollars = {}; 373 | for (const appPricePoint of appPricePoints) { 374 | const tier = appPricePoint.relationships.priceTier.data.id; 375 | priceTiersInDollars[tier] = appPricePoint.attributes.customerPrice; 376 | } 377 | return priceTiersInDollars; 378 | } 379 | 380 | const priceTiersInDollars = await getAllPriceTiersInDollars(appId); 381 | console.log(priceTiersInDollars[await getPriceTierForApp(appId)]); 382 | ``` 383 | 384 | ### Set your app's price schedule 385 | 386 | 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). 387 | 388 | You can write that code like this: 389 | 390 | ```js 391 | await update({ type: 'apps', id: appId }, { 392 | relationships: { 393 | prices: [ 394 | { type: "appPrices", id: "${price0}" }, 395 | { type: "appPrices", id: "${price1}" } 396 | ] 397 | }, 398 | included: [ 399 | { 400 | type: "appPrices", 401 | id: "${price0}", 402 | attributes: { startDate: null }, 403 | relationships: { 404 | priceTier: { 405 | type: "appPriceTiers", 406 | id: "5" 407 | } 408 | } 409 | }, 410 | { 411 | type: "appPrices", 412 | id: "${price1}", 413 | attributes: { startDate: '2050-01-31' }, 414 | relationships: { 415 | priceTier: { 416 | type: "appPriceTiers", 417 | id: "6" 418 | } 419 | } 420 | }, 421 | ] 422 | }); 423 | ``` 424 | 425 | 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. 426 | 427 | 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`. 428 | 429 | You might prefer to use this convenience function, which does the same thing: 430 | 431 | ```js 432 | async function setPricesForApp(appId, newPrices) { 433 | await update({ type: 'apps', id: appId }, { 434 | relationships: { 435 | prices: newPrices.map((price, i) => 436 | ({ type: "appPrices", id: `\${price${i}}` }) 437 | ) 438 | }, 439 | included: newPrices.map((price, i) => ({ 440 | type: "appPrices", 441 | id: `\${price${i}}`, 442 | attributes: { startDate: price.startDate ?? null }, 443 | relationships: { 444 | priceTier: { 445 | type: "appPriceTiers", 446 | id: String(price.tier) 447 | } 448 | } 449 | })), 450 | }); 451 | } 452 | 453 | await setPricesForApp(appId, [ 454 | {tier: 5}, 455 | {tier: 6, startDate: '2050-01-31'}, 456 | ]); 457 | ``` 458 | 459 | ## Working with In-App Purchases 460 | 461 | 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! 462 | 463 | Query for all IAPs for your app like this: 464 | 465 | ```js 466 | const { data: inAppPurchases } = await readAll(`apps/${app.id}/inAppPurchasesV2`); 467 | ``` 468 | 469 | ### IAP Price Information 470 | 471 | 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`. 472 | 473 | 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: 474 | 475 | ```js 476 | const { data: [inAppPurchase] } = await read(`apps/${app.id}/inAppPurchasesV2`); 477 | const { data: inAppPurchasePrices, included: { inAppPurchasePricePoint, territory } } = 478 | await readAll(`inAppPurchasePriceSchedules/${inAppPurchase.id}/manualPrices`, 479 | { params: { include: "inAppPurchasePricePoint,territory" } } ); 480 | ``` 481 | 482 | 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.) 483 | 484 | The easiest way to get the price tiers for your IAP is to filter to a single territory (e.g. `USA`). 485 | 486 | ```js 487 | const { data: [inAppPurchase] } = await read(`apps/${app.id}/inAppPurchasesV2`); 488 | const { data: inAppPurchasePrices, included: { inAppPurchasePricePoints } } = 489 | await readAll(`inAppPurchasePriceSchedules/${inAppPurchase.id}/manualPrices`, 490 | { params: { 491 | include: "inAppPurchasePricePoint", 492 | "filter[territory]": "USA", 493 | }} 494 | ); 495 | ``` 496 | 497 | That's supposed to return one price object per entry on the price schedule. The one with `startDate: null` represents the current live price. 498 | 499 | So, if you want the current latest price for an IAP, you'd do it like this: 500 | 501 | ```js 502 | async function readPriceForIap(inAppPurchase) { 503 | const { data: inAppPurchasePrices, included: { inAppPurchasePricePoints } } = 504 | await readAll(`inAppPurchasePriceSchedules/${inAppPurchase.id}/manualPrices`, 505 | { params: { 506 | include: "inAppPurchasePricePoint", 507 | "filter[territory]": "USA", 508 | }} 509 | ); 510 | 511 | const currentPriceObject = inAppPurchasePrices.find( 512 | price => price.attributes.startDate === null 513 | ); 514 | 515 | const currentPricePoint = inAppPurchasePricePoints[ 516 | currentPriceObject.relationships.inAppPurchasePricePoint.data.id 517 | ]; 518 | 519 | return currentPricePoint; 520 | } 521 | 522 | const { attributes: { priceTier, customerPrice } } = 523 | await readPriceForIap(inAppPurchase); 524 | ``` 525 | 526 | ### Creating an IAP 527 | 528 | Creating an IAP requires a `/v2` URL, so you'd do it like this, with `version: 2`: 529 | 530 | ```js 531 | const inAppPurchase = await create({ 532 | type: 'inAppPurchases', 533 | attributes: { name: "test", productId: "com.example.test", inAppPurchaseType: "CONSUMABLE"}, 534 | relationships: { app }, 535 | version: 2 536 | }); 537 | ``` 538 | 539 | ### Setting an IAP's price 540 | 541 | First, you'll have to determine the price tier ID you want. (See "Manage App Prices" above for details about price tiers.) 542 | 543 | To get a list of all possible price tiers with their prices in a single territory, you do it like this: 544 | 545 | ```js 546 | const {data: pricePoints} = 547 | await readAll(`inAppPurchases/${inAppPurchase.id}/pricePoints`, 548 | { version: 2, params: { 549 | "filter[territory]": "USA", 550 | limit: 200, 551 | }} 552 | ); 553 | ``` 554 | 555 | Each price point has a `customerPrice`, e.g. `4.99`, and a `priceTier`, e.g. `5`. 556 | 557 | 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: 558 | 559 | ```js 560 | const priceTier = 5; 561 | const {data: [inAppPurchasePricePoint]} = await read( 562 | `inAppPurchases/${inAppPurchase.id}/pricePoints`, 563 | {version: 2, params: { 564 | "filter[priceTier]": priceTier, 565 | "filter[territory]": "USA", 566 | limit: 1, 567 | }} 568 | ); 569 | 570 | await create({ 571 | type: 'inAppPurchasePriceSchedules', 572 | relationships: { 573 | inAppPurchase, 574 | manualPrices: [{ 575 | type: "inAppPurchasePrices", 576 | id: "${price1}" 577 | }] 578 | }, 579 | included: [{ 580 | type: "inAppPurchasePrices", 581 | id: "${price1}", 582 | attributes: { 583 | startDate: null, 584 | }, 585 | relationships: { 586 | inAppPurchaseV2: inAppPurchase, 587 | inAppPurchasePricePoint 588 | } 589 | }] 590 | }); 591 | ``` 592 | 593 | 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. 594 | 595 | 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`. 596 | 597 | If you're setting a price schedule with multiple price changes, you might prefer to use this convenience function, which does the same thing: 598 | 599 | ```js 600 | async function setPricesForIap(inAppPurchase, newPrices) { 601 | const {data: pricePoints} = await readAll( 602 | `inAppPurchases/${inAppPurchase.id}/pricePoints`, 603 | {version: 2, params: { 604 | "filter[territory]"="USA", 605 | limit: 200, 606 | }} 607 | ); 608 | 609 | const pricePointsByTierId = {}; 610 | for (const pricePoint of pricePoints) { 611 | pricePointsByTierId[pricePoint.attributes.priceTier] = pricePoint; 612 | } 613 | 614 | await create({ 615 | type: 'inAppPurchasePriceSchedules', 616 | relationships: { 617 | inAppPurchase, 618 | manualPrices: newPrices.map((price, i) => 619 | ({ type: "inAppPurchasePrices", id: `\${price${i}}` }) 620 | ) 621 | }, 622 | included: newPrices.map((price, i) => ({ 623 | type: "inAppPurchasePrices", 624 | id: `\${price${i}}`, 625 | attributes: { 626 | startDate: price.startDate ?? null, 627 | }, 628 | relationships: { 629 | inAppPurchaseV2: inAppPurchase, 630 | inAppPurchasePricePoint: pricePointsByTierId[price.tier]; 631 | } 632 | }] 633 | }); 634 | } 635 | 636 | await setPricesForIap(inAppPurchase, [ 637 | {tier: 5}, 638 | {tier: 6, startDate: '2050-01-31'}, 639 | ]); 640 | ``` 641 | 642 | ## Raw Requests and Responses 643 | 644 | 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. 645 | 646 | ```js 647 | import { api } from `node-app-store-connect-api`; 648 | 649 | const { fetch } = await api({issuerId, apiKey, privateKey}); 650 | 651 | // read the raw JSON from a fetch request 652 | const { data: [app], included: appStoreVersions, links, meta } = 653 | await fetch('apps?include=appStoreVersions&limit=1').then(r=>r.json()); 654 | 655 | // create an appStoreVersion the long way 656 | const { appStoreVersion } = await fetch('appStoreVersions', { 657 | method: 'POST', 658 | headers: { 'Content-Type': 'application/json' }, 659 | body: JSON.stringify({ data: { 660 | type: 'appStoreVersions', 661 | attributes: { platform: 'IOS', versionString: '1.0.1' }, 662 | relationships: { app: { data: { type: "apps", id: app.id } } } 663 | }}) 664 | }).then(r=>r.json()); 665 | ``` 666 | -------------------------------------------------------------------------------- /index.mjs: -------------------------------------------------------------------------------- 1 | import { SignJWT, importPKCS8 } from 'jose'; 2 | 3 | // issuerId and apiKey from https://appstoreconnect.apple.com/access/api 4 | // p8 file was generated initially, and somebody stored it in ~/.appstoreconnect/private_keys (iTMSTransporter?) 5 | export const api = async function AppStoreConnectApiFetcher({ issuerId, apiKey, privateKey, version = 1, urlBase, 6 | tokenExpiresInSeconds = 1200, automaticRetries = 10, logRequests = false, fetch = globalThis.fetch 7 | } = {}) { 8 | if (!privateKey) throw new Error("You must pass a privateKey parameter"); 9 | if (!urlBase) urlBase = `https://api.appstoreconnect.apple.com`; 10 | 11 | async function _getBearerToken(issuerId, apiKey, privateKey) { 12 | const alg = 'ES256'; 13 | const secret = await importPKCS8(privateKey, alg); 14 | const jwt = await new SignJWT({}) 15 | .setProtectedHeader({ alg, kid: apiKey, typ: 'JWT' }) 16 | .setIssuedAt() 17 | .setIssuer(issuerId) 18 | .setAudience('appstoreconnect-v1') 19 | .setExpirationTime('20m') 20 | .sign(secret) 21 | return jwt; 22 | } 23 | 24 | const bearerToken = await _getBearerToken(issuerId, apiKey, privateKey); 25 | 26 | const authFetch = async function authFetch(url, options) { 27 | if (!options) options = {}; 28 | if (!options.headers) options.headers = {}; 29 | options.headers.Authorization = `Bearer ${bearerToken}`; 30 | const retries = options.automaticRetries ?? automaticRetries; 31 | const log = options.logRequests ?? logRequests; 32 | if (!/^https:\/\//.test(url)) { 33 | // strip leading slash 34 | url = url.replace(/^\//, ""); 35 | if (/^v\d+\//.test(url)) { 36 | // URL includes version number 37 | url = `${urlBase}/${url}`; 38 | } else { 39 | // No version number; add our own 40 | const v = options.version ?? version; 41 | url = `${urlBase}/v${v}/${url}`; 42 | } 43 | } 44 | // try-try-again; sometimes Apple rejects perfectly good bearer tokens 45 | let response; 46 | for (let i = 0; i < (retries+1); i++) { 47 | if (log) console.log(`node-app-store-connect-api: requesting ${url}${options.body ? ` ${options.body}`: ''}`); 48 | try { 49 | response = await fetch(url, options); 50 | } catch (e) { 51 | if (e.code === 'ETIMEDOUT' || e.cause?.code === 'UND_ERR_CONNECT_TIMEOUT') { 52 | if (log) console.log(`node-app-store-connect-api: timed out ${url}`); 53 | if (i === retries) throw e; 54 | continue; 55 | } else { 56 | throw e; 57 | } 58 | } 59 | if (response.status != 401 && response.status != 429 && response.status != 500) return response; 60 | if (log) console.log(`node-app-store-connect-api: failed with ${response.status} ${url}`); 61 | } 62 | return response; 63 | } 64 | 65 | async function read(url, options) { 66 | const { data, included, meta, links } = await fetchJson(url, { crawlAllPages: false, inclusions: 'tree', ...options}); 67 | return { data, included, meta, links }; 68 | } 69 | 70 | async function readAll(url, options) { 71 | const { data, included } = await fetchJson(url, { crawlAllPages: true, inclusions: 'tree', ...options }); 72 | return { data, included }; 73 | } 74 | 75 | async function fetchJson(url, options) { 76 | const inclusions = options?.inclusions; 77 | if (inclusions && inclusions !== true && inclusions !== 'tree') { 78 | throw new Error(`inclusions parameter '${inclusions}' must be either boolean true or a string 'tree'`); 79 | } 80 | if (options?.params) { 81 | if (!/^https:\/\//.test(url)) { 82 | // strip leading slash 83 | url = url.replace(/^\//, ""); 84 | if (/^v\d+\//.test(url)) { 85 | // URL includes version number 86 | url = `${urlBase}/${url}`; 87 | } else { 88 | // No version number; add our own 89 | const v = options.version ?? version; 90 | url = `${urlBase}/v${v}/${url}`; 91 | } 92 | } 93 | const parsed = new URL(url); 94 | const usp = new URLSearchParams(parsed.search); 95 | for (const key in options.params) { 96 | usp.set(key, options.params[key]); 97 | } 98 | parsed.search = usp.toString(); 99 | url = parsed.toString(); 100 | } 101 | const response = await authFetch(url, options); 102 | const text = await response.text(); 103 | const contentType = response.headers.get('content-type'); 104 | const isJson = (contentType === 'application/json' || contentType === 'application/vnd.api+json'); 105 | const crawlAllPages = options?.crawlAllPages ?? true; 106 | if (response.ok) { 107 | if (isJson) { 108 | const result = JSON.parse(text); 109 | if (crawlAllPages && Array.isArray(result.data) && result.links.next) { 110 | if (inclusions) { 111 | const otherResults = await fetchJson(result.links.next, {...options, inclusions: true}); 112 | result.data = result.data.concat(otherResults.data); 113 | if (otherResults.included) { 114 | result.included = (result.included || []).concat(otherResults.included); 115 | } 116 | } else { 117 | return result.data.concat(await fetchJson(result.links.next, options)); 118 | } 119 | } 120 | if (inclusions === 'tree') { 121 | const included = {}; 122 | for (const data of result.included || []) { 123 | if (!included[data.type]) included[data.type] = {}; 124 | included[data.type][data.id] = data; 125 | } 126 | if (crawlAllPages) { 127 | return { data: result.data, included }; 128 | } else { 129 | return { data: result.data, included, meta: result.meta, links: result.links }; 130 | } 131 | } else if (inclusions) { 132 | return { data: result.data, included: result.included }; 133 | } else { 134 | return result.data; 135 | } 136 | } else { 137 | return text; 138 | } 139 | } else { 140 | if (isJson) { 141 | const error = new Error(text); 142 | error.data = JSON.parse(text); 143 | throw error; 144 | } else { 145 | throw new Error(text); 146 | } 147 | } 148 | } 149 | 150 | async function postJson(url, data, options) { 151 | return fetchJson(url, { 152 | method: 'POST', 153 | headers: { 154 | 'Content-Type': 'application/json' 155 | }, 156 | body: JSON.stringify(data), 157 | ...options 158 | }) 159 | } 160 | 161 | // allow users to pass in entire objects, but just send down types and ids 162 | function _trimRelationships(relationships) { 163 | const output = {}; 164 | for (const [key, value] of Object.entries(relationships)) { 165 | if (typeof value === 'object' && value && 'data' in value) { 166 | output[key] = value; 167 | } else if (Array.isArray(value)) { 168 | output[key] = { data: value.map(relation => ({ type: relation.type, id: relation.id })) }; 169 | } else { 170 | output[key] = { data: { type: value.type, id: value.id } }; 171 | } 172 | } 173 | return output; 174 | } 175 | 176 | async function create({ type, attributes, relationships, included, version }) { 177 | const data = { type, attributes }; 178 | if (relationships) data.relationships = _trimRelationships(relationships); 179 | if (included) { 180 | for (const inclusion of included) { 181 | if (inclusion.relationships) inclusion.relationships = _trimRelationships(inclusion.relationships); 182 | } 183 | } 184 | const body = { data }; 185 | if (included) body.included = included; 186 | return postJson(type, body, { version }); 187 | } 188 | 189 | async function update(data, {attributes, relationships, included, version}) { 190 | const requestData = { type: data.type, id: data.id, attributes }; 191 | if (relationships) requestData.relationships = _trimRelationships(relationships); 192 | if (included) { 193 | for (const inclusion of included) { 194 | if (inclusion.relationships) inclusion.relationships = _trimRelationships(inclusion.relationships); 195 | } 196 | } 197 | const body = { data: requestData }; 198 | if (included) body.included = included; 199 | return postJson(`${data.type}/${data.id}`, body, {version, method: 'PATCH'}); 200 | } 201 | 202 | async function remove(data, {version} = {}) { 203 | return fetchJson(`${data.type}/${data.id}`, {version, method: 'DELETE'}); 204 | } 205 | 206 | async function uploadAsset(assetData, buffer, maxTriesPerPart = 10, version) { 207 | const targetStart = 0; 208 | await Promise.all(assetData.attributes.uploadOperations.map(async (uploadOperation, i) => { 209 | const body = Buffer.alloc(uploadOperation.length); 210 | const sourceStart = uploadOperation.offset; 211 | const sourceEnd = uploadOperation.offset + uploadOperation.length; 212 | buffer.copy(body, targetStart, sourceStart, sourceEnd); 213 | const method = uploadOperation.method; 214 | const headers = {}; 215 | for (const requestHeader of uploadOperation.requestHeaders) { 216 | headers[requestHeader.name] = requestHeader.value; 217 | } 218 | for (let tries = 1; tries <= maxTriesPerPart; tries++) { 219 | // https://developer.apple.com/documentation/appstoreconnectapi/uploading_assets_to_app_store_connect 220 | // The provided upload URLs are unauthenticated and time-limited. 221 | // You don’t need to supply a JWT; don’t share the URLs. 222 | if (logRequests) console.log("node-app-store-connect-api: uploading to", 223 | uploadOperation.url, method, "headers: ", JSON.stringify(headers)); 224 | const response = await fetch(uploadOperation.url, { method, headers, body }); 225 | if (response.ok) { 226 | if (logRequests) console.log('node-app-store-connect-api: upload success', response.status, response.statusText, response.headers); 227 | break; 228 | } else { 229 | const errorText = `Failed uploading chunk ${i} of ${assetData.data.type}/${assetData.data.id}: ` + 230 | `${response.status} ${response.statusText} ${await response.text()}`; 231 | if (logRequests) console.log('node-app-store-connect-api:', errorText); 232 | if (tries >= maxTriesPerPart) { 233 | throw new Error(errorText); 234 | } 235 | } 236 | } 237 | })); 238 | await update(assetData, { version, attributes: { 239 | uploaded: true, 240 | }}); 241 | } 242 | 243 | async function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } 244 | 245 | async function pollForUploadSuccess(assetUrl, logHeader = "", delayInMilliseconds = 1000, maxTries = 60) { 246 | if (logHeader) logHeader += ' '; 247 | let tries = 0; 248 | while (true) { 249 | if (maxTries) { 250 | tries++; 251 | if (tries >= maxTries) throw new Error(`${logHeader}${assetUrl} upload state was ${state} after ${maxTries} tries`); 252 | } 253 | let assetData; 254 | try { 255 | assetData = await fetchJson(assetUrl); 256 | } catch (e) { 257 | if (e?.data?.errors?.[0]?.status == 500) { 258 | console.log(`${logHeader}${assetUrl} 500 error`); 259 | await sleep(delayInMilliseconds); 260 | continue; 261 | } 262 | } 263 | const assetDeliveryState = assetData?.attributes?.assetDeliveryState; 264 | const state = assetDeliveryState?.state; 265 | if (!state) throw new Error(`${logHeader}${assetUrl} couldn't find attributes.assetDeliveryState.state: ${JSON.stringify(assetData)}`); 266 | if (state === 'COMPLETE') return; 267 | if (state === 'FAILED') throw new Error(`${logHeader}${assetUrl} upload failed: ${JSON.stringify(assetDeliveryState.errors)}`); 268 | //console.log(`${logHeader} ${state} ${assetUrl}`); 269 | await sleep(delayInMilliseconds); 270 | } 271 | } 272 | 273 | return { read, readAll, fetch: authFetch, fetchJson, postJson, create, update, remove, uploadAsset, pollForUploadSuccess }; 274 | } 275 | 276 | -------------------------------------------------------------------------------- /package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "node-app-store-connect-api", 3 | "version": "5.0.0", 4 | "lockfileVersion": 3, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "node-app-store-connect-api", 9 | "version": "5.0.0", 10 | "license": "ISC", 11 | "dependencies": { 12 | "jose": "^4.11.2", 13 | "md5": "^2.3.0" 14 | }, 15 | "engines": { 16 | "node": ">=18" 17 | } 18 | }, 19 | "node_modules/charenc": { 20 | "version": "0.0.2", 21 | "resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz", 22 | "integrity": "sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==", 23 | "engines": { 24 | "node": "*" 25 | } 26 | }, 27 | "node_modules/crypt": { 28 | "version": "0.0.2", 29 | "resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz", 30 | "integrity": "sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==", 31 | "engines": { 32 | "node": "*" 33 | } 34 | }, 35 | "node_modules/is-buffer": { 36 | "version": "1.1.6", 37 | "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", 38 | "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==" 39 | }, 40 | "node_modules/jose": { 41 | "version": "4.11.2", 42 | "resolved": "https://registry.npmjs.org/jose/-/jose-4.11.2.tgz", 43 | "integrity": "sha512-njj0VL2TsIxCtgzhO+9RRobBvws4oYyCM8TpvoUQwl/MbIM3NFJRR9+e6x0sS5xXaP1t6OCBkaBME98OV9zU5A==", 44 | "funding": { 45 | "url": "https://github.com/sponsors/panva" 46 | } 47 | }, 48 | "node_modules/md5": { 49 | "version": "2.3.0", 50 | "resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz", 51 | "integrity": "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==", 52 | "dependencies": { 53 | "charenc": "0.0.2", 54 | "crypt": "0.0.2", 55 | "is-buffer": "~1.1.6" 56 | } 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "node-app-store-connect-api", 3 | "version": "5.0.4", 4 | "description": "Node library to interface with the App Store Connect API", 5 | "main": "index.mjs", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "author": "Dan Fabulich ", 10 | "license": "ISC", 11 | "dependencies": { 12 | "jose": "^4.11.2", 13 | "md5": "^2.3.0" 14 | }, 15 | "keywords": [ 16 | "apple", 17 | "app store", 18 | "appstore" 19 | ], 20 | "repository": { 21 | "type": "git", 22 | "url": "https://github.com/dfabulich/node-app-store-connect-api" 23 | }, 24 | "engines": { 25 | "node": ">=18" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /samples/upload-preview.mjs: -------------------------------------------------------------------------------- 1 | import { api } from '../index.mjs'; 2 | import { basename } from 'path'; 3 | import { stat, readFile } from 'fs/promises'; 4 | 5 | // ported from Python sample at https://developer.apple.com/documentation/appstoreconnectapi/app_metadata/uploading_app_previews 6 | 7 | // KEY CONFIGURATION - Put your API Key info here. 8 | 9 | const issuerId = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"; 10 | const apiKey = "XXXXXXXXXX"; 11 | const privateKey = ` 12 | ----- BEGIN PRIVATE KEY----- 13 | XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX 14 | XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX 15 | XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX 16 | XXXXXXXX 17 | ----- END PRIVATE KEY----- 18 | `; 19 | 20 | // UPLOAD - This is where the interaction with App Store Connect API happens. 21 | async function upload(bundleId, platform, versionString, locale, previewType, filePath) { 22 | /* 23 | 1. Look up the app by bundle id. 24 | 2. Look up the version by platform and version number. 25 | 3. Get all localizations for the version and looks for the requested locale. 26 | 4. Create the localization if the requested localization doesn't exist. 27 | 5. Get all available app preview sets from the localization. 28 | 6. Create the app preview set for the requested type if it doesn't exist. 29 | 7. Reserve an app preview in the selected app preview set. 30 | 8. Upload the preview 31 | 9. Poll for upload success 32 | */ 33 | 34 | if (apiKey == "XXXXXXXXXX") { 35 | throw new Error("Missing the API key. Configure your key information at the top of the upload-preview.mjs file first."); 36 | }; 37 | 38 | const {read, readAll, create, uploadAsset, pollForUploadSuccess} = await api({issuerId, apiKey, privateKey}); 39 | 40 | console.log("Find (or create) app preview set."); 41 | 42 | // 1. Look up the app by bundle id. 43 | const { data: [app] } = await read(`apps?filter[bundleId]=${bundleId}&limit=1`); 44 | if (!app) throw new Error(`No app found with bundle id ${bundleId}`); 45 | 46 | // 2. Look up the version by platform and version number. 47 | const { data: [version] } = await read(`apps/${app.id}/appStoreVersions?filter[versionString]=${versionString}&filter[platform]=${platform}&limit=1`); 48 | if (!version) throw new Error(`No app store version found with version ${version}`); 49 | 50 | // 3. Get all localizations for the version and look for the requested locale. 51 | let { data: localizations } = await readAll(`appStoreVersions/${version.id}/appStoreVersionLocalizations`); 52 | // note that appStoreVersionLocalizations doesn't support filtering by locale; we have to do it ourselves, client-side. 53 | let localization = localizations.find(localization => localization.attributes.locale === locale); 54 | 55 | // 4. If the requested localization does not exist, create it. 56 | // Localized attributes are copied from the primary locale so there's no need to worry about them here. 57 | if (!localization) { 58 | localization = await create({ 59 | type: "appStoreVersionLocalizations", 60 | attributes: { locale }, 61 | relationships: { appStoreVersion: version } 62 | }); 63 | } 64 | 65 | // 5. Get all available app preview sets from the localization. 66 | let { data: appPreviewSets } = await readAll(localization.relationships.appPreviewSets.links.related); 67 | let appPreviewSet = appPreviewSets.find(appPreviewSet => appPreviewSet.attributes.previewType === previewType); 68 | 69 | // 6. If an app preview set for the requested type doesn't exist, create it. 70 | if (!appPreviewSet) { 71 | appPreviewSet = await create({ 72 | type: "appPreviewSets", 73 | attributes: { previewType }, 74 | relationships: {appStoreVersionLocalization: localization} 75 | }); 76 | } 77 | 78 | // 7. Reserve an app preview in the selected app preview set. 79 | // Tell the API to create a preview before uploading the preview data. 80 | console.log("Reserve new app preview."); 81 | 82 | const preview = await create({ 83 | type: "appPreviews", 84 | attributes: { 85 | fileName: basename(filePath), 86 | fileSize: (await stat(filePath)).size, 87 | }, 88 | relationships: { appPreviewSet } 89 | }); 90 | 91 | // 8. Upload each part according to the returned upload operations. 92 | console.log("Upload preview asset."); 93 | await uploadAsset(preview, await readFile(filePath)); 94 | 95 | // 9. Poll for upload success 96 | console.log("Asset uploaded. Poll for success."); 97 | await pollForUploadSuccess(preview.links.self); 98 | 99 | console.log(`App preview successfully uploaded to: ${preview.links.self}`); 100 | console.log("You can verify success in App Store Connect or using the API."); 101 | } 102 | 103 | if (process.argv.length !== 8) { 104 | console.error("usage: node upload-preview.mjs bundleId platform version locale previewType filePath"); 105 | console.error(" e.g. com.example.myapp IOS 1.0.2 en-US IPHONE_65 /path/to/movie.mp4") 106 | process.exit(1); 107 | } 108 | 109 | const [,,bundleId, platform, version, locale, previewType, filePath] = process.argv; 110 | await upload(bundleId, platform, version, locale, previewType, filePath); -------------------------------------------------------------------------------- /samples/upload-screenshot.mjs: -------------------------------------------------------------------------------- 1 | import { api } from '../index.mjs'; 2 | import { basename } from 'path'; 3 | import { stat, readFile } from 'fs/promises'; 4 | 5 | // ported from Python sample at https://developer.apple.com/documentation/appstoreconnectapi/app_metadata/uploading_app_previews 6 | 7 | // KEY CONFIGURATION - Put your API Key info here. 8 | 9 | const issuerId = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"; 10 | const apiKey = "XXXXXXXXXX"; 11 | const privateKey = ` 12 | ----- BEGIN PRIVATE KEY----- 13 | XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX 14 | XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX 15 | XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX 16 | XXXXXXXX 17 | ----- END PRIVATE KEY----- 18 | `; 19 | 20 | // UPLOAD - This is where the interaction with App Store Connect API happens. 21 | async function upload(bundleId, platform, versionString, locale, screenshotDisplayType, filePath) { 22 | /* 23 | 1. Look up the app by bundle id. 24 | 2. Look up the version by platform and version number. 25 | 3. Get all localizations for the version and looks for the requested locale. 26 | 4. Create the localization if the requested localization doesn't exist. 27 | 5. Get all available app screenshot sets from the localization. 28 | 6. Create the app screenshot set for the requested type if it doesn't exist. 29 | 7. Reserve an app screenshot in the selected app screenshot set. 30 | 8. Upload the screenshot 31 | 9. Poll for upload success 32 | */ 33 | 34 | if (apiKey == "XXXXXXXXXX") { 35 | throw new Error("Missing the API key. Configure your key information at the top of the upload-screenshot.mjs file first."); 36 | }; 37 | 38 | const {read, readAll, create, uploadAsset, pollForUploadSuccess} = await api({issuerId, apiKey, privateKey}); 39 | 40 | console.log("Find (or create) app screenshot set."); 41 | 42 | // 1. Look up the app by bundle id. 43 | const { data: [app] } = await read(`apps?filter[bundleId]=${bundleId}&limit=1`); 44 | if (!app) throw new Error(`No app found with bundle id ${bundleId}`); 45 | 46 | // 2. Look up the version by platform and version number. 47 | const { data: [version] } = await read(`apps/${app.id}/appStoreVersions?filter[versionString]=${versionString}&filter[platform]=${platform}&limit=1`); 48 | if (!version) throw new Error(`No app store version found with version ${version}`); 49 | 50 | // 3. Get all localizations for the version and look for the requested locale. 51 | let { data: localizations } = await readAll(`appStoreVersions/${version.id}/appStoreVersionLocalizations`); 52 | // note that appStoreVersionLocalizations doesn't support filtering by locale; we have to do it ourselves, client-side. 53 | let localization = localizations.find(localization => localization.attributes.locale === locale); 54 | 55 | // 4. If the requested localization does not exist, create it. 56 | // Localized attributes are copied from the primary locale so there's no need to worry about them here. 57 | if (!localization) { 58 | localization = await create({ 59 | type: "appStoreVersionLocalizations", 60 | attributes: { locale }, 61 | relationships: {appStoreVersion: version} 62 | }); 63 | } 64 | 65 | // 5. Get all available app screenshot sets from the localization. 66 | let { data: appScreenshotSets } = await readAll(localization.relationships.appScreenshotSets.links.related); 67 | let appScreenshotSet = appScreenshotSets.find(appScreenshotSet => appScreenshotSet.attributes.screenshotDisplayType === screenshotDisplayType); 68 | 69 | // 6. If an app screenshot set for the requested type doesn't exist, create it. 70 | if (!appScreenshotSet) { 71 | appScreenshotSet = await create({ 72 | type: "appScreenshotSets", 73 | attributes: { screenshotDisplayType }, 74 | relationships: {appStoreVersionLocalization: localization} 75 | }); 76 | } 77 | 78 | // 7. Reserve an app screenshot in the selected app screenshot set. 79 | // Tell the API to create a screenshot before uploading the screenshot data. 80 | console.log("Reserve new app screenshot."); 81 | 82 | const screenshot = await create({ 83 | type: "appScreenshots", 84 | attributes: { 85 | fileName: basename(filePath), 86 | fileSize: (await stat(filePath)).size, 87 | }, 88 | relationships: { appScreenshotSet } 89 | }); 90 | 91 | // 8. Upload each part according to the returned upload operations. 92 | console.log("Upload screenshot asset."); 93 | await uploadAsset(screenshot, await readFile(filePath)); 94 | 95 | // 9. Poll for upload success 96 | console.log("Asset uploaded. Poll for success."); 97 | await pollForUploadSuccess(screenshot.links.self); 98 | 99 | console.log(`App screenshot successfully uploaded to: ${screenshot.links.self}`); 100 | console.log("You can verify success in App Store Connect or using the API."); 101 | } 102 | 103 | if (process.argv.length !== 8) { 104 | console.error("usage: node upload-screenshot.mjs bundleId platform version locale screenshotDisplayType filePath"); 105 | console.error(" e.g. com.example.myapp IOS 1.0.2 en-US APP_IPHONE_65 /path/to/screenshot.png") 106 | process.exit(1); 107 | } 108 | 109 | const [,,bundleId, platform, version, locale, screenshotDisplayType, filePath] = process.argv; 110 | await upload(bundleId, platform, version, locale, screenshotDisplayType, filePath); --------------------------------------------------------------------------------