HomeGuides → Managed Pricing

Managed App Pricing: reading the merchant's plan

Managed Pricing is a genuinely good trade — Shopify hosts the plan page, collects the money, and you delete your Billing API code. What nobody tells you up front is that nothing notifies your app when the plan changes. No webhook. You have to read it.

From wiring a live app on Managed Pricing · August 2026 · by SPELL

The gap, stated plainly

Under the Billing API you owned the subscription object, so you always knew. Under Managed Pricing Shopify owns it — and no app_subscriptions/update webhook fires for a Managed Pricing change. A merchant can upgrade, downgrade or cancel and your database will happily keep serving the old entitlement until something makes you look.

The three read paths, ranked

1. The redirect parameters (cheapest, and you should persist them)

When a merchant picks a plan on Shopify's hosted page, they're sent back to your app's return URL with the plan appended:

https://your-app.example.com/?shop=store.myshopify.com&plan_handle=pro

Persist plan_handle against the shop the moment you see it. Two notes: use plan_handle, not charge_id — the latter is on its way out. And this only tells you about the transition you just witnessed, so it is a cache, never the source of truth: a merchant who cancels from Shopify's own settings never passes through your app at all.

2. The Partner API Active Subscription query (canonical)

This is the authoritative read, and the only one that reliably reflects a cancellation or downgrade the merchant did without opening your app:

{
  app(id: $appId) {
    activeSubscription(shopId: $shopId) {
      items { handle price { amount currencyCode } }
    }
  }
}

The cost is real: it needs Partner API credentials and your organization ID, and it lives outside the per-shop session you already have in a request handler. Worth it for a nightly reconcile; too heavy for every page load.

3. The Admin API installation query (convenient, undocumented for this)

{
  currentAppInstallation {
    activeSubscriptions {
      name
      status
      lineItems {
        plan { pricingDetails { ... on AppRecurringPricing { price { amount currencyCode } } } }
      }
    }
  }
}

This works today and it works from the session you already have. Be aware you're leaning on behaviour Shopify documents for the Billing API and hasn't committed to for Managed Pricing. Treat it as the fast path, with path 1 as fallback and path 2 as the reconciler.

Gate on price, not on the plan's name

The obvious implementation is name === 'Pro'. Don't. Plan names are merchant-facing strings, they get localized, and they get renamed the first time marketing touches the pricing page. The durable rule is a recurring price greater than zero means paid.

One trap that will cost you an afternoon: on a development store every plan is $0, because dev stores don't get charged. If your gate is purely price-based you will test on a dev store, see $0, and conclude your detection is broken when it is working perfectly. Detect the plan by handle or name for display, and by price for entitlement, and keep the two functions separate.

// what the merchant is actually on — drives the UI
const subscribedPro = await readPlan(admin);
// what they're allowed to do — grace keeps review and dev unblocked
const entitled = BILLING_ENFORCED ? subscribedPro : true;

Keep that BILLING_ENFORCED flag off until you have watched detection work against a real paid subscription in production. A billing gate that misfires during App Store review reads to the reviewer as a broken app.

Two things that are not what they look like

Looks likeActually
The App Events API will tell me about plan changesApp Events exist for usage meters. An app with no usage-based charges emits no events at all — so "confirm usage events are firing" as a pre-switch check is simply not applicable to a flat-rate app.
Cancelling gives me status: canceledCancelling an incomplete subscription lands on a distinct terminal state, and any webhook you do have that syncs entitlement from the subscription's price must skip dead statuses — otherwise it resurrects features on a record that was just cancelled.

The reconcile that closes the loop

  1. On every admin request, read path 3 and refresh the stored plan. Cheap, current, covers the vast majority of state changes.
  2. On the redirect back from the plan page, write plan_handle. This is what carries you when the Admin read is unavailable.
  3. On a schedule, walk your installed shops through path 2. This is the only step that catches a shop that hasn't opened your app since cancelling.
  4. When all three disagree, err toward granting access. A merchant wrongly locked out of something they paid for writes a one-star review; a merchant who keeps a paid feature for an extra day costs you nothing.

Related: the real Built for Shopify requirements, and fixing "Translation missing" in an app block.

Verified against a live subscription

Everything above is what we wired into a production app on Managed Pricing and then checked against a real paid plan — not what the documentation implies should happen.

Book a call →