Stripe
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.
@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
export default defineNuxtConfig({
modules: ['@nuxthub/core', '@frigear-nu/stripe'], // installs Users for you
hub: { db: { dialect: 'sqlite', driver: 'libsql' }, kv: true },
})
Generate the migration
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
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.
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.
| Option | Type | Default | Purpose |
|---|---|---|---|
settings.webhookPath | string | '/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 config | Env var | Purpose |
|---|---|---|
stripe.secretKey | NUXT_STRIPE_SECRET_KEY | The secret API key (sk_...), or a restricted key. |
stripe.webhookSecret | NUXT_STRIPE_WEBHOOK_SECRET | The signing secret of the webhook endpoint (whsec_...). |
stripe.apiVersion | NUXT_STRIPE_API_VERSION | Optional. Overrides STRIPE_API_VERSION. |
public.stripe.publishableKey | NUXT_PUBLIC_STRIPE_PUBLISHABLE_KEY | The publishable key (pk_...), exposed to the browser. |
Server
These helpers are auto-imported in Nitro.
| Helper | Returns | What it does |
|---|---|---|
useStripe() | Stripe | The 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) | void | Listens to events at runtime. See In your app. |
defineStripeEventHandler(handler) | handler | Types 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,
})
},
})
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
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
500and 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).
| Hook | When | Payload |
|---|---|---|
stripe:event | After 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.
| Method | Path | Auth | Purpose |
|---|---|---|---|
POST | /api/stripe/webhook (settings.webhookPath) | none, Stripe signature | Verifies the event and passes it to the handlers. |
constructEventAsync.| Status | When |
|---|---|
200 | { received: true }, or { received: true, duplicate: true } for an event id already processed. |
400 bad_request | The stripe-signature header or the body is missing, or the signature is invalid. |
501 not_configured | NUXT_STRIPE_WEBHOOK_SECRET is not set. |
500 handler_failed | At 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
- Stripe setup – connect your Stripe account and pick the webhook events.
- Sell event tickets – follow one payment from checkout to a webhook event.