Billing as Code

Define your billing plans in a TypeScript config file and sync them to AuthGate and Stripe with a single CLI command. Plans as code means they are version-controlled, reviewable in pull requests, and deployable through your existing CI/CD pipeline.


Installation

npm install @auth-gate/billing

Quick start

1. Generate a starter config

npx @auth-gate/billing init

This creates an authgate.billing.ts file in your project root with a sample plan definition.

2. Edit the config

Open authgate.billing.ts and define your plans. See Config format below.

3. Set environment variables

AUTHGATE_API_KEY=ag_...
AUTHGATE_BASE_URL=https://...

4. Preview changes

npx @auth-gate/billing sync

Runs a dry-run and prints a diff of what will be created, updated, or archived. No changes are applied.

5. Apply changes

npx @auth-gate/billing sync --apply

Executes the diff against AuthGate and Stripe.


Config format

Use defineBilling() to declare your plans:

authgate.billing.ts

import { defineBilling } from "@auth-gate/billing";

export const billing = defineBilling({
  features: {
    api_calls: { type: "metered" },
    analytics: { type: "boolean" },
  },
  plans: {
    starter: {
      name: "Starter",
      description: "For individuals",
      entitlements: {
        api_calls: { limit: 1000 },
      },
      prices: [
        { amount: 999, currency: "usd", interval: "monthly" },
        { amount: 9999, currency: "usd", interval: "yearly" },
      ],
    },
    pro: {
      name: "Pro",
      description: "For growing teams",
      entitlements: {
        api_calls: { limit: 50000 },
        analytics: true,
      },
      prices: [
        { amount: 2999, currency: "usd", interval: "monthly" },
        { amount: 29999, currency: "usd", interval: "yearly" },
      ],
    },
  },
});

// Default export for CLI compatibility
export default billing;

Key points about the config:

  • Plan keys (starter, pro) are stable identifiers used internally. If you need to rename a key after syncing, use renamedFrom to preserve existing subscribers.
  • Amounts are in cents999 = $9.99, 2999 = $29.99.
  • Features registry defines typed features as "boolean" (on/off) or "metered" (usage-based with limits).
  • Entitlements map features to plans — true for boolean features, { limit: number } for metered features.
  • The named billing export enables type-safe billing across your app.

Type-safe billing

defineBilling() returns a typed object that carries your plan and feature keys as literal types. Pass it to factory functions to get full IDE autocomplete everywhere — like tRPC, but for billing.

React hooks

app/billing.ts

import { createBillingHooks } from "@auth-gate/react";
import { billing } from "../authgate.billing";

export const {
  useFeature,
  useUsage,
  useBillingAuth,
  BillingGate,
} = createBillingHooks(billing);

Now all plan/feature parameters are typed:

useFeature(billing.features.api_calls);  // type-safe — pass the object directly
useFeature("typo");                      // compile error

<BillingGate plan={billing.plans.pro}>   // full autocomplete ↗
  <ProContent />
</BillingGate>

const { has } = useBillingAuth();
has({ plan: billing.plans.pro });           // type-safe — no .key needed
has({ feature: billing.features.analytics }); // type-safe — no .key needed

Server helpers (Next.js)

import { createBillingHelpers } from "@auth-gate/nextjs";
import { billing } from "../authgate.billing";

const helpers = createBillingHelpers({
  billing,
  baseUrl: process.env.AUTHGATE_URL!,
  apiKey: process.env.AUTHGATE_API_KEY!,
});

await helpers.checkEntitlement({ userId, plan: billing.plans.pro });  // full autocomplete ↗
await helpers.reportUsage({
  userId,
  events: { metric: billing.features.api_calls, quantity: 1 },  // full autocomplete ↗
});

Testing

import { createTestBilling } from "@auth-gate/testing/billing";
import { billing } from "../authgate.billing";

const t = createTestBilling({ billing });

t.subscribe("user_1", billing.plans.pro);                   // full autocomplete ↗
t.checkEntitlement("user_1", billing.features.analytics);  // full autocomplete ↗
t.reportUsage("user_1", billing.features.api_calls, 500);  // full autocomplete ↗

Environment variables

VariableDescription
AUTHGATE_API_KEYYour project API key (ag_...)
AUTHGATE_BASE_URLBase URL of your AuthGate instance

CLI commands

CommandDescription
npx @auth-gate/billing syncDry-run — print the diff without applying it
npx @auth-gate/billing sync --applyApply the diff to AuthGate and Stripe
npx @auth-gate/billing sync --apply --forceApply including archiving plans that have active subscribers
npx @auth-gate/billing sync --strictTreat warnings as errors (recommended for CI/CD)
npx @auth-gate/billing migrate <from> <to>Create and execute a migration between two plans
npx @auth-gate/billing migrate <from> <to> --dry-runPreview a migration without executing
npx @auth-gate/billing migrate --id <migrationId>Execute a pending migration by ID
npx @auth-gate/billing migrate --id <migrationId> --batch-size 10Execute with a custom batch size
npx @auth-gate/billing initGenerate a starter authgate.billing.ts config

How sync works

When you run npx @auth-gate/billing sync, the CLI:

  1. Loads and validates your authgate.billing.ts config
  2. Fetches the current plan state from the AuthGate server
  3. Computes a diff: which plans to create, which to update, and which to archive
  4. In dry-run mode: prints the diff to stdout and exits
  5. With --apply: executes the changes against the AuthGate database and Stripe

Plans are matched by their config key (starter, pro, etc.). If a plan key is present in the config but not on the server, it is created. If it exists on both sides, it is updated. If it exists on the server but is absent from the config, it is archived.


Renaming plans

If you need to change a plan's config key (e.g., pro to professional), use renamedFrom to tell the sync engine that this is a rename rather than an archive-and-create:

authgate.billing.ts

import { defineBilling } from "@auth-gate/billing";

export default defineBilling({
  plans: {
    professional: {
      name: "Professional",
      renamedFrom: "pro",
      prices: [
        { amount: 2999, currency: "usd", interval: "monthly" },
        { amount: 29999, currency: "usd", interval: "yearly" },
      ],
    },
  },
});

When the sync engine sees renamedFrom: "pro", it updates the existing product's config key from "pro" to "professional" instead of archiving one and creating the other. All existing subscribers are preserved — the underlying product ID stays the same.

The old key is stored in a previousConfigKeys array, so chain renames (proprofessionalbusiness) work correctly across multiple syncs.


Migrating subscribers

Move subscribers from one plan to another using the migrations array in your config or the CLI.

Declarative (config)

authgate.billing.ts

export default defineBilling({
  plans: {
    pro: {
      name: "Pro",
      prices: [
        { amount: 2999, currency: "usd", interval: "monthly" },
        { amount: 29999, currency: "usd", interval: "yearly" },
      ],
    },
  },
  migrations: [
    { from: "starter", to: "pro" },
  ],
});

Run npx @auth-gate/billing sync --apply and the migration will execute automatically after the sync completes.

Imperative (CLI)

Migrate subscribers directly from the command line without editing config:

npx @auth-gate/billing migrate starter pro

Preview the migration first with --dry-run:

npx @auth-gate/billing migrate starter pro --dry-run

Or execute a previously created migration by ID:

npx @auth-gate/billing migrate --id mig_abc123

Price matching

By default, prices are matched by (interval, currency) pair — a monthly USD price on the source plan maps to the monthly USD price on the target plan. For custom mapping, use priceMapping:

migrations: [{
  from: "starter",
  to: "pro",
  priceMapping: {
    "starter_monthly_999_usd": "pro_monthly_2999_usd",
    "starter_yearly_9999_usd": "pro_yearly_29999_usd",
  },
}]

Behavior

  • Only active and trialing subscriptions are migrated. past_due subscribers are skipped with a warning.
  • Migrations run in batches of 100 (configurable with --batch-size) with rate limiting to stay within Stripe API limits.
  • Each subscriber's plan change is prorated according to their current billing period.
  • Migrations are tracked in the database and are idempotent — re-running skips already-migrated subscribers.

Email notifications

When plans are migrated or archived, affected subscribers can receive email notifications. This is opt-in and disabled by default.

Enabling notifications

Enable billing notifications in the AuthGate dashboard under Project Settings > Billing > Notifications. When enabled, emails are queued during migration and sent in the background by a cron job.

Template types

TemplateSent when
plan-migratedA subscriber is moved to a different plan
plan-archivedA plan the subscriber is on is discontinued
price-changedA subscription price is updated

Customization

Billing email templates appear in the AuthGate dashboard email template editor alongside authentication templates. You can customize the subject line, copy, and branding to match your application.

Webhooks

Regardless of email notification settings, the following webhook events are always fired:

EventPayload
plan.renamedproductId, oldConfigKey, newConfigKey
plan.archivedproductId, configKey
subscription.migratedsubscriptionId, endUserId, migrationId, oldPriceId, newPriceId

Coexistence with the dashboard

Config-managed plans and dashboard-managed plans can coexist in the same project:

  • Plans synced from authgate.billing.ts appear as read-only in the AuthGate dashboard. You cannot edit them through the UI — all changes must go through the config file.
  • Plans created directly in the dashboard are unaffected by sync and remain fully editable in the UI.
  • Both types of plans are available for subscription and appear in GET /api/proxy/billing/products.

CI/CD integration

Add plan syncing to your deployment pipeline so billing configuration is always in sync with your code:

.github/workflows/deploy.yml

- name: Sync billing plans
  run: npx @auth-gate/billing sync --apply
  env:
    AUTHGATE_API_KEY: ${{ secrets.AUTHGATE_API_KEY }}
    AUTHGATE_BASE_URL: ${{ secrets.AUTHGATE_BASE_URL }}

Run the dry-run with --strict in pull request checks to catch warnings early:

.github/workflows/pr.yml

- name: Preview billing plan changes
  run: npx @auth-gate/billing sync --strict
  env:
    AUTHGATE_API_KEY: ${{ secrets.AUTHGATE_API_KEY }}
    AUTHGATE_BASE_URL: ${{ secrets.AUTHGATE_BASE_URL }}

The dry-run exits with a non-zero code if validation fails. With --strict, any warnings (stale renamedFrom, plans with active subscribers being archived, etc.) also cause a non-zero exit, so issues are caught before reaching production.

Was this page helpful?