---
title: "Billing & Subscriptions"
description: "Learn how plan prices, subscriptions and the billing provider assign plans to workspaces, and how to reconcile and recover subscriptions."
canonical_url: "https://www.vuesion.dev/docs/core-concepts/billing-and-subscriptions"
---

> Index of every page on this site: [llms.txt](https://www.vuesion.dev/llms.txt)

# Billing & Subscriptions

Plan prices, subscriptions and the billing provider assign plans to workspaces. Entitlements stay in [Plans & Entitlements](https://www.vuesion.dev/docs/core-concepts/plans-and-entitlements).

## Plan prices

A plan price is one purchasable billing option of a plan:

```text
Pro
├── PlanFeature   maxMembers = 10
├── PlanPrice     EUR 19 / month
└── PlanPrice     EUR 190 / year
```

Both prices belong to the same plan and therefore grant the same entitlements. The billing interval is pricing, not product configuration, so monthly and yearly billing never need separate plans.

A plan price has the following properties:

- `interval`: `MONTH` or `YEAR`.
- `amount`: the price in the minor unit of the currency as an integer, for example `1900` for EUR 19.00 or `1900` for JPY 1,900. It must not be negative. Zero is allowed, for example for testing.
- `currency`: a three-letter ISO 4217 code. It is normalized to uppercase. Vuesion validates only the shape of the code and keeps no list of currencies.
- `externalId`: the identifier of the purchasable price in the configured billing provider, for example a variant ID or a price ID. It is unique and contains no provider-specific naming, because Vuesion is configured with one billing provider per installation.
- `isActive`: **this price is offered for new purchases.** Deactivating a price keeps it, so it remains available to existing purchases.

The amount is the price Vuesion shows before checkout. The billing provider remains authoritative for the amount actually charged. Vuesion does not synchronize prices with the provider.

Rules:

- A plan has **at most one active price per billing interval**. A plan may have no active price, for example the Free plan, which needs no price at all.
- The service locks the plan row while it creates or activates a price, so concurrent requests cannot activate two prices for the same plan and interval.
- `interval` and `externalId` cannot be changed after creation, because they describe the price object in the billing provider. When the provider price changes in a way that requires a new provider object, deactivate the old plan price and create a new one.
- `amount`, `currency`, and `isActive` can be edited.
- Prices may exist on inactive plans. Purchasing will require both an active plan and an active price.
- A price can be deleted while no subscription uses it, including expired ones. A plan with prices cannot be deleted.

Platform Admins manage prices in the **Prices** section of the plan detail page, or through `usePlanPriceService()` on the server. The admin enters the price in the major unit of the currency; the number of minor-unit digits is taken from `Intl.NumberFormat`, so currencies without cents, such as JPY, are converted correctly.

## Subscriptions

A subscription is the local projection of a subscription in the billing provider. It belongs to a workspace, never to the user who paid:

```text
Workspace
├── WorkspacePlan ──────────────→ Plan
├── WorkspaceFeatureOverride
└── Subscription ──→ PlanPrice ─→ Plan
```

A free workspace needs no subscription: it is simply assigned the default plan. Vuesion never creates subscription rows that do not exist in the billing provider.

A subscription stores the provider IDs (`externalId`, `externalCustomerId`), the purchased price, a provider-independent `status`, `cancelAtPeriodEnd`, and `currentPeriodEnd`. Invoices, payments, and taxes stay in the billing provider.

### Status and access

<table>
<thead>
  <tr>
    <th>
      Status
    </th>
    
    <th>
      Meaning
    </th>
    
    <th>
      Workspace plan
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        ACTIVE
      </code>
    </td>
    
    <td>
      paid, including when cancelled at the period end
    </td>
    
    <td>
      the purchased plan
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        PAST_DUE
      </code>
    </td>
    
    <td>
      a renewal failed and the provider retries it
    </td>
    
    <td>
      the purchased plan
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        UNPAID
      </code>
    </td>
    
    <td>
      the retries failed
    </td>
    
    <td>
      the default plan
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        PAUSED
      </code>
    </td>
    
    <td>
      payment collection is paused
    </td>
    
    <td>
      the default plan
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        EXPIRED
      </code>
    </td>
    
    <td>
      the subscription has ended
    </td>
    
    <td>
      the default plan, once
    </td>
  </tr>
</tbody>
</table>

A cancelled subscription stays `ACTIVE` with `cancelAtPeriodEnd = true` until the provider reports it as `EXPIRED`. The mapping lives in `grantsPaidPlan()` in `src/server/domain/subscription/subscription-status.ts`.

### Current subscription and plan ownership

Every status except `EXPIRED` makes a subscription **current**: `UNPAID` and `PAUSED` subscriptions still exist in the provider and may recover. A workspace has at most one current subscription and may keep any number of expired ones as history.

While a workspace has a current subscription, **billing manages its plan**. No field marks this; the current subscription is the ownership:

- `useSubscriptionService().syncSubscription(workspaceId, state)` is the only billing writer of `WorkspacePlan`. It assigns the purchased plan or the active default plan according to the status.
- Platform Admins cannot assign another plan (`409`). They can still configure feature overrides, which survive every billing change.
- The workspace cannot be deleted, and neither can the user who owns it (`409`).
- Workspace ownership cannot be transferred (`409`). The subscription must first expire.

When the current subscription expires, the workspace is assigned the default plan once and plan management returns to normal: Platform Admins can assign plans again, and a new subscription can start. Workspaces without a current subscription are never reset by billing, so manually assigned plans stay as they are.

### Synchronization

`syncSubscription` receives the workspace separately from the provider-independent `SubscriptionState`, because the workspace is trusted application context and must not be derived from provider data. In one transaction that locks the workspace, it:

1. resolves the plan price by `externalPriceId` and fails with `422` for an unknown price, without changing the workspace plan,
2. creates the subscription or updates it by `externalId`; a subscription of another workspace is rejected with `409` and never moved,
3. rejects a new current subscription with `409` while the workspace already has one, instead of choosing a winner,
4. expires an ended trial, converts an active trial when the subscription grants a paid plan, and assigns the workspace plan,
5. handles the change of the effective plan (see [Effective plan changes](https://www.vuesion.dev/docs/core-concepts/plans-and-entitlements#effective-plan-changes)) and, after the commit, sends the [subscription emails](https://www.vuesion.dev/docs/core-concepts/plans-and-entitlements#subscription-emails).

Existing subscriptions keep resolving their plan even when the price or the plan has been deactivated. `isActive` only controls new purchases and assignments.

A subscription that is reported as `EXPIRED` and was never current locally is stored as history and leaves the workspace plan unchanged.

There is no API endpoint for the synchronization. It is called by the billing integration.

### Why Subscription and WorkspacePlan are separate

`WorkspacePlan` answers *"which configuration does this workspace use?"*. `Subscription` answers *"what did the provider sell to this workspace?"*. The answers differ often:

- A free workspace has a plan but no subscription.
- A Platform Admin can assign a plan without any payment, e.g. for a partner.
- An `UNPAID`, `PAUSED`, or `EXPIRED` subscription still points to the purchased price, but the workspace uses the default plan.
- Expired subscriptions remain as history, while the workspace has exactly one plan.

Keeping them separate means entitlements are always resolved the same way, from the effective plan (see [Effective entitlements](https://www.vuesion.dev/docs/core-concepts/plans-and-entitlements#effective-entitlements)), no matter how the plan was assigned. Billing only decides which plan is assigned, and only through `syncSubscription`.

### Lifecycle

```text
checkout            owner chooses a price → provider-hosted checkout → nothing changes locally
subscription        provider webhook → provider GET → syncSubscription → purchased plan
plan change         owner switches price → provider change → syncSubscription → new plan immediately
cancellation        owner cancels → ACTIVE, cancelAtPeriodEnd → plan kept until the period ends
resume              owner resumes before the period ends → ACTIVE → plan kept
payment problems    PAST_DUE keeps the plan; UNPAID or PAUSED → default plan until it recovers
expiration          provider reports EXPIRED → default plan once → plan management returns to admins
trial               owner starts a trial → trial plan is effective → expires at endsAt, or converts on subscription
```

Every step that changes the workspace plan goes through `syncSubscription`. The following sections describe each part.

## Billing provider

Stripe and Lemon Squeezy are the included billing providers. Vuesion supports one billing provider per installation, selected with `NUXT_BILLING_PROVIDER` (`stripe`, otherwise Lemon Squeezy; default `lemonsqueezy`), so neither plans, prices, nor subscriptions store which provider they belong to. For Stripe taxes and invoices, see [Stripe products and plan prices](https://www.vuesion.dev/docs/development/configuration#stripe-products-and-plan-prices).

The billing domain depends only on `useBillingProviderService()` in `src/server/services/use-billing-provider-service.ts`:

```text
createCheckout({ externalPriceId, checkoutReference, email?, redirectUrl })  → { url }
getSubscription(externalSubscriptionId)                                      → SubscriptionState
changeSubscriptionPrice(externalSubscriptionId, externalPriceId)             → SubscriptionPriceChange
cancelSubscription(externalSubscriptionId)                                   → SubscriptionState
resumeSubscription(externalSubscriptionId)                                   → SubscriptionState
getPortalUrl(externalSubscriptionId)                                         → signed URL
listSubscriptions()                                                          → ProviderSubscription[]
```

The operations return the same provider-independent `SubscriptionState` that `syncSubscription` consumes:

```ts [Subscription.ts]
export type SubscriptionState = {
  externalId: string;
  externalCustomerId: string;
  externalPriceId: string;
  status: SubscriptionStatus;
  cancelAtPeriodEnd: boolean;
  currentPeriodEnd: Date | null;
};
```

`listSubscriptions` only returns `{ externalId, externalPriceId, status }` of each provider subscription, for the detection of unknown subscriptions. `changeSubscriptionPrice` returns either `{ type: 'UPDATED', subscription }` or `{ type: 'PORTAL_REQUIRED', url }` when the customer must confirm the change at the provider first. The operations never synchronize the local subscription themselves; that is the job of the calling billing flow.

`useBillingProviderService()` returns the implementation of the selected provider (`use-stripe-billing-provider-service.ts` or `use-lemon-squeezy-billing-provider-service.ts`). In demo mode, it returns a local simulation instead (`use-demo-billing-provider-service.ts`), so the public demo needs no provider configuration and never calls a provider; see **Development → Configuration → Demo mode → Simulated billing**.

The Lemon Squeezy implementation:

- creates checkouts through the API for the configured store, restricted to the selected variant, with the opaque checkout reference as custom data (`checkout_data.custom.reference`) and without a custom price, so the variant price is charged; every checkout expires 24 hours after it was created (`expires_at`), so an old checkout link cannot start a subscription later,
- maps the provider statuses to Vuesion statuses; `on_trial` becomes `ACTIVE`, `cancelled` becomes `ACTIVE` with `cancelAtPeriodEnd = true` until the provider reports `expired`, and an unknown status fails with `500`,
- uses `ends_at` as `currentPeriodEnd` for cancelled and expired subscriptions and `renews_at` otherwise,
- changes plans with the provider's default proration and cancels at the end of the period,
- fetches the short-lived signed Customer Portal URL of a subscription on demand,
- lists the subscriptions of the configured store (`filter[store_id]`) page by page, 100 per page, one request after another,
- validates only the response fields it uses, and fails with `422` when the provider rejects a request and with `500` otherwise. Requests time out after 10 seconds and are never retried.

The Stripe implementation calls the Stripe API with `fetch` (no SDK) and:

- creates a Checkout Session in subscription mode with the checkout reference as subscription metadata (`reference`); every checkout expires after 23 hours,
- maps the statuses: `active` and `trialing` become `ACTIVE`, `past_due` becomes `PAST_DUE`, `unpaid` and `incomplete` become `UNPAID`, `paused` becomes `PAUSED`, and `canceled` and `incomplete_expired` become `EXPIRED`,
- reads `currentPeriodEnd` from the first subscription item and uses `ended_at` for ended subscriptions,
- changes plans with `create_prorations`; a failed payment rejects the change,
- cancels and resumes through `cancel_at_period_end`,
- creates a customer portal session on demand,
- lists all subscriptions of the Stripe account, 100 per page,
- never returns `PORTAL_REQUIRED`; only Lemon Squeezy does.

### Plan & Billing and checkout

The **Plan & Billing** section of the workspace settings shows the workspace plan with its effective limits, the current subscription, and, without a current subscription, the purchasable plans. It reads the local projection through `GET /api/workspaces/:id/billing` and never calls the provider. The response contains no provider IDs or URLs.

A plan can be purchased when it is active and has at least one active price. Plans are listed by name, and each active price can be chosen.

Only the workspace owner manages billing (`canManageWorkspaceBilling`). Other members see the same state without checkout actions. `POST /api/workspaces/:id/billing/checkout` accepts only a `planPriceId` and, for the owner of a workspace without a current subscription, resolves everything else on the server:

```text
External ID   → from the active plan price of an active plan
reference     → createBillingReference(workspaceId)
email         → email of the signed-in user
return URL    → /workspaces/:id/settings?checkout=returned
```

It returns the URL of the provider-hosted checkout and changes nothing locally: no subscription, no plan assignment, no entitlements. A workspace with a current subscription gets `409`.

`?checkout=returned` only means that the browser came back from the checkout. It is no proof of payment. The page shows a neutral processing message and refreshes the billing state every 2 seconds, at most 5 times, until the subscription synchronized by the webhook appears. After that it shows that the checkout is still being processed; a later reload shows the latest state.

### Contextual upgrade entry points

A feature limit may point to Plan & Billing, but plan selection, prices, checkout, and subscription management stay there. The feature UI only explains why an action is unavailable.

The member limit is the first example: when a workspace has used all member slots, `WorkspaceMemberSlots` shows the limit, and users who manage billing get **View plans**, which opens `/workspaces/:id/settings#plan-and-billing`. Admins are asked to contact the workspace owner. The invite flow needs no billing knowledge, and the server keeps enforcing the limit. Invited users who cannot join because of the limit see the error and are asked to contact the workspace owner.

### Subscription management

The workspace owner manages a current subscription in the same Plan & Billing section. Other members see its state without actions. The client only sends a `planPriceId`; the subscription and the provider IDs are always resolved on the server.

```text
PATCH /api/workspaces/:id/billing/subscription          { planPriceId } → { portalUrl }
POST  /api/workspaces/:id/billing/subscription/cancel   → 204
POST  /api/workspaces/:id/billing/subscription/resume   → 204
POST  /api/workspaces/:id/billing/portal                → { url }
POST  /api/workspaces/:id/billing/trial                 → 204 (body: { planId })
```

The actions depend on the status (`src/shared/domain/subscription/subscription-actions.ts`, used by the API and the UI):

<table>
<thead>
  <tr>
    <th>
      Subscription
    </th>
    
    <th>
      Switch plan
    </th>
    
    <th>
      Cancel
    </th>
    
    <th>
      Resume
    </th>
    
    <th>
      Manage billing
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        ACTIVE
      </code>
    </td>
    
    <td>
      ✓
    </td>
    
    <td>
      ✓
    </td>
    
    <td>
      
    </td>
    
    <td>
      ✓
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        ACTIVE
      </code>
      
      , cancelled at period end
    </td>
    
    <td>
      
    </td>
    
    <td>
      
    </td>
    
    <td>
      ✓
    </td>
    
    <td>
      ✓
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        PAST_DUE
      </code>
      
      , <code>
        UNPAID
      </code>
      
      , <code>
        PAUSED
      </code>
    </td>
    
    <td>
      
    </td>
    
    <td>
      
    </td>
    
    <td>
      
    </td>
    
    <td>
      ✓
    </td>
  </tr>
</tbody>
</table>

Other actions return `409`, as does every action without a current subscription.

- **Switch plan** changes the subscription to another active price of an active plan, including from monthly to yearly. The change takes effect immediately, and the provider applies its default proration; Vuesion calculates no amounts. Lemon Squeezy does not change PayPal subscriptions through the API but returns a signed portal URL instead (`portalUrl`). The browser opens it, the customer confirms the change there, and the webhook synchronizes it.
- **Cancel** cancels at the end of the billing period. The subscription stays `ACTIVE` with `cancelAtPeriodEnd`, and the workspace keeps its plan until the provider expires the subscription.
- **Resume** undoes the cancellation before the period ends. The provider decides whether that is still possible.
- **Manage billing** opens the provider's customer portal with a freshly signed URL. It handles payment methods, billing details, invoices, and the recovery of payment problems, so it is available for every current subscription.

Every change is first made at the provider; the state it returns is then synchronized immediately, so the page shows the result without waiting for the webhook. Nothing changes locally when the provider rejects a request. If the local synchronization fails after the provider succeeded, the request fails without undoing the provider change, and the provider's update webhook repairs the local state.

### Webhooks

The webhook of the selected provider keeps subscriptions in sync. It needs no session; it is authenticated by a signature of the raw request body. Every other event is acknowledged without action.

- `POST /api/webhooks/lemonsqueezy`: header `X-Signature` (HMAC); processes `subscription_created` and `subscription_updated`.
- `POST /api/webhooks/stripe`: header `Stripe-Signature` with a 5-minute tolerance; processes `customer.subscription.created`, `customer.subscription.updated` and `customer.subscription.deleted`.

For a relevant event, `useSubscriptionService().syncProviderSubscription()`:

1. resolves the workspace: a known subscription keeps its workspace, and a new subscription needs the signed billing reference that the checkout passed to the provider,
2. ignores the event when the referenced workspace no longer exists, e.g. after a demo reset,
3. fetches the current subscription from the provider and synchronizes it with `syncSubscription`.

The subscription state in the payload is ignored. Webhooks can arrive late, twice, or out of order; because every event synchronizes the current provider state, an old event cannot roll a subscription back. An invalid signature returns `401`, and a failed synchronization returns an error status, so the provider retries the delivery.

The billing reference (`createBillingReference(workspaceId)` and `verifyBillingReference(reference)` in `src/server/domain/subscription/billing-reference.ts`) is the workspace ID signed with `NUXT_BILLING_REFERENCE_SECRET`. It only proves which workspace a checkout belongs to; whether a subscription exists and what was purchased always comes from the provider API.

### Reconciliation

Three paths keep local subscriptions in line with the provider, and all of them write through `syncSubscription`:

```text
direct management change  →  provider state ─────────┐
webhook                   →  provider GET   ─────────┼→  syncSubscription()
scheduled reconciliation  →  provider GET   ─────────┘
```

The hourly `subscription-reconciliation` task (`useSubscriptionService().reconcileSubscriptions()`) is the safety net for missed webhooks and for provider changes whose local synchronization failed, for example a subscription that expired at the provider while the webhook was lost.

- It synchronizes only locally known current subscriptions (every status except `EXPIRED`), one after another in a stable order.
- For each subscription, it fetches the provider state and synchronizes it with the subscription's own workspace. The provider state must belong to the requested subscription.
- A failed subscription is logged with its subscription and workspace IDs and leaves its local state unchanged; if the provider no longer has it (`HTTP 404`), a `missing_subscription` incident is reported as well; the remaining subscriptions are still processed. A failed lookup never expires a subscription: only a provider state of `EXPIRED` does.
- It does not retry within a run and never changes anything at the provider. The next run and the provider's webhook retries are the further attempts.
- Afterwards, it detects unknown provider subscriptions (see below).
- It returns `{ processed, succeeded, failed, unknown }`. `unknown` is the number of detected unknown provider subscriptions, or `null` when the provider subscriptions could not be listed.

#### Unknown provider subscriptions

If every delivery of the provider's creation webhook fails, Vuesion never creates the local subscription, and the customer stays on the default plan although they paid. The provider API does not return the signed billing reference, so only the webhook can assign a subscription to a workspace.

After synchronizing the known subscriptions, the reconciliation therefore lists the provider subscriptions and reports each one that

- is not `EXPIRED` at the provider,
- has no local subscription with its External ID, in any status, and
- uses the External ID of a configured plan price, including inactive prices and plans.

Each one is logged as a warning that contains `Unknown provider subscription detected` and contains its External ID and status, so monitoring can alert on it, and is reported as an `unknown_subscription` [incident](https://www.vuesion.dev/docs/core-concepts/incidents#billing-incidents) with the customer email that the provider listed. The same subscription is reported again by every run until it is no longer unknown; repeated runs increment the occurrences of its incident.

Detection is read-only: it never creates subscriptions, changes workspace plans, assigns a workspace, or changes anything at the provider. A matching plan price does not prove that the subscription belongs to this installation, because several installations can share a store or its variants. Investigate every warning; if the subscription belongs to this installation, resend its original creation webhook from the provider dashboard (see [Subscription recovery runbook](#subscription-recovery-runbook)). If the provider subscriptions cannot be listed, the failure is logged, the known subscriptions stay reconciled, and the next run tries again. Lemon Squeezy lists the subscriptions of the configured store; its API key belongs to either Test Mode or Live Mode and only lists subscriptions of its mode. Stripe has no store: every subscription of the account with a configured plan price is checked, and its key belongs to one mode (see [Billing test and live mode](https://www.vuesion.dev/docs/development/configuration#billing-test-and-live-mode)). The demo provider lists no subscriptions.

#### Synchronization warnings

Webhook failures only return an error status to the provider, so the synchronization logs the failures that need an operator as warnings that contain `Billing subscription synchronization failed` and contain a stable reason and the External ID of the provider subscription:

```text
reason=workspace_not_found     subscription="<externalId>" workspace="<workspaceId>"
reason=subscription_conflict   subscription="<externalId>" workspace="<workspaceId>" currentSubscription="<id>"
reason=unknown_price           subscription="<externalId>" price="<externalPriceId>"
```

- `workspace_not_found`: the workspace of the subscription no longer exists. A new subscription of a deleted workspace is still acknowledged and ignored.
- `subscription_conflict`: the workspace already has another current subscription. Nothing changes; the existing subscription and the workspace plan stay as they are.
- `unknown_price`: no plan price uses the provider price. Nothing changes; configure the plan price or investigate the subscription.

The warnings never contain customer data, payment data, or the signed billing reference. Each failure is also reported as an [incident](https://www.vuesion.dev/docs/core-concepts/incidents#billing-incidents) of the same type. The synchronization does not recover these cases automatically; investigate each warning, together with the unknown provider subscriptions, at the provider. A failed reconciliation additionally logs its own error line.

### Subscription recovery runbook

This runbook helps operators handle a customer who paid but whose subscription never reached Vuesion, and the related synchronization warnings. The only supported recovery of a missing subscription is to resend the original webhook from the billing provider. The steps apply to both providers and name Stripe where it differs from Lemon Squeezy. Vuesion's only administrative action ends a local subscription that no longer exists at the provider.

#### Detection signals

The **Incident Board** (`/admin/incidents`, filter by the source `billing`) lists the [billing incidents](https://www.vuesion.dev/docs/core-concepts/incidents#billing-incidents) with their diagnostic context. The server logs contain these lines:

```text
⚠️ Unknown provider subscription detected: Provider subscription "<externalId>" (<status>) uses a configured plan price but is unknown locally and may require investigation.
⚠️ Billing subscription synchronization failed: reason=<reason> subscription="<externalId>" ...
❌ Reconciling subscription "<id>" of workspace "<workspaceId>" failed: <message>
❌ Detecting unknown provider subscriptions failed: <message>
```

An unknown provider subscription means:

- The subscription exists at the provider (the configured Lemon Squeezy store or the Stripe account) and is not `EXPIRED` there. `<status>` is the mapped Vuesion status.
- No local subscription has its External ID, in any status.
- Its variant (Stripe: price) matches the External ID of a configured plan price.

It does not prove that a customer is missing access. The subscription may belong to another installation that uses the same store or Stripe account. Vuesion never assigns it to a workspace.

The synchronization reasons are described in [Synchronization warnings](#synchronization-warnings).

Some webhook rejections are not logged, because they are expected HTTP errors. They are only visible as the response of the delivery in the provider dashboard:

<table>
<thead>
  <tr>
    <th>
      Response
    </th>
    
    <th>
      Cause
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        400
      </code>
    </td>
    
    <td>
      Invalid payload, or a new subscription without a valid billing reference
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        401
      </code>
    </td>
    
    <td>
      Invalid webhook signature
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        409
      </code>
    </td>
    
    <td>
      <code>
        subscription_conflict
      </code>
      
       (logged), or the subscription belongs to another workspace
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        422
      </code>
    </td>
    
    <td>
      <code>
        unknown_price
      </code>
      
       (logged), or the provider rejected the subscription request
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        500
      </code>
    </td>
    
    <td>
      Missing billing configuration, or the provider could not be reached or returned an invalid response
    </td>
  </tr>
</tbody>
</table>

#### Investigate an unknown subscription

1. Copy the External ID of the provider subscription from the warning or the `subscriptionId` of the incident.
2. Open the Lemon Squeezy store that `NUXT_LEMON_SQUEEZY_STORE_ID` configures, or the Stripe account of `NUXT_STRIPE_SECRET_KEY`.
3. Switch to the mode of the deployment's API key: Test Mode or Live Mode (Stripe: test or live mode). Each mode only contains its own subscriptions.
4. Find the subscription by its ID.
5. Check its current status. A subscription that has expired in the meantime is no longer reported and needs no recovery.
6. Check that its variant (Stripe: price) is the External ID of a plan price in **Platform → Plans**.
7. Check whether another installation, for example a staging deployment, uses the same store or Stripe account and may own the subscription.
8. Check that no local subscription exists for it, with a read-only query:```sql
SELECT "id", "workspaceId", "status" FROM "Subscription" WHERE "externalId" = '<externalId>';
```
9. Search the logs for synchronization warnings with the same `subscription="<externalId>"`. If there is one, continue with [When recovery is not possible](#when-recovery-is-not-possible).

A matching variant, customer email, amount, or purchase time does not prove which workspace the subscription belongs to. Only the signed billing reference in the original webhook proves the workspace. The customer email in the incident only helps to find the subscription and contact the customer.

#### Resend the original webhook

Find the original creation event of the subscription in the webhook deliveries of this deployment's webhook and resend it:

- Lemon Squeezy: resend `subscription_created` in the Lemon Squeezy dashboard.
- Stripe: resend `customer.subscription.created` in the Stripe Dashboard under **Developers → Webhooks**, on this deployment's endpoint in the mode of the API key.

Do not use **Simulate event** or a test event: it carries no billing reference and is rejected with `400`.

Vuesion processes the resent event like the first delivery:

1. The signature must match the webhook secret (`X-Signature` and `NUXT_LEMON_SQUEEZY_WEBHOOK_SECRET`, or `Stripe-Signature` and `NUXT_STRIPE_WEBHOOK_SECRET`). An event from another store, endpoint or mode fails with `401`.
2. The billing reference (Lemon Squeezy: `meta.custom_data.reference`; Stripe: the `reference` metadata of the subscription) must verify with the current `NUXT_BILLING_REFERENCE_SECRET`. A reference of another installation, or one created before the secret was changed, fails with `400`.
3. The referenced workspace must exist. Otherwise, the event is acknowledged with `200` and ignored, and a `workspace_not_found` warning is logged.
4. Vuesion fetches the current subscription from the provider API and synchronizes it. The state in the event payload is ignored, so resending an old event cannot restore an outdated status.
5. The variant (Stripe: price) must match a plan price (`unknown_price`), and the workspace must not have another current subscription (`subscription_conflict`).

If the subscription has expired at the provider in the meantime, it is stored as an `EXPIRED` subscription and the workspace plan stays unchanged.

The provider may not keep every past delivery available for resending. Webhooks of the public demo are ignored, so this runbook does not apply to demo mode.

#### Verify the recovery

A `200` response alone does not prove recovery: a deleted workspace is also acknowledged with `200`. Check each layer:

1. **Webhook**: the resent delivery returned `200`, and the logs contain no synchronization warning for the subscription.
2. **Local subscription**: the query above returns one row with the expected workspace.
3. **Status**: the local `status` matches the current status at the provider, mapped as in [Status and access](#status-and-access).
4. **Workspace plan**: **Platform → Workspaces** shows the purchased plan for `ACTIVE` and `PAST_DUE`, and the default plan for `UNPAID` and `PAUSED`.
5. **Entitlements**: the workspace's **Plan & Billing** section shows the subscription and the expected limits. Feature overrides still replace plan values.
6. **Reconciliation**: the next hourly run no longer reports the subscription as unknown. In development, run it with `/_nitro/tasks/subscription-reconciliation`.
7. **Incident**: resolve the incident on the Incident Board only after the recovery is verified. Resolving deletes the incident but repairs nothing; if the problem still exists, it is reported again as a new incident.

#### When recovery is not possible

**The original webhook is no longer available.** Vuesion cannot reconstruct the workspace of the subscription. The provider API does not return the billing reference, and Vuesion provides no administrative action to create the subscription. Do not create it or change the workspace plan in the database; resolve the customer's billing at the provider.

**The workspace no longer exists.** The subscription cannot be associated with it, and it must not be assigned to another workspace. Investigate the subscription at the provider and resolve any ongoing billing obligation with its administrative tools, for example by cancelling or refunding it.

**The subscription no longer exists at the provider.** Reconciliation reports it as a `missing_subscription` [incident](/docs/core-concepts/incidents#billing-incidents), and the local subscription keeps its state. An admin ends it with **End subscription** in the incident details, which calls `PATCH /api/admin/subscriptions/:id/end`. The request re-checks the provider and returns `409` while the subscription still exists there. Otherwise the subscription becomes `EXPIRED`, the workspace falls back to the default plan, and the related incident is deleted.

**The workspace has another current subscription.** Vuesion refuses to replace it (`subscription_conflict`). Do not delete or expire local records. Investigate both provider subscriptions and their billing state at the provider before taking further action. Once the provider expires one of them and the change is synchronized, resend the event again if the other subscription should remain.

**The price is unknown.** No plan price uses the subscription's variant, so Vuesion cannot resolve the plan. Compare the variant with the External IDs in **Platform → Plans** and check that the deployment uses the correct mode. Add a plan price only if the variant is a real offer of this installation, not to silence the warning. Do not change the External ID of existing prices; create a new price instead (see [Plan prices](#plan-prices)).

**The provider is unavailable.** The webhook fails with `500` and the provider retries it. Known subscriptions are synchronized again by the next reconciliation run. If the subscriptions cannot be listed, unknown subscriptions are not reported for that run. An unknown subscription still needs a successful webhook delivery; reconciliation never associates it with a workspace.

#### Safety rules

- Never bypass the webhook signature or the billing reference verification, and never edit or construct webhook payloads.
- Never associate a subscription with a workspace based on email, variant, price, amount, or timestamps.
- Never change `WorkspacePlan` or `Subscription` rows manually to simulate a recovery.
- Never assume that a successful payment means Vuesion received the webhook. Check the local subscription.
- Never log or share API keys, webhook secrets, billing references, or customer payment data while investigating.

### Adding a billing provider

Only the provider boundary knows a provider. Lemon Squeezy and Stripe each have the same set of files (Lemon Squeezy shown; Stripe uses `use-stripe-billing-provider-service.ts`, `stripe-webhook.ts`, `stripe.post.ts` and `NUXT_STRIPE_*`):

```text
src/server/services/use-lemon-squeezy-billing-provider-service.ts   provider API
src/server/services/lemon-squeezy-webhook.ts                        signature check and event parsing
src/server/api/webhooks/lemonsqueezy.post.ts                        webhook route
nuxt.config.ts                                                      runtime config, webhook route rule
.env-example                                                        NUXT_LEMON_SQUEEZY_* variables
```

Plans, plan prices, subscriptions, workspace plans, entitlements, the billing API, the reconciliation, the Plan & Billing UI, and the demo provider stay unchanged. To add another provider, follow the Stripe files (`use-stripe-billing-provider-service.ts`, `stripe-webhook.ts`, `stripe.post.ts`):

1. **Implement the provider service**, e.g. `use-example-billing-provider-service.ts`, with the seven operations and the types `CheckoutRequest`, `SubscriptionPriceChange`, and `ProviderSubscription` from `use-billing-provider-service.ts`. Map the provider subscription to `SubscriptionState`: the provider price ID becomes `externalPriceId`, every provider status maps to one `SubscriptionStatus`, and a subscription cancelled at the period end stays `ACTIVE` with `cancelAtPeriodEnd = true` until it ends. Attach `checkoutReference` to the checkout so that the provider returns it with the subscription events. Return `PORTAL_REQUIRED` only if the provider requires the customer to confirm a price change. `listSubscriptions()` returns the External ID, price ID, and status of every subscription of the configured account, without customer or payment data.
2. **Select it** in `useBillingProviderService()` next to the existing providers. Keep the demo branch.
3. **Parse the webhook.** Verify the provider signature against the raw request body and return a `BillingWebhookEvent` (`externalSubscriptionId`, `checkoutReference`) for subscription events and `null` for every other event. Do not read the subscription state from the payload.
4. **Add the webhook route**, e.g. `src/server/api/webhooks/example.post.ts`, like `stripe.post.ts`: ignore requests in demo mode, parse the event, and call `useSubscriptionService().syncProviderSubscription(externalSubscriptionId, checkoutReference)`. Exclude the route from the rate limiter in `nuxt.config.ts`.
5. **Add the configuration**: the keys in the runtime config of `nuxt.config.ts` and the variables in `.env-example`. Keep `NUXT_BILLING_REFERENCE_SECRET`.
6. **Replace the External IDs** of all plan prices with the provider's price IDs, including in your seed and the demo fixture (`src/server/demo/seed-demo-data.ts`).
7. **Add specs** for the new files and update **Development → Configuration**. To drop a provider, see [Remove one provider](https://www.vuesion.dev/docs/removal/billing#remove-one-provider).

The subscription service then handles checkout, webhooks, management, and reconciliation of the new provider without further changes.

## Billing boundary

The billing concepts have separate responsibilities:

```text
Plan            → entitlement configuration
PlanPrice       → purchasable billing option of a plan
Subscription    → local projection of the provider subscription
WorkspacePlan   → plan assigned to a workspace
WorkspaceTrial  → temporary plan of a workspace
Entitlements    → effective access of a workspace
```

Application code resolves entitlements through the Entitlement Service. It never reads subscriptions, trials or the assigned plan to decide what a workspace may do.

## Next steps

Continue with **Internationalization** to learn how Vuesion handles languages, translations, locale-aware routing, and user preferences.

## Sitemap

See the full [sitemap](https://www.vuesion.dev/sitemap.md) for all pages.
