◉RORK LABJP
●EXPO — EAS Observe now records native crashes (Oct 7). On SDK 57, update to 57.0.21 or later●RORK — GPT-6.1 Sol (Sep 29) and Claude Sonnet 5.5 (Sep 28) are now in the model menu●RN 0.88 — The stable release was still unpublished on its Oct 12 target. rc.4 (Oct 7) is the newest; 0.87.1 is Latest●Q&A — Readers report expo-widgets showing a black widget in production builds only●WINDOWS — A walkthrough shows how to ship an iPhone app from Windows only, with no Mac●NEW — Three checks to run first when notifications stop arriving in Expo Go●EXPO — EAS Observe now records native crashes (Oct 7). On SDK 57, update to 57.0.21 or later●RORK — GPT-6.1 Sol (Sep 29) and Claude Sonnet 5.5 (Sep 28) are now in the model menu●RN 0.88 — The stable release was still unpublished on its Oct 12 target. rc.4 (Oct 7) is the newest; 0.87.1 is Latest●Q&A — Readers report expo-widgets showing a black widget in production builds only●WINDOWS — A walkthrough shows how to ship an iPhone app from Windows only, with no Mac●NEW — Three checks to run first when notifications stop arriving in Expo Go
Articles/Business
⟐ Business/2026-04-20Advanced

Cutting App Store Review Replies From 32 Minutes a Day to 8 — App Store Connect API × Gemini × Cloudflare Workers in Production

Field notes on an AI-drafted, human-approved review reply pipeline: why 0.85 became my confidence threshold, JWT clock-skew workarounds, and Crashlytics-driven reply branching, with three months of measured numbers.

App Store Connect API5Google Play APIGemini API5review managementCloudflare Workers25automation8Rork Max235

✦ Premium Article

There's a task that catches up with every app developer eventually: responding to user reviews on the App Store and Google Play.

From "love this app, using it every day!" to "crashes constantly, can't use it," keeping up with thoughtful replies is a time cost that's easy to underestimate. When I tracked it carefully across multiple apps, I was spending an average of 32 minutes per day on review monitoring and responses — about 16 hours a month. Not nothing.

I've been running iOS and Android apps as an indie developer since 2014, and out of every operational chore in that workflow, review responses were the last holdout — the one task that resisted automation longest. Pasting a generic template tanks the rating; ignoring reviews tanks first-impression conversion. So I built a pipeline that drafts replies with AI and lets me approve them with a single tap. Below is the full architecture, plus the Rork-built mobile dashboard and Cloudflare Workers backend code I run in production.

"Won't the responses feel robotic?" is the natural concern. In practice, Gemini generates responses that feel like a real developer wrote them, understanding your app's context and adjusting tone by territory. The first time you see it in action, the quality is genuinely surprising.

Why Review Responses Actually Move Your Ratings

I'm not going to justify this with someone else's industry statistics. Here's only what I can observe from my own apps.

On both the App Store and Google Play, the reviewer gets notified when a developer replies, and they can edit their review afterward. So a reply is two things at once: copy that prospective users will read, and a second chance for the person who left one star to reconsider.

Out of all the one- and two-star reviews I've replied to, the ones that got revised upward are a minority. But that minority has been almost entirely replies that were clearly not templated. The "someone actually listened" experience is what travels — not the fact that a reply exists. Get that backwards and automation makes things worse, not better.

The reasons responses pile up unaddressed are equally real though. Running multiple apps means just checking all the platforms takes time. Writing variations of the same bug acknowledgment for the hundredth time is tedious. Non-English reviews (Korean, German, Arabic) require translation effort. And emotionally charged one-star reviews take mental energy to engage with constructively.

The solution here is a hybrid model: AI drafts the response, you approve it. Full automation is tempting but wrong — a final human review means nothing problematic goes out. High-confidence responses (clear, unambiguous cases) get sent automatically; uncertain ones come to you.

App Store Connect API Setup

Generating Your API Key

Head to https://appstoreconnect.apple.com/access/integrations/api and create a new Team Key. A critical detail: the .p8 private key file can only be downloaded once at creation time. Store it securely immediately.

You'll need three values:

  • Issuer ID: The UUID shown at the top of the API keys page
  • Key ID: The 10-character alphanumeric string shown after key creation
  • Private Key: The contents of the downloaded .p8 file

Implementing JWT Authentication

App Store Connect API uses JWT authentication with the ES256 algorithm. Here's the Cloudflare Workers implementation:

// src/appstore/auth.ts
// JWT token generation for App Store Connect API
 
interface AppStoreConnectConfig {
  issuerId: string;
  keyId: string;
  privateKey: string; // .p8 file content, header/footer lines stripped
}
 
async function generateAppStoreToken(
  config: AppStoreConnectConfig
): Promise<string> {
  const header = {
    alg: "ES256",
    kid: config.keyId,
    typ: "JWT",
  };
 
  const now = Math.floor(Date.now() / 1000);
  const payload = {
    iss: config.issuerId,
    iat: now,
    exp: now + 19 * 60, // 19 min — stays safely under the 20-minute maximum
    aud: "appstoreconnect-v1",
  };
 
  const encodeBase64Url = (obj: object): string =>
    btoa(JSON.stringify(obj))
      .replace(/\+/g, "-")
      .replace(/\//g, "_")
      .replace(/=/g, "");
 
  const headerEncoded = encodeBase64Url(header);
  const payloadEncoded = encodeBase64Url(payload);
  const signingInput = `${headerEncoded}.${payloadEncoded}`;
 
  // ES256 signing via Cloudflare Workers WebCrypto API
  const pemKey = `-----BEGIN PRIVATE KEY-----\n${config.privateKey}\n-----END PRIVATE KEY-----`;
  const keyBuffer = pemToBuffer(pemKey);
 
  const cryptoKey = await crypto.subtle.importKey(
    "pkcs8",
    keyBuffer,
    { name: "ECDSA", namedCurve: "P-256" },
    false,
    ["sign"]
  );
 
  const encoder = new TextEncoder();
  const signature = await crypto.subtle.sign(
    { name: "ECDSA", hash: "SHA-256" },
    cryptoKey,
    encoder.encode(signingInput)
  );
 
  const signatureEncoded = btoa(
    String.fromCharCode(...new Uint8Array(signature))
  )
    .replace(/\+/g, "-")
    .replace(/\//g, "_")
    .replace(/=/g, "");
 
  return `${signingInput}.${signatureEncoded}`;
}
 
function pemToBuffer(pem: string): ArrayBuffer {
  const base64 = pem
    .replace(/-----BEGIN PRIVATE KEY-----/, "")
    .replace(/-----END PRIVATE KEY-----/, "")
    .replace(/\s/g, "");
 
  const binary = atob(base64);
  const buffer = new ArrayBuffer(binary.length);
  const view = new Uint8Array(buffer);
  for (let i = 0; i < binary.length; i++) {
    view[i] = binary.charCodeAt(i);
  }
  return buffer;
}
 
export { generateAppStoreToken };

Fetching Reviews

// src/appstore/reviews.ts
 
interface Review {
  id: string;
  rating: number;
  title: string;
  body: string;
  reviewerNickname: string;
  territory: string;
  createdDate: string;
}
 
async function fetchReviews(
  appId: string,
  token: string,
  limit = 20
): Promise<Review[]> {
  const url = new URL(
    `https://api.appstoreconnect.apple.com/v1/apps/${appId}/customerReviews`
  );
  url.searchParams.set("limit", String(limit));
  url.searchParams.set("sort", "-createdDate");
  url.searchParams.set("filter[rating]", "1,2,3"); // Low ratings get priority
 
  const response = await fetch(url.toString(), {
    headers: {
      Authorization: `Bearer ${token}`,
      "Content-Type": "application/json",
    },
  });
 
  if (!response.ok) {
    const error = await response.text();
    throw new Error(`App Store Connect API error: ${response.status} - ${error}`);
  }
 
  const data = (await response.json()) as {
    data: Array<{
      id: string;
      attributes: {
        rating: number;
        title: string | null;
        body: string;
        reviewerNickname: string;
        territory: string;
        createdDate: string;
      };
    }>;
  };
 
  return data.data.map((item) => ({
    id: item.id,
    rating: item.attributes.rating,
    title: item.attributes.title ?? "",
    body: item.attributes.body,
    reviewerNickname: item.attributes.reviewerNickname,
    territory: item.attributes.territory,
    createdDate: item.attributes.createdDate,
  }));
}
 
export { fetchReviews };
✦

Thank you for reading this far.

Continue Reading

What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.

WHAT YOU'LL LEARN
✦Architecture and full implementation of an AI-drafts / human-approves review pipeline handling 1,200-1,800 new reviews a month across a portfolio of iOS and Android apps
✦Crashlytics integration pattern that auto-branches reply tone based on whether a crash is newly detected, fixed-pending-release, or already shipped
✦Confidence-threshold tuning, JWT clock-skew workarounds, and Google Play quota realities — the kind of judgment data you can't get from official docs
Secure payment via Stripe · Cancel anytime
✦

Unlock This Article

Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.

or
Unlock all articles with Membership →
Share

Thank You for Reading

Rork Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • ✦Copy-paste ready implementation code
  • ✦New advanced guides published daily
  • ✦$5/mo or $15 for lifetime access
View Membership →

Related Articles

⟐ Business2026-10-01
Multiseat Purchases Are Already On: Decide Whether to Keep Them Before October 22
Multiseat purchases for subscriptions have been on by default in App Store Connect since September 16. Before Volume Purchasing starts on October 22, here is how an indie developer can decide, in four questions, whether to keep them.
⟐ Business2026-09-22
A successful payment never tells you what to unlock. Routing four products through one Checkout
Tips, single articles, monthly plans and lifetime access all came back from Stripe looking identical. Here is how I moved entitlement off the amount and onto metadata, and why I now fail closed when the entitlement store goes quiet.
⟐ Business2026-05-15
Rebuilding a Live Wallpaper App with Rork Max: Measured Hours and Post-Relaunch Metrics
I rebuilt a wallpaper app that was already live using Rork Max, timing every stage. Measured hours per phase, the spec details the generated code got wrong, how existing subscribers were migrated safely, and 30-day post-relaunch metrics.
📚RECOMMENDED BOOKS
Build a Large Language Model (From Scratch)
Sebastian Raschka
LLM Dev
Prompt Engineering for LLMs
Berryman & Ziegler
Prompting
AI Engineering
Chip Huyen
AI Eng
* Contains affiliate links