| name | shopify-billing |
| description | Guide for implementing Shopify's Billing API in Remix apps using @shopify/shopify-app-remix. Covers subscriptions, one-time purchases, usage-based billing, discounts, and the project's billing implementation patterns. |
Shopify Billing Skill
The Billing API allows you to charge merchants for your app using recurring subscriptions or one-time purchases.
[!IMPORTANT]
GraphQL Only: The REST Billing API is deprecated. Always use the GraphQL Admin API for billing operations.
Billing Overview
Shopify supports three billing models:
- Time-based subscriptions - Recurring charges at set intervals (30 days or annual)
- Usage-based subscriptions - Charges based on app usage during 30-day cycles
- One-time purchases - Single charges for features or services
Project Implementation Pattern
This project uses @shopify/shopify-app-remix (v3.8+) which provides a simplified billing helper. Here's how billing is structured:
app/config/plans.ts → Plan configurations (prices, limits, features)
app/enums/BillingPlans.ts → Plan enum values
app/shopify.server.ts → Billing config generation
app/routes/billing.subscription.tsx → Initiate subscription request
app/routes/billing.subscription-confirm.tsx → Handle Shopify confirmation callback
app/routes/app.billing.tsx → Billing UI and cancellation
1. Plan Configuration (app/config/plans.ts)
Define your plans with pricing, intervals, and usage limits:
import { BillingInterval } from "@shopify/shopify-app-remix/server";
export interface PlanLimit {
taggerOperations: number | null;
bulkOperations: number | null;
cleanerOperations: number | null;
aiRuleGenerator: number | null;
}
export interface PlanConfig {
id: BillingPlans | "Free";
name: string;
price: number;
interval: BillingInterval | "Forever";
currency: string;
features: string[];
limits: PlanLimit;
isAnnual: boolean;
label?: string;
}
export const PLANS: Record<string, PlanConfig> = {
Free: {
id: "Free",
name: "Free",
price: 0,
interval: "Forever" as const,
currency: "USD",
features: ["200 Tagger tags/month", "500 Bulk Operations/month"],
limits: { taggerOperations: 200, bulkOperations: 500, ... },
isAnnual: false,
},
Basic: {
id: BillingPlans.Basic,
name: "Basic",
price: 4.99,
interval: BillingInterval.Every30Days,
currency: "USD",
features: ["2,000 Tagger tags/month", "Unlimited AI"],
limits: { taggerOperations: 2000, aiRuleGenerator: null, ... },
isAnnual: false,
},
Pro: {
id: BillingPlans.Pro,
name: "Pro",
price: 14.99,
interval: BillingInterval.Every30Days,
currency: "USD",
features: ["Everything unlimited", "Priority Support"],
limits: { taggerOperations: null, bulkOperations: null, ... },
isAnnual: false,
label: "Best Value",
},
};
2. Billing Config in shopify.server.ts
Generate billing config from PLANS for the Shopify app:
import { PLANS } from "./config/plans";
const billingConfig: any = {};
Object.values(PLANS).forEach((plan) => {
if (plan.id !== "Free" && plan.interval !== "Forever") {
billingConfig[plan.id] = {
amount: plan.price,
currencyCode: plan.currency,
interval: plan.interval,
trialDays: 7,
lineItems: [
{
interval: plan.interval,
amount: plan.price,
currencyCode: plan.currency,
},
],
};
}
});
const shopify = shopifyApp({
billing: billingConfig,
});
3. Initiate Subscription (billing.subscription.tsx)
Create a subscription request with optional coupon discounts:
import type { LoaderFunctionArgs } from '@remix-run/node';
import { redirect } from '@remix-run/node';
import { BillingInterval } from '@shopify/shopify-app-remix/server';
import { authenticate, BillingPlans } from '~/shopify.server';
import { getMyshopify } from '~/utils/get-myshopify';
const COUPONS = {
FIRST50: {
discountPercentage: 0.5,
durationLimitInIntervals: 1,
oneTimeUse: true,
},
WELCOME30: {
discountPercentage: 0.3,
durationLimitInIntervals: 2,
oneTimeUse: true,
},
} as const;
export const loader = async ({ request }: LoaderFunctionArgs) => {
const { billing, session } = await authenticate.admin(request);
const myshopify = getMyshopify(session.shop);
const url = new URL(request.url);
const plan = url.searchParams.get('plan') as BillingPlans;
const couponCode = url.searchParams.get('coupon')?.toUpperCase();
const planConfig = PLANS[plan];
if (!planConfig || planConfig.id === 'Free') {
return { status: 0 };
}
let couponApplied = false;
let appliedCoupon: typeof COUPONS[keyof typeof COUPONS] | null = null;
if (couponCode && couponCode in COUPONS) {
const coupon = COUPONS[couponCode as keyof typeof COUPONS];
if (coupon.oneTimeUse) {
const settings = await Settings.findOne({ shop: session.shop });
const alreadyUsed = settings?.usedCoupons?.some(
(c: { code: string }) => c.code === couponCode
);
if (alreadyUsed) {
return redirect(`/app/billing?error=coupon_already_used&code=${couponCode}`);
}
}
appliedCoupon = coupon;
couponApplied = true;
}
const billingOptions: Parameters<typeof billing.request>[0] = {
plan: plan,
isTest: process.env.NODE_ENV !== 'production',
returnUrl: `https://admin.shopify.com/store/${myshopify}/apps/${process.env.SHOPIFY_API_KEY}/billing/subscription-confirm?plan=${plan}${couponApplied ? `&coupon=${couponCode}` : ''}`,
replacementBehavior: 'STANDARD',
};
if (couponApplied && appliedCoupon && couponCode) {
if (planConfig.interval === BillingInterval.Every30Days ||
planConfig.interval === BillingInterval.Annual) {
billingOptions.lineItems = [
{
interval: planConfig.interval,
discount: {
durationLimitInIntervals: appliedCoupon.durationLimitInIntervals,
value: {
percentage: appliedCoupon.discountPercentage,
},
},
},
];
}
}
await billing.request(billingOptions);
await ActivityService.createLog({
shop: session.shop,
resourceType: "Billing",
resourceId: plan,
action: "Billing Request",
detail: `${plan} plan subscription requested${couponApplied && appliedCoupon ? ` with coupon ${couponCode} (${appliedCoupon.discountPercentage * 100}% off)` : ''}`,
status: "Success",
});
return null;
};
4. Confirm Subscription (billing.subscription-confirm.tsx)
Handle the callback after merchant approves the charge:
export const loader = async ({ request }: LoaderFunctionArgs) => {
const { session, billing } = await authenticate.admin(request);
const url = new URL(request.url);
const plan = url.searchParams.get('plan') as BillingPlans;
const couponCode = url.searchParams.get('coupon')?.toUpperCase();
try {
const billingCheck = await billing.check({
plans: [plan],
isTest: process.env.NODE_ENV !== 'production',
});
if (billingCheck.hasActivePayment) {
await shopService.updateApp(session.shop, APP_ID, {
plan: plan,
accessToken: session.accessToken,
});
if (couponCode) {
await Settings.findOneAndUpdate(
{ shop: session.shop },
{
$push: {
usedCoupons: {
code: couponCode,
usedAt: new Date(),
plan: plan,
},
},
},
{ upsert: true }
);
}
await ActivityService.createLog({
shop: session.shop,
resourceType: "Billing",
resourceId: plan,
action: "Billing Confirmed",
detail: `${plan} plan subscription confirmed and activated${couponCode ? ` with coupon ${couponCode}` : ''}`,
status: "Success",
});
if (billingCheck.appSubscriptions?.[0]?.id) {
console.log(`[Billing] Subscription activated: ${billingCheck.appSubscriptions[0].id} for ${session.shop}`);
}
}
return redirect('/app/billing');
} catch (error) {
console.error("Error verifying billing:", error);
return redirect('/app/billing');
}
};
5. Check Subscription Status
Using billing.check() (Non-blocking)
export const loader = async ({ request }) => {
const { billing, session } = await authenticate.admin(request);
const billingCheck = await billing.check({
plans: ["Basic", "Pro"],
isTest: true,
});
if (billingCheck.hasActivePayment) {
const subscription = billingCheck.appSubscriptions[0];
}
return { hasActivePayment: billingCheck.hasActivePayment };
};
Using billing.require() (Blocking)
export const loader = async ({ request }) => {
const { billing, session } = await authenticate.admin(request);
const billingCheck = await billing.require({
plans: ["Basic", "Pro"],
isTest: true,
onFailure: async () => {
return redirect('/app/billing?prompt=upgrade');
},
});
const subscription = billingCheck.appSubscriptions[0];
return { subscription };
};
6. Cancel Subscription
export const action = async ({ request }) => {
const { billing, session } = await authenticate.admin(request);
const formData = await request.formData();
const plan = formData.get("plan") as BillingPlans;
if (request.method === 'DELETE') {
try {
const billingCheck = await billing.require({
plans: [plan],
onFailure: async () => {
throw new Error('No plan active');
},
});
const subscription = billingCheck.appSubscriptions[0];
console.log(`[Billing] Cancelling subscription ${subscription.id} for ${session.shop}`);
await billing.cancel({
subscriptionId: subscription.id,
isTest: process.env.NODE_ENV !== 'production',
prorate: true,
});
await shopService.updateApp(session.shop, APP_ID, {
plan: "free",
});
await ActivityService.createLog({
shop: session.shop,
resourceType: "Billing",
resourceId: plan,
action: "Billing Cancelled",
detail: `${plan} plan subscription cancelled`,
status: "Success",
});
return redirect('/app/billing');
} catch (error) {
throw error;
}
}
};
7. Billing UI Example (app.billing.tsx)
Key patterns for the billing interface:
export const loader = async ({ request }) => {
const { session } = await authenticate.admin(request);
const [usage, plan, settings] = await Promise.all([
UsageService.getCurrentUsage(session.shop),
UsageService.getPlanType(session.shop),
Settings.findOne({ shop: session.shop })
]);
const planConfig = PLANS[plan] || PLANS.Free;
const limits = planConfig.limits;
return json({ usage, plan, limits });
};
Subscription URL Pattern:
const subscriptionUrl = couponCode
? `/billing/subscription?plan=${planConfig.id}&coupon=${encodeURIComponent(couponCode)}`
: `/billing/subscription?plan=${planConfig.id}`;
<Button url={subscriptionUrl}>Upgrade</Button>
8. Billing Helper API Reference
billing.request(options)
Initiates a subscription charge. Returns a confirmation URL.
await billing.request({
plan: string,
isTest: boolean,
returnUrl: string,
replacementBehavior?: 'STANDARD' | 'APPLY_IMMEDIATELY' | 'APPLY_ON_NEXT_BILLING_CYCLE',
lineItems?: Array<{
interval: BillingInterval,
discount?: {
durationLimitInIntervals: number,
value: {
percentage: number,
amount?: { amount: number, currencyCode: string }
}
}
}>
});
billing.check(options)
Checks if shop has active subscription (non-blocking).
const result = await billing.check({
plans: string[],
isTest: boolean,
});
billing.require(options)
Checks subscription and throws if not active (blocking).
const result = await billing.require({
plans: string[],
isTest: boolean,
onFailure: async () => {
}
});
billing.cancel(options)
Cancels an active subscription.
await billing.cancel({
subscriptionId: string,
isTest: boolean,
prorate: boolean,
});
9. Replacement Behaviors
Control how new subscriptions interact with existing ones:
| Behavior | Description |
|---|
STANDARD | Smart default: immediate for upgrades, deferred for downgrades |
APPLY_IMMEDIATELY | Cancel current subscription immediately |
APPLY_ON_NEXT_BILLING_CYCLE | Wait until current cycle ends |
Example:
billingOptions.replacementBehavior = 'STANDARD';
billingOptions.replacementBehavior = 'APPLY_IMMEDIATELY';
billingOptions.replacementBehavior = 'APPLY_ON_NEXT_BILLING_CYCLE';
10. Webhooks
Subscribe to these webhook topics to monitor billing events:
| Webhook Topic | Description |
|---|
APP_SUBSCRIPTIONS_UPDATE | Subscription status changes (activated, cancelled, etc.) |
APP_PURCHASES_ONE_TIME_UPDATE | One-time purchase status changes |
APP_SUBSCRIPTIONS_APPROACHING_CAPPED_AMOUNT | Usage reaches 90% of cap |
Webhook Handler Example
export const action = async ({ request }) => {
const { topic, shop, payload } = await authenticate.webhook(request);
if (topic === "APP_SUBSCRIPTIONS_UPDATE") {
const subscription = payload.appSubscription;
if (subscription.status === "CANCELLED") {
await shopService.updateApp(shop, APP_ID, { plan: "free" });
} else if (subscription.status === "ACTIVE") {
console.log(`Subscription activated for shop ${shop}`);
}
}
return new Response(JSON.stringify({ success: true }));
};
11. Best Practices
Test Mode
isTest: process.env.NODE_ENV !== 'production' || session.shop === process.env.SHOP_ADMIN
Confirmation URL
- MUST redirect merchant to confirmation URL
- Charge is NOT active until merchant approves
- After approval, merchant redirects to your
returnUrl
Coupon System
- Store used coupons in database for one-time use
- Check usage before applying discount
- Log coupon usage for analytics
Subscription Management
- An app can have only one active subscription per merchant
- Creating a new subscription replaces the existing one
- Use
replacementBehavior to control timing
- Handle
APP_SUBSCRIPTIONS_UPDATE webhook for cancellations
Proration
await billing.cancel({
subscriptionId: subscription.id,
prorate: true,
});
Error Handling
- Always check
userErrors in mutation responses
- Handle declined charges gracefully
- Provide clear messaging about billing status
- Log all billing events for debugging
12. Common Patterns
Plan Upgrade with Discount
Switch Billing Cycle
billing.request({
plan: "ProAnnual",
replacementBehavior: 'STANDARD',
});
Downgrade to Free
await billing.cancel({
subscriptionId: subscription.id,
prorate: true,
});
13. Project-Specific Notes
- Trial Days: All paid plans get 7-day free trial
- Test Mode: Automatically enabled in non-production
- Proration: Enabled for all cancellations
- Coupon Storage:
Settings.usedCoupons array
- Plan Storage:
shops.app[].plan field
- VIP Status: Separate system for gifted plans
Resources