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.
- AS App Store customer reviewsTickets, chats, calls, reviews
- ⟳ Your jobPull new · map fields · every 15 min
- { } Universal APIPOST /track · /trackhistorical
- 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.
What you need
Section titled “What you need”- 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 (
.p8file), 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.
1. Get API access in App Store Connect
Section titled “1. Get API access in App Store Connect”Apple’s steps are in Creating API Keys for App Store Connect API and Generating Tokens for API Requests.
- 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.
- Click Generate API Key (or +), name the key, choose a role under Access, and click Generate.
- Click Download API Key to get the private key. You can download it only once, and Apple keeps no copy.
- Copy the Issuer ID near the top of the page and the key’s Key ID.
- Each request needs a JSON Web Token signed with ES256: header
algES256,kidyour key ID,typJWT; payloadissyour issuer ID,iat,expandaudappstoreconnect-v1. Apple rejects most tokens that live longer than 20 minutes. Send it asAuthorization: Bearer SIGNED_TOKEN. - Get the app’s resource ID from
GET https://api.appstoreconnect.apple.com/v1/apps.
2. Pull new reviews
Section titled “2. Pull new reviews”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}/customerReviewslists an app’s reviews.limitgoes up to 200.- Incremental: there is no date filter. Sort with
sort=-createdDate(newest first) and page untilcreatedDateis older than your cursor, then stop. - Pagination: request
links.nextuntil it is missing. include=responseadds your developer responses toincluded, ascustomerReviewResponsesresources. Each review points to its response inrelationships.response.data.id.- Rate limit (Apple’s): the limit is per API key, per rolling hour. Every response has an
X-Rate-Limitheader such asuser-hour-lim:3500;user-hour-rem:500;, and Apple says actual limits vary. Over the limit you get429withRATE_LIMIT_EXCEEDED.
3. Map the fields
Section titled “3. Map the fields”Each review is one incoming message under its own sessionId. Your developer response is an outgoing message in the same session.
| Dimension Labs | App Store Connect | Notes |
|---|---|---|
text | attributes.title and attributes.body (incoming), response attributes.responseBody (outgoing) | Join the title and body with a line break. |
userId | review id | reviewerNickname is a display name, not a stable ID, so use the review ID and don’t send the nickname. |
incoming | true for the review, false for your response. | |
sessionId | review id | Replace any colons. |
conversationId | review id | |
dimensionlabs_timestamp | attributes.createdDate (review), response attributes.lastModifiedDate | Convert with toMillis. A response within a second of the review is moved a second later. |
platformJson | rating, territory, the app ID, response state |
4. Send it
Section titled “4. Send it”// 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.
5. Run it
Section titled “5. Run it”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-01pages 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.
App Store Connect reference
Section titled “App Store Connect reference”- Creating API Keys for App Store Connect API
- Generating Tokens for API Requests
- List all customer reviews for an app
- CustomerReview.Attributes
- CustomerReviewResponseV1.Attributes
- CustomerReviewsResponse
- Identifying Rate Limits
Checked against Apple’s documentation on 2026-10-13.