gpc-sdk-usage
Use @gpc-cli/api and @gpc-cli/auth as standalone TypeScript SDK packages for programmatic Google Play access.
When to use
- Building a backend service that interacts with Google Play
- Creating custom dashboards or automation scripts
- Programmatic release management from TypeScript/JavaScript
- Using the typed API client directly (not through the CLI)
- Integrating Google Play operations into a larger application
Inputs required
- Node.js 20+ and TypeScript 5+
- @gpc-cli/api and @gpc-cli/auth packages
- Service account key — JSON file or raw JSON string
Procedure
0. Install packages
npm install @gpc-cli/api @gpc-cli/auth
These are standalone packages — no need to install the full CLI.
1. Authenticate
import { resolveAuth } from "@gpc-cli/auth";
// From file path
const auth = await resolveAuth({
serviceAccountPath: "/path/to/key.json",
});
// From JSON string (e.g., from environment variable)
const auth = await resolveAuth({
serviceAccountJson: process.env.PLAY_SA_KEY,
});
// From environment (GPC_SERVICE_ACCOUNT or GOOGLE_APPLICATION_CREDENTIALS)
const auth = await resolveAuth();
Read: references/auth-patterns.md for advanced auth patterns and token caching.
2. Create API client
import { createApiClient } from "@gpc-cli/api";
const client = createApiClient({
auth,
maxRetries: 3,
timeout: 30_000,
});
The client provides typed access to all 230 Google Play Developer API endpoints across the Android Publisher v3, Play Developer Reporting v1beta1, Play Custom App Publishing v1 (v0.9.56+), and Games Configuration v1configuration (v0.9.86+) APIs.
2a. Create the Enterprise client (v0.9.56+)
For private app publishing via the Play Custom App Publishing API, use a separate factory:
import { createEnterpriseClient, type CustomApp } from "@gpc-cli/api";
const enterprise = createEnterpriseClient({ auth });
const app: CustomApp = await enterprise.apps.create(
"1234567890", // developer account ID (int64, from Play Console URL)
"./app.aab", // bundle path
{
title: "My Private App",
languageCode: "en_US",
organizations: [{ organizationId: "customer-org-id" }],
},
);
console.log("Assigned package name:", app.packageName);
// com.google.customapp.A1B2C3D4E5 (Google-assigned, you cannot influence)
Notes:
- Private apps are permanently private. Once created, they cannot be made public.
- After creation, subsequent operations (version uploads, tracks, listings) go through the regular
createApiClient()using the returnedpackageName. - Requires the "create and publish private apps" permission on your service account in Play Console.
- The underlying
HttpClient.uploadCustomApp<T>(path, filePath, metadata, contentType)method handles a multipart resumable upload where the initial session-initiation POST carries the JSON metadata. SeeResumableUploadOptions.initialMetadatafor reusing this pattern with other Google APIs.
See the gpc-enterprise skill for the CLI equivalent and full setup walkthrough.
Read: references/api-reference.md for the complete client API with all namespaces and methods.
3. Edit lifecycle
Most Google Play operations require an edit session:
const APP = "com.example.app";
// 1. Create an edit
const edit = await client.edits.insert(APP);
// 2. Make changes within the edit
const tracks = await client.tracks.list(APP, edit.id);
const details = await client.details.get(APP, edit.id);
// 3. Validate before committing
await client.edits.validate(APP, edit.id);
// 4. Commit the edit (applies all changes)
await client.edits.commit(APP, edit.id);
// Optional: commit with options (v0.9.51+)
await client.edits.commit(APP, edit.id, {
changesNotSentForReview: true,
changesInReviewBehavior: "HALT_REVIEW",
});
Important: Only one edit can be open at a time. Always commit or delete edits.
4. Common patterns
Create a custom closed testing track (v0.9.79+)
// Create a custom closed testing track before uploading to it
const track = await client.edits.tracks.create(packageName, "my-custom-track");
Custom tracks must be created before any release can be assigned to them. After creation, use the standard client.tracks.update() call to push a release to the new track.
Upload a release
const edit = await client.edits.insert(APP);
// Upload the bundle
const bundle = await client.bundles.upload(APP, edit.id, "app-release.aab");
// Upload with device tier config (v0.9.51+)
const bundle2 = await client.bundles.upload(APP, edit.id, "app-release.aab", {
deviceTierConfigId: "my-tier-config",
});
// Set the track
await client.tracks.update(APP, edit.id, "beta", {
track: "beta",
releases: [{
versionCodes: [bundle.versionCode],
status: "completed",
releaseNotes: [
{ language: "en-US", text: "Bug fixes and improvements" },
],
}],
});
// Commit
await client.edits.validate(APP, edit.id);
await client.edits.commit(APP, edit.id);
List and respond to reviews
// No edit needed for reviews
const reviews = await client.reviews.list(APP, {
maxResults: 50,
translationLanguage: "en",
startIndex: 0, // pagination offset (v0.9.51+)
});
for (const review of reviews.reviews ?? []) {
if (review.comments?.[0]?.userComment?.starRating <= 2) {
await client.reviews.reply(APP, review.reviewId, "Thanks for the feedback!");
}
}
Manage subscriptions
// No edit needed for subscriptions
const subs = await client.subscriptions.list(APP);
// Get a specific subscription
const sub = await client.subscriptions.get(APP, "premium_monthly");
// Update with mutation options (v0.9.51+)
await client.subscriptions.update(APP, "premium_monthly", data, "price", {
allowMissing: true,
latencyTolerance: "PRODUCT_UPDATE_LATENCY_TOLERANCE_LATENCY_TOLERANT",
});
// Activate a base plan
await client.subscriptions.activateBasePlan(APP, "premium_monthly", "monthly");
Verify purchases
// No edit needed for purchases
const purchase = await client.purchases.getProduct(APP, "coins_100", purchaseToken);
if (purchase.purchaseState === 0 && purchase.acknowledgementState === 0) {
await client.purchases.acknowledgeProduct(APP, "coins_100", purchaseToken);
}
Upload deobfuscation files (v0.9.51+)
// Upload ProGuard mapping
await client.deobfuscation.upload(APP, edit.id, versionCode, "mapping.txt", "proguard");
// Upload native debug symbols
await client.deobfuscation.upload(APP, edit.id, versionCode, "symbols.zip", "nativeCode");
Manage expansion files (v0.9.51+)
// Get expansion file info
const obb = await client.expansionFiles.get(APP, edit.id, versionCode, "main");
// Upload a new expansion file
const uploaded = await client.expansionFiles.upload(APP, edit.id, versionCode, "main", "main.obb");
// Patch expansion file references
await client.expansionFiles.patch(APP, edit.id, versionCode, "main", {
referencesVersion: 10,
});
List one-time products with pagination (v0.9.51+)
const products = await client.oneTimeProducts.list(APP, {
pageSize: 25,
pageToken: nextToken,
});
5. Pagination
Use the built-in pagination utilities for large result sets:
import { paginate, paginateAll } from "@gpc-cli/api";
// Async generator (stream results)
for await (const page of paginate(
(token) => client.subscriptions.list(APP, { pageToken: token }),
{ limit: 100 },
)) {
for (const sub of page) {
console.log(sub.productId);
}
}
// Collect all results
const all = await paginateAll(
(token) => client.subscriptions.list(APP, { pageToken: token }),
);
New in v0.9.83:
paginateAllnow returns a real continuation token, so--limit+--next-pagecorrectly resumes across reviews, users, purchases, IAP, and subscriptions. Every CLI list command also shares the unified{ <key>, nextPageToken, meta.count, message? }JSON envelope (extended to grants, testers, and tracks in v0.9.87). Scripts reading a bare array from list commands will break — update to destructure the keyed field.
6. Rate limiting
Since v0.9.47, createApiClient() automatically applies rate limiting to all API calls using Google's 6-bucket model (3,000 queries/min each). No manual configuration needed:
// Rate limiting is automatic — all calls are throttled by resource type
const client = createApiClient({ auth });
// Buckets: edits, purchases, reviews, reporting, monetization, default
To customize rate limits (e.g., for shared quota across multiple processes):
import { createRateLimiter, RATE_LIMIT_BUCKETS } from "@gpc-cli/api";
// Override specific buckets
const limiter = createRateLimiter([
{ ...RATE_LIMIT_BUCKETS.edits, maxTokens: 1500 }, // Half of default
{ ...RATE_LIMIT_BUCKETS.purchases, maxTokens: 1500 },
]);
const client = createApiClient({ auth, rateLimiter: limiter });
The resolveBucket(path) function maps API paths to buckets automatically:
/edits/paths →editsbucket/purchases/,/orders→purchasesbucket/reviews→reviewsbucket- Reporting API →
reportingbucket /subscriptions,/oneTimeProducts,/inappproducts→monetizationbucket- Everything else →
defaultbucket
7. Error handling
import { PlayApiError } from "@gpc-cli/api";
import { AuthError } from "@gpc-cli/auth";
try {
await client.edits.insert(APP);
} catch (error) {
if (error instanceof AuthError) {
console.error(`Auth failed: ${error.code}`);
} else if (error instanceof PlayApiError) {
console.error(`API error ${error.status}: ${error.code}`);
console.error(`Suggestion: ${error.suggestion}`);
}
}
Changelog generation (v0.9.62+)
The changelog pipeline from gpc changelog generate is exposed as standalone @gpc-cli/core exports — useful for CI tooling that wants the clustered/linted data structure directly.
import {
generateChangelog,
resolveLocales,
renderPlayStore,
PLAY_STORE_LIMIT, // 500
type LocaleBundle,
type GeneratedChangelog,
} from "@gpc-cli/core";
const generated: GeneratedChangelog = await generateChangelog({
from: "v0.9.61",
to: "HEAD",
});
// GitHub target: three renderers exposed as RENDERERS["md" | "json" | "prompt"]
// Play Store target: resolveLocales + renderPlayStore
const locales = await resolveLocales("en-US,fr-FR,de-DE");
const { output, bundle } = renderPlayStore(generated, {
locales,
format: "json",
});
for (const entry of bundle.locales) {
console.log(`${entry.language}: ${entry.chars}/${entry.limit} (${entry.status})`);
}
For --locales auto, pass { client, packageName } as the second arg to resolveLocales — it calls client.listings.list to infer the locale set from your live Play Store listing.
Apply release notes to a draft (v0.9.64+)
import {
applyReleaseNotes,
validateBundleForApply,
bundleToReleaseNotes,
waitForBundleProcessing,
} from "@gpc-cli/core";
// Convert a LocaleBundle to the API shape
const releaseNotes = bundleToReleaseNotes(bundle);
// Validate (returns blocked locale errors, if any)
const errors = validateBundleForApply(bundle);
if (errors.length > 0) throw new Error(errors.join(", "));
// Write into the latest draft on a track
await applyReleaseNotes(client, "com.example.app", "production", releaseNotes);
// waitForBundleProcessing (v0.9.64+, extended v0.9.77): polls bundles.list
// after AAB upload with Fibonacci backoff (2s, 3s, 5s, 8s, 13s, 21s, 34s ~86s)
// until the uploaded versionCode appears. Fixes large-AAB race.
// v0.9.77 also adds multi-retry guard on validate/commit (15s, 30s, 45s).
await waitForBundleProcessing(client, "com.example.app", editId, versionCode);
VitalsThresholds in config types (v0.9.82+)
VitalsThresholds is now part of the typed config surface exposed by @gpc-cli/config:
import type { GpcConfig } from "@gpc-cli/config";
const config: GpcConfig = {
vitals: {
thresholds: { crashRate: 2.0 },
},
};
VitalsThresholds is also present on ResolvedConfig (the fully merged runtime shape). Use it when building tooling that reads or writes GPC config files programmatically.
OfferPhaseDetails on Orders (v0.9.79+)
The flat offerPhase string field on Orders is deprecated. Read from offerPhaseDetails instead:
const order = await client.purchases.orders.get(APP, orderId);
// Deprecated: order.offerPhase
// Preferred:
const phase = order.offerPhaseDetails; // OfferPhaseDetails — phase type, cycle counts, pricing
download() exponential backoff (v0.9.80+)
client.download() (used for APK/AAB binary downloads) now retries automatically with exponential backoff, matching the retry behavior of request(). No code changes needed — transient 5xx errors and network timeouts are retried transparently.
Bulk reports from the Play GCS bucket (v0.9.93+)
Play's monthly bulk reports are CSV objects in a Cloud Storage bucket linked to the developer account, not a Publisher API resource. Breaking in v0.9.93: the non-functional client.reports.list was removed from @gpc-cli/api, and the old downloadReport export was removed from @gpc-cli/core. Use the three new @gpc-cli/core exports instead.
import { resolveAuth, DEFAULT_SCOPES, STORAGE_READ_ONLY_SCOPE } from "@gpc-cli/auth";
import {
listReports,
downloadStatsReport,
downloadFinancialReport,
resolveReportsBucket,
parseMonth,
type ListReportsResult,
} from "@gpc-cli/core";
// The reports path needs one extra scope. Request it only here: storage-scoped tokens are
// cached under a separate key, so other commands' tokens never carry storage access.
const auth = await resolveAuth({
serviceAccountPath: "key.json",
scopes: [...DEFAULT_SCOPES, STORAGE_READ_ONLY_SCOPE],
});
// pubsite_prod_<developerId>, or an explicit reports.bucket value
const bucket = resolveReportsBucket({ developerId: "1234567890" });
const month = parseMonth("2026-02");
const { reports, nextPageToken }: ListReportsResult = await listReports(
auth,
bucket,
"installs",
{ packageName: "com.example.app", month, maxResults: 50 },
);
// Stats: per app, one CSV per dimension (reviews reports have no dimension)
const stats = await downloadStatsReport(
auth, bucket, "com.example.app", "installs", month, "country",
);
console.log(stats.objectName, stats.csv); // gunzipped, UTF-16 decoded to UTF-8
// Financial: account-level, ZIP archives
const fin = await downloadFinancialReport(auth, bucket, "earnings", month);
if (fin.kind === "csv") {
console.log(fin.text);
} else {
for (const entry of fin.entries) console.log(entry.name, entry.csv.length);
}
auth only has to satisfy ReportsAuth ({ getAccessToken(): Promise<string> }), so any token source works. Failures throw GpcError with the REPORT_* codes (see gpc-troubleshooting); the first-run one is REPORT_ACCESS_DENIED, raised until the service account is granted "View app information and download bulk reports (read-only)" in Play Console.
API correctness history (recent)
- v0.9.57:
apprecovery.cancel/deployURLs now use plural/appRecoveries/.dataSafety.updateisPOST, notPUT. PhantomdataSafety.getwas removed.onetimeproducts.offers.activateOffer/deactivateOfferadded. NewgetVitalsErrorCountfunction. - v0.9.58 / v0.9.59: Vitals LMK metric set is
lmkRateMetricSetwith metricsuserPerceivedLmkRate,userPerceivedLmkRate7dUserWeighted,userPerceivedLmkRate28dUserWeighted,distinctUsers. (v0.9.58 shipped the wrong resource name; v0.9.59 is the corrected build.)
Verification
resolveAuth()returns a valid auth clientcreateApiClient({ auth })creates a working clientclient.edits.insert(APP)successfully opens an edit- API calls return typed responses
- Error handling catches
PlayApiErrorandAuthError
Failure modes / debugging
| Symptom | Likely Cause | Fix |
|---|---|---|
AUTH_NO_CREDENTIALS |
No auth source found | Pass serviceAccountPath or set GPC_SERVICE_ACCOUNT |
AUTH_INVALID_KEY |
Bad JSON in key file | Re-download from Google Cloud Console |
| Edit insert fails with 403 | Service account lacks API access | Enable Google Play Developer API in GCP |
| Concurrent edit conflict | Another edit is open | Commit or delete the existing edit first |
PlayApiError with status 429 |
Rate limited | Use createRateLimiter() with appropriate buckets |
| Types not resolving | Wrong TypeScript config | Ensure moduleResolution: "bundler" or "node16" |
client.reports.list is not a function |
Removed in v0.9.93 (it never worked) | Use listReports / downloadStatsReport / downloadFinancialReport from @gpc-cli/core |
REPORT_ACCESS_DENIED from a reports call |
Service account lacks the bulk-reports grant | Enable "View app information and download bulk reports (read-only)" in Play Console, then retry |
| Reports call returns 403 despite the grant | Token was minted without devstorage.read_only |
Pass scopes: [...DEFAULT_SCOPES, STORAGE_READ_ONLY_SCOPE] to resolveAuth |
Related skills
- gpc-setup — service account creation and auth configuration
- gpc-plugin-development — building plugins that use the SDK internally
- gpc-troubleshooting — interpreting API error codes