Set PPP-adjusted subscription prices automatically across App Store Connect and Google Play. Get cheap subscriptions in low-PPP markets without doing 175 territories by hand.
One config file. Two stores. Dry-run, snapshot, apply, restore.
python set_ppp_pricing.py --config pricing.json # dry-run preview
python set_ppp_pricing.py --config pricing.json --platform ios --apply # push to App Store
python set_ppp_pricing.py --config pricing.json --platform android --apply
python set_ppp_pricing.py --restore <snapshot>.json # roll back
If you set your weekly subscription at $4.99 USD and let Apple/Google auto-convert with FX rates, your Indian users see ₹420 — about a third of the median Indian daily wage for a single week of your app. Your conversion rate in IN/BR/TR/EG/PK collapses, and you blame "those markets don't pay" when really you priced like a SoHo coffee shop.
Purchasing power parity (PPP) pricing fixes that. Instead of FX-converting, you pick a coefficient per country (India 0.30×, Brazil 0.45×, Switzerland 1.10×) and let the storefronts charge proportionally. Steam, Spotify, Netflix, and most subscription apps that operate at scale all do this. RevenueCat publishes their recommended coefficients; this repo ships a similar curated table you can edit.
The naive alternative — letting Apple/Google auto-convert your USD price at FX rates — leaves serious revenue on the table in emerging markets, where conversion lift from a properly localized price often runs 2–4×.
End-to-end, when you run --apply:
- Computes target prices. For each country in
ppp_tiers.json, multiplies yourbase_usdby the country's coefficient. - Snapshots current state to a timestamped JSON file (so you can
--restorelater). - For iOS: fetches Apple's price-point catalog (smart per-currency caching — see below), then for each territory picks the price point whose USD-equivalent is closest to (and at-or-below) the PPP target. POSTs that to App Store Connect's
subscriptionPricesendpoint. Handles Apple's commitment-band rejections with a binary-search recovery loop. - For Android: calls Google's
convertRegionPricesto get the canonical (currency, amount) per region, scales by your PPP coefficient, charm-rounds, and patches the subscription'sregionalConfigsin one PATCH call.
Dry-run is the default. --apply is the only flag that mutates store state.
In App Store Connect → Users and Access → Integrations → App Store Connect API, generate a key with the App Manager role (Admin works too). Apple's docs: Creating API Keys for App Store Connect API.
You'll get:
- A
.p8private key file (you can only download it once — save it) - A Key ID (e.g.
ABCD123456) - An Issuer ID (UUID, shown at the top of the page)
In Google Play Console → Setup → API access, link a Google Cloud project and create a service account. Grant it the Service Manager role on the play.googleapis.com androidpublisher API, and add it as a Play Console user with the Manage orders and subscriptions + View financial data permissions on the relevant app(s). Download the JSON credentials.
Google's docs: Set up API access.
Either set environment variables:
export ASC_KEY_ID=ABCD123456
export ASC_ISSUER_ID=57246542-xxxx-xxxx-xxxx-xxxxxxxxxxxx
export ASC_API_KEY_PATH=~/keys/AuthKey_ABCD123456.p8
export GOOGLE_PLAY_CREDS_PATH=~/keys/play-service-account.json…or drop a single credentials file at ~/.config/ppp-pricing/credentials.json:
{
"asc_key_id": "ABCD123456",
"asc_issuer_id": "57246542-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"asc_api_key_path": "~/keys/AuthKey_ABCD123456.p8",
"google_play_creds_path": "~/keys/play-service-account.json"
}Env vars take precedence; the file is a fallback. Both are read on startup — you only need one.
git clone https://github.com/iosdevmax/ppp-pricing.git
cd ppp-pricing
pip install -r requirements.txtPython 3.10+ recommended (the script uses dict[str, ...] and X | Y type syntax).
# 1. Copy the example config and fill in your product IDs.
cp pricing.example.json pricing.json
$EDITOR pricing.json
# 2. Dry-run — prints a per-country preview table per product.
python set_ppp_pricing.py --config pricing.json
# 3. Apply iOS first (smaller blast radius, snapshots automatically).
python set_ppp_pricing.py --config pricing.json --platform ios --apply
# 4. Then Android.
python set_ppp_pricing.py --config pricing.json --platform android --apply
# 5. Verify in App Store Connect / Play Console for 2-3 markets (US, IN, BR is a good sample).
# 6. If something looks wrong, restore.
python set_ppp_pricing.py --restore ~/.local/share/ppp-pricing/snapshots/com.example.app/ios_20260507T120000Z.jsonA snapshot is taken automatically before every --apply (use --no-snapshot to skip — not recommended). Snapshots live under ~/.local/share/ppp-pricing/snapshots/<bundle_id>/ and the script auto-detects platform from the snapshot's contents on --restore.
This repo ships with first-class Claude Code integration so you can drive the workflow with /ppp-pricing instead of remembering CLI flags:
- Slash command at
.claude/commands/ppp-pricing.md— type/ppp-pricing(optionally with a path to apricing.json) and Claude walks the dry-run → snapshot → apply → diff sequence, sequences weekly before yearly, and recognizes Apple's commitment-band rejection so it can prompt you to switch the pricing model. - Skill at
.claude/skills/ppp-pricing/SKILL.md— auto-triggers when Claude detects you're working on subscription pricing for an iOS/Android app, even without an explicit slash command.
Drop the files into a project's .claude/ directory:
mkdir -p .claude/commands .claude/skills
cp /path/to/ppp-pricing/.claude/commands/ppp-pricing.md .claude/commands/
cp -R /path/to/ppp-pricing/.claude/skills/ppp-pricing .claude/skills/Symlink (or copy) into ~/.claude/:
mkdir -p ~/.claude/commands ~/.claude/skills
ln -s "$(pwd)/.claude/commands/ppp-pricing.md" ~/.claude/commands/ppp-pricing.md
ln -s "$(pwd)/.claude/skills/ppp-pricing" ~/.claude/skills/ppp-pricingRun those from inside this repo's checkout. Symlinks mean you pick up updates automatically when you git pull.
Once installed, in any project that has a pricing.json:
/ppp-pricing
/ppp-pricing path/to/custom-pricing.json
Claude will verify your credentials, run dry-run, surface the preview table, then apply per platform with snapshots — pausing for confirmation before each mutating step.
Dry-run preview table (truncated):
=== weekly (base $5.99) ===
CC Coef USD Local
AR 0.30 $1.80 ARS 1759.00
BR 0.45 $2.70 BRL 13.99
DE 1.00 $5.99 EUR 5.99
EG 0.30 $1.80 EGP 87.00
IN 0.35 $2.10 INR 175.00
JP 0.80 $4.79 JPY 729
NO 1.10 $6.59 NOK 70.99
PK 0.30 $1.80 PKR 499.00
TR 0.45 $2.70 TRY 91.99
US 1.00 $5.99 USD 5.99
After --apply, the diff scripts show before/after:
$ python diff_ios_prices.py --config pricing.json --snapshot <snap>.json
=== weekly (com.example.app.weekly) ===
CC Cur Before After %Δ
---------------------------------------------
ARG ARS 5290.00 1759.00 -67% ▼
BRA BRL 29.99 13.99 -53% ▼
DEU EUR 5.99 5.99 +0%
EGY EGP 294.99 87.00 -71% ▼
IND INR 499.00 175.00 -65% ▼
JPN JPY 899 729 -19% ▼
NOR NOK 59.00 70.99 +20% ▲
PKR PKR 1599.00 499.00 -69% ▼
TUR TRY 299.00 91.99 -69% ▼
USA USD 5.99 5.99 +0%
changed: 152 of 175
Numbers above are illustrative — your output depends on base_usd, your tier table, and live FX.
Apple's subscriptions/{id}/pricePoints endpoint returns up to ~800 price points per territory across 175 territories — that's >100k rows per subscription if you fetch naively. But Apple's price values per (territory, tier) are identical across:
- Territories sharing a currency (e.g. AT and DE both EUR — same prices, just different
tfield in the encoded ID). - Subscriptions in the same group (your weekly and yearly products see the same EUR price tiers — just different
sfield).
The price-point IDs are base64-encoded JSON {s, t, p} (subscription, territory, tier). The script:
- Fetches one representative territory per currency per subscription.
- For sister territories, substitutes the
tfield in each price-point ID locally. - For sibling subscriptions, substitutes the
sfield locally.
Result: ~30 fetches instead of 175 × N_subs. Verified empirically that Apple accepts these synthesized IDs on POST.
Cache is on disk per-territory (~/.cache/ppp-pricing/ios/<sub>/<ISO3>.json), so a partial run resumes cheaply. The cache is universal across apps that share Apple's price-point catalog (which is all of them), so you can amortize it across multiple side-projects.
Apple validates yearly prices against a hidden ~8×–12× ratio of the weekly price (more on this below). The picker:
- Picks the highest price point whose USD-equivalent is ≤ target (slight discount, lowers Apple's yearly floor).
- On rejection, moves the bracket window (
lo/hi) based on the error message and tries the midpoint. - Up to 30 attempts — converges in 4–5 in practice.
Google's monetization.convertRegionPrices endpoint takes a USD amount and returns the canonical (currency, amount) per region using their internal FX. The script calls this once with base_usd, then scales each region by its PPP coefficient and patches.
Two practical wins:
- Per-region currency mapping is automatic. Google decides whether Albania gets ALL or USD, whether Argentina gets ARS or USD, etc. The mapping is stable per
regionsVersion(which the API returns). - Integer-only currencies are auto-detected. When Google's response has
nanos: 0, the script knows the currency takes integer amounts only (XOF, JPY, KRW, IDR, VND, …) and rounds appropriately — no hardcoded list of zero-decimal currencies to maintain.
The whole Android apply is one subscriptions.patch per product (the regionalConfigs array is replaced wholesale).
- iOS snapshot records
(territory, price_point_id)pairs. Restore re-POSTs each one. - Android snapshot records the full
regionalConfigsarray. Restore patches it back. - Snapshot directory is scoped by bundle/package id, so multiple apps don't collide.
These cost real time to learn the hard way. Documenting them so you don't have to.
When you create a yearly subscription with the "Monthly with 12-Month Commitment" pricing model, Apple silently auto-creates a parallel slot p=1 price tied to a derived monthly amount. From then on, your yearly price is locked within roughly 8× to 12× the weekly price in that territory. Try to set a deeper PPP discount — say, India yearly at $9.99 when your weekly is $4.99 — and Apple rejects with INVALID_PRICE_TOO_HIGH (yes, "too high" when it's actually too low; see below).
Fix: when creating the yearly product, choose "Annual upfront only" as the pricing model. This skips the monthly-derivation logic entirely and lets you set arbitrary yearly prices per territory. If you've already shipped with the wrong model, you may need a fresh product to escape — Apple won't let you change pricing models on a live SKU.
Apple's error names are inverted from the natural reading:
| Apple says | Reality |
|---|---|
INVALID_PRICE_TOO_HIGH / exceeds the allowed commitment |
Your price is below the floor Apple set from the weekly-vs-yearly ratio |
lower than the required minimum / TOO_LOW |
Your price is above the ceiling |
The binary-search recovery loop in apply_ios() reads both phrasings and walks in the right direction. If you're debugging by hand, just remember: error messages mean the opposite of what they say.
Apple does not document the 8×–12× ratio. It's enforced at the API level, varies subtly by territory, and changes when you adjust the weekly. The script handles this by:
- Sequencing products in config order (cheapest first), with a 60s sleep between products so the new weekly price propagates before the yearly's ratio check runs.
- Bias-under picking — choosing the highest price point ≤ target for the weekly, which lowers Apple's auto-derived yearly floor and gives the yearly more PPP headroom.
- Walk-up/walk-down recovery when a target price is rejected.
If yearly territories still report out_of_range after all of this, the fundamental fix is to add a monthly subscription product to the group. Apple's validation then walks weekly → monthly → yearly stepwise, giving each step its own ratio, which gives the yearly far wider pricing flexibility.
Less painful than Apple, but a few things to know:
Google's monetization.convertRegionPrices is the source of truth for which currency a given region takes. Some regions that have a local currency still require USD — Albania, Argentina, Belarus, Cuba, El Salvador, Ecuador, etc. The list changes. Don't try to maintain it; let convertRegionPrices decide and trust the response.
When convertRegionPrices returns {units: 1000, nanos: 0} for a region, that currency only accepts integer amounts. The script detects this from nanos: 0 rather than maintaining a list. New zero-decimal currencies (or changes to existing ones) are picked up automatically.
Every convertRegionPrices response includes a regionVersion.version (e.g. "2022/02"). When you patch subscriptions.patch, you must pass the same version (regionsVersion_version=...) — Google rejects mismatches. The script always uses the version returned by the most recent convert call, so you never set this by hand.
subscriptions.patch with updateMask=basePlans replaces the entire regionalConfigs array. Regions you don't include lose their explicit override and fall back to Google's auto-conversion of the default price. The script writes a config for every region returned by convertRegionPrices, so this isn't usually a problem — but if you've manually customized a few regions, the snapshot/restore is your safety net.
{
"ios_bundle_id": "com.example.app",
"android_package": "com.example.app",
"products": [
{
"name": "weekly",
"ios_subscription_id": "com.example.app.weekly",
"android_product_id": "weekly_subscription",
"android_base_plan_id": "weekly",
"base_usd": 5.99
}
]
}| Field | Purpose |
|---|---|
ios_bundle_id |
Your app's bundle identifier in App Store Connect |
android_package |
Your app's package name in Play Console |
products[].name |
Friendly name used in logs and --products filter |
products[].ios_subscription_id |
Product ID as registered in App Store Connect |
products[].android_product_id |
Subscription ID in Play Console |
products[].android_base_plan_id |
Base plan ID under that subscription |
products[].base_usd |
Your USD anchor price (the 1.00× tier) |
Order matters. Apple processes products in the order listed in the config, with a 60s pause between. Put your weekly/cheapest product first so its price propagates before yearly's ratio check.
{
"_default_coefficient": 0.6,
"tiers": {
"0.30": { "label": "very low", "countries": ["AR", "EG", ...] },
"0.35": { "label": "low", "countries": ["IN", "VN", ...] },
"1.00": { "label": "base", "countries": ["US", "GB", ...] },
"1.10": { "label": "high", "countries": ["CH", "NO", ...] }
}
}The keys are coefficients (multipliers on base_usd). Edit freely. Countries not listed get _default_coefficient. ISO 3166-1 alpha-2 codes.
The shipped tiers are loosely based on Steam/Spotify/RevenueCat regional pricing — not raw World Bank PPP. Raw PPP under-prices markets prone to VPN arbitrage and misses high-cost-of-living markets that aren't actually rich (e.g. Iceland). Tune for your audience.
| Var | Purpose | Default |
|---|---|---|
ASC_KEY_ID |
App Store Connect API key ID | (required for iOS) |
ASC_ISSUER_ID |
App Store Connect issuer UUID | (required for iOS) |
ASC_API_KEY_PATH |
Path to the .p8 private key |
(required for iOS) |
GOOGLE_PLAY_CREDS_PATH |
Path to the Play service account JSON | (required for Android) |
PPP_CACHE_DIR |
Where to cache iOS price points | ~/.cache/ppp-pricing |
PPP_SNAPSHOTS_DIR |
Where to write snapshots | ~/.local/share/ppp-pricing/snapshots |
python set_ppp_pricing.py [options]
--config PATH pricing.json (required unless --restore)
--platform {ios,android,both} default: both
--apply actually push (default: dry-run)
--no-snapshot skip pre-apply snapshot (not recommended)
--no-live-fx skip live FX fetch, use hardcoded table
--products NAMES comma-separated `name`s to limit the run
--restore PATH restore prices from a snapshot JSON
Diff scripts:
python diff_ios_prices.py --config pricing.json --snapshot <ios-snapshot>.json
python diff_android_prices.py --config pricing.json --snapshot <android-snapshot>.json
- Subscriptions only. This script doesn't touch in-app purchase consumables/non-consumables. Both stores have separate pricing APIs for those.
- Subscriptions must already exist. Create products in App Store Connect / Play Console first; this only sets prices.
- Apple's territory list is fixed. This script only writes prices for the territories in the PPP table. Unlisted territories keep whatever they had (or fall back to Apple's default).
- No A/B testing. The script writes one price per (product, territory). If you want to A/B test pricing, use Apple's
subscriptionPricePointsdirectly or RevenueCat's experimentation features.
MIT — see LICENSE.