shopify-billing
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; // null = unlimited
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";
// Generate billing config from PLANS
// Format compatible with @shopify/shopify-app-remix v3.8+ billing.request()
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, // Free trial for all paid plans
// lineItems can be overridden during billing.request() for coupons/discounts
lineItems: [
{
interval: plan.interval,
amount: plan.price,
currencyCode: plan.currency,
},
],
};
}
});
const shopify = shopifyApp({
// ... other config
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';
// Coupon configuration
const COUPONS = {
FIRST50: {
discountPercentage: 0.5, // 50% off
durationLimitInIntervals: 1, // 1 billing cycle
oneTimeUse: true,
},
WELCOME30: {
discountPercentage: 0.3, // 30% off
durationLimitInIntervals: 2, // 2 billing cycles
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();
// Validate plan
const planConfig = PLANS[plan];
if (!planConfig || planConfig.id === 'Free') {
return { status: 0 };
}
// Check coupon usage
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;
}
// Build billing request
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}` : ''}`,
// Use STANDARD for smart replacement behavior
// - Immediate for upgrades
// - Deferred for downgrades
replacementBehavior: 'STANDARD',
};
// Apply coupon discount via lineItems override
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);
// Log activity
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 {
// Verify subscription is active
const billingCheck = await billing.check({
plans: [plan],
isTest: process.env.NODE_ENV !== 'production',
});
if (billingCheck.hasActivePayment) {
// Update shop's plan in database
await shopService.updateApp(session.shop, APP_ID, {
plan: plan,
accessToken: session.accessToken,
});
// Record coupon usage
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",
});
// Log subscription ID for tracking
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];
// Access subscription details
}
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 () => {
// Redirect to billing page or show upgrade prompt
return redirect('/app/billing?prompt=upgrade');
},
});
// If we reach here, shop has active subscription
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, // Issue prorated credit for unused portion
});
// Update shop to free plan
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);
// Get current usage and plan
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 });
};
// In your component:
// - Display usage progress bars
// - Show plan comparison with monthly/annual toggle
// - Include coupon input field
// - Handle upgrade/downgrade actions
Subscription URL Pattern:
// Build subscription URL with coupon
const subscriptionUrl = couponCode
? `/billing/subscription?plan=${planConfig.id}&coupon=${encodeURIComponent(couponCode)}`
: `/billing/subscription?plan=${planConfig.id}`;
// Button links to subscription URL
<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, // Plan ID from billingConfig
isTest: boolean, // Enable test mode
returnUrl: string, // Merchant redirect after approval
replacementBehavior?: 'STANDARD' | 'APPLY_IMMEDIATELY' | 'APPLY_ON_NEXT_BILLING_CYCLE',
lineItems?: Array<{ // Optional: override for discounts
interval: BillingInterval,
discount?: {
durationLimitInIntervals: number,
value: {
percentage: number, // 0.5 = 50%
amount?: { amount: number, currencyCode: string }
}
}
}>
});
billing.check(options)
Checks if shop has active subscription (non-blocking).
const result = await billing.check({
plans: string[], // Plan IDs to check
isTest: boolean,
});
// Returns: { hasActivePayment: boolean, appSubscriptions: [...] }
billing.require(options)
Checks subscription and throws if not active (blocking).
const result = await billing.require({
plans: string[],
isTest: boolean,
onFailure: async () => {
// Called when no active subscription
// Return redirect or throw error
}
});
billing.cancel(options)
Cancels an active subscription.
await billing.cancel({
subscriptionId: string,
isTest: boolean,
prorate: boolean, // Credit merchant for unused time
});
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:
// For plan upgrades/downgrades
billingOptions.replacementBehavior = 'STANDARD';
// For immediate plan changes (e.g., merchant request)
billingOptions.replacementBehavior = 'APPLY_IMMEDIATELY';
// To avoid mid-cycle charges
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
// app/routes/webhooks.appSubscriptionsUpdate.tsx
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") {
// Handle cancellation - revoke access
await shopService.updateApp(shop, APP_ID, { plan: "free" });
} else if (subscription.status === "ACTIVE") {
// Grant access to features
console.log(`Subscription activated for shop ${shop}`);
}
}
return new Response(JSON.stringify({ success: true }));
};
11. Best Practices
Test Mode
// Always use test mode in development
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
replacementBehaviorto control timing - Handle
APP_SUBSCRIPTIONS_UPDATEwebhook for cancellations
Proration
// Always prorate when cancelling for better UX
await billing.cancel({
subscriptionId: subscription.id,
prorate: true, // Issue credit for unused time
});
Error Handling
- Always check
userErrorsin 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
// /billing/subscription?plan=Pro&coupon=WELCOME30
// → 30% off for 2 billing cycles
Switch Billing Cycle
// From Monthly to Annual
billing.request({
plan: "ProAnnual",
replacementBehavior: 'STANDARD', // Shopify will handle timing
});
Downgrade to Free
// Cancel paid subscription
await billing.cancel({
subscriptionId: subscription.id,
prorate: true,
});
// Then update shop to free plan
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.usedCouponsarray - Plan Storage:
shops.app[].planfield - VIP Status: Separate system for gifted plans