DEV Community

Cover image for How to read (and reply to) App Store and Google Play reviews with their APIs
Ernist Isabekov
Ernist Isabekov

Posted on

How to read (and reply to) App Store and Google Play reviews with their APIs

A practical TypeScript guide to the App Store Connect and Google Play review APIs, with the gotchas I hit along the way.

tags: ios, android, typescript, tutorial

I make small iOS and Android apps, and for a long time I read reviews the slow way: open App Store Connect, scroll, open Play Console, scroll. I missed a lot. A 1★ crash report I found two weeks late. A review in Russian I skipped because I couldn't read it quickly.

So I started pulling reviews through the official APIs instead. Both stores have one, both are free, and both have a few surprises. Here's everything you need, in TypeScript, plus the gotchas that cost me time.

App Store Connect

1. Create an API key

In App Store Connect → Users and Access → Integrations → App Store Connect API, create a team key. For reviews, the Customer Support role is enough: it can read reviews and reply, nothing else.

You get three things:

  • Issuer ID (a UUID at the top of the page)
  • Key ID (10 characters)
  • a .p8 file, which you can download only once, so keep it safe

2. Sign a JWT

Apple wants a short-lived ES256 JWT on every request. I use jose:

import { importPKCS8, SignJWT } from "jose";

async function appStoreToken(issuerId: string, keyId: string, p8: string) {
  const key = await importPKCS8(p8, "ES256");
  const now = Math.floor(Date.now() / 1000);
  return new SignJWT({})
    .setProtectedHeader({ alg: "ES256", kid: keyId, typ: "JWT" })
    .setIssuer(issuerId)
    .setIssuedAt(now)
    .setExpirationTime(now + 15 * 60) // Apple allows 20 minutes max
    .setAudience("appstoreconnect-v1")
    .sign(key);
}
Enter fullscreen mode Exit fullscreen mode

Reuse the token until it's about to expire. Signing one per request works, but it's wasteful.

3. Fetch reviews

You need the app's numeric Apple ID (it's in the App Store URL, e.g. id1234567890).

const API = "https://api.appstoreconnect.apple.com";

async function appStoreReviews(appId: string, token: string) {
  const params = new URLSearchParams({
    sort: "-createdDate",
    limit: "200",
    include: "response", // also return your existing replies
  });
  let url: string | undefined = `${API}/v1/apps/${appId}/customerReviews?${params}`;
  const reviews = [];
  while (url) {
    const res: Response = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
    if (!res.ok) throw new Error(`App Store Connect: ${res.status} ${await res.text()}`);
    const page: { data: any[]; links?: { next?: string } } = await res.json();
    for (const r of page.data) {
      const { rating, title, body, reviewerNickname, createdDate, territory } = r.attributes;
      reviews.push({ id: r.id, rating, title, body, author: reviewerNickname, date: createdDate, territory });
    }
    url = page.links?.next; // follow pagination
  }
  return reviews;
}
Enter fullscreen mode Exit fullscreen mode

territory is a 3-letter country code (USA, DEU, KGZ). Reviews come from every country, which is the part App Store Connect's web UI makes painful.

4. Reply

async function appStoreReply(reviewId: string, text: string, token: string) {
  const res = await fetch(`${API}/v1/customerReviewResponses`, {
    method: "POST",
    headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
    body: JSON.stringify({
      data: {
        type: "customerReviewResponses",
        attributes: { responseBody: text },
        relationships: { review: { data: { type: "customerReviews", id: reviewId } } },
      },
    }),
  });
  if (!res.ok) throw new Error(`Reply failed: ${res.status} ${await res.text()}`);
  return res.json(); // state is PENDING_PUBLISH until Apple shows it
}
Enter fullscreen mode Exit fullscreen mode

Posting again to the same review replaces your previous reply. Replies aren't public instantly: they stay PENDING_PUBLISH for a while.

Google Play

1. Create a service account

  1. In Google Cloud Console, create a service account and download its JSON key.
  2. Enable the Google Play Android Developer API for that project.
  3. In Play Console → Users and permissions, invite the service account's email and give it View app information and Reply to reviews for your apps.

Permissions can take a while (sometimes hours) to start working. If you get a 403 right after setting it up, wait before you start debugging.

2. Get an access token

Google uses OAuth with a JWT you sign with the service account's private key:

import { importPKCS8, SignJWT } from "jose";

async function playToken(sa: { client_email: string; private_key: string }) {
  const tokenUrl = "https://oauth2.googleapis.com/token";
  const key = await importPKCS8(sa.private_key, "RS256");
  const now = Math.floor(Date.now() / 1000);
  const assertion = await new SignJWT({ scope: "https://www.googleapis.com/auth/androidpublisher" })
    .setProtectedHeader({ alg: "RS256", typ: "JWT" })
    .setIssuer(sa.client_email)
    .setAudience(tokenUrl)
    .setIssuedAt(now)
    .setExpirationTime(now + 3600)
    .sign(key);
  const res = await fetch(tokenUrl, {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams({ grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer", assertion }),
  });
  if (!res.ok) throw new Error(`Google token: ${res.status} ${await res.text()}`);
  return (await res.json()).access_token as string;
}
Enter fullscreen mode Exit fullscreen mode

(You can also use the googleapis package; I wanted zero heavy dependencies.)

3. Fetch reviews

const PLAY = "https://androidpublisher.googleapis.com/androidpublisher/v3/applications";

async function playReviews(packageName: string, token: string) {
  const reviews = [];
  let pageToken: string | undefined;
  do {
    const params = new URLSearchParams({ maxResults: "100" });
    if (pageToken) params.set("token", pageToken);
    const res = await fetch(`${PLAY}/${packageName}/reviews?${params}`, { headers: { Authorization: `Bearer ${token}` } });
    if (!res.ok) throw new Error(`Google Play: ${res.status} ${await res.text()}`);
    const page = await res.json();
    for (const r of page.reviews ?? []) {
      const user = r.comments?.find((c: any) => c.userComment)?.userComment;
      if (!user) continue;
      reviews.push({
        id: r.reviewId,
        rating: user.starRating,
        text: user.text,
        language: user.reviewerLanguage,
        appVersion: user.appVersionName,
        device: user.deviceMetadata?.productName,
      });
    }
    pageToken = page.tokenPagination?.nextPageToken;
  } while (pageToken);
  return reviews;
}
Enter fullscreen mode Exit fullscreen mode

4. Reply

async function playReply(packageName: string, reviewId: string, text: string, token: string) {
  const res = await fetch(`${PLAY}/${packageName}/reviews/${reviewId}:reply`, {
    method: "POST",
    headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
    body: JSON.stringify({ replyText: text }),
  });
  if (!res.ok) throw new Error(`Reply failed: ${res.status} ${await res.text()}`);
  return res.json();
}
Enter fullscreen mode Exit fullscreen mode

The gotchas

Google Play only returns the last 7 days. The reviews API only gives you reviews created or edited in roughly the last week. There's no way to fetch your full history through it. If you want history, sync regularly and store reviews yourself from day one. (Play Console offers older reviews as CSV exports in Google Cloud Storage, but that's a separate setup.)

Reply limits are very different. Google Play replies max out at 350 characters. App Store replies can be about 5,970. Write for the shorter one if you reuse templates.

Old Play reviews hide the title in the text. Some reviews come back as "Title\tBody" in a single field. Split on the tab.

Play has no "list my apps" endpoint. You need the package names yourself. A cheap way to check access is GET .../reviews?maxResults=1 for each one: a 403 means the service account isn't invited to that app yet.

Handle 429s. Both APIs rate-limit. Retry with exponential backoff and respect Retry-After.

Reviews come in every language. Both APIs give you the language or country, so you can translate before you read, and reply in the reviewer's language. Users notice.

What I did with it

Once I had all reviews in one place, the useful part wasn't reading them. It was grouping them. Ten reviews saying "widget doesn't update" in five languages is one bug, not ten complaints.

I ended up turning my scripts into a small tool called Starquill: it syncs both stores, groups reviews into issues, pings me on Telegram when a 1★ comes in, and drafts replies in the reviewer's language. It's free for one app if you don't want to build this yourself. But the code above is everything you need to roll your own.

If you've hit other gotchas with these APIs, I'd love to hear them in the comments.

Top comments (0)