Loading...
Loading...
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.
npx skill4agent add toilahuongg/shopify-agents-kit shopify-billing[!IMPORTANT] GraphQL Only: The REST Billing API is deprecated. Always use the GraphQL Admin API for billing operations.
@shopify/shopify-app-remixapp/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 cancellationapp/config/plans.tsimport { 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",
},
};shopify.server.tsimport { 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,
});billing.subscription.tsximport 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;
};billing.subscription-confirm.tsxexport 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');
}
};billing.check()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 };
};billing.require()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 };
};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;
}
}
};app.billing.tsxexport 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// 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>billing.request(options)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)const result = await billing.check({
plans: string[], // Plan IDs to check
isTest: boolean,
});
// Returns: { hasActivePayment: boolean, appSubscriptions: [...] }billing.require(options)const result = await billing.require({
plans: string[],
isTest: boolean,
onFailure: async () => {
// Called when no active subscription
// Return redirect or throw error
}
});billing.cancel(options)await billing.cancel({
subscriptionId: string,
isTest: boolean,
prorate: boolean, // Credit merchant for unused time
});| Behavior | Description |
|---|---|
| Smart default: immediate for upgrades, deferred for downgrades |
| Cancel current subscription immediately |
| Wait until current cycle ends |
// 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';| Webhook Topic | Description |
|---|---|
| Subscription status changes (activated, cancelled, etc.) |
| One-time purchase status changes |
| Usage reaches 90% of cap |
// 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 }));
};// Always use test mode in development
isTest: process.env.NODE_ENV !== 'production' || session.shop === process.env.SHOP_ADMINreturnUrlreplacementBehaviorAPP_SUBSCRIPTIONS_UPDATE// Always prorate when cancelling for better UX
await billing.cancel({
subscriptionId: subscription.id,
prorate: true, // Issue credit for unused time
});userErrors// /billing/subscription?plan=Pro&coupon=WELCOME30
// → 30% off for 2 billing cycles// From Monthly to Annual
billing.request({
plan: "ProAnnual",
replacementBehavior: 'STANDARD', // Shopify will handle timing
});// Cancel paid subscription
await billing.cancel({
subscriptionId: subscription.id,
prorate: true,
});
// Then update shop to free planSettings.usedCouponsshops.app[].plan