gplay monetization (subscriptions + one-time products)
gplay subscriptions and gplay iap own the monetization catalog as
declarative, version-controlled files (ADR-0041). Shared conventions (auth,
--package pinning, output, exit codes, --dry-run/--confirm) are in
gplay-cli-usage. Both namespaces are [experimental].
| Namespace | What it holds | Default --dir |
|---|---|---|
subscriptions |
subscriptions, their base plans (config + per-territory prices), their offers, lifecycle state | ./monetization/subscriptions |
iap |
one-time products, v2 monetization.onetimeproducts ∪ legacy inappproducts |
./monetization/iap |
Both sit outside the Edit lifecycle (like compliance, device-tiers,
recovery, orders), direct package-scoped writes, no editId, so
gplay edits begin does not batch them.
The loop
pull → edit the .json files → apply --dry-run → apply [--confirm]
One <productId>.json per product, holding the API resource verbatim
minus server-derived noise. pull then apply with no edits is a guaranteed
no-op. Commit the directory; the diff in review is the catalog change.
Mirror semantics: the trap if you know metadata
metadata apply is additive: a locale live online but absent on disk is
left alone. Monetization is the opposite. The directory is the complete
declared catalog, a live subscription, product or offer with no file is a
delete in the plan. A monetization catalog is a closed set whose omissions
must be visible; a store listing tree is a partial view.
Consequences to internalize before running anything:
- Never point
--dirat a fresh/empty directory.applyrefuses when the directory holds no.jsonwhile the app has live products (it would delete them all), but a partially populated directory is a legitimate plan full of deletes. Alwayspullfirst. pullis destructive locally too. It removes stale.jsonfiles so the directory mirrors Play. It refuses to erase a populated directory when the live catalog reads back empty (a mis-set--packageor a scope loss); that refusal is a signal, not an obstacle to work around. Non-.jsonfiles are never touched.- Deleting a subscription is additionally guarded server-side: Google refuses to delete one with a published base plan.
Gates
| Situation | Gate | Exit without it |
|---|---|---|
| Plan contains any delete (product or offer) | --confirm |
3 |
iap apply promotes a live legacy product to v2 |
--migrate |
3 |
subscriptions prices migrate (reprices live subscribers) |
--confirm |
3 |
| Creates, patches, state changes | none, they run directly | n/a |
CI=true never auto-confirms. GPLAY_READONLY refuses every apply and
migrate outright (exit 4, not resolvable by adding a flag).
State changes are not gated (activate/deactivate are reversible), but they are listed prominently in every plan view because they move buyer availability.
Subscriptions
gplay subscriptions pull # → ./monetization/subscriptions/*.json
# …edit the files…
gplay subscriptions apply --dry-run # ONLINE read, prints the plan, changes nothing
gplay subscriptions apply # creates/patches/state changes
gplay subscriptions apply --confirm # …when the plan also deletes
- Base plans ride the parent patch. Base plan config (billing type,
per-territory
regionalConfigsprices) is declared inline underbasePlansand patched with the subscription; the API has no create/patch on the sub-resource. Its endpoints only manage state and subscriber price migration. - Offers are embedded but real.
pullnests each offer underbasePlans[].offers, a file construct the API resource does not carry.applysplits them back out and reconciles them through the offers endpoints under the keyproductId/basePlanId/offerId. Don't expect that array in an API response. state:declares lifecycle, reconciled via:activate/:deactivate, never a patch. DeclareACTIVEorINACTIVE. Omitting the field leaves state unmanaged, the metadata stance. An unreachable transition (DRAFTfrom anything,INACTIVEfromDRAFT) is a usage error naming it.- Reconciled fields only.
listings,taxAndComplianceSettings,restrictedPaymentCountries,basePlans. TheupdateMaskis exactly the changed managed fields; nothing outside that projection drifts or diffs.archivedis not reconciled (deprecated/output-only upstream).
Prices
# Derive per-region prices from one base price, a computation, no write:
gplay subscriptions prices convert --price 4.99 --currency USD --output json
Paste the returned Money objects into a base plan's regionalConfigs, then
rehearse with apply --dry-run. --output json is the
ConvertRegionPricesResponse verbatim. "Not a write" does not mean offline:
convert calls the convertRegionPrices API (today's exchange rates), so it
needs a credential and the package axis; it just never mutates anything.
# Reprice EXISTING subscribers, money-moving, one base plan per call:
gplay subscriptions prices migrate \
--product premium --base-plan monthly \
--region FR --region DE \
--oldest 2026-01-01T00:00:00Z \
--price-increase-type opt-in \
--dry-run # offline preview, lists the gate in "requires"
gplay subscriptions prices migrate … --confirm
This is the one deliberate exception to "editing files never touches a live
purchaser." apply changes what new buyers pay; migrate changes what
existing subscribers pay. An apply diff never triggers a migration;
that separation is pinned by a test upstream, so don't expect a price edit to
propagate to current subscribers.
- Cohorts older than
--oldest(RFC-3339) migrate, scoped to the--regions you repeat. --price-increase-type opt-inrequires subscribers to accept or churn;opt-out(where Google allows it) applies automatically with notice.- No bulk migration, the batch sibling is deliberately not wrapped. One base plan per invocation.
One-time products (iap)
gplay iap pull # v2 ∪ legacy → ./monetization/iap/*.json
gplay iap apply --dry-run
gplay iap apply [--confirm] [--migrate]
pull unions the v2 and legacy surfaces by product ID, and a file's origin
is its shape; no gplay-invented marker:
| Field present | Model |
|---|---|
sku |
legacy inappproducts |
productId |
v2 onetimeproducts |
Legacy is inert: gplay never creates, edits or deletes a legacy product,
the only gesture is the one-way promotion to v2 (rewrite the file with
productId and apply with --migrate; rehearse with --dry-run first).
When a legacy file is involved (an unexpected refusal, a promotion to plan,
or the question of why pull reads two surfaces), read
iap-legacy.md.
--regions-version
create/patch require Google's regions version string. gplay pins the
current published value (2022/02) and exposes --regions-version to
override when Google publishes a new one, a flag, not a config knob, so the
pin stays visible in CI logs.
CI gate
gplay subscriptions apply --dry-run --output json # the plan, a gplay-owned shape
gplay iap apply --dry-run --output json
apply --output json emits the plan, not an API echo, a recorded
ADR-0003 exception, like metadata apply, [experimental] until it
graduates: {package, dryRun, changes[], summary{…}, requires[]}, where each
change carries op (create/patch/delete/activate/deactivate, plus
migrate on iap) and its identity. pull --output json is the API
pass-through (the merged ListSubscriptionsResponse, or the composite
{"oneTimeProducts":[…],"inappproduct":[…]}), but the files are the real
output there.
A drift check is one line, fail the job when the plan is non-empty:
gplay subscriptions apply --dry-run --output json | jq -e '.changes | length == 0'
Permissions
The Discovery snapshot ties no specific Play permission enum to the
monetization methods, so gplay's 403 hint points at the surface rather than
naming a capability: grant the service account access to the app's
monetization setup in Play Console (Users & permissions), then retry.
403 → exit 11, 404 on the package → exit 30 (verify --package or the pin).