---
url: 'https://stripe.rafael.ltd/guide/getting-started.md'
---
# Getting started

stripekit turns your Stripe catalog into a file in your repo and reconciles your **own** Stripe account to match it — then (with `init`) drops correct billing code into your app. No hosted service, no merchant of record, no revenue share.

## Requirements

* Node.js 20+
* A Stripe account (test mode is fine — `init` prompts for a `sk_test_…` key)

## Install

```bash
npm install -D stripekit
# or: pnpm add -D stripekit / yarn add -D stripekit
```

## 1. Create a config

Create `stripe.config.ts` in your project root:

```ts
import { defineConfig } from 'stripekit'

export default defineConfig({
  products: {
    pro: {
      name: 'Pro',
      prices: {
        monthly: { amount: 2000, currency: 'usd', interval: 'month' },
        yearly: { amount: 19200, currency: 'usd', interval: 'year' },
      },
      features: { seats: 5, projects: 'unlimited' },
    },
  },
  portal: { cancellations: true, planSwitching: true },
  webhooks: { path: '/api/stripe/webhook' },
})
```

Already have products in Stripe? Generate the config from your account instead:

```bash
npx stripekit pull
```

## 2. Point stripekit at your account

Set your Stripe **test** secret key in the environment or `.env.local`:

```bash
# .env.local
STRIPE_SECRET_KEY=sk_test_...
```

stripekit reads `STRIPE_SECRET_KEY` from your environment, then `.env.local`, then `.env`. The `sk_test_`/`sk_live_` prefix decides which mode it targets.

## 3. Preview and apply

```bash
npx stripekit plan   # terraform-style diff — nothing is changed
npx stripekit push   # create/update products, prices, webhook, portal
```

`plan` prints exactly what `push` will do:

```
stripekit — reconciling test mode

  + product "pro" — Pro
  + price "pro_monthly" — 20.00 USD / month
  + price "pro_yearly" — 192.00 USD / year
  + customer portal configuration

Plan: 4 to create.
```

Run `push` again and you'll get **No changes.** — the reconciler is idempotent.

## 4. Promote to live

The same config targets both modes. When you're ready, swap in your live key and promote:

```bash
STRIPE_SECRET_KEY=sk_live_... npx stripekit push --live
```

Live mode asks for confirmation before touching anything (use `--yes` in CI).

## Next steps

* [How it works](/guide/how-it-works) — immutability, tagging, and convergence
* [`stripekit init`](/cli/init) — scaffold the webhook handler and billing code
* [Config reference](/config) — every field in `stripe.config.ts`

---

---
url: 'https://stripe.rafael.ltd/guide/how-it-works.md'
---
# How it works

stripekit is a **reconciler**. You describe the desired state of your catalog in `stripe.config.ts`; stripekit reads the current state of your Stripe account, computes the difference, and applies the minimal set of changes to close the gap — like Terraform, but for Stripe products, prices, the webhook endpoint, and the customer portal.

## The loop

```
stripe.config.ts ──▶ desired state ─┐
                                    ├──▶ diff ──▶ plan ──▶ apply
Stripe account ────▶ current state ─┘
```

Every step is deterministic. Given the same config and account, `plan` always prints the same plan, and applying it always converges: run `push` twice and the second run reports **No changes**.

## It only ever touches what it created

Every object stripekit manages is tagged — prices carry a stable `lookup_key`, and products, prices, webhook endpoints, and portal configs carry `stripekit_*` metadata. Reconciliation reads and writes **only** tagged objects.

Anything you created by hand in the Stripe dashboard is invisible to stripekit and is never modified or archived. That safety is by construction, not by convention.

## Prices are immutable

Stripe prices can't have their amount, currency, or interval changed. So when you edit a price in config, stripekit doesn't attempt an illegal update — it:

1. Creates a **new** price with the new amount,
2. Atomically moves the `lookup_key` onto it (`transfer_lookup_key`),
3. Archives the old price.

Your application code references the **lookup key** (`pro_monthly`), never a price ID — so it keeps working across the swap. Existing subscriptions continue on their original price until they renew or you migrate them; stripekit never disrupts them.

## Archive, never delete

Removing a product or price from config **archives** it (`active: false`). Stripe forbids deleting prices that have ever been used, and archiving is reversible — re-add the item to config and stripekit reactivates it rather than creating a duplicate.

## One config, two modes

Test and live are just two targets of the same file. You develop against `sk_test_…`, and `push --live` promotes the identical catalog to production. There's no separate "production config" to drift out of sync.

## Idempotent and crash-safe

Every create carries a stable idempotency key derived from its content, so re-running after a failure never double-creates. If a run is interrupted mid-replace, the next `plan` detects the stray and heals it. Rate limits are retried with backoff.

## Built for agents, too

Because the whole thing is a deterministic CLI with `--json` and `--yes`, an AI coding agent runs the exact same commands you do and gets the exact same result. There's no dashboard clicking to automate and no webhook handler to hallucinate — the correctness lives in the tool.

---

---
url: 'https://stripe.rafael.ltd/guide/generated-code.md'
---
# Generated code

`stripekit init` scaffolds billing code into your Next.js app that you **own and can edit** — there's no runtime dependency on stripekit. Every file is marked "Generated by stripekit. Safe to edit."

## What gets written

```
lib/stripe/
  state.ts        # StripeCustomerState + the CustomerStore interface
  client.ts       # the configured Stripe client (server-only)
  sync.ts         # syncStripeCustomerState + getOrCreateCustomerId
  customer.ts     # getCustomerState / isSubscribed / hasFeature
  store.ts        # your chosen adapter (KV or Drizzle)
  auth.ts         # getUserId, wired to your auth library
  schema.ts       # (Drizzle) the stripe_customers table
  db.ts           # (Drizzle) a pooled Postgres client
app/api/stripe/
  webhook/route.ts   # signature-verified webhook handler
  checkout/route.ts  # start a Checkout Session by lookup key
  portal/route.ts    # open the customer portal
```

## The sync pattern

The core idea (from ["How I Stay Sane Implementing Stripe"](https://github.com/t3dotgg/stripe-recommendations)) is that **one function is the only writer of billing state**:

```ts
// lib/stripe/sync.ts
export async function syncStripeCustomerState(customerId: string) {
  // Refetch the full subscription from Stripe and overwrite the stored state.
  // Idempotent — safe to call from out-of-order, duplicated webhooks.
}
```

Both the webhook handler and (optionally) your checkout-success page call it. Because it always refetches and overwrites, event ordering and duplicate delivery can't corrupt your data — which is why the handler needs no event-dedup table.

::: warning A 2026 gotcha, handled for you
Since Stripe's "Basil" API version, `current_period_end` moved from the subscription to the subscription **item**. The generated `sync.ts` reads it from `subscription.items.data[0]` — copying a pre-2025 tutorial here silently gives you `undefined`.
:::

## Feature gating

Features you declare in `stripe.config.ts` are stored in product metadata and mirrored into the customer's state during sync. Gate on them without any Stripe call in the request path:

```ts
import { hasFeature, getFeature, isSubscribed } from '@/lib/stripe/customer'

if (await isSubscribed(userId)) {
  /* ... */
}
if (await hasFeature(userId, 'priority_support')) {
  /* ... */
}
const seats = await getFeature(userId, 'seats') // "5"
```

Features only apply while the subscription is `active` or `trialing`.

## Starting checkout and the portal

Both routes are `POST` and read the current user via `getUserId`:

```ts
// Start checkout for a plan — reference the STABLE lookup key, not a price id
await fetch('/api/stripe/checkout', {
  method: 'POST',
  body: JSON.stringify({ lookupKey: 'pro_monthly' }),
})
  .then((r) => r.json())
  .then(({ url }) => (location.href = url))

// Open the customer portal
await fetch('/api/stripe/portal', { method: 'POST' })
  .then((r) => r.json())
  .then(({ url }) => (location.href = url))
```

## Wiring you must finish

* **Auth**: `getUserId` is wired to your detected library. For Auth.js, make sure `session.user.id` is populated in your callbacks; if no library was detected, implement `getUserId` yourself (it throws until you do).
* **Middleware**: Stripe must reach your webhook route unauthenticated — exclude the webhook path from any auth middleware matcher.
* **Env**: set `STRIPE_SECRET_KEY`, your adapter's vars, and `NEXT_PUBLIC_APP_URL`. `stripekit push` fills in `STRIPE_WEBHOOK_SECRET` and `STRIPE_PORTAL_CONFIGURATION_ID`.
* **Drizzle**: run a migration to create the `stripe_customers` table.

---

---
url: 'https://stripe.rafael.ltd/cli/init.md'
---
# stripekit init

Scaffold `stripe.config.ts` and correct-by-construction billing code into your **Next.js App Router** app.

```bash
npx stripekit init
```

## What it does

1. **Detects** your project — Next.js, whether `app/` lives under `src/`, your `@/`-style import alias, and your auth library (Clerk, Auth.js, or better-auth).
2. **Writes `stripe.config.ts`** (a starter catalog) if you don't have one.
3. **Scaffolds billing code** you own into `lib/stripe/` and `app/api/stripe/` (see [Generated code](/guide/generated-code)).
4. **Prints the remaining manual steps** — which packages to install, which env vars to set, and any auth/middleware wiring.

Existing files are never overwritten — anything already present is reported as skipped.

## Options

| Flag                                        | Description                                                                                      |
| ------------------------------------------- | ------------------------------------------------------------------------------------------------ |
| `--adapter <kv\|drizzle>`                   | Where synced customer state is stored. Defaults to an interactive prompt (or `kv` with `--yes`). |
| `--auth <clerk\|authjs\|better-auth\|none>` | Auth library for `getUserId`. Defaults to auto-detection.                                        |
| `-y, --yes`                                 | Accept defaults without prompting (for CI / agents).                                             |

## Storage adapters

* **`kv`** — Upstash Redis (or Vercel Marketplace KV). Self-contained; just needs `UPSTASH_REDIS_REST_URL` / `UPSTASH_REDIS_REST_TOKEN`. Great for getting started.
* **`drizzle`** — Postgres via Drizzle. Adds a `stripe_customers` table (`schema.ts`) and a pooled client (`db.ts`); needs `DATABASE_URL` and a migration.

Both implement the same `CustomerStore` interface, so `sync.ts` never imports a DB client directly — swap adapters by editing one file.

## After init

```bash
# 1. install the packages init listed
npm install stripe server-only @upstash/redis

# 2. set STRIPE_SECRET_KEY (+ adapter env) in .env.local

# 3. create the products, webhook, and portal on Stripe
npx stripekit push
```

`push` writes `STRIPE_WEBHOOK_SECRET` and `STRIPE_PORTAL_CONFIGURATION_ID` back to your env file automatically.

::: tip Local webhooks
For local development, run [`stripekit dev`](/cli/dev) — it forwards events and writes the local signing secret to `.env.local` for you.
:::

## Scope

`init` currently targets **Next.js App Router**. The reconciler (`plan` / `push` / `pull`) works with any stack today — `init` support for other frameworks is on the roadmap.

---

---
url: 'https://stripe.rafael.ltd/cli/plan.md'
---
# stripekit plan

Preview the changes `push` would make. **Never mutates anything** — it's a read-only dry run.

```bash
npx stripekit plan
```

```
stripekit — reconciling test mode

  + product "pro" — Pro
  + price "pro_monthly" — 20.00 USD / month
  ~ price "pro_yearly" — nickname
  ± price "team_monthly" — amount changed; recreate + move lookup key + archive old
  - price "legacy_monthly" — archive

Plan: 1 to create, 1 to update, 1 to replace, 1 to archive.
```

Symbols mirror the effect: `+` create, `~` update in place, `±` replace (for immutable price changes), `-` archive.

## Options

| Flag          | Description                                                                    |
| ------------- | ------------------------------------------------------------------------------ |
| `--json`      | Machine-readable output (for agents / CI). Emits `{ mode, summary, actions }`. |
| `--url <url>` | Base URL used when planning the webhook endpoint.                              |

## Notes

* The mode (test vs live) is inferred from your `STRIPE_SECRET_KEY` prefix.
* `plan` reads only stripekit-managed objects — products you created by hand are never shown or touched.
* `stripekit push --dry-run` is equivalent.

---

---
url: 'https://stripe.rafael.ltd/cli/push.md'
---
# stripekit push

Reconcile your Stripe account to match `stripe.config.ts` — creating and updating products, prices, the webhook endpoint, and the customer portal.

```bash
npx stripekit push
```

`push` prints the plan, applies it, and (idempotently) reports **No changes** on a second run.

## Test vs live

The mode is inferred from your key's prefix:

* **`sk_test_…`** → test mode. `push` applies directly.
* **`sk_live_…`** → live mode. `push` **refuses** unless you pass `--live`, then asks for confirmation.

```bash
# apply to test mode
npx stripekit push

# promote the same catalog to live mode
STRIPE_SECRET_KEY=sk_live_... npx stripekit push --live
```

## Options

| Flag          | Description                                                                                    |
| ------------- | ---------------------------------------------------------------------------------------------- |
| `--dry-run`   | Show the plan without applying (same as `stripekit plan`).                                     |
| `--live`      | Required to apply changes to a live-mode account.                                              |
| `-y, --yes`   | Skip the interactive confirmation prompt (needed for CI; combine with `--live` for live mode). |
| `--json`      | Machine-readable output. In live JSON mode, both `--live` and `--yes` are required.            |
| `--url <url>` | Base URL used to register the webhook endpoint.                                                |

## What it writes back

When `push` creates the webhook endpoint or the portal configuration, it writes the resulting values to your env file (`.env.local` if present, else `.env`):

* `STRIPE_WEBHOOK_SECRET` — the signing secret, which Stripe only returns at creation time.
* `STRIPE_PORTAL_CONFIGURATION_ID` — the managed portal configuration, passed explicitly by the generated portal route.

## Webhook registration

The webhook endpoint is registered only when a deployment URL is available (via `--url` or an env var such as `NEXT_PUBLIC_APP_URL`). Without one, `push` skips the webhook and tells you — use the Stripe CLI to forward events during local development.

## Safety

* Only stripekit-managed objects are touched; hand-made products are invisible to it.
* Prices are replaced (never mutated) and removed items are archived (never deleted).
* Every create carries a stable idempotency key, so a re-run after a failure won't double-create.

---

---
url: 'https://stripe.rafael.ltd/cli/pull.md'
---
# stripekit pull

Generate `stripe.config.ts` from your existing Stripe catalog. **Read-only** — it never mutates Stripe. Use it to adopt an account you already have.

```bash
npx stripekit pull
```

This reads every active product and price on the account and writes a `stripe.config.ts` you can review and start managing.

## Options

| Flag               | Description                                            |
| ------------------ | ------------------------------------------------------ |
| `-o, --out <file>` | Output path. Defaults to `stripe.config.ts`.           |
| `--stdout`         | Print the config to stdout instead of writing a file.  |
| `--json`           | Emit `{ productCount, priceCount, warnings, source }`. |
| `-y, --yes`        | Overwrite an existing config without prompting.        |

## What it can't represent

Some Stripe prices don't map to stripekit's simple per-unit model. `pull` **skips** them rather than guessing, and reports each as a warning:

* Prices with no fixed `unit_amount` (tiered or decimal-only pricing).
* Products left with no representable price (they'd fail the "at least one price" rule).

Everything it can represent — including `tax_behavior` — round-trips: pulling and then pushing reproduces the same catalog.

## Adopting an existing account

`pull` gives you a config as a **starting point**. Review the generated keys (they become your stable `lookup_key`s), then run `stripekit plan` to see how reconciliation would proceed.

---

---
url: 'https://stripe.rafael.ltd/cli/dev.md'
---
# stripekit dev

Forward Stripe webhooks to your local app during development, and capture the signing secret automatically. Wraps the Stripe CLI's `stripe listen`.

```bash
npx stripekit dev
```

## What it does

1. Reads your `STRIPE_SECRET_KEY` and the webhook path from `stripe.config.ts`.
2. Runs `stripe listen`, forwarding events to `localhost:3000` + your webhook path.
3. Captures the local signing secret from the output and writes `STRIPE_WEBHOOK_SECRET` to `.env.local` — so the generated handler verifies signatures with no copy-paste.
4. Streams forwarded events to your terminal. `Ctrl-C` stops it.

```
stripekit dev — forwarding test webhooks → localhost:3000/api/stripe/webhook
Press Ctrl-C to stop.

✓ Wrote STRIPE_WEBHOOK_SECRET (local) to .env.local — restart your dev server if it's already running.

2026-07-15  --> customer.subscription.created [evt_1Nz...]
2026-07-15  <--  [200] POST http://localhost:3000/api/stripe/webhook
```

## Prerequisites

The [Stripe CLI](https://docs.stripe.com/stripe-cli) must be installed (macOS: `brew install stripe/stripe-cli/stripe`). stripekit passes your key to it via the `STRIPE_API_KEY` environment variable, so you don't need to run `stripe login` separately, and the key never appears in your process list.

## Options

| Flag                 | Description                                                            |
| -------------------- | ---------------------------------------------------------------------- |
| `--port <port>`      | Local port your app runs on. Defaults to `3000`.                       |
| `--path <path>`      | Webhook route path. Defaults to `webhooks.path` in your config.        |
| `--forward-to <url>` | Full forward target, overriding `--port`/`--path` (e.g. a tunnel URL). |
| `--events <list>`    | Comma-separated event types to forward. Defaults to the handler's set. |
| `--all-events`       | Forward every event type.                                              |

## Typical workflow

Run `dev` in one terminal and your app in another:

```bash
# terminal 1
npx stripekit dev

# terminal 2
npm run dev
```

The `stripe listen` signing secret is stable for your account/device, so it's written once. If your dev server was already running when the secret was first written, restart it so Next.js picks up the new `.env.local` value.

---

---
url: 'https://stripe.rafael.ltd/cli/check.md'
---
# stripekit check

A doctor that verifies your stripekit setup is healthy — keys, config, drift, and webhook/portal wiring — in one command.

```bash
npx stripekit check
```

```
stripekit check

  ✓ Stripe key (test mode)
  ✓ stripe.config.ts — 2 product(s)
  ✓ Config vs. account — in sync
  ✓ Webhook endpoint — https://app.example.com/api/stripe/webhook
  ✓ Customer portal

✓ Looks good.
```

## What it checks

| Check                  | Passes when                                                                                      |
| ---------------------- | ------------------------------------------------------------------------------------------------ |
| **Stripe key**         | `STRIPE_SECRET_KEY` is present, well-formed, and accepted by Stripe (reports test vs live mode). |
| **stripe.config.ts**   | The config file exists and validates.                                                            |
| **Config vs. account** | Your account matches the config — no pending `push`. Warns with the count if it's drifted.       |
| **Webhook endpoint**   | A managed webhook endpoint exists and `STRIPE_WEBHOOK_SECRET` is set.                            |
| **Customer portal**    | A managed portal configuration exists and `STRIPE_PORTAL_CONFIGURATION_ID` is set.               |

Each check reports `✓` ok, `!` warning, or `✗` failure. `check` exits non-zero if any check fails, so it's useful in CI.

## Options

| Flag          | Description                                                            |
| ------------- | ---------------------------------------------------------------------- |
| `--json`      | Machine-readable output: `{ ok, items: [{ label, status, detail }] }`. |
| `--url <url>` | Base URL used when checking the webhook endpoint.                      |

## Fail-fast order

Checks run most-fundamental first and stop early on a hard failure — no key means no point checking drift. Fix failures top to bottom.

---

---
url: 'https://stripe.rafael.ltd/cli/mcp.md'
---
# stripekit mcp

Run stripekit as a [Model Context Protocol](https://modelcontextprotocol.io) server, so an AI agent (Claude Desktop, Cursor, Claude Code, …) can reconcile and inspect your Stripe account directly.

```bash
npx stripekit mcp
```

It's a **stdio** server — the right model for a dev tool: it runs locally, on demand, against your project. There's nothing to host.

## Tools

| Tool              | What it does                                          | Safety                                                                                                       |
| ----------------- | ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ |
| `stripekit_plan`  | Preview the changes `push` would make.                | Read-only                                                                                                    |
| `stripekit_check` | Verify key, config, drift, and webhook/portal wiring. | Read-only                                                                                                    |
| `stripekit_pull`  | Generate `stripe.config.ts` from the account.         | Read-only                                                                                                    |
| `stripekit_push`  | Reconcile the account to `stripe.config.ts`.          | **Dry-run by default.** `apply: true` to actually apply; `live: true` also required to touch a live account. |

Every tool returns structured JSON, and takes an optional `cwd` (the project directory containing `stripe.config.ts`).

## Configure your agent

Point your MCP client at the server and set `cwd` to your project (so it finds `stripe.config.ts` and reads `STRIPE_SECRET_KEY` from `.env.local`):

```json
{
  "mcpServers": {
    "stripekit": {
      "command": "npx",
      "args": ["-y", "stripekit@latest", "mcp"],
      "cwd": "/absolute/path/to/your/project"
    }
  }
}
```

* **Claude Desktop:** add this to `claude_desktop_config.json`.
* **Cursor:** add it to `.cursor/mcp.json`.
* **Claude Code:** `claude mcp add stripekit -- npx -y stripekit@latest mcp`.

If your key isn't in the project's `.env.local`, pass it through the client's `env` option instead.

## Add to Cursor

One click installs the server into Cursor:

[![Add to Cursor](https://cursor.com/deeplink/mcp-install-dark.svg)](cursor://anysphere.cursor-deeplink/mcp/install?name=stripekit\&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsInN0cmlwZWtpdEBsYXRlc3QiLCJtY3AiXX0=)

Cursor doesn't take a `cwd`, so run it from the project that has your `stripe.config.ts`, or open that project in Cursor before calling the tools.

## Install as a Claude Code plugin

stripekit ships a Claude Code plugin that wires up the MCP server (and a skill) in two commands:

```bash
/plugin marketplace add rafaelcg/stripekit
/plugin install stripekit@stripekit-marketplace
```

That's it — the `stripekit_*` tools become available, and the plugin's skill tells the agent when and how to use them.

## Safety

The write tool mirrors the CLI's guarantees: dry-run unless you ask to apply, a hard `live` gate for live-mode keys, prices replaced (not mutated), and removals archived (not deleted). Read-only tools can't change anything.

---

---
url: 'https://stripe.rafael.ltd/config.md'
---
# Config reference

`stripe.config.ts` describes the desired state of your Stripe catalog. Author it with `defineConfig` for full type-checking and autocomplete:

```ts
import { defineConfig } from 'stripekit'

export default defineConfig({/* ... */})
```

## Keys

Every product key and price key must be **lowercase**, start with a letter, and contain only letters, numbers, and underscores (`/^[a-z][a-z0-9_]*$/`). A product key and price key are joined into a stable `lookup_key` — `pro` + `monthly` → `pro_monthly` — that your application code references. That combination must be unique across your whole config; stripekit rejects collisions.

## `products`

A map of product key → product. At least one product is required.

```ts
products: {
  pro: {
    name: 'Pro',                        // required — display name
    description: 'For growing teams',   // optional
    prices: { /* ... */ },              // required — at least one price
    features: { seats: 5 },             // optional — entitlements
    active: true,                       // optional — default true; set false to archive
    metadata: { tier: 'growth' },       // optional — extra Stripe metadata
  },
}
```

| Field         | Type                                          | Notes                                                                         |
| ------------- | --------------------------------------------- | ----------------------------------------------------------------------------- |
| `name`        | `string`                                      | **Required.** Product display name.                                           |
| `description` | `string`                                      | Optional marketing description.                                               |
| `prices`      | `Record<string, PriceConfig>`                 | **Required.** At least one price.                                             |
| `features`    | `Record<string, string \| number \| boolean>` | Stored in product metadata; read by the generated `hasFeature()` helper.      |
| `active`      | `boolean`                                     | Defaults to `true`. `false` archives the product.                             |
| `metadata`    | `Record<string, string>`                      | Merged onto the Stripe product. Keys starting with `stripekit_` are reserved. |

### `prices`

A map of price key → price.

```ts
prices: {
  monthly: { amount: 2000, currency: 'usd', interval: 'month' },
  yearly:  { amount: 19200, currency: 'usd', interval: 'year' },
  lifetime:{ amount: 9900, currency: 'usd' },   // no interval → one-time
}
```

| Field           | Type                                          | Notes                                                                                                              |
| --------------- | --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
| `amount`        | `number`                                      | **Required.** In the currency's minor unit — `2000` = $20.00. Immutable in Stripe; changing it triggers a replace. |
| `currency`      | `string`                                      | **Required.** 3-letter ISO-4217 code, e.g. `'usd'`.                                                                |
| `interval`      | `'day' \| 'week' \| 'month' \| 'year'`        | Recurring interval. **Omit for a one-time price.**                                                                 |
| `intervalCount` | `number`                                      | Intervals between charges. Defaults to `1`. Ignored for one-time prices.                                           |
| `nickname`      | `string`                                      | Label shown in the Stripe dashboard.                                                                               |
| `taxBehavior`   | `'inclusive' \| 'exclusive' \| 'unspecified'` | Once set to inclusive/exclusive it can't change (Stripe rule); stripekit replaces the price if you try.            |

### `features`

Feature entitlements are stored in the product's metadata (`stripekit_feature_*`) and surfaced by the generated `hasFeature(userId, key)` helper — plan-gating without a hosted control plane.

```ts
features: { seats: 5, projects: 'unlimited', priority_support: true }
```

## `webhooks`

```ts
webhooks: {
  path: '/api/stripe/webhook',   // required — your handler route
  events: ['checkout.session.completed', /* ... */], // optional — override the default set
}
```

The webhook endpoint is registered only when a deployment URL is available (`--url` or an env var like `NEXT_PUBLIC_APP_URL`). For local development use [`stripekit dev`](/cli/dev) to forward events instead. Omitting `events` uses the minimal set the generated handler processes.

## `portal`

Configure the Stripe customer portal, or set `false` to leave it unmanaged.

```ts
portal: {
  cancellations: true,       // allow subscription cancellation
  planSwitching: true,       // allow switching between your plans
  paymentMethodUpdate: true, // allow updating the payment method
  invoiceHistory: true,      // show invoice history
}
```

All four default to `true` when a `portal` object is present. Plan-switching requires that switchable prices share a single currency.

## `sync`

Records which storage adapter the generated code uses to persist synced customer
state.

```ts
sync: {
  adapter: 'drizzle'
} // 'drizzle' | 'kv'
```

[`stripekit init`](/cli/init) scaffolds the matching store; you pick the adapter
with `init --adapter` or the interactive prompt.
