# App Store customer reviews

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

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](/api/tracker), using the App Store Connect API.

## 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 (`.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](/api/tracker#your-api-key).

## 1. Get API access in App Store Connect

Apple's steps are in [Creating API Keys for App Store Connect API](https://developer.apple.com/documentation/appstoreconnectapi/creating-api-keys-for-app-store-connect-api) and [Generating Tokens for API Requests](https://developer.apple.com/documentation/appstoreconnectapi/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`.

## 2. Pull new reviews

```bash
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`.

## 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

```js
// 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.



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

Save the code as `app-store-reviews.mjs` in a project set up as in [Set up your project](/api/tracker#set-up-your-project), and run it with the [scheduled sync](/api/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](/dimensions/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](/data/upload-a-file) and check it first with the [file checker](/data/check-a-file), then run a backfill.

## App Store Connect reference

- [Creating API Keys for App Store Connect API](https://developer.apple.com/documentation/appstoreconnectapi/creating-api-keys-for-app-store-connect-api)
- [Generating Tokens for API Requests](https://developer.apple.com/documentation/appstoreconnectapi/generating-tokens-for-api-requests)
- [List all customer reviews for an app](https://developer.apple.com/documentation/appstoreconnectapi/get-v1-apps-_id_-customerreviews)
- [CustomerReview.Attributes](https://developer.apple.com/documentation/appstoreconnectapi/customerreview/attributes-data.dictionary)
- [CustomerReviewResponseV1.Attributes](https://developer.apple.com/documentation/appstoreconnectapi/customerreviewresponsev1/attributes-data.dictionary)
- [CustomerReviewsResponse](https://developer.apple.com/documentation/appstoreconnectapi/customerreviewsresponse)
- [Identifying Rate Limits](https://developer.apple.com/documentation/appstoreconnectapi/identifying-rate-limits)

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