Commerce

Stripe

Gives your app one verified Stripe webhook and a Stripe customer per user, for Payments and Subscriptions.

Stripe tells your app that money moved by calling a webhook, and every feature that takes money needs those events. This module receives them once: it verifies each event, skips duplicates and passes it to whoever listens. It also links each user to a Stripe customer. You rarely install it on its own: Payments and Subscriptions install it for you.

The package is @frigear-nu/stripe. It stores no products or prices, only each user's Stripe customer id.

What you get

  • Server routes (on by default): one webhook endpoint at /api/stripe/webhook.
  • Composables and helpers (on by default): a shared Stripe client, a Stripe customer for any user, and two ways to listen to events.
ServerClientMobile
Installed for you:@frigear-nu/module-kit and Users.You add:@nuxthub/core with key-value storage (KV) on. hub.kv is on by default, and the build fails without it.Optional: nothing.

Quick setup

Install Stripe

pnpm add @frigear-nu/stripe @nuxthub/core

Add it to nuxt.config.ts

nuxt.config.ts
export default defineNuxtConfig({
  modules: ['@nuxthub/core', '@frigear-nu/stripe'], // installs Users for you
  hub: { db: { dialect: 'sqlite', driver: 'libsql' }, kv: true },
})

Generate the migration

Without a migration, every write to the users table fails: the database lacks the stripe_customer_id column.

The module adds a column to users. Generate a migration and commit it:

pnpm db:generate
Use test keys (sk_test_...) until the whole flow works: live keys charge real cards.

Connect your Stripe account

Set the keys and add the webhook endpoint in the Stripe dashboard, as Stripe setup describes. That page also lists the events each module needs.

What you should see: with stripe listen forwarding to your dev server, stripe trigger customer.created shows the event delivered with status 200.

Options

Options go under the stripe key.

OptionTypeDefaultPurpose
settings.webhookPathstring'/api/stripe/webhook'The path of the webhook endpoint.

settings.webhookPath gets a leading / when it has none. @frigear-nu/stripe exports the default as DEFAULT_WEBHOOK_PATH, and the pinned API version as STRIPE_API_VERSION (currently 2026-09-30.endive).

Runtime config

Keys are secrets, so set them through runtime config, never process.env.

Runtime configEnv varPurpose
stripe.secretKeyNUXT_STRIPE_SECRET_KEYThe secret API key (sk_...), or a restricted key.
stripe.webhookSecretNUXT_STRIPE_WEBHOOK_SECRETThe signing secret of the webhook endpoint (whsec_...).
stripe.apiVersionNUXT_STRIPE_API_VERSIONOptional. Overrides STRIPE_API_VERSION.
public.stripe.publishableKeyNUXT_PUBLIC_STRIPE_PUBLISHABLE_KEYThe publishable key (pk_...), exposed to the browser.

Server

These helpers are auto-imported in Nitro.

HelperReturnsWhat it does
useStripe()StripeThe shared client. It uses fetch, so it runs on Cloudflare Workers.
ensureStripeCustomer(userId)Promise<string>The user's Stripe customer id, found or created on first use.
getStripeCustomerUser(customerId)Promise<User | null>The local user of a Stripe customer id.
onStripeEvent(type, handler)voidListens to events at runtime. See In your app.
defineStripeEventHandler(handler)handlerTypes a handler file.

useStripe() throws 501 not_configured when the secret key is missing.

ensureStripeCustomer first reads the stored id, then searches Stripe by email, then creates a customer and stores it. Concurrent calls end up with one id. An unknown user throws 404.

@frigear-nu/stripe/utils exports registerStripeEventHandler, onStripeEvent and defineStripeEventHandler, and runs outside Nuxt as well. Module authors who send people to Stripe validate return URLs with resolveSameOriginUrl(event, url, { fallback, name }) from @frigear-nu/module-kit/server.

Listen to events

Register a handler at build time from a module, or at runtime from your app.

type StripeEventHandlerContext = {
  stripe: Stripe          // the useStripe() instance
  userId: number | null   // local user owning the event's customer, null if unknown
  event: H3Event          // the Nitro event of the webhook request
}

type StripeEventHandler<T extends StripeEventType = StripeEventType> = (
  event: StripeEventOf<T>,
  ctx: StripeEventHandlerContext,
) => void | Promise<void>

// Build time, from a module's setup()
registerStripeEventHandler(nuxt: Nuxt, options: {
  events: string[] | '*'  // Stripe event types, or '*' for every event
  handler: string         // absolute path to a file whose default export is a defineStripeEventHandler(...) result
  order?: number          // lower runs first, default 100
}): void

// Runtime, from a Nitro plugin
onStripeEvent<T extends StripeEventType>(
  type: T | T[] | '*',
  handler: StripeEventHandler<T>,
): void

In a module

A module registers a handler file from setup():

import { createResolver, defineNuxtModule } from '@nuxt/kit'
import { registerStripeEventHandler } from '@frigear-nu/stripe/utils'

export default defineNuxtModule({
  meta: { name: '@frigear-nu/example' },
  setup(options, nuxt) {
    const resolver = createResolver(import.meta.url)
    registerStripeEventHandler(nuxt, {
      events: ['customer.subscription.updated', 'checkout.session.completed'],
      handler: resolver.resolve('./runtime/server/stripe/example-events'),
      order: 10,
    })
  },
})
runtime/server/stripe/example-events.ts
import { defineStripeEventHandler } from '@frigear-nu/stripe/utils'

export default defineStripeEventHandler<'customer.subscription.updated' | 'checkout.session.completed'>(
  async (event, { stripe, userId }) => {
    // `event` is narrowed to the generic's event types. Must be idempotent.
    if (!userId) return
  },
)

Handler files are bundled into #stripe/handlers after every module's setup(), so the registration order does not matter.

In your app

server/plugins/stripe.ts
export default defineNitroPlugin(() => {
  onStripeEvent('invoice.payment_failed', async (event, ctx) => {
    // notify the user, flag the account, ...
  })
})

Runtime handlers have order 100. They run after build-time handlers of the same order.

Delivery and retries

  • Matching handlers run one after another, sorted by order. Handlers of equal order keep their registration order.
  • An error in one handler is logged, and the others still run.
  • If any handler throws, the webhook answers 500 and Stripe retries the whole event. So every handler must be idempotent.
  • The event id is stored in KV, under stripe:event:<event id> for 7 days, only after all handlers succeed. A duplicate delivery is then acknowledged without running them again.

Hooks

Your app can also answer this Nitro hook from a server plugin (server/plugins/*.ts).

HookWhenPayload
stripe:eventAfter the handlers of every verified, new event ran, also when they failed.(event: Stripe.Event, ctx: StripeEventHandlerContext)

Errors in stripe:event are logged and never fail the webhook.

Routes

Routes the module registers, and who may call them.

MethodPathAuthPurpose
POST/api/stripe/webhook (settings.webhookPath)none, Stripe signatureVerifies the event and passes it to the handlers.
Put no auth or CSRF middleware in front of this route: it verifies the raw body with constructEventAsync.
StatusWhen
200{ received: true }, or { received: true, duplicate: true } for an event id already processed.
400 bad_requestThe stripe-signature header or the body is missing, or the signature is invalid.
501 not_configuredNUXT_STRIPE_WEBHOOK_SECRET is not set.
500 handler_failedAt least one handler threw, and Stripe will retry.

Database

Tables the module adds. Generate and commit a migration after installing.

The module adds one nullable column to users, through a database extension: stripeCustomerId (stripe_customer_id). Do not add it to your own server/db/extend.sqlite.ts.

Next steps

Copyright © 2026