Skip to content

iosdevmax/ppp-pricing

Repository files navigation

ppp-pricing

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

Why bother with PPP pricing

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×.


What this script does

End-to-end, when you run --apply:

  1. Computes target prices. For each country in ppp_tiers.json, multiplies your base_usd by the country's coefficient.
  2. Snapshots current state to a timestamped JSON file (so you can --restore later).
  3. 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 subscriptionPrices endpoint. Handles Apple's commitment-band rejections with a binary-search recovery loop.
  4. For Android: calls Google's convertRegionPrices to get the canonical (currency, amount) per region, scales by your PPP coefficient, charm-rounds, and patches the subscription's regionalConfigs in one PATCH call.

Dry-run is the default. --apply is the only flag that mutates store state.


Setup

1. App Store Connect API key

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 .p8 private 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)

2. Google Play service account

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.

3. Tell the script where they live

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.

4. Clone and install

git clone https://github.com/iosdevmax/ppp-pricing.git
cd ppp-pricing
pip install -r requirements.txt

Python 3.10+ recommended (the script uses dict[str, ...] and X | Y type syntax).


Quickstart

# 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.json

A 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.


Using with Claude Code

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 a pricing.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.

Install (project-local)

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/

Install (global, available in every project)

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-pricing

Run those from inside this repo's checkout. Symlinks mean you pick up updates automatically when you git pull.

Usage

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.


Example output

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.


Architecture & design notes

iOS: bulk-fetch with currency derivation

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:

  1. Territories sharing a currency (e.g. AT and DE both EUR — same prices, just different t field in the encoded ID).
  2. Subscriptions in the same group (your weekly and yearly products see the same EUR price tiers — just different s field).

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 t field in each price-point ID locally.
  • For sibling subscriptions, substitutes the s field 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.

iOS: binary-search recovery for commitment bands

Apple validates yearly prices against a hidden ~8×–12× ratio of the weekly price (more on this below). The picker:

  1. Picks the highest price point whose USD-equivalent is ≤ target (slight discount, lowers Apple's yearly floor).
  2. On rejection, moves the bracket window (lo/hi) based on the error message and tries the midpoint.
  3. Up to 30 attempts — converges in 4–5 in practice.

Android: convertRegionPrices does the FX work

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).

Snapshot/restore

  • iOS snapshot records (territory, price_point_id) pairs. Restore re-POSTs each one.
  • Android snapshot records the full regionalConfigs array. Restore patches it back.
  • Snapshot directory is scoped by bundle/package id, so multiple apps don't collide.

Apple's Subscription Pricing Quirks

These cost real time to learn the hard way. Documenting them so you don't have to.

The "Monthly with 12-Month Commitment" trap

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.

INVALID_PRICE_TOO_HIGH actually means "below allowed band"

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.

Commitment band is enforced server-side, undocumented

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:

  1. 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.
  2. 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.
  3. 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.


Google Play Pricing Quirks

Less painful than Apple, but a few things to know:

Use convertRegionPrices, don't roll your own FX

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.

Integer-only currencies: auto-detect, don't hardcode

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.

regionsVersion matters

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.

regionalConfigs is a wholesale replace

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.


Configuration reference

pricing.json

{
  "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.

ppp_tiers.json

{
  "_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.

Environment variables

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

CLI reference

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

Limitations

  • 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 subscriptionPricePoints directly or RevenueCat's experimentation features.

License

MIT — see LICENSE.

About

No description, website, or topics provided.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages