smithery/toilahuongg

shopify-billing

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.

Installation

$ npx skills add smithery/toilahuongg --skill shopify-billing

Similar popular skills

Related neighbors and high-traction skills in the same topics — useful to compare before installing.

Also in this package

Other skills from smithery/toilahuongg · top by installs.

npx skills add smithery/toilahuongg

Browse all from smithery/toilahuongg

More details

Agent compatibility

Declared targets from SKILL.md / docs. Unmarked agents are not listed — the skill may still install via the CLI.

Claude Code Not declared
Cursor Not declared
Codex Not declared
GitHub Copilot Not declared
Windsurf Not declared
Gemini CLI Not declared
Cline Not declared
OpenCode Not declared

Package contents

Files included with this skill beyond the listing page.

  • skill md SKILL.md 18,763 B
  • docs SUMMARY.md 264 B

History

  1. First recorded snapshot · 0 installs

SKILL.md

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:

  1. Time-based subscriptions - Recurring charges at set intervals (30 days or annual)
  2. Usage-based subscriptions - Charges based on app usage during 30-day cycles
  3. 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
APPLYONNEXTBILLINGCYCLE 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
APPSUBSCRIPTIONSUPDATE Subscription status changes (activated, cancelled, etc.)
APPPURCHASESONETIMEUPDATE One-time purchase status changes
APPSUBSCRIPTIONSAPPROACHINGCAPPEDAMOUNT 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 replacementBehavior to control timing
  • Handle APPSUBSCRIPTIONSUPDATE 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

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.usedCoupons array
  • Plan Storage: shops.app[].plan field
  • VIP Status: Separate system for gifted plans

Resources