Blog

linkedPurchaseToken: how Google Play plan changes really work

August 27, 2026 · Tierux Team

Every subscription backend eventually hits the same bug report: a subscriber upgrades from monthly to annual, and now your database shows two active entitlements for the same person — one that should have been retired, and one that replaced it. The subscriber isn't being billed twice, but your system thinks they have two active grants. This is almost always a missed linkedPurchaseToken.

Google Play does not let you "modify" a subscription in place. Every upgrade, downgrade, or resubscription is a brand-new purchase, with a brand-new purchase token. The only thing connecting it to the subscription it replaced is a field called linkedPurchaseToken on the new purchase's subscriptionsv2 resource. If your server doesn't read that field and act on it, the old token keeps granting access forever — an entitlement leak that compounds with every plan change your users make.

The token chain, concretely

Say a subscriber is on pro_monthly with purchase token A. They tap "switch to annual" in your paywall. Play processes the change and, depending on your replacement mode, either applies it immediately or defers it to the next renewal. Either way, the app receives a new purchase token, B, for pro_annual. When your server verifies B against the Android Publisher API, the response carries:

{
  "subscriptionState": "SUBSCRIPTION_STATE_ACTIVE",
  "lineItems": [{ "productId": "pro_annual", ... }],
  "linkedPurchaseToken": "A"
}

B.linkedPurchaseToken === A is Play's way of saying "this purchase supersedes that one." Your job is to look up the entitlement tied to token A and retire it — without touching the identity boundary (token A must belong to the same subscriber as token B) and without clobbering a legitimate second subscriber who happens to share a product mapping.

Tierux's entitlementService.ts does this at verify time. When a fresh verification carries a linkedPurchaseToken, it first looks up the prior purchase by the SHA-256 hash of that token (raw tokens are never stored — more on that in our token-security post). If a prior row exists and belongs to a different user, the request is rejected outright:

if (prior && prior.userId !== input.userId) {
  throw new AppError(
    'TOKEN_ALREADY_LINKED_TO_ANOTHER_USER',
    409,
    'Purchase token is already linked to another subscriber.',
  );
}

Only after that identity check does the retirement happen. The prior entitlement is superseded — but only once the new token's own verified state comes back active. On a deferred downgrade, Play reports the new token as not-yet-active until the current billing period ends, so the old (higher-tier) entitlement is deliberately left untouched until it naturally expires. Superseding it early would cut off access the subscriber already paid for.

The free-tier cap wrinkle

There's a subtlety worth calling out for anyone building this from scratch: a plan change should never trip a "new subscriber" cap check, because it isn't a new subscriber — it's an existing one changing tiers. But you don't know that until you've verified the new token and read linkedPurchaseToken off the response, which means the cap-check decision and the Play API round trip are entangled. Getting this wrong either double-counts a churn-neutral plan change against your free-tier limit, or lets a genuinely new signup slip through uncounted.

Same problem, arriving via webhook instead of the client

The client-verify path isn't the only place this shows up. A plan change can also arrive as a Real-Time Developer Notification before your app ever calls /verify again — Play pushes a SUBSCRIPTION_PURCHASED or SUBSCRIPTION_RENEWED event carrying only packageName and the new purchaseToken, with no userId at all. Your RTDN processor has to resolve that cold token back to a subscriber before it can do anything:

// No local record for this token yet — it's new. Re-query Play,
// discover linkedPurchaseToken, and carry userId/productId forward
// from the purchase it replaces.
const verification = await verifier.verifySubscriptionPurchase({
  packageName, productId: subscriptionId, purchaseToken, ...
});
if (verification.linkedPurchaseToken) {
  const priorPurchase = await store.findPurchaseByTokenHash(
    app.id, sha256(verification.linkedPurchaseToken),
  );
  // priorPurchase.userId carries forward to the new token's record
}

This is the same resolution logic as the verify path, run from the other direction: the notification only carries the new token, so the processor re-queries Play for that token's own state, reads its linkedPurchaseToken, and stitches the new purchase to the existing subscriber via the old one. If the old token was never recorded by your backend to begin with — say the plan change originated before the app was onboarded — the lookup returns nothing and the event is correctly treated as a no-op rather than a crash.

What breaks if you skip this

None of this requires exotic engineering — it requires reading one optional field on every subscription verification response, and building the retirement path to run inside the same transaction as the new grant. But it's exactly the kind of detail that's invisible until a real subscriber upgrades and your support inbox gets a "why do I have two subscriptions" ticket.

Related reading

linkedPurchaseToken glossary entry

The short reference definition, including how it differs from Apple's original-transaction lineage.

RTDN vs polling

Why plan-change notifications need to reach your server within seconds, not on next app open.

Purchase verification

How Tierux verifies both the initial purchase and every plan change against Google Play, server-side.

Stop chasing plan-change bugs

Free tier — unlimited apps, 1 paywall. No credit card, no revenue share.

Start free