Shopify app notes · 16

Charging for Your App

How public apps charge merchants today with Shopify App Pricing — setting up plans, sending merchants to choose one, and checking who has paid without ever locking out a paying customer.

Shopify App Pricing Partner API

Do you need this at all?

custom apps

Probably not

Built for one client? You can invoice them directly, however you normally bill. Shopify's billing is not required, and there is no revenue share.

public apps

Yes — required

Every charge from an App Store app must go through Shopify's billing system. Merchants pay on their normal Shopify bill, and Shopify pays you.


Two systems — use the new one

Shopify App PricingManual Pricing (Billing API)
Plans definedIn the dashboardIn your code
Plan pageHosted by ShopifyYou build it
Trials, proration, test chargesHandled for youYou write them
Use forNew public apps — the defaultExisting integrations, one-time purchases, unsupported models
Trap — most tutorials teach the old way

Many guides — and the Shopify template's own examples — show billing.request(), appSubscriptionCreate and a billing: block in shopifyApp(). That is Manual Pricing.

For a new public app, do not write that code. With Shopify App Pricing, plans live in the dashboard and Shopify creates the subscription when a merchant approves a plan. Your app only checks the result.

You may also see the old name "Managed Pricing" — it was renamed to Shopify App Pricing.

Shopify App Pricing does not support one-time purchases. If you need a single upfront charge, that is one of the cases for Manual Pricing.


How it works

merchant opens app → you check for a plan → none? send to Shopify's plan page
merchant approves → Shopify charges → back to your welcome link → you verify, then unlock

Your code has three jobs: check whether a merchant has a plan, redirect them if not, and verify after they return. Everything about money is Shopify's.


1

Set up your plans

This is in the Partner Dashboard

Pricing lives with your App Store listing in the Partner Dashboard — even if your organization has moved app configuration to the Dev Dashboard. If you cannot find it in the Dev Dashboard, that is why.

Turn it on: Partner Dashboard → App distribution → All apps → your app → Distribution → Shopify App Store listing → Manage listing → under Published languages, Edit → Pricing content → Manage → Settings → Pricing method → Shopify App Pricing → Switch.

Add a plan: from the Pricing page, under Public plans, click Add. For each plan choose:

  • Billing — free, monthly, yearly, or monthly with a yearly option.
  • Price.
  • Free trial — in days, optional.
  • Welcome link — where merchants land after approving, such as /welcome.
  • Display name and top features — for each language your listing supports. A plan only shows to merchants whose language has a description.

You can have up to eight public plans, plus private plans for specific merchants.

2

Get Partner API access

Subscription status comes from the Partner API — a different API from the Admin API you have used so far, with its own credentials.

  1. Create a Partner API client with the Manage apps permission.
  2. Add these to your .env (and the server's):
SHOPIFY_PARTNER_ORG_ID=1234567                 # in your Partner Dashboard URL
SHOPIFY_PARTNER_API_ACCESS_TOKEN=prtapi_...    # secret
SHOPIFY_APP_GID=gid://shopify/App/1234         # your app's id
Public apps only

The subscription lookup only works for public apps. Custom apps cannot use it — another reason custom apps bill clients directly.

3

Write the subscription check

app/partner-api.server.js
export async function fetchActiveSubscription(shopId) {
  const res = await fetch(
    `https://partners.shopify.com/${process.env.SHOPIFY_PARTNER_ORG_ID}/api/2026-07/graphql.json`,
    {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        "X-Shopify-Access-Token": process.env.SHOPIFY_PARTNER_API_ACCESS_TOKEN,
      },
      body: JSON.stringify({
        query: `query ($appId: ID!, $shopId: ID!) {
          activeSubscription(appId: $appId, shopId: $shopId) {
            billingPeriod
            trialEndsAt
            items { handle }
          }
        }`,
        variables: { appId: process.env.SHOPIFY_APP_GID, shopId },
      }),
    },
  );

  const { data, errors } = await res.json();

  // THROW on failure — never treat an error as "no subscription".
  if (!res.ok || errors) {
    throw new Error(`Partner API failed: ${JSON.stringify(errors ?? res.status)}`);
  }

  // null means genuinely no plan
  return data.activeSubscription;
}

Update the version in the URL to the current Partner API version when you build this.

Trap — locking out a paying merchant

If the check fails — Shopify is throttling you, the network blips — and your code treats that as "no subscription", you redirect a paying merchant to the pricing page as if they had never paid. That is exactly the kind of bug that produces angry reviews.

Rule: an error is not the same as "unpaid". Only a successful response of null means no plan. That is why the function throws instead of returning null on failure.

4

Gate the app and redirect

Put the check in your admin layout, so every page is covered:

app/routes/app.jsx
import { fetchActiveSubscription } from "../partner-api.server";

export const loader = async ({ request }) => {
  // Note: redirect comes from authenticate.admin, NOT from react-router
  const { admin, redirect, session } = await authenticate.admin(request);

  const appHandle   = "your-app-handle";    // the "handle" in shopify.app.toml
  const storeHandle = session.shop.replace(".myshopify.com", "");

  // The Partner API wants the shop's GID, not its domain
  const shopRes = await admin.graphql(`{ shop { id } }`);
  const { data: { shop } } = await shopRes.json();

  const subscription = await fetchActiveSubscription(shop.id);

  if (!subscription) {
    return redirect(
      `https://admin.shopify.com/store/${storeHandle}/charges/${appHandle}/pricing_plans`,
      { target: "_top" },
    );
  }

  return { apiKey: process.env.SHOPIFY_API_KEY };
};
Trap — the redirect that goes nowhere

Your app runs inside an iframe in the Shopify admin, and the pricing page is outside it. React Router's normal redirect changes the iframe, not the page — so the merchant sees nothing happen, or a broken frame.

Use the redirect returned by authenticate.admin, with target: "_top", which can move the whole browser window.

Mind the rate limit

The Partner API allows about four requests per second. Checking on every page view for every merchant will hit it. Cache a confirmed subscription per shop for a few minutes — five is sensible. A merchant who just approved is still checked immediately, and a cancellation is noticed when the cache expires.

5

The welcome link

After approving, the merchant returns to the welcome link you configured, with a plan_handle parameter telling you which plan they chose:

/welcome?plan_handle=pro

Use it to show onboarding for that plan — but verify with fetchActiveSubscription before unlocking paid features. A URL parameter is something anyone can type.

6

Test without paying

  • A dev store in the same organization as your app can select any plan at no charge. Shopify creates the subscription with effective prices of $0.
  • There is a $0 private test plan for testing before any public plan is published.
  • To let other developers test a paid plan free, tick Free for partners and developers on that plan. Real stores are still charged the real price.

Test the full loop: no plan → redirected → approve → land on welcome link → app unlocked. Then cancel the plan and confirm the app sends you back.


Charging for usage

If you charge per use — per SMS sent, per order processed — configure usage pricing on the plan, then report each billable event to Shopify through the App Events API. Shopify totals them and adds them to the merchant's bill.

Report events from the server as they happen, and make the reporting safe to retry. The same idempotency rules as webhooks apply (Note 10) — reporting one event twice means charging twice.


Moving an existing app over

Apps already using the Billing API can switch. While both systems overlap, check both: the Partner API returns only Shopify App Pricing contracts, so a merchant on an old Billing API subscription would look unpaid.

Until existing subscriptions are migrated, also check billing.check() — which reports hasActivePayment for old Billing API subscriptions and one-time purchases — before redirecting anyone.


Common problems

The redirect does nothing

You used React Router's redirect. Use the one from authenticate.admin with target: "_top".

Paying merchants get sent to the pricing page

Almost always a failed Partner API call being treated as "no plan" — often throttling. Make the check throw on errors, and cache confirmed subscriptions.

activeSubscription always returns null
  • The app is a custom app — the lookup only works for public apps.
  • SHOPIFY_APP_GID is wrong, or you passed the shop domain instead of its GID.
  • The merchant is on an old Billing API subscription that has not been migrated.
My plan does not appear on the pricing page

It needs a display name and features for the merchant's language. Check each published language has a description.

I cannot find the pricing settings

They are in the Partner Dashboard, under your App Store listing — not in the Dev Dashboard.


Checklist

  • Shopify App Pricing enabled, with plans that have names and features in every listing language.
  • No billing.request or appSubscriptionCreate in a new public app.
  • Partner API credentials in every environment's .env.
  • The check throws on errors; only a successful null means unpaid.
  • Confirmed subscriptions cached for a few minutes.
  • Redirect uses authenticate.admin's redirect with target: "_top".
  • Paid features re-verified after the welcome link, not trusted from the URL.
  • The full approve and cancel loop tested on a dev store.

Cheat sheet

# which system
new public app           Shopify App Pricing (plans in the dashboard)
custom app               invoice the client directly
one-time charge          Manual Pricing (Billing API)

# where plans live
Partner Dashboard → app → Distribution → listing → Pricing content

# your code's three jobs
check     activeSubscription(appId, shopId)   Partner API
redirect  …/store/{store}/charges/{app}/pricing_plans   target "_top"
verify    after the welcome link returns

# rules
error ≠ unpaid          throw, don't return null
cache confirmed plans   ~5 min (4 req/sec limit)
redirect from authenticate.admin, not react-router