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.
Do you need this at all?
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.
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 Pricing | Manual Pricing (Billing API) | |
|---|---|---|
| Plans defined | In the dashboard | In your code |
| Plan page | Hosted by Shopify | You build it |
| Trials, proration, test charges | Handled for you | You write them |
| Use for | New public apps — the default | Existing integrations, one-time purchases, unsupported models |
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
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.
Set up your plans
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.
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.
- Create a Partner API client with the Manage apps permission.
- 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
The subscription lookup only works for public apps. Custom apps cannot use it — another reason custom apps bill clients directly.
Write the subscription check
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.
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.
Gate the app and redirect
Put the check in your admin layout, so every page is covered:
app/routes/app.jsximport { 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 };
};
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.
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.
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.
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_GIDis 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.requestorappSubscriptionCreatein a new public app. - Partner API credentials in every environment's
.env. - The check throws on errors; only a successful
nullmeans unpaid. - Confirmed subscriptions cached for a few minutes.
- Redirect uses
authenticate.admin'sredirectwithtarget: "_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