---
title: "Catalog as code"
description: "Declare meters, plans, packs, and feature benefits once in billing.catalog.ts, push them with `nuxt-backend billing sync`, and let the generated id map keep provider UUIDs out of your code."
canonical_url: "https://nuxt-backend.dev/docs/billing/catalog"
---
# Catalog as code

> Declare meters, plans, packs, and feature benefits once in billing.catalog.ts, push them with `nuxt-backend billing sync`, and let the generated id map keep provider UUIDs out of your code.

Your billing catalog is a TypeScript file. `defineBillingCatalog` (from `nuxt-backend/billing`) declares the **meters**, **plans**, **packs**, and **feature benefits**; `npx nuxt-backend billing sync` pushes them to the billing provider and writes the resulting id map to `backend/billing.generated.ts` — the only place provider UUIDs live. `setupBilling(components, { catalog, email })` reads that map for the active `BILLING_ENVIRONMENT`.

Granting stays fully provider-native: "the Pro plan includes 500 credits/month" becomes a **meter-credit benefit** attached to the subscription product, granted by the provider every billing cycle (once at purchase for one-time packs). The package only pushes the catalog and reads the balances.

## Declare

```ts [backend/billing.catalog.ts]
import { defineBillingCatalog } from 'nuxt-backend/billing'

export default defineBillingCatalog({
  currency: 'usd',                                 // every price below; defaults to the organization's presentment currency
  meters: {
    // A usage meter, keyed by the event name spends use (`spendCredits({ meter: 'credits' })`).
    credits: {},                                   // aggregation: 'sum' over `amount` (the default)
    generations: { aggregation: 'count' },         // 1 credit per event, not refundable
  },
  plans: {
    pro: {
      name: 'Pro',
      description: 'For products finding their feet.',
      interval: 'month',                           // 'month' | 'year'
      price: 2900,                                 // cents
      credits: { meter: 'credits', units: 500 },   // granted every cycle; rollover defaults to false for plans
      features: ['priority_support'],
      trial: { interval: 'day', count: 14 },       // free trial before the first charge
      usage: [{ meter: 'credits', unitAmount: 5 }], // overage: 5 cents per credit past the allowance, invoiced per cycle
    },
  },
  packs: {
    credits500: {
      name: '500 credits',
      price: 2000,
      credits: { meter: 'credits', units: 500 },   // granted once at purchase; rollover defaults to true for packs
    },
  },
  features: {
    // `key` doubles as the friendly gate name: useFeatures().has('priority_support')
    priority_support: { description: 'Priority support' },
  },
})
```

<field-group>
<field name="currency" type="string">

ISO 4217, lowercase (`'usd'`, `'eur'`) — the currency every `price` and usage price is created in. Defaults to the organization's default presentment currency, which the sync reads and reports (`exists currency usd — organization default`).

</field>

<field name="meters[key]" type="CatalogMeter">

`aggregation` — `'sum'` (default) sums `property` (default `'amount'`) across events, allowing multi-credit spends in one event; `'count'` counts events (one credit each, not refundable). `eventName` defaults to the key.

</field>

<field name="plans[key]" type="CatalogPlan">

`name`, `description?`, `interval`, `price` (cents), `credits?` (a `CatalogCreditGrant`: `meter`, `units`, `rollover?`), `features?` (keys from `features`), `trial?` (a `CatalogTrial`: `interval` of `'day' | 'week' | 'month' | 'year'` and `count?`, default 1 — the free trial before the first charge; `npx nuxt-backend doctor` warns when a plan declares one but the organization has not turned on trial-abuse prevention), `usage?` (`CatalogUsagePrice[]`: `meter`, `unitAmount` in cents — a string for sub-cent precision — and `cap?`, the most cents one cycle may bill; a metered price on top of `price`, so a plan can include an allowance and then charge overage), `taxBehavior?` (`'inclusive' | 'exclusive' | 'location'`, default the organization's setting), `customFields?` (keys from `customFields`).

</field>

<field name="packs[key]" type="CatalogPack">

`name`, `description?`, `price` (cents), `credits` (granted once at purchase), `taxBehavior?`, `customFields?`.

</field>

<field name="features[key]" type="CatalogFeature">

`description`. Created as a custom benefit tagged with the key, so `useFeatures().has(key)` matches it by metadata — no UUIDs in the app.

</field>

<field name="customFields[key]" type="CatalogCustomField">

A field the checkout collects and stores on the order or subscription — the answer rides along to the provider dashboard and the webhook payloads. `type` (`'text' | 'number' | 'checkbox' | 'date' | 'select'`), `name?` (the checkout label, default the key), `slug?` (the key the answer is stored under, default the key), `required?` (default `false`), `options?` (`{ value, label }[]`, for `'select'`). Plans and packs opt in by key through their `customFields`.

</field>
</field-group>

## Sync

```bash
npx nuxt-backend billing sync
```

```text
[nuxt-backend] billing sync → sandbox
  created      meter    credits (meter_…)
  created      benefit  priority_support (benefit_…)
  created      benefit  pro-credits (benefit_…)
  created      product  pro (prod_…)
  created      benefit  credits500-credits (benefit_…)
  created      product  credits500 (prod_…)
  created      product  credits500-gift (prod_…)
  created      webhook  https://<slug>.convex.site/billing/events (wh_…)
[nuxt-backend] Wrote backend/billing.generated.ts
[nuxt-backend] Webhook endpoint created; its secret is stored on the dev deployment as BILLING_WEBHOOK_SECRET.
[nuxt-backend] Refreshed the deployment's products (billing:syncProducts).
```

What the command guarantees:

- **Find-or-create, idempotent.** Every managed object carries `metadata: { managedBy: 'nuxt-backend', key }` and is looked up by that tag on the next run (`exists` in the log). Nothing remote is ever **updated** (prices on subscribed products are a dashboard task) or **deleted**.
- **Drift is reported, never fixed.** A managed object whose key left the catalog shows as `drift` in the log and stays in the provider — as does a managed product whose `name` or `price`, or a meter whose aggregation or event name, no longer matches the catalog (the provider never rewrites either once it carries orders or usage; rename the key to publish a new one).
- **Benefits attach on creation.** A plan's `credits` become a `<plan>-credits` meter-credit benefit; its `features` attach the matching custom benefits. A feature named in a plan but missing from `features` is a `warn`.
- **Each pack gets a private gift twin.** `<pack>-gift` has the pack's name with " (gift)", its prices and none of its benefits, and is private: it is what a purchaser buys for someone else, so the purchase grants them nothing (see [Gifts](https://nuxt-backend.dev/docs/billing/credits/gifts)). It stays out of `products`, the price list and the checkout allow-list, and lands in `gifts`, by pack key. It follows its pack's live price, amount and currency: a twin is created at the price the pack has at the provider, and a pack priced again in the dashboard moves its twin to the new price on the next sync. A benefit attached to a twin in the dashboard is detached, since the purchaser would receive it. Both show as `updated` in the log (`would-update` on a dry run); the twin is the one product the sync rewrites, since nobody subscribes to it. A catalog key that is a pack's twin key (`<pack>-gift`) is refused before anything is created.
- **Prices, trials and checkout fields are created with the product.** The fixed `price` plus one metered price per `usage` entry, in the catalog `currency`; `trial` rides on the product and `taxBehavior` on each of its prices, and its `customFields` keys attach the checkout fields created just before it (`custom-field` in the log). A `usage` price or custom field naming a key missing from the catalog is a `warn`.
- **The webhook endpoint is provisioned** at `<site url>/billing/events`, subscribed to the provider's full live event catalog, and its secret stored on the deployment as `BILLING_WEBHOOK_SECRET` through the `env push` engine: never printed, so it lands in no terminal history or agent transcript. The site URL is `--webhook <url>` when given, else `NUXT_PUBLIC_BACKEND_SITE_URL` (under production, only from the process env, since `.env.local` holds the dev deployment's), else the `.convex.site` origin of the deployment the secret goes to. An endpoint whose secret would land on another deployment is refused, and so is a production sync with no URL the provider can reach (pass `--webhook <url>`, or `--webhook off`); sandbox skips the webhook instead and says why. Sandbox writes the deployment `.env.local` names, and refuses a production key; `--env production` writes the production deployment, and refuses a dev or preview `CONVEX_DEPLOY_KEY`. An existing endpoint's secret is stored when the deployment lacks one, and a deployment's secret is kept unless you pass `--force`. If storing fails, the command exits `1` and prints the `npx convex env set BILLING_WEBHOOK_SECRET` command that asks for the value.
- **The generated map is per environment.** `billing.generated.ts` keeps `sandbox` and `production` side by side; syncing one leaves the other untouched.

### Flags

<table>
<thead>
  <tr>
    <th>
      Flag
    </th>
    
    <th>
      Effect
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        --env sandbox|production
      </code>
    </td>
    
    <td>
      Target environment (default: <code>
        BILLING_ENVIRONMENT
      </code>
      
       from the env or <code>
        .env.local
      </code>
      
      , else <code>
        sandbox
      </code>
      
      ).
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        --dry-run
      </code>
    </td>
    
    <td>
      Print the plan (<code>
        would-create
      </code>
      
       entries) without creating anything or writing the generated file.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        --webhook <url>
      </code>
    </td>
    
    <td>
      Provision the endpoint at this URL instead of the derived one; <code>
        --webhook off
      </code>
      
       skips webhook provisioning.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        --force
      </code>
    </td>
    
    <td>
      Let a new endpoint's secret replace the deployment's <code>
        BILLING_WEBHOOK_SECRET
      </code>
      
      ; without it, an endpoint whose secret would replace one is not created.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        --adopt key=<id>[,key=<id>…]
      </code>
    </td>
    
    <td>
      Record an existing product, meter or custom field under a catalog key instead of creating a managed twin. Adopted products keep their benefits exactly as they are (no double-granting).
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        --cwd <dir>
      </code>
    </td>
    
    <td>
      Project directory.
    </td>
  </tr>
</tbody>
</table>

`BILLING_ACCESS_TOKEN` must be in the environment or `.env.local`; the command exits 1 without it, without a `billing.catalog.ts`, or on an unknown `--env`.

## The generated map

```ts [backend/billing.generated.ts]
/* Generated by `npx nuxt-backend billing sync` — do not edit. */
import type { BillingCatalogIds } from 'nuxt-backend/billing'

export const catalog: Partial<Record<'sandbox' | 'production', BillingCatalogIds>> = {
  sandbox: {
    products: { pro: 'prod_…', credits500: 'prod_…' },
    packs: ['credits500'],
    gifts: { credits500: 'prod_…' },
    meters: { credits: { meterId: 'meter_…', eventName: 'credits', property: 'amount' } },
  },
}
```

`setupBilling(components, { catalog, email })` fills its `products` map and named `credits` meters from the entry matching `BILLING_ENVIRONMENT`. `products` is also the list a checkout may sell, `packs` names the keys a [gift](https://nuxt-backend.dev/docs/billing/credits/gifts) may buy, and `gifts` the private product a purchaser pays for when they do. Explicit `products` / `credits` config still wins when you need it. The same keys are what `appConfig.backend.billing.plans[].key` refers to on the pricing page, what `useBilling().products` is keyed by, and what `useCredits('credits')` / `spendCredits({ meter: 'credits' })` name.

<tip>

Every sync then runs `billing:syncProducts` on the deployment, so the reactive products table knows the products it just created (the webhook endpoint was created after them); from then on `product.*` webhooks keep it fresh. When the deployment can't load them, because it has no `BILLING_ACCESS_TOKEN` yet or runs another `BILLING_ENVIRONMENT` than the one synced, the sync says which and finishes; set it, then run `npx convex run billing:syncProducts`. It is an **internal** action — a deployment bootstrap, reachable from the CLI and the dashboard, never from a client.

</tip>

<page-keys>

- Try it live in the playground: [/playground/platform/billing](https://nuxt-backend.dev/playground/platform/billing)
- API reference: [/docs/reference/api/billing](https://nuxt-backend.dev/docs/reference/api/billing)
- Source: [https://github.com/qruto/nuxt-backend/blob/main/src/convex/catalog.ts](https://github.com/qruto/nuxt-backend/blob/main/src/convex/catalog.ts)

</page-keys>


## Sitemap

See the full [sitemap](https://nuxt-backend.dev/sitemap.md) for all pages.
