← Files Shopify App BuilderARCHIVED FILE

skills/app-billing/SKILL.md

23.5 KB · Oct 3, 2026 · 06:31 UTC

↓ Download file

---
name: app-billing
description: "Implement recurring, usage-based, one-time, and hybrid Shopify app billing. Covers appSubscriptionCreate, appUsageRecordCreate, trials, capped amounts, replacement behavior, test mode, current revenue-share rules, and pricing tiers."
---

# Shopify App Billing

Shopify apps can charge merchants through Shopify's billing system. Charges are collected via the merchant's Shopify payment method and appear on their bill. Three models are supported: **recurring** (fixed monthly/annual), **usage-based** (pay-per-action), and **one-time** (one-off charges). You can also combine them (hybrid).

## Revenue Share Model

For developers eligible for Shopify's standard rates:
- You keep **100% of the first $1,000,000 USD in lifetime gross app revenue earned from January 1, 2025**.
- Above that threshold, Shopify's revenue share is **15%**, so you keep **85%** before other fees and taxes.
- All billing is separately subject to a **2.9% processing fee**, applicable sales tax, and potentially regional regulatory fees.
- Shopify applies special eligibility rules to very large developers and aggregates revenue across associated developer accounts. Verify the current policy before financial modeling.

This means your pricing directly affects what you keep:
```
App charges $10 while eligible for the 0% revenue-share tier
├─ Merchant pays: $10.00
├─ Processing fee: $0.29, before tax or regional fees
└─ Developer amount before tax/regional fees: $9.71

App charges $100 above the $1M lifetime threshold
├─ Merchant pays: $100.00
├─ Revenue share: $15.00
├─ Processing fee: $2.90, before tax or regional fees
└─ Developer amount before tax/regional fees: $82.10
```

**Planning rule:** Model revenue share, processing fees, taxes, refunds, and cost to serve separately; don't assume gross charges equal payout.

## Billing Models

### 1. Recurring (Fixed Interval)

**Use Case:** Subscription model (e.g., "Pro plan $99/month")
**Charging:** First charge immediate on approval; subsequent charges on anniversary date
**Cancellation:** Merchant can cancel anytime; charged through current period end

**appSubscriptionCreate Mutation:**
```graphql
mutation CreateRecurringSubscription {
  appSubscriptionCreate(
    input: {
      trialDays: 7
      lineItems: [
        {
          plan: {
            appRecurringPricingDetails: {
              interval: MONTHLY  # or ANNUAL
              price: { amount: "9.99", currencyCode: "USD" }
            }
          }
        }
      ]
      returnUrl: "https://your-app.com/billing/confirm"
    }
  ) {
    appSubscription {
      id
      confirmationUrl  # Merchant must visit this URL to approve
      lineItems {
        id
        plan {
          pricingDetails {
            ... on AppRecurringPricingDetails {
              interval
              price { amount currencyCode }
            }
          }
        }
      }
      status  # PENDING, ACTIVE, DECLINED, EXPIRED, FROZEN, CANCELLED
      currentPeriodEnd
      trialDays
      trialEndsOn
    }
    userErrors {
      field
      message
    }
  }
}
```

**Remix Implementation (Recurring Billing):**
```typescript
import { json, redirect } from '@shopify/remix-oxygen';
import { authenticate } from '~/shopify.server';

export const action = async ({ request }) => {
  if (request.method !== 'POST') return json({ error: 'POST only' }, { status: 405 });

  const { admin } = await authenticate.admin(request);

  const response = await admin.graphql(`
    mutation CreateRecurringSubscription($input: AppSubscriptionInput!) {
      appSubscriptionCreate(input: $input) {
        appSubscription {
          id
          confirmationUrl
          status
          currentPeriodEnd
          lineItems {
            id
            plan {
              pricingDetails {
                ... on AppRecurringPricingDetails {
                  interval
                  price { amount currencyCode }
                }
              }
            }
          }
        }
        userErrors {
          field
          message
        }
      }
    }
  `, {
    variables: {
      input: {
        trialDays: 7,
        lineItems: [
          {
            plan: {
              appRecurringPricingDetails: {
                interval: 'MONTHLY',
                price: { amount: '9.99', currencyCode: 'USD' },
              },
            },
          },
        ],
        returnUrl: 'https://your-app.com/billing/confirm',
      },
    },
  });

  const { appSubscription, userErrors } = response.data?.appSubscriptionCreate || {};

  if (userErrors?.length > 0) {
    return json({ errors: userErrors }, { status: 400 });
  }

  // Merchant must visit confirmation URL to approve billing
  return redirect(appSubscription.confirmationUrl);
};

export const loader = async ({ request }) => {
  const { admin, session } = await authenticate.admin(request);
  const url = new URL(request.url);
  const charge = url.searchParams.get('charge_id');

  if (!charge) {
    return json({ message: 'Waiting for merchant approval' });
  }

  // Query subscription status after merchant approves
  const response = await admin.graphql(`
    query GetSubscription($id: ID!) {
      appSubscription(id: $id) {
        id
        status
        currentPeriodEnd
        returnUrl
        lineItems {
          id
          plan {
            pricingDetails {
              ... on AppRecurringPricingDetails {
                interval
                price { amount currencyCode }
              }
            }
          }
        }
      }
    }
  `, {
    variables: { id: charge },
  });

  const subscription = response.data?.appSubscription;

  if (subscription?.status === 'ACTIVE') {
    return json({ success: true, subscription });
  }

  return json({ success: false, subscription });
};
```

### 2. Usage-Based (Pay-Per-Action)

**Use Case:** Charge per email sent, API call, report generated, etc.
**Charging:** Metered; merchant is charged monthly for accumulated usage
**Cap Amount:** Optional; maximum the merchant can be charged per period

**appSubscriptionCreate Mutation (Usage-Based):**
```graphql
mutation CreateUsageSubscription {
  appSubscriptionCreate(
    input: {
      trialDays: 0
      lineItems: [
        {
          plan: {
            appUsagePricingDetails: {
              cappedAmount: {
                amount: "100.00"  # Max charge per billing period
                currencyCode: "USD"
              }
              terms: "$0.01 per email sent"  # Display string
            }
          }
        }
      ]
      returnUrl: "https://your-app.com/billing/confirm"
    }
  ) {
    appSubscription {
      id
      confirmationUrl
      lineItems {
        id
        plan {
          pricingDetails {
            ... on AppUsagePricingDetails {
              cappedAmount { amount currencyCode }
              terms
            }
          }
        }
      }
      status
    }
    userErrors {
      field
      message
    }
  }
}
```

**Recording Usage (appUsageRecordCreate):**
```graphql
mutation RecordUsage($subscriptionLineId: ID!, $quantity: Float!, $idempotencyKey: String!) {
  appUsageRecordCreate(
    subscriptionLineId: $subscriptionLineId
    quantity: $quantity
    idempotencyKey: $idempotencyKey
  ) {
    appUsageRecord {
      id
      createdAt
      quantity
    }
    userErrors {
      field
      message
    }
  }
}
```

**Remix Implementation (Usage-Based):**
```typescript
import { json } from '@shopify/remix-oxygen';
import { authenticate } from '~/shopify.server';

// Step 1: Create usage-based subscription
export const action = async ({ request }) => {
  if (request.method !== 'POST') return json({ error: 'POST only' }, { status: 405 });

  const { admin } = await authenticate.admin(request);

  const response = await admin.graphql(`
    mutation CreateUsageSubscription($input: AppSubscriptionInput!) {
      appSubscriptionCreate(input: $input) {
        appSubscription {
          id
          confirmationUrl
          lineItems {
            id
            plan {
              pricingDetails {
                ... on AppUsagePricingDetails {
                  cappedAmount { amount currencyCode }
                  terms
                }
              }
            }
          }
        }
        userErrors { field message }
      }
    }
  `, {
    variables: {
      input: {
        lineItems: [
          {
            plan: {
              appUsagePricingDetails: {
                cappedAmount: { amount: '100.00', currencyCode: 'USD' },
                terms: '$0.10 per report generated',
              },
            },
          },
        ],
        returnUrl: 'https://your-app.com/billing/confirm',
      },
    },
  });

  const { appSubscription } = response.data?.appSubscriptionCreate || {};
  return redirect(appSubscription.confirmationUrl);
};

// Step 2: Record usage when action occurs (e.g., report generated)
export const recordUsage = async (
  admin,
  subscriptionLineId: string,
  quantity: number
) => {
  const idempotencyKey = `${subscriptionLineId}-${Date.now()}`; // Prevent duplicates

  const response = await admin.graphql(`
    mutation RecordUsage(
      $subscriptionLineId: ID!
      $quantity: Float!
      $idempotencyKey: String!
    ) {
      appUsageRecordCreate(
        subscriptionLineId: $subscriptionLineId
        quantity: $quantity
        idempotencyKey: $idempotencyKey
      ) {
        appUsageRecord { id createdAt quantity }
        userErrors { field message }
      }
    }
  `, {
    variables: {
      subscriptionLineId,
      quantity,
      idempotencyKey,
    },
  });

  const { appUsageRecord, userErrors } = response.data?.appUsageRecordCreate || {};

  if (userErrors?.length > 0) {
    console.error('Usage record error:', userErrors);
    return null;
  }

  return appUsageRecord;
};

// Step 3: In report generation endpoint
export const generateReport = async ({ request }) => {
  const { admin, session } = await authenticate.admin(request);
  const data = await request.json();

  // Generate report
  const reportId = await createReport(session.shop, data);

  // Record usage (charge for report)
  const subscriptionLineId = 'gid://shopify/AppSubscriptionLine/123';
  await recordUsage(admin, subscriptionLineId, 1); // 1 report = 1 charge unit

  return json({ success: true, reportId });
};
```

### 3. One-Time Charge

**Use Case:** Upfront license purchase, setup fee, premium feature unlocks
**Charging:** Immediate; merchant approves and is charged once
**No Recurring:** Does not repeat; separate call required for each charge

**appSubscriptionCreate Mutation (One-Time):**
```graphql
mutation CreateOneTimeCharge {
  appSubscriptionCreate(
    input: {
      lineItems: [
        {
          plan: {
            appOneTimePricingDetails: {
              price: { amount: "49.99", currencyCode: "USD" }
            }
          }
        }
      ]
      returnUrl: "https://your-app.com/billing/confirm"
    }
  ) {
    appSubscription {
      id
      confirmationUrl
      lineItems {
        id
        plan {
          pricingDetails {
            ... on AppOneTimePricingDetails {
              price { amount currencyCode }
            }
          }
        }
      }
      status
    }
    userErrors { field message }
  }
}
```

### 4. Hybrid (Recurring + Usage-Based)

**Use Case:** Base subscription + pay-per-extra-action (e.g., "Pro $99/month + $0.05 per extra report")
**Charging:** Base charge monthly + usage charges accumulated during month

**appSubscriptionCreate Mutation (Hybrid):**
```graphql
mutation CreateHybridSubscription {
  appSubscriptionCreate(
    input: {
      lineItems: [
        {
          plan: {
            appRecurringPricingDetails: {
              interval: MONTHLY
              price: { amount: "99.00", currencyCode: "USD" }
            }
          }
        },
        {
          plan: {
            appUsagePricingDetails: {
              cappedAmount: { amount: "1000.00", currencyCode: "USD" }
              terms: "$0.05 per extra report (beyond 100/month)"
            }
          }
        }
      ]
      returnUrl: "https://your-app.com/billing/confirm"
    }
  ) {
    appSubscription {
      id
      confirmationUrl
      lineItems { id plan { pricingDetails { ... on AppRecurringPricingDetails { interval price { amount currencyCode } } ... on AppUsagePricingDetails { cappedAmount { amount currencyCode } terms } } } }
    }
    userErrors { field message }
  }
}
```

## Billing Configuration in Remix

**Require Billing (MUST_USE_BILLING):**

In your Remix root component, block access until billing is confirmed:

```typescript
// app/root.tsx
import { authenticate } from '~/shopify.server';

export const loader = async ({ request }) => {
  const { billing } = await authenticate.admin(request);

  // MUST_USE_BILLING: Prevent app usage without active subscription
  await billing.require({
    plans: ['basic', 'premium', 'unlimited'], // At least one required
    onFailUrl: '/billing', // Redirect if no active subscription
  });

  return null;
};
```

**Request Billing (Optional):**

Allow app usage but encourage upgrade:

```typescript
export const loader = async ({ request }) => {
  const { billing } = await authenticate.admin(request);

  // OPTIONAL: App works without billing, but show upsell
  const response = await billing.request({
    plan: 'premium',
    isTest: false,
  });

  return json({ needsBilling: !response.appSubscription });
};
```

**Test Mode:**

Simulate billing without charging:

```typescript
export const loader = async ({ request }) => {
  const { billing } = await authenticate.admin(request);

  // Test mode: Billing is mocked; no real charges
  await billing.require({
    plans: ['test-plan'],
    isTest: true, // Set to false for production
  });

  return null;
};
```

**Cancel Subscription:**

```graphql
mutation CancelSubscription($id: ID!) {
  appSubscriptionCancel(id: $id) {
    appSubscription {
      id
      status  # CANCELLED
      returnUrl
    }
    userErrors { field message }
  }
}
```

## Pricing Strategy & Tier Ladder

**Common Pricing Models:**

### Flat Pricing (Simple)
```
Starter: $29/month (up to 100 products)
Pro: $99/month (up to 10,000 products)
Enterprise: $499/month (unlimited)
```

### Tiered Usage (Pay-Per-Action)
```
Base: $0/month (free tier, 100 reports/month)
Standard: $0.05 per report above 100
Premium: $0.02 per report above 100 (volume discount)
```

### Freemium + Paid Features
```
Free: $0 (basic features only)
Plus: $9.99/month (advanced analytics)
Pro: $49.99/month (API access + custom integrations)
```

### Feature-Gated Tiers
```
Standard: $49/month
├─ Dashboard
├─ Email notifications
└─ 30-day history

Pro: $149/month
├─ Everything in Standard
├─ API access
├─ Custom rules
└─ Unlimited history
```

**Recommended Tier Ladder:**
1. **Free Tier** (required; builds trust)
   - Basic functionality
   - Limited usage (e.g., 10 actions/month)
   - No integrations
   - Community support only

2. **Starter** ($19-49/month)
   - 1-2 intermediate features
   - Moderate usage (e.g., 1,000 actions/month)
   - Email support

3. **Professional** ($99-299/month)
   - All features
   - High usage (unlimited or 100K+ actions)
   - Priority support
   - API access

4. **Enterprise** (custom pricing)
   - Custom features
   - Dedicated support
   - SLA guarantee
   - White-label option

**Pricing Psychology:**
- Odd pricing ($19.99 vs $20) increases conversion
- Annual pricing 20-30% cheaper than monthly (increases LTV)
- "Pro" tier should be sweet spot; most conversions
- Free tier must have real value; 10-15% convert to paid

## Full Working Examples

### Example 1: Flat Recurring Billing (3-Tier Plan)

**routes/billing/create.tsx (Create Subscription):**
```typescript
import { json, redirect } from '@shopify/remix-oxygen';
import { authenticate } from '~/shopify.server';
import { Form } from '@remix-run/react';

export const action = async ({ request }) => {
  if (request.method !== 'POST') return json({ error: 'POST only' }, { status: 405 });

  const formData = await request.formData();
  const plan = formData.get('plan') as string;
  const { admin } = await authenticate.admin(request);

  const planConfig = {
    starter: { price: '29.99', interval: 'MONTHLY' },
    pro: { price: '99.99', interval: 'MONTHLY' },
    enterprise: { price: '499.99', interval: 'MONTHLY' },
  };

  const config = planConfig[plan as keyof typeof planConfig];
  if (!config) return json({ error: 'Invalid plan' }, { status: 400 });

  const response = await admin.graphql(`
    mutation CreateSubscription($input: AppSubscriptionInput!) {
      appSubscriptionCreate(input: $input) {
        appSubscription { id confirmationUrl status }
        userErrors { field message }
      }
    }
  `, {
    variables: {
      input: {
        lineItems: [
          {
            plan: {
              appRecurringPricingDetails: {
                interval: config.interval,
                price: { amount: config.price, currencyCode: 'USD' },
              },
            },
          },
        ],
        returnUrl: 'https://your-app.com/billing/confirm',
      },
    },
  });

  const { appSubscription } = response.data?.appSubscriptionCreate || {};
  return redirect(appSubscription.confirmationUrl);
};

export const loader = async ({ request }) => {
  await authenticate.admin(request);
  return null;
};

export default function BillingPlans() {
  return (
    <div>
      <h1>Choose Your Plan</h1>
      <Form method="post">
        <label>
          <input type="radio" name="plan" value="starter" /> Starter - $29/month
        </label>
        <label>
          <input type="radio" name="plan" value="pro" /> Pro - $99/month
        </label>
        <label>
          <input type="radio" name="plan" value="enterprise" /> Enterprise - $499/month
        </label>
        <button type="submit">Subscribe</button>
      </Form>
    </div>
  );
}
```

### Example 2: Usage-Based Billing (Per-Report)

**routes/api/report-create.tsx (Record Usage):**
```typescript
import { json } from '@shopify/remix-oxygen';
import { authenticate } from '~/shopify.server';
import { prisma } from '~/db.server';

export const action = async ({ request }) => {
  if (request.method !== 'POST') return json({ error: 'POST only' }, { status: 405 });

  const { admin, session } = await authenticate.admin(request);
  const data = await request.json();

  // Generate report
  const report = await prisma.report.create({
    data: {
      shop: session.shop,
      name: data.name,
      generatedAt: new Date(),
    },
  });

  // Get subscription line for usage tracking
  const subscription = await getActiveSubscription(session.shop);
  if (subscription?.usageLineId) {
    // Record 1 report = 1 usage unit (charge $0.10)
    await admin.graphql(`
      mutation RecordUsage(
        $subscriptionLineId: ID!
        $quantity: Float!
        $idempotencyKey: String!
      ) {
        appUsageRecordCreate(
          subscriptionLineId: $subscriptionLineId
          quantity: $quantity
          idempotencyKey: $idempotencyKey
        ) {
          appUsageRecord { id quantity }
          userErrors { field message }
        }
      }
    `, {
      variables: {
        subscriptionLineId: subscription.usageLineId,
        quantity: 1,
        idempotencyKey: `${report.id}-${Date.now()}`,
      },
    });
  }

  return json({ success: true, reportId: report.id });
};

async function getActiveSubscription(shop: string) {
  return prisma.subscription.findUnique({
    where: { shop },
    select: { usageLineId: true },
  });
}
```

### Example 3: Freemium + Paid Features (Feature Gates)

**routes/app.reports.tsx (Feature-Gated Page):**
```typescript
import { json } from '@shopify/remix-oxygen';
import { useLoaderData } from '@remix-run/react';
import { authenticate } from '~/shopify.server';

export const loader = async ({ request }) => {
  const { admin, billing, session } = await authenticate.admin(request);

  // Check if merchant has active paid subscription
  const { appSubscriptions } = await admin.graphql(`
    query {
      appSubscriptions(first: 1) {
        edges {
          node {
            id
            status
            lineItems {
              plan {
                pricingDetails {
                  ... on AppRecurringPricingDetails {
                    price { amount }
                  }
                }
              }
            }
          }
        }
      }
    }
  `);

  const isPaid = appSubscriptions?.edges?.[0]?.node?.status === 'ACTIVE';

  // Load reports
  const reports = await getReports(session.shop);

  return json({ reports, isPaid });
};

export default function ReportsPage() {
  const { reports, isPaid } = useLoaderData<typeof loader>();

  return (
    <div>
      <h1>Reports</h1>
      {isPaid ? (
        <div>
          {/* Show all reports */}
          {reports.map((r) => (
            <div key={r.id}>{r.name}</div>
          ))}
        </div>
      ) : (
        <div className="upgrade-banner">
          <p>Unlock unlimited reports with Pro plan</p>
          <a href="/billing">Upgrade Now</a>
        </div>
      )}
    </div>
  );
}

async function getReports(shop: string) {
  // Fetch from database
  return [];
}
```

## Handle Subscription Lifecycle

**Webhook: app/subscribed**

When merchant approves subscription:

```typescript
// webhooks/app-subscribed.ts
export const webhooks = {
  APP_SUBSCRIBED: {
    deliveryMethod: DeliveryMethod.Http,
    callbackUrl: '/webhooks/app-subscribed',
  },
};

export async function handleAppSubscribed(shop, body) {
  const { appSubscription } = JSON.parse(body);

  // Store subscription in database
  await storeSubscription(shop, {
    subscriptionId: appSubscription.id,
    status: appSubscription.status,
    currentPeriodEnd: appSubscription.currentPeriodEnd,
    lineItems: appSubscription.lineItems,
  });

  // Send confirmation email
  await sendEmail(shop, 'Subscription activated');
}
```

**Webhook: billing_attempt.failure**

When payment fails:

```typescript
export async function handleBillingFailure(shop, body) {
  const { appSubscription } = JSON.parse(body);

  // Notify merchant
  await sendEmail(shop, 'Payment failed; please update payment method');

  // Optionally freeze features after multiple failures
  await disableFeatures(shop);
}
```

**Cleanup on Uninstall:**

```typescript
export async function handleAppUninstalled(shop) {
  // Delete billing records for this merchant
  await prisma.subscription.deleteMany({ where: { shop } });
  await prisma.usageRecord.deleteMany({ where: { shop } });
}
```

## Troubleshooting

| Issue | Cause | Fix |
|-------|-------|-----|
| **"Invalid currency code"** | Currency not supported by Shopify | Use USD, EUR, GBP, CAD, AUD, JPY, or merchant's shop currency |
| **Billing confirmation URL returns 404** | Merchant link expired (24h limit) | Generate new confirmation URL; store in database with expiry |
| **appUsageRecordCreate returns "invalid subscription line"** | Wrong subscriptionLineId | Query active subscription to get correct lineId |
| **"subscription is frozen"** | Payment failed; account suspended | Fix payment method; contact Shopify support |
| **Test mode billing not mocking** | isTest flag not set | Set `isTest: true` in billing.require() |
| **Duplicate usage charges** | No idempotencyKey or not unique | Generate unique key per usage record; include timestamp |

## Resources

- **Shopify app billing:** https://shopify.dev/docs/apps/launch/billing
- **GraphQL billing objects and mutations:** https://shopify.dev/docs/api/admin-graphql/latest/objects/AppSubscription
- **Revenue share:** https://shopify.dev/docs/apps/launch/distribution/revenue-share
- **Shopify App Store listing:** https://shopify.dev/docs/apps/launch/shopify-app-store/app-listing

SHA-256: 081716e59bd765ccc4a0624c68cc7635d5407494de52b5dfd1943bf598369b66