How-to

How to Ship Cross-Platform Subscriptions Without SKU Hell

By RdyGo 9 min read cross-platform subscriptions mobile subscriptions IAP provisioning entitlements SKU management
Cover illustration for "How to Ship Cross-Platform Subscriptions Without SKU Hell"

A growing subscription codebase produces a specific kind of exhaustion. It is the feeling, three quarters after launch, of discovering that a pricing-experiment decision made in a product review now means four engineers — across iOS, Android, backend, and QA — need to touch code to ship the experiment. None of the work is hard; all of it is annoying; the line between “easy to experiment with pricing” and “easy to ship experiments” turns out to be where the team actually lives.

The cause is always the same. The code knows about SKUs, and SKUs are the wrong abstraction.

Why SKUs multiply

Every subscription store needs its own products. Apple requires a product ID per price point, per region. Google Play’s base-plan-plus-offer model looks different but multiplies the same way. Stripe’s prices are their own first-class objects. Amazon and Roku each have their own constructs.

Count the axes:

  • Plans. Monthly, annual, family, student, enterprise, founder. Six plans is not unusual.
  • Regions. Local pricing in at least the US, UK, EU, Canada, Australia, plus the six-to-twelve other regions you care about. Fifteen is conservative.
  • Variants per plan. Intro offer, winback offer, promotional pricing, grandfathered legacy pricing. Four is light.

Six plans × fifteen regions × four variants = 360 distinct product configurations. Multiply by five stores for full cross-platform coverage and you are provisioning 1,800 SKU-shaped objects.

None of this is unreasonable on its own. Each store behaves sensibly in isolation. The problem arises when client code knows about these SKUs by name.

The failure mode: SKU-centric client code

SKU-centric client code looks like this, in one form or another:

// iOS — SKU-centric (the anti-pattern)
if user.hasPurchased("pro_monthly_us_499") ||
   user.hasPurchased("pro_monthly_ca_699") ||
   user.hasPurchased("pro_monthly_uk_399") {
  unlockProFeatures()
}

Every pricing change — new region, new plan variant, new promotional SKU — requires updating every such check in the client. The iOS team schedules a release. The Android team schedules a release. By the time both binaries have rolled to users, the promotion is half-over.

The worst version of this anti-pattern parses the SKU string to infer intent:

// The dangerous case — parsing the SKU string
let sku = purchase.productId
if sku.contains("pro") && sku.contains("monthly") {
  unlockProFeatures()
}

This works until someone renames a SKU or ships a new format, and then it works almost entirely except for the edge cases that cost the most. SKU strings are not a stable interface.

The fix: entitlement-centric clients

The fix is mechanical. Client code branches on entitlements, not SKUs. An entitlement is a capability — pro_features, remove_ads, family_sharing_enabled. The mapping from SKUs to entitlements lives server-side, versioned, revisable without a client deploy.

// iOS — entitlement-centric
if user.canAccess("pro_features") {
  unlockProFeatures()
}

The check is identical in every region, for every SKU, for every variant. The server owns the mapping:

sku                           →  entitlements
pro_monthly_us_499            →  [pro_features]
pro_monthly_ca_699            →  [pro_features]
pro_monthly_uk_399            →  [pro_features]
pro_annual_us_4900            →  [pro_features, annual_bonus]
pro_plus_monthly_us_899       →  [pro_features, pro_plus_features]
pro_plus_annual_us_8900       →  [pro_features, pro_plus_features, annual_bonus]

A new region launches. The server-side map gains fifteen rows (one per SKU in the new region, each mapping to [pro_features]). The client code does not change. The existing client binaries — including the one from eighteen months ago — pick up the new behaviour because their canAccess("pro_features") check flows through the same server-side entitlement resolver.

This is the architectural point of entitlement-centric subscription platforms. Everything else is tooling around it.

What the provisioning layer looks like

The layer needs four things:

1. A product catalogue. One row per store SKU, with a pointer to one or more entitlements. The catalogue is editable by the ops team without a code deploy.

2. A webhook bridge. One handler per store, converting store-specific receipt events (purchase, renewal, refund, grace period, lapse) into normalised entitlement changes in the subscription database. This is where the real complexity lives — Apple’s server notifications, Google’s real-time developer notifications, Stripe’s events, Amazon’s RTN, Roku’s webhooks are each different.

3. An entitlement resolver. Given a user’s active purchase set, compute the active entitlements. This is a join against the product catalogue, plus merge semantics for overlapping entitlements (a pro+ purchase implies pro).

4. A client SDK that caches the resolved entitlements. Not the SKUs, not the receipts — just the entitlements, with a short TTL and a push-update path for when the server-side resolution changes mid-session.

Teams that build this themselves end up with a Postgres table for the catalogue, five webhook handlers, a resolver service, and SDKs per platform. It is a month of engineering to get right, plus ongoing maintenance as stores evolve. This is the gap that platforms like OpenRevKit are built to close — the handlers for all five stores are already written, the catalogue is a managed surface, and the client SDKs are off-the-shelf.

Walkthrough: launching a new region

Suppose the product team has decided to launch localised pricing in India. On the SKU-centric codebase, this is:

  1. Engineering provisions new SKUs in each store’s console, per plan variant.
  2. Engineering updates the client code to recognise the new SKU IDs as pro-features-granting.
  3. iOS and Android teams cut new releases.
  4. The release rolls out over 7–14 days; users on older binaries are stuck.
  5. Support handles the confused-customer tickets during rollout.

On the entitlement-centric codebase:

  1. Ops provisions the new SKUs in each store’s console.
  2. Ops adds the new SKUs to the catalogue, mapping each to pro_features.
  3. The change is live immediately. No code deploy. Old binaries work.
  4. Users in India who purchase the new SKUs get their entitlements resolved correctly by the server.
  5. Support has no confused-customer tickets.

The cost difference is typically about three engineer-days per region launch, give or take — more when the release also requires a pricing experiment that would need more binary cuts.

Walkthrough: a pricing-split experiment

Suppose the product team wants to A/B-test two price points for Pro in the US: $4.99 and $6.99. On the SKU-centric codebase:

  1. Engineering provisions two new SKUs.
  2. Engineering adds a branching path in the client to assign users to one cohort or the other based on a feature flag.
  3. Engineering updates all entitlement checks to recognise both new SKUs.
  4. Both platforms release binaries.
  5. The experiment runs against the rolled-out user base, excluding anyone on an older binary.

On the entitlement-centric codebase:

  1. Ops provisions the two SKUs.
  2. Ops adds both SKUs to the catalogue, mapping both to pro_features.
  3. The paywall A/B assigns users to one SKU or the other at purchase time; the server resolves the entitlement correctly.
  4. No code deploy. No binary cut.

The experiment ships in a day instead of a week, and the eligible user base is every installed binary, not just the post-upgrade users.

What to watch out for when migrating

A team migrating from SKU-centric to entitlement-centric runs into two specific issues:

Grandfathered legacy pricing. Users on old pricing plans need their existing entitlements preserved. The catalogue has to include all historical SKUs, not just the current ones. A migration that silently drops coverage of a legacy SKU revokes entitlements from real customers.

Refund ordering. When a refund event arrives for a prior charge, the entitlement calculation needs to know whether the subsequent renewal charge is still valid. Most teams that hand-roll this get the ordering wrong the first time, because store webhooks do not arrive strictly in purchase order. The fix is to sort by the store’s own event sequence number, not by receipt time.

Both are solvable. Both are surprise-the-first-time class bugs.

The underlying claim

SKUs are a deployment detail. Entitlements are the contract.

That is the one sentence worth taking away. The rest — webhook handlers, SDK shapes, catalogue formats — is engineering around that sentence. Every subscription codebase that has been running in production for longer than three years eventually relearns this, usually the hard way, during a regional launch or a pricing experiment. The teams that learn it up front save themselves the migration.


Related reading:

Frequently asked

Why do cross-platform subscription SKUs multiply so quickly?

Each store requires its own SKU per price point, per region, and per plan variant. Two plans across five stores and ten regions is not 2 SKUs, it is 100. A promotional price is another 100. An intro offer adds another variant per combination. The count compounds multiplicatively, and each SKU is a string your client code might branch on.

What is an entitlement provisioning layer?

A server-side component that maps store-specific SKUs to entitlements — the capabilities you want the client to branch on. The client asks 'can this user access pro features,' not 'does this user have SKU pro_monthly_4_99_US.' When pricing changes, the mapping changes on the server; the client does not.

Can I implement this without a third-party platform?

Yes. The pattern is a database table (product IDs to entitlements), a webhook handler per store, and a client SDK that reads a cached entitlement set. The complexity that makes most teams adopt a platform is keeping the webhook handlers correct across five stores' quirks — each store has receipt format, lifecycle, and refund semantics that differ in ways that are easy to get subtly wrong.

How should refunds and chargebacks flow through the entitlement model?

Refund events from a store revoke the entitlements the SKU granted; chargebacks revoke and flag. The trick is ordering — a refund that arrives after a renewal charge must only revoke the refunded charge, not the current active entitlement. Most store webhook pipelines need an explicit reconciliation step that sorts events by eventId or stripe_event_time, not receipt time.

What breaks first at scale with SKU-centric code?

Regional rollouts. The moment you ship a new pricing experiment to a single country, you have added N new SKUs (one per plan variant in that country) and the client code that checks product IDs now has to know about all N. Every region is a re-binary unless the client was designed to branch on entitlements. The other common breaking point is intro-offer expiration logic — clients that parse SKU names to infer intro status inevitably parse the wrong string when a new SKU format ships.

Referenced products

Related entries