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
Resources
More from toilahuongg/shopify-agents-kit
shopify-polaris-icons
Guide for using Shopify Polaris Icons in Shopify Apps. Covers icon usage patterns, accessibility, tone variants, and common icon categories for commerce applications.
19email-template-design
Design and build professional HTML email templates with inline CSS for broad email client compatibility. Use this skill when the user asks to create, design, or build email templates, newsletters, transactional emails (order confirmations, receipts, shipping notifications, password resets), marketing emails, welcome series, onboarding emails, abandoned cart emails, drip campaigns, or any HTML email layout. Covers responsive design, dark mode support, and compatibility with Gmail, Outlook (desktop + web), Apple Mail, Yahoo, and mobile clients.
18shopify-extensions
Guide for building and managing Shopify Extensions (Admin, Checkout, Theme, Post-purchase, etc.) using the latest Shopify CLI and APIs.
14shopify-api
Comprehensive guide for Shopify APIs in Remix apps. Covers Admin GraphQL/REST, Storefront API, all resources (products, orders, customers, inventory, collections, discounts, fulfillments, metafields, files), bulk operations, webhooks, resource pickers, and TypeScript patterns. Use when querying/mutating Shopify data or building integrations.
14shopify-polaris-viz
Guide for creating data visualizations in Shopify Apps using the Polaris Viz library. Use this skill when building charts, graphs, dashboards, or any data visualization components that need to integrate with the Shopify Admin aesthetic. Covers BarChart, LineChart, DonutChart, SparkLineChart, and theming.
13code-investigator
Comprehensive code investigation and audit tool. Discovers all project features, then dispatches parallel subagents to analyze issues, risks, dead code, missing functionality, and redundancies. Produces a prioritized risk report. Use this skill when the user asks to "investigate code", "audit project", "find risks", "check code quality", "analyze codebase", "what's wrong with this code", "project health check", "code review entire project", "find dead code", "find redundant code", or any request for a thorough codebase analysis.
11