Modules

Stripe

Stripe core - the SDK client, user-to-customer linking, one verified webhook endpoint and an event-listener API other modules plug into.

Provider plumbing for Stripe. It owns the Stripe client, the link between a user and a Stripe customer, and one webhook endpoint. Feature modules such as Subscriptions and Payments register listeners for the Stripe events they care about; they never verify signatures or deal with retries themselves.

Stripe stays the source of truth for catalog data: the module stores no products or prices, only the customer id per user. Policy (seasons, discounts, ...) is injected through hooks in the feature modules, not encoded in Stripe.

Requires@nuxthub/core with KV enabled (hub.kv, set by default; the build throws if it is disabled), @frigear-nu/module-kit and @frigear-nu/users.

Quick setup

export default defineNuxtConfig({
  modules: ['@nuxthub/core', '@frigear-nu/users', '@frigear-nu/stripe'],
  hub: { db: { dialect: 'sqlite', driver: 'libsql' }, kv: true },
  stripe: {
    settings: { webhookPath: '/api/stripe/webhook' }, // default
  },
})

Options

OptionTypeDefaultPurpose
settings.webhookPathstring/api/stripe/webhookPath the webhook is served on.

The default path is exported as DEFAULT_WEBHOOK_PATH, and the pinned API version as STRIPE_API_VERSION (currently 2026-09-30.endive), both from @frigear-nu/stripe.

Environment variables

Read them through runtime config, never process.env.

VariableRuntime configPurpose
NUXT_STRIPE_SECRET_KEYstripe.secretKeyServer-side secret API key (sk_... / restricted key).
NUXT_STRIPE_WEBHOOK_SECRETstripe.webhookSecretSigning secret of the webhook endpoint (whsec_...).
NUXT_STRIPE_API_VERSIONstripe.apiVersionOptional. Overrides STRIPE_API_VERSION.
NUXT_PUBLIC_STRIPE_PUBLISHABLE_KEYpublic.stripe.publishableKeyPublishable key (pk_...), exposed to the client.

KV

Webhook idempotency uses NuxtHub KV; unstorage must be resolvable by the host app. Processed event ids are stored under stripe:event:<event id>. No extra table is needed.

Server

All utilities are auto-imported in Nitro.

UtilityReturnsDescription
useStripe()StripeShared client built on fetch, so it runs on Cloudflare Workers. Throws 500 when secretKey is missing.
ensureStripeCustomer(userId)Promise<string>The stored stripeCustomerId, else a Stripe search by email, else a new customer, which is persisted. Concurrent calls converge on one id. Throws 404 for an unknown user.
getStripeCustomerUser(customerId)Promise<User | null>The local users row for a Stripe customer id.
onStripeEvent(type, handler)voidRuntime listener, see Listening to events.
defineStripeEventHandler(handler)handlerTyped identity helper for handler files.

@frigear-nu/stripe/utils is framework-agnostic and also exports registerStripeEventHandler, onStripeEvent, defineStripeEventHandler and the shared redirect guard:

// Accepts a relative path ("/x") or an absolute URL on `origin`; returns an absolute URL
// on that origin, or null when unsafe (protocol-relative, other host, unparsable).
function resolveSameOriginUrl(input: string, origin: string): string | null

Every module that sends people through Stripe (payments, subscriptions, events) uses it for return URLs.

Listening to events

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

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
  },
)

Handlers are bundled into a generated server template (#stripe/handlers) after every module has run setup(), so registration order between modules does not matter and no runtime scanning happens. Registering the same file for the same events twice is a no-op.

In a host app

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

Runtime handlers use order 100, after build-time handlers of equal order.

Delivery and retries

  • Matching handlers run sequentially, sorted by order (stable for equal values). An error in one is logged and does not stop the others.
  • If any handler throws, the webhook responds 500 and Stripe retries the whole event, so handlers must be idempotent.
  • The event id is written to KV (TTL 7 days) only after all handlers succeed; a duplicate delivery is acknowledged without running handlers again.

Hooks

HookWhenPayload
stripe:eventNitro hook, after the handlers ran, for every verified, not yet processed event, including events whose handlers failed (and will be retried). Errors in it are logged and never fail the webhook.(event: Stripe.Event, ctx: StripeEventHandlerContext)

Routes

MethodPathAuthPurpose
POST/api/stripe/webhook (webhookPath)none (Stripe signature)Verifies the signature, dispatches the event to handlers.

It reads the raw body and verifies with constructEventAsync. Make sure no auth or CSRF middleware sits in front of it.

StatusMeaning
200{ received: true }, or { received: true, duplicate: true } for an already processed event id.
400Missing stripe-signature header, missing body, or an invalid signature.
500NUXT_STRIPE_WEBHOOK_SECRET is not set, or at least one handler threw (Stripe will retry).

Stripe dashboard setup

  1. In Developers > Webhooks, add an endpoint pointing at https://<your-host>/api/stripe/webhook.
  2. Select the events your installed modules need (for example the list in Subscriptions).
  3. Copy the signing secret into NUXT_STRIPE_WEBHOOK_SECRET.

For local development, forward events with the Stripe CLI and use the secret it prints:

stripe listen --forward-to localhost:3000/api/stripe/webhook

Database

Extends the users table with a nullable stripeCustomerId (stripe_customer_id) column through extendDatabaseTable. Do not add it to your host extend.sqlite.ts.

The module changes the generated schema, not the database: generate and commit a migration in every host app and fixture that uses it. See Module Kit.
Copyright © 2026