# Google Play reviews

> Send Google Play reviews of your Android app to Dimension Labs with the universal API.

This brings in the Google Play reviews of your app's production version: each review as a customer message, with its star rating and device details, and your developer reply when there is one. You run it on your side with the [universal API](/api/tracker), using the Google Play Developer Reply to Reviews API.

## What you need

- A Google Play Console account that can invite users and grant permissions, and a Google Cloud project.
- A Google Cloud service account with the **Reply to reviews** permission in Play Console, and its credentials.
- 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 Google Play

Google's steps are in [Getting Started](https://developers.google.com/android-publisher/getting_started) and [Reply to Reviews](https://developers.google.com/android-publisher/reply-to-reviews).

1. Create a Google Cloud project, open the **Google Play Developer API** page in the Google Cloud Console and click **Enable**.
2. In the Google Cloud Console, go to **Service Accounts**, click **Create service account** and follow the steps. Then open the service account, go to **Keys**, and add a JSON key. Keep the key file in a secrets manager; Google's page links to its guide on OAuth 2.0 for server to server applications.
3. In Play Console, go to **Users & Permissions** and click **Invite new users**. Enter the service account's email address, grant the **Reply to reviews** permission, and click **Invite user**.
4. Authorize with the `https://www.googleapis.com/auth/androidpublisher` scope and send the access token as `Authorization: Bearer ACCESS_TOKEN`.

## 2. Pull new reviews

```bash
curl -H "Authorization: Bearer $PLAY_ACCESS_TOKEN" \
  "https://androidpublisher.googleapis.com/androidpublisher/v3/applications/com.example.app/reviews?maxResults=100"
```

- `reviews.list` returns reviews for production versions only, and only reviews that include a comment. A rating without a comment is not available.
- **Recent only:** Google says the API returns only reviews created or modified within the last week. The first result is the most recently created or modified, so page until `lastModified` is older than your cursor, then stop.
- **Pagination:** the default is 10 per page and `maxResults` goes up to 100. Pass `tokenPagination.nextPageToken` as `token` for the next page.
- In `comments`, the first entry is the `userComment`. If you have replied, your reply is the second entry, as `developerComment`.
- A review title and body arrive together in `text`, separated by a tab.
- **Quota (Google's):** 200 `GET` requests per hour, per app, for the Reply to Reviews API.

## 3. Map the fields

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

| Dimension Labs | Google Play | Notes |
|---|---|---|
| `text` | `userComment.text` (incoming), `developerComment.text` (outgoing) | Replace the tab between title and body with a line break. Add `translationLanguage=en` to get translated text, with the original in `originalText`. |
| `userId` | `reviewId` | Google says a user can write only one review per app, so the review ID also stands for the reviewer. Don't send `authorName`. |
| `incoming` | `userComment` or `developerComment` | `true` for `userComment`, `false` for `developerComment`. |
| `sessionId` | `reviewId` | Replace any colons. |
| `conversationId` | `reviewId` | |
| `dimensionlabs_timestamp` | `lastModified.seconds` | Unix seconds: `toMillis` turns them into milliseconds. A reply within a second of the review is moved a second later. |
| `platformJson` | `starRating`, `reviewerLanguage`, `appVersionName`, `appVersionCode`, `androidOsVersion`, `device`, the package name | App version lets you compare reviews across releases. |

## 4. Send it

Install the auth library in your project first: `npm install google-auth-library`. Instead of a key file on disk, the code reads the service account's JSON key from an environment variable, which works the same on your computer, Lambda or Cloud Run.

```js
// google-play-reviews.mjs. Environment: PLAY_SERVICE_ACCOUNT_JSON (the service account's JSON key, as text),
// PLAY_PACKAGES (comma-separated package names, for example com.example.app), DL_API_KEY.



const HOUR = 60 * 60 * 1000;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const auth = new GoogleAuth({
  credentials: JSON.parse(process.env.PLAY_SERVICE_ACCOUNT_JSON),
  scopes: ['https://www.googleapis.com/auth/androidpublisher'],
});

// Uses a fresh access token on every call, and retries the quota (429) and server errors (5xx) with a growing pause.
async function get(url) {
  for (let attempt = 0; ; attempt++) {
    const { token } = await (await auth.getClient()).getAccessToken();
    const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
    if (res.ok) return res.json();
    if ((res.status !== 429 && res.status < 500) || attempt === 3) throw new Error(`Google Play returned ${res.status}: ${await res.text()}`);
    await sleep((Number(res.headers.get('retry-after')) || 2 ** attempt) * 1000);
  }
}

async function syncApp(packageName, since, historical) {
  let token = '', newest = since, done = false;
  while (!done) {
    const q = new URLSearchParams({ maxResults: '100', ...(token && { token }) });
    const page = await get(`https://androidpublisher.googleapis.com/androidpublisher/v3/applications/${packageName}/reviews?${q}`);
    for (const r of page.reviews ?? []) {
      const user = r.comments?.find((c) => c.userComment)?.userComment;
      const dev = r.comments?.find((c) => c.developerComment)?.developerComment;
      if (!user) continue;
      const modified = toMillis(Number(user.lastModified?.seconds ?? 0));
      if (modified < since) { done = true; break; }
      newest = Math.max(newest, modified);
      const id = r.reviewId.replace(/:/g, '-');
      const base = { userId: id, sessionId: id, conversationId: id, platformJson: { packageName, rating: user.starRating,
        language: user.reviewerLanguage, appVersionName: user.appVersionName, androidOsVersion: user.androidOsVersion, device: user.device } };
      const text = user.text?.replace(/\t/g, '\n').trim();
      if (text) await sendToDimensionLabs({ ...base, text, incoming: true, dimensionlabs_timestamp: modified }, { historical: historical || modified < Date.now() - HOUR });
      if (dev?.text?.trim()) {
        const replied = Math.max(toMillis(Number(dev.lastModified.seconds)), modified + 1000);
        await sendToDimensionLabs({ ...base, text: dev.text.trim(), incoming: false, dimensionlabs_timestamp: replied }, { historical: historical || replied < Date.now() - HOUR });
      }
    }
    token = page.tokenPagination?.nextPageToken;
    if (!token) done = true;
  }
  return newest;
}

// Sends every review created or modified at or after `since` (Unix ms), for each app. Returns the new `since`.
export async function syncPlayReviews({ since, historical = false }) {
  let newest = since;
  for (const pkg of process.env.PLAY_PACKAGES.split(',').map((x) => x.trim()).filter(Boolean)) {
    newest = Math.max(newest, await syncApp(pkg, since, historical));
  }
  return newest;
}
```

A review the user edits comes back with a newer `lastModified` and is sent again under the same `sessionId`; anything unchanged is a duplicate, and is removed automatically. One Dimension Labs source can hold all your apps: the package name goes in `platformJson.packageName`.

## 5. Run it

Save the code as `google-play-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 { syncPlayReviews as sync } from './google-play-reviews.mjs';`.

- **Every 15 minutes:** the scheduled job sends new reviews and replies 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. Four runs an hour, a page or two each, stay well inside Google's quota of 200 `GET` requests an hour per app.
- **Keep it running:** the API only reaches back one week, so a gap longer than that loses reviews.
- **History:** for anything older than a week, Google points to downloading reviews as a CSV file from Play Console: [upload that file](/data/upload-a-file), check it first with the [file checker](/data/check-a-file), then run a [backfill](/dimensions/backfill) from the **Backfill** tab.
- **Re-runs are safe:** duplicates are removed automatically.

## Google Play reference

- [Getting Started](https://developers.google.com/android-publisher/getting_started)
- [Reply to Reviews](https://developers.google.com/android-publisher/reply-to-reviews)
- [Method: reviews.list](https://developers.google.com/android-publisher/api-ref/rest/v3/reviews/list)
- [REST Resource: reviews](https://developers.google.com/android-publisher/api-ref/rest/v3/reviews)
- [Quotas](https://developers.google.com/android-publisher/quotas)

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