Skip to content

02 Get your data in /Integrations

App Store customer reviews

Send App Store customer reviews from App Store Connect to Dimension Labs with the universal API.

  1. AS App Store customer reviewsTickets, chats, calls, reviews
  2. ⟳ Your jobPull new · map fields · every 15 min
  3. { } Universal APIPOST /track · /trackhistorical
  4. Dimension LabsRead in full · enriched daily

This brings in the customer reviews of your apps on the App Store: each review as a customer message, with its rating and territory, and your developer response when there is one. You run it on your side with the universal API, using the App Store Connect API.

  • An App Store Connect account with the Admin role to create a team API key, or permission to generate an individual API key.
  • An App Store Connect API key: its private key (.p8 file), key ID and issuer ID, and the app’s resource ID.
  • A Dimension Labs API key for a new Universal API source. See Your API key.

Apple’s steps are in Creating API Keys for App Store Connect API and Generating Tokens for API Requests.

  1. In App Store Connect, go to Users and Access, then the Integrations tab, then App Store Connect API in the left column, with Team Keys selected.
  2. Click Generate API Key (or +), name the key, choose a role under Access, and click Generate.
  3. Click Download API Key to get the private key. You can download it only once, and Apple keeps no copy.
  4. Copy the Issuer ID near the top of the page and the key’s Key ID.
  5. Each request needs a JSON Web Token signed with ES256: header alg ES256, kid your key ID, typ JWT; payload iss your issuer ID, iat, exp and aud appstoreconnect-v1. Apple rejects most tokens that live longer than 20 minutes. Send it as Authorization: Bearer SIGNED_TOKEN.
  6. Get the app’s resource ID from GET https://api.appstoreconnect.apple.com/v1/apps.
Terminal window
curl -H "Authorization: Bearer $ASC_TOKEN" \
"https://api.appstoreconnect.apple.com/v1/apps/APP_ID/customerReviews?sort=-createdDate&limit=200&include=response"
  • GET /v1/apps/{id}/customerReviews lists an app’s reviews. limit goes up to 200.
  • Incremental: there is no date filter. Sort with sort=-createdDate (newest first) and page until createdDate is older than your cursor, then stop.
  • Pagination: request links.next until it is missing.
  • include=response adds your developer responses to included, as customerReviewResponses resources. Each review points to its response in relationships.response.data.id.
  • Rate limit (Apple’s): the limit is per API key, per rolling hour. Every response has an X-Rate-Limit header such as user-hour-lim:3500;user-hour-rem:500;, and Apple says actual limits vary. Over the limit you get 429 with RATE_LIMIT_EXCEEDED.

Each review is one incoming message under its own sessionId. Your developer response is an outgoing message in the same session.

Dimension LabsApp Store ConnectNotes
textattributes.title and attributes.body (incoming), response attributes.responseBody (outgoing)Join the title and body with a line break.
userIdreview idreviewerNickname is a display name, not a stable ID, so use the review ID and don’t send the nickname.
incomingtrue for the review, false for your response.
sessionIdreview idReplace any colons.
conversationIdreview id
dimensionlabs_timestampattributes.createdDate (review), response attributes.lastModifiedDateConvert with toMillis. A response within a second of the review is moved a second later.
platformJsonrating, territory, the app ID, response state
// app-store-reviews.mjs. Environment: ASC_KEY_ID, ASC_ISSUER_ID, ASC_PRIVATE_KEY (the contents of the .p8 file),
// ASC_APP_IDS (comma-separated app resource IDs), DL_API_KEY.
import { sendToDimensionLabs, toMillis } from './dimensionlabs.mjs';
import { sign } from 'node:crypto';
const HOUR = 60 * 60 * 1000;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const b64 = (v) => Buffer.from(typeof v === 'string' ? v : JSON.stringify(v)).toString('base64url');
// An env var often holds the key on one line with "\n" in place of line breaks; put the line breaks back.
const privateKey = (process.env.ASC_PRIVATE_KEY ?? '').replace(/\\n/g, '\n');
// A new token for every request: Apple rejects most tokens that live longer than 20 minutes.
function appStoreToken() {
const now = Math.floor(Date.now() / 1000);
const input = `${b64({ alg: 'ES256', kid: process.env.ASC_KEY_ID, typ: 'JWT' })}.${b64({ iss: process.env.ASC_ISSUER_ID, iat: now, exp: now + 1200, aud: 'appstoreconnect-v1' })}`;
return `${input}.${sign('sha256', Buffer.from(input), { key: privateKey, dsaEncoding: 'ieee-p1363' }).toString('base64url')}`;
}
// Retries Apple's rate limit (429) and server errors (5xx) with a growing pause.
async function get(url) {
for (let attempt = 0; ; attempt++) {
const res = await fetch(url, { headers: { Authorization: `Bearer ${appStoreToken()}` } });
if (res.ok) return res.json();
if ((res.status !== 429 && res.status < 500) || attempt === 5) throw new Error(`App Store Connect returned ${res.status}: ${await res.text()}`);
await sleep((Number(res.headers.get('retry-after')) || 2 ** attempt) * 1000);
}
}
async function syncApp(appId, since, historical) {
let next = `https://api.appstoreconnect.apple.com/v1/apps/${appId}/customerReviews?sort=-createdDate&limit=200&include=response`;
let newest = since;
while (next) {
const page = await get(next);
const replies = Object.fromEntries((page.included ?? []).filter((i) => i.type === 'customerReviewResponses').map((i) => [i.id, i.attributes]));
next = page.links?.next ?? null;
for (const r of page.data) {
const a = r.attributes, created = toMillis(a.createdDate);
if (created < since) { next = null; break; }
newest = Math.max(newest, created);
const id = r.id.replace(/:/g, '-');
const reply = replies[r.relationships?.response?.data?.id];
const base = { userId: id, sessionId: id, conversationId: id, platformJson: { appId, rating: a.rating, territory: a.territory, replyState: reply?.state } };
const text = [a.title, a.body].filter((s) => s?.trim()).join('\n');
if (text) await sendToDimensionLabs({ ...base, text, incoming: true, dimensionlabs_timestamp: created }, { historical: historical || created < Date.now() - HOUR });
if (reply?.responseBody?.trim()) {
const replied = Math.max(toMillis(reply.lastModifiedDate), created + 1000);
await sendToDimensionLabs({ ...base, text: reply.responseBody.trim(), incoming: false, dimensionlabs_timestamp: replied }, { historical: historical || replied < Date.now() - HOUR });
}
}
}
return newest;
}
// Sends every review created at or after `since` (Unix ms), for each app. Returns the new `since`.
export async function syncAppStoreReviews({ since, historical = false }) {
let newest = since;
for (const appId of process.env.ASC_APP_IDS.split(',').map((x) => x.trim()).filter(Boolean)) {
newest = Math.max(newest, await syncApp(appId, since, historical));
}
return newest;
}

This picks reviews up by createdDate, so a response you write after the review was pulled is not sent. To catch those, run a history load over the last few weeks now and then (node run.mjs with a recent date): it sends the new responses, and everything else in it is a duplicate, removed automatically. Then run a backfill. One Dimension Labs source can hold all your apps: the app ID goes in platformJson.appId.

Save the code as app-store-reviews.mjs in a project set up as in Set up your project, and run it with the scheduled sync runner: import { syncAppStoreReviews as sync } from './app-store-reviews.mjs';.

  • History first: node run.mjs 2020-01-01 pages back through every review since that date and sends it to /trackhistorical. Then start a backfill from the Backfill tab.
  • Then every 15 minutes: the scheduled job sends new reviews to /track, and they are enriched automatically every day. Anything more than an hour old when the job sees it goes to /trackhistorical, and needs a backfill.
  • Re-runs are safe: duplicates are removed automatically.

If you have reviews in a file instead, upload it and check it first with the file checker, then run a backfill.

Checked against Apple’s documentation on 2026-10-13.