Stripe
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.
@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
| Option | Type | Default | Purpose |
|---|---|---|---|
settings.webhookPath | string | /api/stripe/webhook | Path 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.
| Variable | Runtime config | Purpose |
|---|---|---|
NUXT_STRIPE_SECRET_KEY | stripe.secretKey | Server-side secret API key (sk_... / restricted key). |
NUXT_STRIPE_WEBHOOK_SECRET | stripe.webhookSecret | Signing secret of the webhook endpoint (whsec_...). |
NUXT_STRIPE_API_VERSION | stripe.apiVersion | Optional. Overrides STRIPE_API_VERSION. |
NUXT_PUBLIC_STRIPE_PUBLISHABLE_KEY | public.stripe.publishableKey | Publishable 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.
| Utility | Returns | Description |
|---|---|---|
useStripe() | Stripe | Shared 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) | void | Runtime listener, see Listening to events. |
defineStripeEventHandler(handler) | handler | Typed 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
500and 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
| Hook | When | Payload |
|---|---|---|
stripe:event | Nitro 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
| Method | Path | Auth | Purpose |
|---|---|---|---|
| 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.
| Status | Meaning |
|---|---|
200 | { received: true }, or { received: true, duplicate: true } for an already processed event id. |
400 | Missing stripe-signature header, missing body, or an invalid signature. |
500 | NUXT_STRIPE_WEBHOOK_SECRET is not set, or at least one handler threw (Stripe will retry). |
Stripe dashboard setup
- In Developers > Webhooks, add an endpoint pointing at
https://<your-host>/api/stripe/webhook. - Select the events your installed modules need (for example the list in Subscriptions).
- 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.