Payments

Accept payments and manage subscriptions with Stripe

This boilerplate includes a complete Stripe integration with support for both authenticated and guest checkouts, subscription management, and webhook handling.

Overview

The payment system provides:

  • Config-driven pricing - Define all plans and products in a single TypeScript file
  • Two payment types - One-time purchases and recurring subscriptions
  • Two payment modes - Auth (with user accounts) or authless (guest checkouts)
  • Automatic account creation - In auth mode, guest checkouts automatically create user accounts
  • Stripe Checkout - Secure, hosted payment pages
  • Subscription management - Recurring billing and cancellations
  • Customer portal - Self-service subscription management with automatic redirect for active subscribers
  • Webhook handling - Automatic payment and subscription sync with idempotency protection
  • Upgrade/downgrade support - Plan changes routed through the Stripe portal for existing subscribers
  • Pending plan tracking - Scheduled downgrades are reflected in the UI before they take effect
  • Configurable free trials - Disabled by default; enable with a positive trialPeriodDays value
  • Multiple currencies - Dynamic pricing based on location
  • License key generation - Automatic license keys for one-time purchases (opt-in feature flag)
  • Lifetime access - One-time products can grant permanent subscription access
  • Smart subscription handling - Automatically cancels recurring subscriptions when lifetime licenses are purchased
  • Static landing page pricing - LandingPricingTable component for SSR-safe public pricing pages

Quick start

1. Configure your pricing

Edit shared/config/payments.config.ts. Use the built-in createPriceConfig helper to register both your test and live Stripe price IDs together. This prevents "price not found" errors when a cached client bundle sends the other environment's ID:

export const paymentsConfig = {
  mode: 'auth', // 'auth' or 'authless'
  currencies: ['usd', 'eur'],
  defaultCurrency: 'usd',
  trialPeriodDays: 0, // 0 or omit to disable trials globally; use a positive integer (e.g. 7) to enable
  features: {
    licenseKeys: {
      enabled: false, // Set to true to activate license key generation for one-time products
    },
  },
  subscriptions: [
    {
      name: 'Pro',
      id: 'pro',
      description: 'For professionals',
      prices: {
        monthly: {
          usd: createPriceConfig('price_test_xxx', 'price_live_xxx', {
            id: 'pro-monthly-usd',
            amount: 2900, // $29.00 (optional – display only)
          }),
          eur: createPriceConfig('price_test_yyy', 'price_live_yyy', {
            id: 'pro-monthly-eur',
            amount: 2500, // €25.00
          }),
        },
        yearly: {
          usd: createPriceConfig('price_test_zzz', 'price_live_zzz', {
            id: 'pro-yearly-usd',
            amount: 29000, // $290.00 (save $58)
          }),
        },
      },
    },
  ],
  products: [
    {
      name: 'Lifetime License',
      id: 'lifetime',
      prices: {
        usd: createPriceConfig('price_test_aaa', 'price_live_aaa', {
          id: 'lifetime-usd',
          amount: 9900, // $99.00
        }),
      },
      createsSubscription: true, // Grants lifetime subscription access
      generateLicenseKey: true,  // Requires features.licenseKeys.enabled = true
    },
  ],
}
The amount field is optional and for display purposes only. Stripe will charge the amount configured in your Stripe dashboard, regardless of what's in this config. Always ensure they match!

2. Create products in Stripe

Create your products and prices in both your Stripe test and live dashboards:

# Create product
stripe products create --name="Pro" --description="Professional plan"

# Create prices
stripe prices create --product=prod_xxx --unit-amount=2900 --currency=usd --recurring[interval]=month

3. Add both Stripe price IDs to config

Copy the price IDs from both your Stripe test and live dashboards and pass them to createPriceConfig:

usd: createPriceConfig(
  'price_test_1abc123', // From Stripe test dashboard
  'price_live_1abc123', // From Stripe live dashboard
  { id: 'pro-monthly-usd', amount: 2900 }
)

The active ID is selected at build time based on NUXT_PUBLIC_STRIPE_LIVE_MODE. See the environment variables section.

4. Use in your app

<script setup>
const { subscriptionPlans } = usePricing()
const { openCheckout } = useCheckout()
const { isTrialEligible } = storeToRefs(useUserStore())

function subscribe(plan) {
  const priceConfig = plan.prices.monthly.usd
  // Pass isTrialEligible as wantsTrial: eligible users get a trial,
  // returning customers go straight to a paid checkout automatically.
  openCheckout(priceConfig.stripeId, isTrialEligible.value)
}
</script>

<template>
  <div v-for="plan in subscriptionPlans" :key="plan.id">
    <h3>{{ plan.name }}</h3>
    <ul><li v-for="f in plan.features">{{ f }}</li></ul>
    <button @click="subscribe(plan)">Subscribe</button>
  </div>
</template>
If the authenticated user already has an active subscription, openCheckout() will automatically redirect them to the Stripe Customer Portal to change their plan instead of starting a new checkout. No extra code needed.

Trial periods

Trials are disabled by default. Set trialPeriodDays to a positive integer in your payments.config.ts to enable them.

Enabling trials

// shared/config/payments.config.ts
export const paymentsConfig = {
  trialPeriodDays: 7, // Enable 7-day free trial for new subscriptions
  // ... other config
}
Set trialPeriodDays to 0 (or omit the field entirely) to disable trials globally. Common values when enabled are 7, 14, or 30 days.

Default behavior

const { openCheckout } = useCheckout()

// No trial by default (wantsTrial defaults to false)
await openCheckout('price_xxxxx')

Enabling trials for a specific checkout

The intended pattern is to pass isTrialEligible directly as the wantsTrial argument. New users automatically receive a trial while returning customers are sent straight to a paid checkout, with no conditional logic on your end:

const { openCheckout } = useCheckout()
const { isTrialEligible } = storeToRefs(useUserStore())

// Eligible users get a trial; ineligible users go straight to paid checkout
await openCheckout('price_xxxxx', isTrialEligible.value)

The trial will only be applied when both isTrialEligible is true and trialPeriodDays is set to a positive integer in your config. The server enforces eligibility independently, so this is safe even if the value is manipulated client-side.

Trials only apply to subscription products. One-time payments are not affected by the trial setting.

Trial eligibility system

The template includes built-in trial eligibility tracking to prevent abuse. Each user can only receive one trial per account, ensuring fair usage across your customer base.

How it works:

  • Each user has an isTrialEligible boolean in their UserData record (defaults to true)
  • When a user creates any subscription, they're automatically marked as ineligible for future trials
  • Server-side enforcement prevents ineligible users from receiving trials, even if explicitly requested via API
  • New users and unauthenticated checkouts are always eligible for trials

Business logic: Once a user becomes a paying customer (with or without using a trial), they permanently lose trial eligibility. This prevents the abuse scenario where users subscribe without trial → cancel → return later to claim a trial on the same account.

Recommended usage: pass isTrialEligible to openCheckout()

const { openCheckout } = useCheckout()
const { isTrialEligible } = storeToRefs(useUserStore())

// The second argument is the wantsTrial boolean.
// Passing isTrialEligible means eligible users get a trial automatically
// and returning customers never see one. No extra if/else needed.
await openCheckout(priceConfig.stripeId, isTrialEligible.value)

You can also use it to conditionally show trial-specific UI:

// Show "Start your 7-day free trial" vs "Subscribe now" based on eligibility
if (isTrialEligible.value) {
  // Display trial CTA
}

Resetting eligibility (customer support):

// Server-side only (e.g., in an admin API endpoint)
await prisma.userData.update({
  where: { userId: 'user_id_here' },
  data: { isTrialEligible: true }
})
Consider creating an admin UI or support tool to manage trial eligibility for your customer support team. This allows you to handle exceptions (billing errors, VIP customers, etc.) without direct database access.

Active subscriber handling

When an authenticated user who already has an active (or trialing) subscription clicks a plan, the template automatically redirects them to the Stripe Customer Portal subscription update flow rather than creating a duplicate checkout. This keeps all plan changes within the same Stripe subscription, so proration, schedules, and billing history all work correctly.

How it works end to end:

  1. PricingPlans.vue detects the active subscription client-side and calls openPortal('subscription_update') directly, skipping the checkout endpoint entirely.
  2. As a server-side safety net, create-checkout-session also checks for an active subscription and returns { redirect: 'portal' } if one is found.
  3. openCheckout() in useCheckout checks the response for this signal and opens the portal automatically.

No code changes are needed in your own components. The built-in openCheckout() composable handles both cases transparently.

Pending plan

When a subscriber schedules a plan change through the Stripe portal (e.g. a downgrade that takes effect at the end of the billing period), the webhook now detects the upcoming phase from the Stripe subscription schedule and stores it as pendingPlan on the Subscription record.

The built-in SubscriptionStatus component displays this to the user ("Plan change to X scheduled at next renewal").

Database migration

This feature requires a new database column. After pulling this branch, run:

npx prisma migrate dev

Accessing pendingPlan in your code

const { subscription } = useSubscription()

// subscription.pendingPlan is null when no change is scheduled
if (subscription.value?.pendingPlan) {
  console.log(`Downgrade to "${subscription.value.pendingPlan}" scheduled`)
}

License key generation

One-time products can automatically generate license keys on purchase. This is an opt-in feature that must be enabled at the config level before any product-level generateLicenseKey flag takes effect.

Enabling the feature

// shared/config/payments.config.ts
export const paymentsConfig = {
  features: {
    licenseKeys: {
      enabled: true, // Enable license key generation globally
    },
  },
  // ...
}
If generateLicenseKey: true is set on a product but features.licenseKeys.enabled is false (or omitted), config validation will log an error and no keys will be generated. Always enable the feature flag when using generateLicenseKey.

Once enabled, add generateLicenseKey: true to any one-time product:

products: [
  {
    name: 'Software License',
    id: 'software',
    prices: {
      usd: createPriceConfig('price_test_xxx', 'price_live_xxx', { id: 'software-usd' }),
    },
    generateLicenseKey: true, // Requires features.licenseKeys.enabled = true
    createsSubscription: true // Optional: grant subscription access
  },
]

When a customer purchases, the system:

  1. Creates payment record
  2. Generates license key (format: XXXXX-XXXXX-XXXXX-XXXXX)
  3. Stores in database
  4. Sends license key via email to customer
  5. Displays license key on checkout success page with copy-to-clipboard functionality
  6. If createsSubscription: true, creates a lifetime subscription and cancels any existing recurring subscriptions

Access keys in your code:

const payment = await prisma.payment.findUnique({
  where: { id: paymentId },
  include: { licenseKeys: true },
})

console.log(payment.licenseKeys)
// [{ key: 'ABC12-DEF34-GHI56-JKL78', status: 'active' }]

Composable API

The payment system provides composables with clear separation of concerns:

usePricing() - Access pricing config

Load subscription plans and one-time products from config:

const {
  subscriptionPlans,      // All subscription plans
  oneTimeProducts,        // All one-time products
  currency,               // Current selected currency
  availableCurrencies,    // All supported currencies
  getSubscriptionPriceId, // Get Stripe price ID
  findPlan,               // Find plan by id
} = usePricing()

useCheckout() - Payment operations

Initiate payments and manage billing:

const { openCheckout, openPortal } = useCheckout()

// Start checkout (no trial by default)
await openCheckout('price_xxxxx')

// Start checkout with trial (only applies when trialPeriodDays > 0 in config)
await openCheckout('price_xxxxx', true)

// Open Stripe Customer Portal (general)
await openPortal()

// Open portal to a specific flow (e.g. plan update)
await openPortal('subscription_update')

Active subscribers who call openCheckout() are automatically redirected to the Stripe portal for plan changes.

useSubscription() - Subscription state

Access subscription information and control access:

const {
  subscription,       // Current subscription details (includes pendingPlan)
  currentPlan,        // Plan id (e.g., 'pro')
  subscriptionStatus, // Status (active, trialing, etc.)
  isSubscribed,       // Boolean
  hasAccess,          // Check access to plans
  fetchSubscription,  // Refresh data
} = useSubscription()

// Control feature access - accepts string or array
if (hasAccess(['pro', 'elite'])) {
  // User has Pro OR Elite plan AND active subscription
}

if (hasAccess('basic')) {
  // Always returns true - basic plan is accessible to all
}

How hasAccess() works:

  • Accepts a plan ID (string) or array of plan IDs
  • Returns true if user has one of the specified plans AND an active subscription
  • Special case: 'basic' plan always returns true (no subscription required)
  • Plan names are case-insensitive
For one-time payments, you only need usePricing() and useCheckout(). For subscriptions with access control, use all three composables together.

Payment types

Recurring subscriptions

Monthly, yearly, quarterly, or weekly recurring billing:

  • Automatic recurring charges
  • Subscription management via customer portal
  • Configurable trial periods (disabled by default)
  • Upgrade/downgrade capabilities via Stripe portal
  • Pending plan tracking for scheduled changes
  • Access control by plan

Example config:

subscriptions: [
  {
    name: 'Pro',
    id: 'pro',
    prices: {
      monthly: {
        usd: createPriceConfig('price_test_xxx', 'price_live_xxx', { id: 'pro-monthly-usd' }),
      },
      yearly: {
        usd: createPriceConfig('price_test_yyy', 'price_live_yyy', { id: 'pro-yearly-usd' }),
      },
    },
  },
]

One-time payments

Single charge without recurring billing:

  • Lifetime licenses
  • One-off products or services
  • Optional automatic license key generation (requires features.licenseKeys.enabled)
  • Can optionally grant subscription access
  • No recurring billing or management needed

Example config:

products: [
  {
    name: 'Lifetime License',
    id: 'lifetime',
    prices: {
      usd: createPriceConfig('price_test_xxx', 'price_live_xxx', { id: 'lifetime-usd' }),
    },
    generateLicenseKey: true,
    createsSubscription: true, // Grants permanent subscription access
  },
]

Creating subscriptions from one-time products

The createsSubscription option allows a one-time purchase to grant subscription access. This is perfect for lifetime licenses:

  • When createsSubscription: true:
    • Creates a subscription record with 100-year expiration
    • Automatically cancels any existing recurring subscriptions
    • User gains full subscription access through hasAccess() checks
    • Works with all access control features (middleware, API protection, etc.)
  • When omitted or false:
    • Only creates a payment record
    • No subscription access granted
    • Suitable for one-off purchases that don't need feature access

Use case example:

products: [
  {
    name: 'Lifetime Pro License',
    id: 'lifetime',
    description: 'One-time payment, lifetime access',
    prices: {
      usd: createPriceConfig('price_test_xxx', 'price_live_xxx', {
        id: 'lifetime-usd',
        amount: 19900, // $199
      }),
    },
    createsSubscription: true, // ← Grants subscription access
    generateLicenseKey: true,  // ← Requires features.licenseKeys.enabled = true
  },
  {
    name: 'E-book',
    id: 'ebook',
    prices: {
      usd: createPriceConfig('price_test_yyy', 'price_live_yyy', { id: 'ebook-usd' }),
    },
    // No createsSubscription - just a simple one-time purchase
  },
]
The system automatically detects whether a price is for a subscription or one-time payment based on the config structure. No manual configuration needed!

Payment modes

Auth mode (default)

Payments are tied to user accounts with automatic account creation:

  • Authenticated users: Use their existing account
  • Guest users: Account is automatically created after payment
  • Full dashboard access for all customers
  • Email verification sent automatically
  • Better user experience with unified authentication
In auth mode, users don't need to manually create an account. They can go straight to checkout, and an account will be automatically created when their payment succeeds.

Authless mode

Pure guest checkout without any user accounts:

  • Checkout with email only
  • No user dashboard access
  • Lower friction for simple payment flows
  • Subscription management via Stripe's customer portal only
  • Works with both recurring subscriptions and one-time payments
  • Suitable for simple monetization without user management complexity

Set in your pricing config:

// shared/config/payments.config.ts
export const paymentsConfig = {
  mode: 'authless', // Change from 'auth' to 'authless'
  // ...
}
Authless mode limitation: The subscription middleware cannot enforce access control at the client level in authless mode (no user session to check). The middleware will log a warning and allow access. Always protect authless routes with API-level subscription checks using requireSubscription() on your server endpoints.

Configuration

Environment variables

.env
# Stripe keys
STRIPE_SECRET_KEY="sk_test_..."
NUXT_PUBLIC_STRIPE_PUBLISHABLE_KEY="pk_test_..."
STRIPE_WEBHOOK_SECRET="whsec_..."

# Set to "true" to use live Stripe price IDs from payments.config.ts
# Must be set at build time (before `nuxt build`). Defaults to false (test mode).
NUXT_PUBLIC_STRIPE_LIVE_MODE=false

# Site URLs (used in Stripe redirects)
NUXT_PUBLIC_SITE_URL="http://localhost:3000"
NUXT_PUBLIC_STRIPE_LIVE_MODE is a build-time variable. Change it before running nuxt build for production, as it cannot be changed at runtime without rebuilding.

Test vs. live price IDs

The createPriceConfig helper bundles both Stripe price IDs into each price entry. At build time, the correct ID is selected based on NUXT_PUBLIC_STRIPE_LIVE_MODE:

import { createPriceConfig } from '@@/shared/config/payments.config'

// Both IDs are stored; the active one is selected at build time
usd: createPriceConfig(
  'price_test_1abc123', // Used when NUXT_PUBLIC_STRIPE_LIVE_MODE is false (default)
  'price_live_1abc123', // Used when NUXT_PUBLIC_STRIPE_LIVE_MODE=true
  { id: 'pro-monthly-usd', amount: 2900 }
)

Both IDs are also registered in the server-side price lookup map via stripeLookupIds. This means the server can resolve incoming webhook price IDs from either environment without throwing a "price not found" error, which is useful when testing live webhooks against a dev build.

Payments config structure

The payments config file (shared/config/payments.config.ts) defines all your plans and products:

export interface PaymentsConfig {
  mode: 'auth' | 'authless' // Payment mode
  subscriptions?: SubscriptionPlan[]
  products?: OneTimeProduct[]
  /** Optional feature toggles */
  features?: PaymentsFeaturesConfig
  currencies: string[]
  defaultCurrency: string
  /**
   * Trial period in days. Set to 0 (or omit) to disable trials globally.
   * Use a positive integer (e.g. 7, 14, 30) to enable trials.
   */
  trialPeriodDays?: number
}

export interface PaymentsFeaturesConfig {
  licenseKeys?: {
    /** Enables license key generation and checkout success page license handling */
    enabled?: boolean
  }
}

export interface SubscriptionPlan {
  name: string           // Display name
  id: string             // Internal identifier
  description?: string
  prices: {
    monthly?: Record<string, PriceConfig>
    yearly?: Record<string, PriceConfig>
    quarterly?: Record<string, PriceConfig>
    weekly?: Record<string, PriceConfig>
  }
}

export interface OneTimeProduct {
  name: string
  id: string
  description?: string
  prices: Record<string, PriceConfig>
  /** Requires features.licenseKeys.enabled = true */
  generateLicenseKey?: boolean
  createsSubscription?: boolean // Grants subscription access
}

export interface PriceConfig {
  id: string              // Internal ID
  stripeId: string        // Active Stripe price ID (test or live, set at build time)
  amount?: number         // Amount in cents (optional, for display only)
  originalAmount?: number // Pre-discount amount in cents (for strikethrough display)
  /**
   * Both test and live Stripe price IDs. Populated automatically by createPriceConfig().
   * Used by the server to resolve incoming price IDs from either environment.
   */
  stripeLookupIds?: readonly [test: string, live: string]
}

/**
 * Helper to create a PriceConfig with both test and live Stripe price IDs.
 * The active stripeId is selected at build time via NUXT_PUBLIC_STRIPE_LIVE_MODE.
 */
declare function createPriceConfig(
  testPriceId: string,
  livePriceId: string,
  rest: Omit<PriceConfig, 'stripeId' | 'stripeLookupIds'>
): PriceConfig

Stripe setup

  1. Create a Stripe account at stripe.com
  2. Get your API keys from the Stripe dashboard
  3. Create products and prices in both your test and live Stripe dashboards:
    • Go to Products → Add Product
    • Create a product (e.g., "Pro Plan")
    • Add prices (monthly, yearly)
    • Copy the price IDs from each dashboard (e.g., price_test_1abc123 and price_live_1abc123)
  4. Add both price IDs to config:
// shared/config/payments.config.ts
prices: {
  monthly: {
    usd: createPriceConfig(
      'price_test_1abc123', // Test price ID
      'price_live_1abc123', // Live price ID
      { id: 'pro-monthly-usd', amount: 2900 }
    ),
  },
}
The amount field is optional. Add it if you want to display prices on your pricing page. If omitted, you can show a generic "Get started" button instead.

Displaying prices

If you add the optional amount field to your price configs, the pricing page will automatically display them:

<script setup>
const { subscriptionPlans } = usePricing()

function formatPrice(plan, interval, currency) {
  const priceConfig = plan.prices[interval]?.[currency]
  
  if (!priceConfig?.amount) {
    return 'Contact us' // No amount configured
  }
  
  return `$${(priceConfig.amount / 100).toFixed(2)}` // $29.00
}
</script>

The built-in PricingPlans component handles this automatically:

  • Shows price if amount is configured
  • Shows "Price unavailable" if amount is missing
  • Calculates monthly equivalent for yearly prices
Important: The amount field is for display only. Stripe will always charge the amount configured in your Stripe dashboard. Ensure they match to avoid confusion!
  1. Set up webhooks:
    • Go to Developers → Webhooks
    • Add endpoint: https://yourdomain.com/api/stripe/webhook
    • Select events:
      • checkout.session.completed
      • customer.subscription.created
      • customer.subscription.updated
      • customer.subscription.deleted
      • invoice.payment_succeeded
      • invoice.payment_failed
    • Copy the webhook signing secret
For local development, use the Stripe CLI to forward webhooks:
stripe listen --forward-to localhost:3000/api/stripe/webhook
This outputs a webhook signing secret for your .env file.

Landing page pricing table

The LandingPricingTable component is a static, SSR-safe pricing table designed for public landing pages. Unlike PricingPlans, it does not depend on authentication or subscription state, so it renders correctly during server-side rendering without hydration mismatches.

It is already wired into PricingSection.vue. To use it standalone on any page:

<template>
  <LandingPricingTable />
</template>

Use PricingPlans (the interactive version) on authenticated or client-only pages where you need the active subscriber portal redirect and current plan highlighting.

Webhook plan resolution

The webhook handler uses a resolvePlanFromPrice function that cascades through multiple sources to determine the correct internal plan ID. This is important because subscription.metadata.plan is set at checkout time and is never updated when a user changes their plan through the Stripe portal.

Resolution order:

  1. Local config lookup (payments.config.ts): fastest, always preferred
  2. Stripe price metadata (price.metadata.plan): useful when config is out of sync
  3. Stripe product metadata (product.metadata.plan): fallback for legacy setups
  4. Product name matching: case-insensitive match against your configured plan IDs and names
  5. Normalized product name: last resort, logged as a warning
  6. Stale checkout metadata: only used when the Stripe API is unreachable, logged as an error
For the most reliable plan resolution, ensure all your Stripe price IDs are registered in payments.config.ts via createPriceConfig. The config lookup (step 1) requires no Stripe API calls and handles both test and live IDs correctly.

Common scenarios

Adding a new plan

  1. Create product and prices in both Stripe test and live dashboards
  2. Copy both price IDs
  3. Add to payments.config.ts:
subscriptions: [
  // ... existing plans
  {
    name: 'Elite',
    id: 'elite',
    description: 'For power users and scaling teams',
    prices: {
      monthly: {
        usd: createPriceConfig('price_test_xxx', 'price_live_xxx', {
          id: 'elite-monthly-usd',
          amount: 9900,
        }),
      },
    },
  },
]

That's it. No database changes, no code changes. The new plan automatically appears on your pricing page.

Adding a new currency

  1. Create prices in both Stripe dashboards for the new currency
  2. Add to config:
export const paymentsConfig = {
  currencies: ['usd', 'eur', 'gbp'],
  // ...
  subscriptions: [
    {
      // ...
      prices: {
        monthly: {
          usd: createPriceConfig('price_test_usd', 'price_live_usd', { id: 'pro-monthly-usd' }),
          eur: createPriceConfig('price_test_eur', 'price_live_eur', { id: 'pro-monthly-eur' }),
          gbp: createPriceConfig('price_test_gbp', 'price_live_gbp', { id: 'pro-monthly-gbp' }), // New currency
        },
      },
    },
  ],
}

Handling lifetime purchases with active subscriptions

When a user with an active recurring subscription purchases a lifetime license (one-time product with createsSubscription: true):

  1. Automatic cancellation - All existing active recurring subscriptions are automatically canceled in Stripe
  2. Immediate access - User receives lifetime subscription access immediately
  3. No double billing - User won't be charged for the recurring subscription again
  4. Database sync - Old subscription records are marked as canceled in the database

This ensures users seamlessly transition from recurring to lifetime access without any manual intervention.

One-time payment with license key

  1. Enable the license key feature in config:
features: {
  licenseKeys: { enabled: true }
}
  1. Create product and price in both Stripe dashboards
  2. Add to config with generateLicenseKey: true:
products: [
  {
    name: 'Software License',
    id: 'software',
    prices: {
      usd: createPriceConfig('price_test_xxx', 'price_live_xxx', { id: 'software-usd' }),
    },
    generateLicenseKey: true, // Automatic key generation
    createsSubscription: true, // Optional: grant subscription access
  },
]

When a customer purchases, the system:

  1. Creates a payment record
  2. Generates a license key (format: XXXXX-XXXXX-XXXXX-XXXXX)
  3. Stores it in the database
  4. Sends the license key via email to the customer
  5. Displays it on the checkout success page with copy-to-clipboard functionality
  6. If createsSubscription: true, creates a lifetime subscription and cancels any existing recurring subscriptions

Access keys in your code:

const payment = await prisma.payment.findUnique({
  where: { id: paymentId },
  include: { licenseKeys: true },
})

console.log(payment.licenseKeys)
// [{ key: 'ABC12-DEF34-GHI56-JKL78', status: 'active' }]

Access control

Server-side (API routes)

Protect API endpoints with subscription requirements:

import { requireSubscription } from '@@/server/utils/require-subscription'

export default defineEventHandler(async event => {
  // Require active subscription to pro or elite plan
  const { subscription, userId } = await requireSubscription(event, {
    plans: ['pro', 'elite'],
  })

  // User has access, proceed with logic
  return { message: 'Welcome!', plan: subscription.plan }
})

Client-side (pages)

Protect pages with the subscription middleware:

<script setup>
// Option 1: Require specific plans
definePageMeta({
  middleware: 'subscription',
  allowedPlans: ['pro', 'elite'],
})

// Option 2: Require a single plan
definePageMeta({
  middleware: 'subscription',
  allowedPlans: ['pro'],
})
</script>

<template>
  <div>
    <h1>Premium feature</h1>
    <p>Only available to subscribers with allowed plans</p>
  </div>
</template>

How it works:

  • Auth mode: Ensures user is authenticated, fetches subscription if needed, and checks if user has one of the allowed plans with an active subscription
  • Authless mode: Logs a warning and allows access at client level (API-level protection recommended)
  • If no allowedPlans specified, only authentication is enforced (auth mode only)
  • The hasAccess() check requires BOTH:
    • User's plan matches one of the allowed plans
    • User has an active subscription (isSubscribed is true)
Important: The allowedPlans check validates that the user has an active subscription AND matches one of the specified plans. In authless mode, use API-level protection with requireSubscription() for reliable access control.

Component-level

Control access within components:

<script setup>
const { hasAccess, currentPlan, isSubscribed } = useSubscription()

// Check access to specific plans
const canAccessFeature = computed(() => hasAccess(['pro', 'elite']))

// Check if user has any active subscription
const isPaidUser = computed(() => isSubscribed.value)
</script>

<template>
  <div v-if="canAccessFeature">
    <p>Your plan: {{ currentPlan }}</p>
    <!-- Premium content for Pro/Elite only -->
  </div>
  <div v-else-if="isPaidUser">
    <p>Upgrade to Pro or Elite to access this feature</p>
    <NuxtLink to="/pricing">View plans</NuxtLink>
  </div>
  <div v-else>
    <p>Subscribe to access premium features</p>
    <NuxtLink to="/pricing">View pricing</NuxtLink>
  </div>
</template>
Use hasAccess() when you need to check for specific plans. Use isSubscribed when you just need to know if the user has any active subscription, regardless of plan.

Customer portal

Allow customers to manage their subscriptions:

<script setup>
const { openPortal } = useCheckout()
</script>

<template>
  <button @click="openPortal">Manage subscription</button>
</template>

The portal allows customers to:

  • Update payment methods
  • View payment history
  • Cancel or reactivate subscriptions
  • Download invoices
  • Change plans (upgrades and downgrades)

Testing

Test mode

Stripe provides test cards:

  • Success: 4242 4242 4242 4242
  • Decline: 4000 0000 0000 0002
  • 3D Secure: 4000 0027 6000 3184

Use any future expiry date and any CVC.

Testing webhooks locally

Use the Stripe CLI:

# Install
brew install stripe/stripe-cli/stripe

# Login
stripe login

# Forward webhooks to local server
stripe listen --forward-to localhost:3000/api/stripe/webhook

# Trigger test events
stripe trigger checkout.session.completed
stripe trigger invoice.payment_succeeded

Troubleshooting

Webhooks not working

  1. Check webhook endpoint URL is correct
  2. Verify webhook secret matches .env file
  3. Check server logs for errors
  4. Use Stripe CLI for local testing
  5. Check WebhookEvent table for errors

Price not found error

If you see "Price not found in configuration":

  1. Verify the Stripe price ID is correct
  2. Check it's added to payments.config.ts via createPriceConfig
  3. If using live mode, ensure NUXT_PUBLIC_STRIPE_LIVE_MODE=true was set at build time
  4. If a cached client bundle is sending the wrong environment's price ID, ensure both test and live IDs are registered. createPriceConfig handles this automatically
  5. Restart your development server

Plans not showing on pricing page

  1. Check payments.config.ts syntax
  2. Verify the config is being imported correctly
  3. Check browser console for errors
  4. Ensure usePricing() is called in setup

Plan not updating after portal plan change

The webhook now resolves the plan from the live Stripe price rather than from stale checkout metadata. If the plan still shows incorrectly after a portal change:

  1. Ensure the Stripe price IDs in your config match what Stripe is sending in webhook events
  2. Check server logs for resolvePlanFromPrice warnings, which indicate which fallback path was used
  3. Consider adding a plan key to your Stripe product or price metadata as a reliable fallback

Best practices

  1. Version control your config - Pricing changes are tracked in git
  2. Use test mode - Always test with Stripe test keys first
  3. Register both price IDs - Use createPriceConfig with test and live IDs for every price entry
  4. Verify webhooks - The system automatically verifies webhook signatures
  5. Handle idempotency - Webhook events are stored to prevent duplicate processing
  6. Test all scenarios - Test subscriptions, one-time payments, auth and authless modes, plan changes via portal
  7. Keep Stripe in sync - Ensure config matches your Stripe dashboard
  8. Run migrations - After pulling updates, run npx prisma migrate dev to apply any schema changes

Reference