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:

  • Two payment modes - Auth (with user accounts) or authless (guest checkouts)
  • Stripe Checkout - Secure, hosted payment pages
  • Subscription management - Recurring billing and cancellations
  • Customer portal - Self-service subscription management
  • Webhook handling - Automatic payment and subscription sync
  • Multiple currencies - Dynamic pricing based on location

Payment modes

Auth mode (default)

Users must create an account before purchasing:

  • Subscriptions are tied to user accounts
  • Full dashboard access
  • Better user experience for authenticated users
  • Easier to manage customer relationships

Authless mode

Users can purchase without creating an account:

  • Guest checkout with email only
  • No user dashboard (unless they sign up later)
  • Lower friction for one-time purchases
  • Subscription management via Stripe's customer portal
The payment mode is set via the NUXT_PUBLIC_PAYMENT_MODE environment variable. Set to 'auth' (default) or 'authless'.

Configuration

Environment variables

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

# Payment mode
NUXT_PUBLIC_PAYMENT_MODE="auth" # or "authless"

# Site URLs (used in Stripe redirects)
NUXT_PUBLIC_SITE_URL="http://localhost:3000"
In production, use live Stripe keys (starting with sk_live_ and pk_live_).

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 Stripe:
    • Go to Products → Add Product
    • Create a product (e.g., "Pro Plan")
    • Add prices (monthly, yearly)
    • Note the price IDs (e.g., price_1abc123)
  4. 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

Database architecture

The boilerplate uses two separate tables for payments and subscriptions:

Payment table

Tracks all payment transactions (one-time and subscription):

model Payment {
  id                 String   @id @default(dbgenerated("gen_random_uuid()"))
  customerEmail      String
  userId             String?   // Null in authless mode
  amount             Int       // Amount in cents
  currency           String
  status             String    // succeeded, failed, pending, refunded
  processorPaymentId String?   // Stripe Payment Intent ID
  subscriptionId     String?   // Link to subscription if applicable
  createdAt          DateTime  @default(now())
}

Subscription table

Manages subscription lifecycle and access control:

model Subscription {
  id                      String    @id @default(dbgenerated("gen_random_uuid()"))
  customerEmail           String
  userId                  String?   // Null in authless mode
  plan                    String    // pro, elite, etc.
  status                  String    // active, canceled, past_due, etc.
  processorSubscriptionId String    @unique // Stripe Subscription ID
  currentPeriodStart      DateTime
  currentPeriodEnd        DateTime
  cancelAtPeriodEnd       Boolean   @default(false)
  payments                Payment[] // Payment history
  createdAt               DateTime  @default(now())
}
For a detailed explanation of the payment architecture, see PAYMENT_SYSTEM.md in the project root.

Creating a checkout session

Client-side

Use the pricing page component or create your own:

app/pages/pricing.vue
<script setup lang="ts">
const loading = ref(false)
const config = useRuntimeConfig()

async function handleCheckout(priceId: string) {
  loading.value = true

  try {
    const response = await $fetch('/api/stripe/checkout-session', {
      method: 'POST',
      body: { priceId },
    })

    // Redirect to Stripe Checkout
    window.location.href = response.url
  } catch (error) {
    console.error('Checkout error:', error)
    toast.error('Failed to create checkout session')
  } finally {
    loading.value = false
  }
}
</script>

<template>
  <Button @click="handleCheckout('price_1abc123')" :disabled="loading"> Subscribe to Pro </Button>
</template>

Server-side API

The checkout session API is in server/api/stripe/checkout-session.post.ts:

server/api/stripe/checkout-session.post.ts
import Stripe from 'stripe'
import { getPaymentConfig } from '~/server/utils/payment-config'

export default defineEventHandler(async event => {
  const { priceId } = await readBody(event)
  const config = useRuntimeConfig()
  const paymentConfig = getPaymentConfig()
  const stripe = new Stripe(config.stripeSecretKey)

  // In auth mode, get the authenticated user
  let customer: { email: string; userId?: string }
  if (paymentConfig.mode === 'auth') {
    const { user } = await requireAuth(event)
    customer = { email: user.email, userId: user.id }
  } else {
    // In authless mode, customer provides email at checkout
    customer = { email: '' } // Stripe Checkout will collect it
  }

  const session = await stripe.checkout.sessions.create({
    mode: 'subscription',
    customer_email: customer.email || undefined,
    line_items: [{ price: priceId, quantity: 1 }],
    success_url: `${config.public.siteUrl}/checkout/success?session_id={CHECKOUT_SESSION_ID}`,
    cancel_url: `${config.public.siteUrl}/pricing`,
    metadata: {
      mode: paymentConfig.mode,
      userId: customer.userId || '',
    },
  })

  return { url: session.url }
})

Webhook handling

Webhooks automatically sync payment and subscription data from Stripe to your database.

Webhook endpoint

The webhook handler is in server/api/stripe/webhook.post.ts:

server/api/stripe/webhook.post.ts
import Stripe from 'stripe'

export default defineEventHandler(async event => {
  const config = useRuntimeConfig()
  const stripe = new Stripe(config.stripeSecretKey)
  const body = await readRawBody(event)
  const sig = getHeader(event, 'stripe-signature')

  // Verify webhook signature
  const webhookEvent = stripe.webhooks.constructEvent(body, sig, config.stripeWebhookSecret)

  // Handle different event types
  switch (webhookEvent.type) {
    case 'checkout.session.completed':
      await handleCheckoutSessionCompleted(webhookEvent.data.object)
      break

    case 'customer.subscription.updated':
      await handleSubscriptionUpdated(webhookEvent.data.object)
      break

    case 'invoice.payment_succeeded':
      await handlePaymentSucceeded(webhookEvent.data.object)
      break

    // ... other event types
  }

  return { received: true }
})

Webhook events explained

checkout.session.completed

  • Triggered when a customer completes checkout
  • Creates the initial subscription record
  • In auth mode with no userId, creates a new user account

customer.subscription.created / customer.subscription.updated

  • Triggered when a subscription is created or updated
  • Updates subscription status, billing dates, plan info

customer.subscription.deleted

  • Triggered when a subscription is canceled
  • Updates subscription status to 'canceled'

invoice.payment_succeeded

  • Triggered when a subscription payment succeeds
  • Creates a Payment record
  • Updates subscription billing dates

invoice.payment_failed

  • Triggered when a subscription payment fails
  • Creates a Payment record with 'failed' status
  • Updates subscription status to 'past_due'

Access control

Server-side (API routes)

Protect API endpoints with subscription requirements:

server/api/protected-feature.ts
import { requireSubscription } from '~/server/utils/require-subscription'

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

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

Client-side (pages)

Protect pages with the subscription middleware:

app/pages/premium-feature.vue
<script setup lang="ts">
definePageMeta({
  middleware: 'subscription',
  requiresSubscription: true,
  requiredPlans: ['pro', 'elite'],
})
</script>

<template>
  <div>
    <h1>Premium Feature</h1>
    <p>Only available to Pro and Elite subscribers</p>
  </div>
</template>

Using the subscription composable

Check subscription status in components:

<script setup lang="ts">
const { subscription, currentPlan, isSubscribed, hasAccess } = useSubscription()

// Check if user has any subscription
const showPremiumContent = computed(() => isSubscribed.value)

// Check if user has specific plan access
const canAccessFeature = computed(() => hasAccess(['pro', 'elite']))
</script>

<template>
  <div v-if="showPremiumContent">
    <p>Your plan: {{ currentPlan }}</p>
  </div>

  <div v-else>
    <Button as-child>
      <NuxtLink to="/pricing">Upgrade to Pro</NuxtLink>
    </Button>
  </div>
</template>

Customer portal

Allow customers to manage their subscriptions:

<script setup lang="ts">
const { openPortal } = useSubscription()

async function handleManageSubscription() {
  await openPortal()
}
</script>

<template>
  <Button @click="handleManageSubscription"> Manage subscription </Button>
</template>

The portal link is generated in server/api/stripe/portal-session.post.ts:

const session = await stripe.billingPortal.sessions.create({
  customer: customerId,
  return_url: `${config.public.siteUrl}/settings`,
})

return { url: session.url }

Dynamic pricing by country

The boilerplate uses IPinfo to detect the user's country and show appropriate pricing:

<script setup lang="ts">
const { data: ipInfo } = await useFetch('/api/ip/info')
const currency = computed(() => {
  // Show Euro for EU countries, USD otherwise
  if (ipInfo.value?.isEU) {
    return 'EUR'
  }
  return 'USD'
})

const prices = {
  USD: { monthly: 29, yearly: 290 },
  EUR: { monthly: 25, yearly: 250 },
}
</script>

<template>
  <div>
    <p>Monthly: {{ prices[currency].monthly }} {{ currency }}</p>
  </div>
</template>
Learn more about IP-based country detection in the geolocation guide.

Testing

Test mode

Stripe provides test cards for development:

  • 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 Stripe CLI
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

Common tasks

Cancel a subscription

const stripe = new Stripe(config.stripeSecretKey)

await stripe.subscriptions.update(subscriptionId, {
  cancel_at_period_end: true,
})

Reactivate a subscription

await stripe.subscriptions.update(subscriptionId, {
  cancel_at_period_end: false,
})

Issue a refund

await stripe.refunds.create({
  payment_intent: paymentIntentId,
  amount: amountInCents, // Optional, refunds full amount if omitted
})

Change subscription plan

const subscription = await stripe.subscriptions.retrieve(subscriptionId)

await stripe.subscriptions.update(subscriptionId, {
  items: [
    {
      id: subscription.items.data[0].id,
      price: newPriceId,
    },
  ],
})

Best practices

Always verify webhooks

// Verify signature to ensure webhook is from Stripe
const webhookEvent = stripe.webhooks.constructEvent(body, signature, webhookSecret)

Handle idempotency

The boilerplate stores webhook events to prevent duplicate processing:

// Check if already processed
const existing = await prisma.webhookEvent.findUnique({
  where: { processorEventId: event.id },
})

if (existing?.processed) {
  return { received: true }
}

Use metadata

Store important data in Stripe metadata:

await stripe.checkout.sessions.create({
  // ...
  metadata: {
    userId: user.id,
    mode: 'auth',
    source: 'web',
  },
})

Test thoroughly

  • Test successful payments
  • Test failed payments
  • Test subscription cancellations
  • Test webhook failures and retries
  • Test both auth and authless modes

Troubleshooting

Webhooks not working

  1. Check webhook endpoint URL is correct
  2. Verify webhook secret matches
  3. Check server logs for errors
  4. Use Stripe CLI to test locally

Payments not creating subscriptions

  1. Check webhook events are being received
  2. Verify database has correct schema
  3. Check for errors in webhook handler logs
  4. Ensure Stripe product/price IDs are correct

Access control not working

  1. Verify subscription status in database
  2. Check currentPeriodEnd is in the future
  3. Ensure middleware is applied to routes
  4. Check for authentication issues

Reference

For detailed Stripe integration guides and best practices, always refer to the official Stripe documentation.