- App Store
- Analytics
- Node.js
- Mobile apps
App Store Connect analytics API: downloads and impressions
Use the App Store Connect analytics API to pull daily downloads, impressions and page views in Node.js: report requests, instances, segments and pitfalls.
- Author
- Extenify Team
- Published
- Reading time
- 15 min read

On this page
The App Store Connect analytics API, officially the Analytics Reports API, is how you get the numbers from the App Analytics dashboard into your own database: daily downloads by source and country, impressions, product page views, installs, deletions, sessions and crashes. It does not answer a query, though. You ask Apple to start generating reports, wait a day or two, then walk a chain of report requests, reports, instances and segments to download gzipped files. Most of the confusion on Apple's developer forums comes from that chain.
This guide explains how the API works, which key roles you need, and the rules that keep your numbers right, then gives a tested Node.js script that signs the JWT, creates or reuses a report request and totals first-time downloads per day. Every endpoint and limit here was checked against Apple's documentation on October 30, 2026.
Key takeaways
- Creating a report request needs an Admin API key. Listing and downloading reports also works with Sales and Reports or Finance keys.
- Create one
ONGOINGrequest per app. The first reports arrive in about 1 to 2 days; after that, new instances appear daily.- Report instances are deleted after 35 days. Download every day and keep your own copy, or use a
ONE_TIME_SNAPSHOT(once per month) to backfill history, which starts on January 1, 2024 for the main reports.- Segment download URLs are valid for 5 minutes, and files are gzipped, tab-delimited text. Read columns by name.
- A newer instance replaces older data for the same date. Never add the same
Datefrom two instances together.
What the App Store Connect analytics API gives you
Apple's Analytics Reports documentation groups the reports into five categories. The API filter values are in brackets:
- App Store Engagement (
APP_STORE_ENGAGEMENT): how people find your app. The App Store Discovery and Engagement report has impressions, product page views and taps by source type, page type and territory. - App Store Commerce (
COMMERCE): downloads, pre-orders, purchases and subscription reports. The App Store Downloads report splits downloads into first-time downloads, redownloads, manual updates, auto-updates and restores. - App Usage (
APP_USAGE): installations and deletions, sessions, crashes, App Clip usage and opt-in rates. - Framework Usage (
FRAMEWORK_USAGE): how your app uses system features, from widgets to Safari extensions. If you ship a Safari web extension, the Safari Extensions Enablement and Safari Extensions Usage reports live here, which complements the store-side view in Safari extension analytics. - Performance (
PERFORMANCE): install success rates, expanded crash rates, disk writes and more.
Many reports come in two versions. In Apple's words, "Standard reports include fields not easily related to uniquely identifiable user data", while "Detailed reports include all fields" with extra privacy measures. For the App Store Downloads report, the detailed version adds Source Info (the referring app or website), Campaign and Page Title. Apple's advice is to "Download the standard report unless you need to analyze the unique fields in the detailed report."
What it does not give you: keyword rankings, the search terms people typed, competitors' numbers, or customer reviews. For reviews, see the guide to App Store review alerts; for other apps' ratings and versions, the iTunes Search API.
How the API is structured
There are four levels, and you walk them in order:
- Report request (
analyticsReportRequests): your standing order for one app. ItsaccessTypeisONGOING("generates reports daily, weekly and monthly") orONE_TIME_SNAPSHOT(current data going "back as far as is available", with no new data afterwards). - Report (
analyticsReports): one per report type in the request, with anameandcategory, for example"App Store Discovery and Engagement Detailed"in theAPP_STORE_ENGAGEMENTcategory. - Instance (
analyticsReportInstances): a batch of data with agranularity(DAILY,WEEKLYorMONTHLY) and aprocessingDate. - Segment (
analyticsReportSegments): the actual file. One instance can have several, and Apple says "you need to download all segments for a given instance to get the complete set of data."
The endpoints, from Apple's guide to downloading analytics reports:
| Step | Endpoint |
|---|---|
| Create a request | POST /v1/analyticsReportRequests |
| List an app's requests | GET /v1/apps/{id}/analyticsReportRequests |
| List reports | GET /v1/analyticsReportRequests/{id}/reports (filter by category or name) |
| List instances | GET /v1/analyticsReports/{id}/instances (filter by granularity, processingDate) |
| List segments | GET /v1/analyticsReportInstances/{id}/segments |
Step 1: Create the right API keys
App Store Connect API keys are created under Users and Access > Integrations > App Store Connect API. Use team keys here: Apple says individual keys carry the access of one user and "aren't able to" access sales and finance, so a team key with an explicit role is the predictable choice.
The role matters. Apple's role table for analytics reports:
| Key role | Create or delete requests | List and download reports |
|---|---|---|
| Admin | Yes | Yes |
| Sales and Reports | No | Yes |
| Finance | No | Yes |
A clean setup uses an Admin key once to create the ONGOING request, then a Sales and Reports key for the daily download job. Apple recommends that role if you share a key with a third party. The open-source analytics tool netrics settled on the same split in an issue about App Store analytics: the Admin key is used once to enable reports and never stored.
The private key (.p8) can be downloaded only once, and "Apple doesn't keep a copy." Store it in your secrets manager, not in the repository.
Step 2: Sign a JWT
Every request carries a JSON Web Token signed with ES256. Apple's token guide requires kid (your key ID) in the header and iss (issuer ID), iat, exp and aud: "appstoreconnect-v1" in the payload. Tokens that expire "more than 20 minutes into the future are not valid" for these endpoints, so sign for 15 minutes and reuse the token until it nearly expires. With the jose package:
import { readFile } from 'node:fs/promises';
import { SignJWT, importPKCS8 } from 'jose';
const key = await importPKCS8(await readFile(process.env.ASC_KEY_PATH, 'utf8'), 'ES256');
const token = await new SignJWT({})
.setProtectedHeader({ alg: 'ES256', kid: process.env.ASC_KEY_ID, typ: 'JWT' })
.setIssuer(process.env.ASC_ISSUER_ID)
.setIssuedAt()
.setExpirationTime('15m')
.setAudience('appstoreconnect-v1')
.sign(key);
If anything is off, such as a wrong key ID or issuer, the API answers with 401 NOT_AUTHORIZED and "Authentication credentials are missing or invalid." That is what we got when we ran the script below with a throwaway key, so check those three values first when you see it.
Step 3: Create or reuse an ONGOING request
Create one ONGOING request per app and keep it. Apple's docs say "Your first report request generates in 1-2 days. Subsequent ONGOING reports are available daily." Creating more requests does not speed anything up and can cause trouble: developers on Apple's forums report a 409 "You already have such an entity" error when creating a request Apple considers a duplicate (thread 827555), and a data connector project found its sync stuck for an app with two ONGOING requests, one of which never produced instances (estuary/connectors #5610). So check first, and only create a request when none is active.
There is one more state to handle. "If you don't retrieve data for a long time, a report request changes to stoppedDueToInactivity. You need to make a new request to resume getting reports." Your job should notice that flag and alert you, since only an Admin key can create the replacement.
Step 4: A Node.js script for daily downloads
The script below does the whole chain for the downloads report: it reuses or creates the request, finds the report, lists daily instances, downloads every segment, and prints first-time downloads per day. It needs Node.js 20 or later and two packages:
npm install jose csv-parse
// asc-analytics.mjs: daily first-time downloads from the App Store Connect Analytics Reports API.
// Env: ASC_KEY_ID, ASC_ISSUER_ID, ASC_KEY_PATH (the .p8 file), ASC_APP_ID (the app's Apple ID)
import { readFile } from 'node:fs/promises';
import { createHash } from 'node:crypto';
import { gunzipSync } from 'node:zlib';
import { SignJWT, importPKCS8 } from 'jose';
import { parse } from 'csv-parse/sync';
const API = 'https://api.appstoreconnect.apple.com';
const { ASC_KEY_ID, ASC_ISSUER_ID, ASC_KEY_PATH, ASC_APP_ID } = process.env;
let cached = { token: '', expiresAt: 0 };
async function token() {
if (Date.now() < cached.expiresAt - 60_000) return cached.token;
const key = await importPKCS8(await readFile(ASC_KEY_PATH, 'utf8'), 'ES256');
const value = await new SignJWT({})
.setProtectedHeader({ alg: 'ES256', kid: ASC_KEY_ID, typ: 'JWT' })
.setIssuer(ASC_ISSUER_ID)
.setIssuedAt()
.setExpirationTime('15m') // Apple rejects tokens that live longer than 20 minutes
.setAudience('appstoreconnect-v1')
.sign(key);
cached = { token: value, expiresAt: Date.now() + 15 * 60_000 };
return value;
}
async function api(pathOrUrl, init = {}) {
const url = pathOrUrl.startsWith('https://') ? pathOrUrl : API + pathOrUrl;
const res = await fetch(url, {
...init,
headers: { Authorization: `Bearer ${await token()}`, 'Content-Type': 'application/json' },
});
if (!res.ok) throw new Error(`${init.method ?? 'GET'} ${url} -> ${res.status} ${await res.text()}`);
return res.json();
}
// Follows links.next so lists longer than one page are complete.
async function all(path) {
const items = [];
for (let next = path; next; ) {
const page = await api(next);
items.push(...page.data);
next = page.links?.next;
}
return items;
}
const qs = (params) => new URLSearchParams(params).toString();
// Reuse the app's active ONGOING request; create one only if none exists (needs an Admin key).
async function ongoingRequestId(appId) {
const requests = await all(`/v1/apps/${appId}/analyticsReportRequests?${qs({ 'filter[accessType]': 'ONGOING' })}`);
const active = requests.find((r) => !r.attributes.stoppedDueToInactivity);
if (active) return active.id;
const created = await api('/v1/analyticsReportRequests', {
method: 'POST',
body: JSON.stringify({
data: {
type: 'analyticsReportRequests',
attributes: { accessType: 'ONGOING' },
relationships: { app: { data: { type: 'apps', id: appId } } },
},
}),
});
console.log('Created an ONGOING request. The first reports arrive in about 1-2 days.');
return created.data.id;
}
async function findReport(requestId, category, name) {
const reports = await all(`/v1/analyticsReportRequests/${requestId}/reports?${qs({ 'filter[category]': category })}`);
const report = reports.find((r) => r.attributes.name.toLowerCase() === name.toLowerCase());
if (!report) throw new Error(`No "${name}" report. Available: ${reports.map((r) => r.attributes.name).join(', ')}`);
return report.id;
}
// Segments are gzipped, tab-delimited text. Read columns by name, never by position.
function parseSegment(gz) {
const text = gunzipSync(gz).toString('utf8');
const header = text.slice(0, text.indexOf('\n'));
return parse(text, { columns: true, delimiter: header.includes('\t') ? '\t' : ',', skip_empty_lines: true, relax_quotes: true });
}
async function downloadSegment(segment) {
const { url, sizeInBytes, checksum } = segment.attributes;
const res = await fetch(url); // a pre-signed URL, valid for 5 minutes: no Authorization header
if (!res.ok) throw new Error(`Segment download failed: ${res.status}`);
const gz = Buffer.from(await res.arrayBuffer());
if (sizeInBytes && gz.length !== sizeInBytes) throw new Error(`Segment ${segment.id}: size mismatch`);
const md5 = createHash('md5').update(gz).digest('hex');
if (checksum && md5 !== checksum) console.warn(`Segment ${segment.id}: checksum ${checksum} does not match MD5 ${md5}`);
return parseSegment(gz);
}
// A newer instance replaces every Date it contains, so the same day is never counted twice.
function latestRowsByDate(instances) {
const byDate = new Map();
for (const { processingDate, rows } of [...instances].sort((a, b) => a.processingDate.localeCompare(b.processingDate))) {
const dates = new Map();
for (const row of rows) {
if (!dates.has(row.Date)) dates.set(row.Date, []);
dates.get(row.Date).push(row);
}
for (const [date, dateRows] of dates) byDate.set(date, { processingDate, rows: dateRows });
}
return byDate;
}
function firstTimeDownloads(byDate) {
const totals = {};
for (const [date, { rows }] of byDate) {
totals[date] = rows
.filter((r) => r['Download Type']?.toLowerCase() === 'first-time download')
.reduce((sum, r) => sum + Number(r.Counts || 0), 0);
}
return Object.fromEntries(Object.entries(totals).sort(([a], [b]) => a.localeCompare(b)));
}
const requestId = await ongoingRequestId(ASC_APP_ID);
const reportId = await findReport(requestId, 'COMMERCE', 'App Downloads Standard');
const instances = await all(`/v1/analyticsReports/${reportId}/instances?${qs({ 'filter[granularity]': 'DAILY', limit: '200' })}`);
const downloaded = [];
for (const instance of instances) {
const segments = await all(`/v1/analyticsReportInstances/${instance.id}/segments`);
const rows = [];
for (const segment of segments) rows.push(...(await downloadSegment(segment)));
downloaded.push({ processingDate: instance.attributes.processingDate, rows });
}
console.table(firstTimeDownloads(latestRowsByDate(downloaded)));
A few notes on the choices in the code:
- Report names. The downloads report appears in the API as
App Downloads Standardin open-source connectors, while Apple's documentation page is titled "App Store Downloads". The script matches the name case-insensitively and, if it finds nothing, prints the names your request actually has. Use that list rather than hard-coding guesses. - Signed URLs. Apple says "The download URLs are valid for 5 minutes after generating", so the script lists segments right before downloading them, and sends no
Authorizationheader to the storage host. - Integrity. Each segment has
sizeInBytesandchecksum. The size check is strict. The checksum in Apple's example is a 32-character hex string, so the script compares it with an MD5 digest and warns rather than fails if they differ. - Values. Apple says "Report values are not case sensitive" and "Report column position might change over time", which is why the code lowercases
Download Typeand parses with named columns.
We tested the parsing and merging functions on synthetic gzipped, tab-delimited segments and a token signed with a generated P-256 key; the network calls need a real key and app.
Step 5: Merge instances without double counting
This is the rule people miss. Apple's data completeness page explains that a daily instance "might contain data for that day, full data for past dates, or both." An instance processed on April 1 can hold rows for March 30 and March 31, and the next day's instance holds March 31 again, now complete.
Apple's instruction is direct: "Instances from a more recent processingDate overwrite instances with an earlier processingDate" and "Don't merge records for a Date of 2024-03-30 across two report instances to avoid double counting. Use the latest instance." That is what latestRowsByDate does. Data for a date is final after the report's completeness window: two days for downloads, three for discovery and engagement, five for installations and deletions. If you prefer simplicity over freshness, wait that long and download each date once.
For storage, a table keyed on (app, report, date, dimensions) that you overwrite per date works well. The guide to PostgreSQL time series shows a schema for daily store stats, and the Node.js cron job guide covers running the download once a day without gaps.
Privacy rules that change your numbers
Expect small differences from the App Analytics dashboard, and know where they come from. Apple's privacy page lists two techniques:
- Thresholding omits values with data from fewer than 5 users or 5 unique devices. Rows for small countries or rare devices simply disappear from detailed reports.
- Noise adds a small random number. In detailed reports, about 95% of values are within plus or minus 4 of the true value.
App usage reports, such as installations and deletions, sessions and crashes, only include users who agreed to share usage data, and a report is not generated at all if fewer than five users contribute for that period. A new or small app may see empty usage reports for a while even though downloads look fine. Sum the standard report for totals and use the detailed one only when you need source info or campaigns.
Common errors and what they mean
| Symptom | Likely cause |
|---|---|
401 NOT_AUTHORIZED |
Wrong key ID, issuer ID or key file, or a token that expires more than 20 minutes ahead |
403 when creating a request |
The key is not an Admin key |
409 when creating a request |
An equivalent request already exists; list the app's requests and reuse it |
200 with an empty data array for instances |
The request is less than 1 to 2 days old, or the app has no data for that report yet (thread 757120) |
| Only about a month of daily history | Instances are deleted after 35 days (thread 843936); download daily or request a snapshot |
403 on a segment URL |
The 5-minute signed URL expired; list the segments again |
| Totals far above the dashboard | Rows for the same Date added from several instances |
A last warning from the forums: developers have reported differences between the dashboard and the API for some metrics, such as crash counts (thread 820611). Pick one source per metric and stick with it in your reports.
Backfill history with a one-time snapshot
The main engagement, downloads and installs reports say "On request, data is available beginning from January 1, 2024." To get it, create a ONE_TIME_SNAPSHOT request. Apple says this "generates a comprehensive data collection of all available historical reports" and that "You can make a single, one-time snapshot request per month." Run it once after you set up the ONGOING request, load it, then let the daily job take over.
Add the public side of the picture
The Analytics Reports API tells you how your own App Store listing performs. It says nothing about the apps you compete with, and nothing about your Google Play listing, where the equivalent data comes from Google Play Console statistics and the monthly reports covered in export Google Play reviews.
Extenify fills in the public side: ratings, review counts, versions and release dates for any App Store or Google Play listing, plus Chrome, Edge and Firefox extensions, recorded every day with alerts when something changes. It works as an app analytics dashboard for every store you ship in, with no SDK. Plans start with a 7-day free trial, no credit card required; see pricing.
App Store Connect analytics API checklist
- A team key with the Admin role created the
ONGOINGrequest, and a Sales and Reports key runs the daily job. - The job checks for an existing request before creating one and alerts on
stoppedDueToInactivity. - JWTs use ES256,
aud: "appstoreconnect-v1"and expire in under 20 minutes. - Segments are downloaded within 5 minutes of listing them, size-checked and parsed by column name.
- The newest instance replaces earlier data for the same
Date. - Data is copied into your own storage daily, because instances expire after 35 days.
- A
ONE_TIME_SNAPSHOTbackfilled history once.
Frequently asked questions
Is there an API for App Store Connect analytics?
Yes. The Analytics Reports API, part of the App Store Connect API, exports App Analytics data in bulk as report files. You create a report request, then download report instances and segments. It covers downloads, impressions, product page views, installs, sessions, crashes and more.
How do I get App Store downloads through the API?
Create an ONGOING analytics report request for the app, wait 1 to 2 days, then list the COMMERCE reports, pick the downloads report, and download the daily instances. Sum the Counts column for rows where Download Type is "First-time Download" to get new downloads.
Can I get impressions and product page views from the API?
Yes, from the App Store Discovery and Engagement report in the APP_STORE_ENGAGEMENT category. Its Event column distinguishes "Impression", "Page view" and "Tap", with source type, page type and territory as dimensions.
Why are my App Store Connect API report instances empty?
A new request takes 1 to 2 days to produce its first reports, and some usage reports are not generated when fewer than five users contribute. If a request is older than that and still empty, check that it is not stoppedDueToInactivity and that you are listing the right granularity.
How long are App Store analytics reports kept?
Report instances are available for 35 days and then deleted. For older data, create a ONE_TIME_SNAPSHOT request, which you can do once per month and which reaches back to January 1, 2024 for the main reports.
Summary
The App Store Connect analytics API turns App Analytics into files you can store and query. Create one ONGOING request per app with an Admin key, then let a Sales and Reports key download daily instances and every segment within the 5-minute URL window. Parse the tab-delimited files by column name, let the newest instance replace older rows for the same date, and copy everything into your own database before the 35-day limit. Backfill once with a snapshot, and expect small, deliberate differences caused by thresholds and noise.
Image credits
- Cover photo: Viewing telescope in the mountains by Jennifer Bourn on the WordPress Photo Directory, CC0.


