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 cents —
999= $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 —
truefor boolean features,{ limit: number }for metered features. - The named
billingexport 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 ↗
The standalone hooks (useFeature, useBillingAuth, etc.) are still exported from @auth-gate/react with string parameters for backward compatibility. The factory is the upgrade path for type safety.
Environment variables
| Variable | Description |
|---|---|
AUTHGATE_API_KEY | Your project API key (ag_...) |
AUTHGATE_BASE_URL | Base URL of your AuthGate instance |
CLI commands
| Command | Description |
|---|---|
npx @auth-gate/billing sync | Dry-run — print the diff without applying it |
npx @auth-gate/billing sync --apply | Apply the diff to AuthGate and Stripe |
npx @auth-gate/billing sync --apply --force | Apply including archiving plans that have active subscribers |
npx @auth-gate/billing sync --strict | Treat 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-run | Preview 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 10 | Execute with a custom batch size |
npx @auth-gate/billing init | Generate a starter authgate.billing.ts config |
Without --apply, sync is always safe to run. Use it in pull request checks to surface plan changes for review before they reach production. Add --strict in CI/CD to fail on any warnings.
How sync works
When you run npx @auth-gate/billing sync, the CLI:
- Loads and validates your
authgate.billing.tsconfig - Fetches the current plan state from the AuthGate server
- Computes a diff: which plans to create, which to update, and which to archive
- In dry-run mode: prints the diff to stdout and exits
- 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.
Archiving a plan with active subscribers requires the --force flag. Without it, the CLI will print a warning and skip the archive step to protect existing subscribers.
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 (pro → professional → business) work correctly across multiple syncs.
renamedFrom is a one-time directive. After the rename is applied, you can remove it from the config. If you leave it in, the sync engine will warn that no matching product was found and proceed normally.
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
activeandtrialingsubscriptions are migrated.past_duesubscribers 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.
Migrations are irreversible. Use --dry-run and small --batch-size values to verify before running a full migration.
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
| Template | Sent when |
|---|---|
plan-migrated | A subscriber is moved to a different plan |
plan-archived | A plan the subscriber is on is discontinued |
price-changed | A 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:
| Event | Payload |
|---|---|
plan.renamed | productId, oldConfigKey, newConfigKey |
plan.archived | productId, configKey |
subscription.migrated | subscriptionId, 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.tsappear 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
syncand 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.