shopify-billing

Shopify Billing Skill

Safety Notice

This listing is imported from skills.sh public index metadata. Review upstream SKILL.md and repository scripts before running.

Copy this and send it to your AI assistant to learn

Install skill "shopify-billing" with this command: npx skills add toilahuongg/shopify-agents-kit/toilahuongg-shopify-agents-kit-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", }, };

  1. 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, });

  1. 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&#x26;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 &#x26;&#x26; appliedCoupon ? with coupon ${couponCode} (${appliedCoupon.discountPercentage * 100}% off) : ''}, status: "Success", });

return null; };

  1. 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'); } };

  1. 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 }; };

  1. 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;
}

} };

  1. 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}&#x26;coupon=${encodeURIComponent(couponCode)} : /billing/subscription?plan=${planConfig.id};

// Button links to subscription URL <Button url={subscriptionUrl}>Upgrade</Button>

  1. 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 });

  1. 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';

  1. 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 })); };

  1. 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 replacementBehavior to control timing

  • Handle APP_SUBSCRIPTIONS_UPDATE webhook 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 userErrors in mutation responses

  • Handle declined charges gracefully

  • Provide clear messaging about billing status

  • Log all billing events for debugging

  1. 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

  1. 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

  • Shopify Billing Overview

  • Time-Based Subscriptions

  • Usage-Based Subscriptions

  • One-Time Purchases

  • Subscription Discounts

  • Shopify App Remix Billing

Source Transparency

This detail page is rendered from real SKILL.md content. Trust labels are metadata-based hints, not a safety guarantee.

Related Skills

Related by shared tags or category signals.

Automation

shopify-api

No summary provided by upstream source.

Repository SourceNeeds Review
Automation

shopify-extensions

No summary provided by upstream source.

Repository SourceNeeds Review
Automation

shopify-functions

No summary provided by upstream source.

Repository SourceNeeds Review
Automation

shopify-metafields

No summary provided by upstream source.

Repository SourceNeeds Review