@madebylars.com/mbl-payment
v1.0.1
Published
A portable Nuxt 4 payments module with Stripe support and a provider abstraction layer
Readme
nuxt-payments
A portable Nuxt 4 payments module with full TypeScript support. Encapsulates all payment provider logic into a self-contained, reusable module — Stripe ships out of the box, and the provider abstraction layer makes it straightforward to add PayPal, Paddle, or any other provider later.
Features
- Stripe support — checkout sessions, subscriptions, customer portal, webhook handling
- Provider abstraction — swap or extend providers without touching module internals
- Auto-imported composables —
useCheckout(),useSubscription(),usePayments() - Stripe.js client plugin —
$stripeavailable everywhere viausePayments() - Webhook signature verification — Stripe's
constructEventunder the hood - Nuxt server hooks — react to payment events from anywhere in your app
- Zod-validated routes — all API endpoints validate inputs at the boundary
- No UI — logic and API only; bring your own components
Installation
npm install @madebylars.com/mbl-paymentAdd the module to nuxt.config.ts:
export default defineNuxtConfig({
modules: ['@madebylars.com/mbl-payment'],
payments: {
defaultProvider: 'stripe',
providers: {
stripe: {
secretKey: process.env.STRIPE_SECRET_KEY,
webhookSecret: process.env.STRIPE_WEBHOOK_SECRET,
publishableKey: process.env.STRIPE_PUBLISHABLE_KEY,
},
},
},
})Environment variables
STRIPE_SECRET_KEY=sk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...
STRIPE_PUBLISHABLE_KEY=pk_live_...Nuxt's built-in runtime config override also works — prefix any key with NUXT_:
NUXT_PAYMENTS_STRIPE_SECRET_KEY=sk_live_...Server API
All routes are registered automatically. No extra setup needed.
POST /api/payments/checkout
Creates a Stripe Checkout session. Either priceId (single-item shorthand) or items (multi-item cart) must be provided.
Body
| Field | Type | Required | Default |
|---|---|---|---|
| priceId | string | One of priceId / items | — |
| items | LineItem[] | One of priceId / items | — |
| successUrl | string (URL) | Yes | — |
| cancelUrl | string (URL) | Yes | — |
| mode | 'payment' \| 'subscription' | No | 'payment' |
| customerId | string | No | — |
| quantity | number | No (only with priceId) | 1 |
| metadata | Record<string, string> | No | — |
LineItem
| Field | Type | Required | Default |
|---|---|---|---|
| priceId | string | Yes | — |
| quantity | number | No | 1 |
Response — CheckoutSession
{
"id": "cs_live_...",
"url": "https://checkout.stripe.com/...",
"status": "open",
"customerId": "cus_..."
}POST /api/payments/webhook
Receives and verifies Stripe webhook events. Point your Stripe dashboard webhook to this endpoint.
Headers required: stripe-signature
After verification, the module emits a Nuxt server hook so your app can react (see Server hooks).
GET /api/payments/portal?customerId=cus_...
Returns a Stripe Billing Portal URL for the given customer.
Query
| Field | Type | Required |
|---|---|---|
| customerId | string | Yes |
Response
{ "url": "https://billing.stripe.com/..." }POST /api/payments/cancel
Cancels an active subscription immediately.
Body
| Field | Type | Required |
|---|---|---|
| subscriptionId | string | Yes |
Response
{ "cancelled": true }Composables
All three composables are auto-imported — no import statement needed.
useCheckout()
const { createSession, redirectToCheckout, loading, error } = useCheckout()
// Single item — priceId shorthand
await redirectToCheckout({
priceId: 'price_...',
successUrl: 'https://example.com/success',
cancelUrl: 'https://example.com/cancel',
})
// Multi-item cart
await redirectToCheckout({
items: [
{ priceId: 'price_abc', quantity: 2 },
{ priceId: 'price_xyz', quantity: 1 },
],
successUrl: 'https://example.com/success',
cancelUrl: 'https://example.com/cancel',
})
// Or get the session back without redirecting
const session = await createSession({ priceId: 'price_...', successUrl: '...', cancelUrl: '...' })| Return | Type | Description |
|---|---|---|
| createSession | (options: CheckoutOptions) => Promise<CheckoutSession \| null> | Creates a checkout session |
| redirectToCheckout | (options: CheckoutOptions) => Promise<void> | Creates a session and redirects the browser |
| loading | Ref<boolean> | True while the request is in flight |
| error | Ref<Error \| null> | Set if the request failed |
useSubscription()
const { subscribe, cancel, currentSubscription, loading, error } = useSubscription()
// Start a Stripe Checkout flow for a subscription
await subscribe({
priceId: 'price_...',
customerId: 'cus_...',
successUrl: 'https://example.com/success',
cancelUrl: 'https://example.com/cancel',
})
// Cancel an active subscription
await cancel('sub_...')| Return | Type | Description |
|---|---|---|
| subscribe | (options) => Promise<void> | Opens Stripe Checkout in subscription mode |
| cancel | (subscriptionId: string) => Promise<void> | Cancels the subscription immediately |
| currentSubscription | Ref<Subscription \| null> | Reactive subscription state (cleared on cancel) |
| loading | Ref<boolean> | True while a request is in flight |
| error | Ref<Error \| null> | Set if a request failed |
usePayments()
const { stripe, getPortalUrl } = usePayments()
// Raw Stripe.js instance — use for custom payment flows
stripe?.elements(...)
// Redirect to Stripe Billing Portal
const url = await getPortalUrl('cus_...')
if (url) window.location.href = url| Return | Type | Description |
|---|---|---|
| stripe | Stripe \| null | Initialized Stripe.js instance |
| getPortalUrl | (customerId: string) => Promise<string \| null> | Fetches the Billing Portal URL |
Server hooks
After each verified webhook event, the module calls a Nitro hook your server plugins can listen to. Register listeners in server/plugins/payments.ts:
// server/plugins/payments.ts
export default defineNitroPlugin((nitroApp) => {
nitroApp.hooks.hook('payments:checkout:completed', async (data) => {
const session = data as Stripe.Checkout.Session
// provision access, send welcome email, etc.
})
nitroApp.hooks.hook('payments:subscription:created', async (data) => { /* ... */ })
nitroApp.hooks.hook('payments:subscription:updated', async (data) => { /* ... */ })
nitroApp.hooks.hook('payments:subscription:cancelled', async (data) => { /* ... */ })
nitroApp.hooks.hook('payments:payment:failed', async (data) => { /* ... */ })
})| Hook | Stripe event |
|---|---|
| payments:checkout:completed | checkout.session.completed |
| payments:subscription:created | customer.subscription.created |
| payments:subscription:updated | customer.subscription.updated |
| payments:subscription:cancelled | customer.subscription.deleted |
| payments:payment:failed | invoice.payment_failed |
TypeScript
All types are exported from the module root:
import type {
LineItem,
CheckoutOptions,
CheckoutSession,
SubscriptionOptions,
Subscription,
RawWebhookEvent,
WebhookResult,
PaymentProvider,
PaymentsModuleOptions,
} from '@madebylars.com/mbl-payment'Adding a provider
- Create
src/runtime/providers/myprovider/index.tsand implement thePaymentProviderinterface:
import type { PaymentProvider } from '@madebylars.com/mbl-payment'
export function createMyProvider(): PaymentProvider {
return {
name: 'myprovider',
async createCheckout(options) { /* ... */ },
async createSubscription(options) { /* ... */ },
async handleWebhook(event) { /* ... */ },
async cancelSubscription(id) { /* ... */ },
}
}- Add a branch to
src/runtime/server/utils/providerFactory.ts:
if (payments.defaultProvider === 'myprovider') {
return createMyProvider()
}- Extend
PaymentsModuleOptions.providersinsrc/types.tswith any new config fields.
Nothing else needs to change — the composables and API routes delegate to whatever provider is active.
Contribution
# Install dependencies
npm install
# Generate type stubs and prepare the playground
npm run dev:prepare
# Develop with the playground
npm run dev
# Build the playground
npm run dev:build
# Run ESLint
npm run lint
# Run Vitest
npm run test
npm run test:watch
# Release a new version
npm run release