Billing & Plan Gating
Billing & Plan Gating
Subscriptions, plan-gated features and usage quotas on top of auto-api: the payment provider owns the money, a
webhook keeps a subscriptions table in sync, a context extender puts the caller's plan on every request, and
resources gate on it like on any other permission. The UI uses Nuxt UI's pricing components.
The examples use Polar with organization-level billing (one subscription per workspace); the same shape works for Stripe or Paddle — only the checkout call and the webhook payload differ.
checkout (createEndpoint) ──► provider ──► webhook ──► subscriptions table
│
request ──► context extender: ctx.requestMeta.billing ◄─────┘
│
├─► permissions: create: ctx => billing.features.includes('projects')
├─► quotas: beforeCreate hook counts rows
└─► GET /api/permissions ──► usePermissions() ──► UI
1. Plans
Keep the catalog in config so server and client read the same thing:
// nuxt.config.ts
export default defineNuxtConfig({
runtimeConfig: {
polar: { accessToken: '', webhookSecret: '', server: 'sandbox' }, // NUXT_POLAR_* in production
public: {
plans: {
free: { label: 'Free', price: '$0', features: ['projects'], limits: { projects: 3 } },
pro: {
label: 'Pro', price: '$19', productId: 'prod_…', features: ['projects', 'reports', 'api'],
limits: { projects: 50 },
},
business: {
label: 'Business', price: '$49', productId: 'prod_…', features: ['projects', 'reports', 'api', 'sso'],
limits: { projects: null }, // null = unlimited
},
},
},
},
})
2. The subscriptions table
// server/database/schema.ts
export const subscriptions = sqliteTable('subscriptions', {
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
organizationId: text('organization_id').notNull().unique(), // tenant column — one per workspace
providerSubscriptionId: text('provider_subscription_id'),
planId: text('plan_id').notNull().default('free'),
status: text('status', { enum: ['active', 'trialing', 'past_due', 'canceled', 'none'] }).notNull().default('none'),
currentPeriodEnd: integer('current_period_end', { mode: 'timestamp' }),
updatedAt: integer('updated_at', { mode: 'timestamp' }).$defaultFn(() => new Date()).$onUpdate(() => new Date()),
})
// Webhook idempotency: providers retry, so remember what was processed.
export const billingEvents = sqliteTable('billing_events', {
id: text('id').primaryKey(), // the provider's event id
type: text('type').notNull(),
receivedAt: integer('received_at', { mode: 'timestamp' }).$defaultFn(() => new Date()),
})
Register the subscription read-only. Writes come from the webhook only, so no request can grant itself a plan:
// modules/billing/auth.ts
import type { HandlerContext, ResourceAuthConfig } from '@websideproject/nuxt-auto-api'
const isOrgAdmin = (ctx: HandlerContext) => ['owner', 'admin'].includes(ctx.user?.orgRole ?? '')
export const subscriptionsAuth: ResourceAuthConfig = {
permissions: {
read: ctx => !!ctx.user, // tenancy limits it to the active workspace's row
create: false,
update: false,
delete: false,
},
custom: {
checkout: { permissions: { create: isOrgAdmin } }, // only workspace admins can buy
portal: { permissions: { read: isOrgAdmin } },
},
}
// modules/billing/index.ts
nuxt.hook('autoApi:registerSchema', (registry) => {
registry.register('subscriptions', {
schema: createModuleImport(resolve('../../server/database/schema'), 'subscriptions'),
authorization: createModuleImport(resolve('./auth'), 'subscriptionsAuth'),
})
})
billingEvents is not registered — it is internal.
3. Checkout and the customer portal
Both are custom endpoints on the subscriptions resource, opened by the named gates above:
// server/utils/polar.ts
import { Polar } from '@polar-sh/sdk'
let _polar: Polar | undefined
export function usePolar() {
const { polar } = useRuntimeConfig()
_polar ??= new Polar({ accessToken: polar.accessToken, server: polar.server as 'sandbox' | 'production' })
return _polar
}
// server/api/billing/checkout.post.ts
import { z } from 'zod'
export default createEndpoint({
resource: 'subscriptions',
operation: 'create',
endpointName: 'checkout', // → custom.checkout: workspace admins only
skipValidation: true, // the Zod body below is the contract, not the table's create schema
body: z.object({ planId: z.string() }),
async handler(ctx, event) {
const plan = useRuntimeConfig().public.plans[ctx.body.planId]
if (!plan?.productId) throw createError({ statusCode: 400, message: 'Unknown or free plan' })
const checkout = await usePolar().checkouts.create({
products: [plan.productId],
externalCustomerId: ctx.tenant!.id as string, // the WORKSPACE is the customer
customerEmail: ctx.user!.email,
metadata: { organizationId: String(ctx.tenant!.id) },
successUrl: `${getRequestURL(event).origin}/app/billing?success=1`,
})
return { url: checkout.url }
},
})
// server/api/billing/portal.get.ts
export default createEndpoint({
resource: 'subscriptions',
operation: 'get',
endpointName: 'portal',
async handler(ctx) {
const session = await usePolar().customerSessions.create({ externalCustomerId: String(ctx.tenant!.id) })
return { url: session.customerPortalUrl }
},
})
ctx.tenant is the active workspace resolved on the server — never trust an organization id from the body.
4. The webhook
The webhook is a plain Nitro route (the provider is not a user), verifies the signature, is idempotent, and writes
with Drizzle directly — the only writer of subscriptions:
// server/api/webhooks/polar.post.ts
import { validateEvent, WebhookVerificationError } from '@polar-sh/sdk/webhooks'
import { eq } from 'drizzle-orm'
import { billingEvents, subscriptions } from '~~/server/database/schema'
export default defineEventHandler(async (event) => {
const body = await readRawBody(event)
if (!body) throw createError({ statusCode: 400 })
let payload: any
try {
payload = validateEvent(body, Object.fromEntries(event.headers.entries()), useRuntimeConfig().polar.webhookSecret)
}
catch (error) {
if (error instanceof WebhookVerificationError) throw createError({ statusCode: 403, message: 'Bad signature' })
throw error
}
const { db } = getDb()
const eventId = event.headers.get('webhook-id') ?? `${payload.type}:${payload.data?.id}`
const [seen] = await db.select().from(billingEvents).where(eq(billingEvents.id, eventId)).limit(1)
if (seen) return { ok: true } // a retry
await db.insert(billingEvents).values({ id: eventId, type: payload.type })
if (payload.type.startsWith('subscription.')) {
const sub = payload.data
const organizationId = sub.customer?.externalId ?? sub.metadata?.organizationId
if (!organizationId) return { ok: true }
const plans = useRuntimeConfig().public.plans
const planId = Object.entries(plans).find(([, p]: any) => p.productId === sub.productId)?.[0] ?? 'free'
const status = ['active', 'trialing', 'past_due'].includes(sub.status) ? sub.status : 'canceled'
const values = {
organizationId,
providerSubscriptionId: sub.id,
planId: status === 'canceled' ? 'free' : planId,
status,
currentPeriodEnd: sub.currentPeriodEnd ? new Date(sub.currentPeriodEnd) : null,
}
await db.insert(subscriptions).values(values)
.onConflictDoUpdate({ target: subscriptions.organizationId, set: values })
}
return { ok: true }
})
event.waitUntil(...) on Cloudflare, a queue elsewhere).5. Put the plan on every request
A context extender runs after authentication and before authorization, so every permission function, hook and custom endpoint can read the plan:
// server/autoapi-plugins.ts
import { createBetterAuthPlugin, defineAutoApiPlugin } from '@websideproject/nuxt-auto-api/plugins'
import { eq } from 'drizzle-orm'
import { subscriptions } from './database/schema'
const billingContext = defineAutoApiPlugin({
name: 'billing-context',
runtimeSetup(ctx) {
const plans = (ctx.runtimeConfig.public as any).plans
ctx.extendContext(async (handlerCtx) => {
const orgId = handlerCtx.user?.organizationId
if (!orgId) return
const [sub] = await handlerCtx.db.select().from(subscriptions).where(eq(subscriptions.organizationId, orgId)).limit(1)
const planId = sub && ['active', 'trialing', 'past_due'].includes(sub.status) ? sub.planId : 'free'
handlerCtx.requestMeta = {
...handlerCtx.requestMeta,
billing: {
planId,
status: sub?.status ?? 'none',
features: plans[planId]?.features ?? [],
limits: plans[planId]?.limits ?? {},
},
}
})
},
})
// Order matters: the session plugin sets ctx.user first.
export default [createBetterAuthPlugin({ /* … see Better-Auth */ }), billingContext]
This costs one indexed query per API request. Cache it (KV, or on the session) if that matters for you.
6. Gate features
A feature is a permission like any other:
// modules/reports/auth.ts
const hasFeature = (name: string) => (ctx: HandlerContext) => ctx.requestMeta?.billing?.features.includes(name) ?? false
export const reportsAuth: ResourceAuthConfig = {
permissions: {
read: hasFeature('reports'),
create: hasFeature('reports'),
update: hasFeature('reports'),
delete: hasFeature('reports'),
aggregate: hasFeature('reports'),
},
}
A Free workspace gets 403 on /api/reports — and GET /api/permissions reports canRead: false, so the UI can
show an upgrade prompt instead of an error. For policies the client can also read (why is it blocked? which plan
unlocks it?), use permission objects and an evaluator — see
Permissions Cookbook › Declarative policies.
7. Enforce quotas
Limits are counted at write time, in a beforeCreate hook:
// modules/projects/hooks.ts
import { and, count, eq, isNull } from 'drizzle-orm'
import { projects } from '~~/server/database/schema'
export const projectsHooks = {
async beforeCreate(data: any, ctx: any) {
const limit = ctx.requestMeta?.billing?.limits?.projects
if (limit == null) return data // unlimited
const [{ n }] = await ctx.db.select({ n: count() }).from(projects)
.where(and(eq(projects.organizationId, ctx.tenant.id), isNull(projects.deletedAt)))
if (n >= limit) {
throw createError({ statusCode: 402, message: `Your plan allows ${limit} projects`, data: { reason: 'quota', key: 'projects' } })
}
return data
},
}
A hook's own status is kept: the client receives 402 with data.reason, which it can turn into an upgrade
prompt. On bulk create every item passes through the hook.
8. The UI
Billing state
// app/composables/useBilling.ts
export function useBilling() {
const { data, refetch } = useAutoApiList('subscriptions', { limit: 1 }) // tenancy → this workspace's row
const sub = computed(() => data.value?.data[0] ?? null)
const plans = useRuntimeConfig().public.plans
const planId = computed(() => sub.value && ['active', 'trialing', 'past_due'].includes(sub.value.status) ? sub.value.planId : 'free')
return {
sub,
planId,
plan: computed(() => plans[planId.value]),
isPastDue: computed(() => sub.value?.status === 'past_due'),
hasFeature: (name: string) => (plans[planId.value]?.features ?? []).includes(name),
refetch,
}
}
Pricing page with UPricingPlans
<!-- app/pages/app/billing.vue -->
<script setup lang="ts">
const { planId, sub, isPastDue, refetch } = useBilling()
const plans = useRuntimeConfig().public.plans
const toast = useToast()
const route = useRoute()
const checkout = useAutoApiEndpointMutation<{ url: string }, { planId: string }>('/api/billing/checkout', {
onSuccess: res => navigateTo((res as any).data.url, { external: true }),
onError: error => toast.add({ title: 'Could not start checkout', description: error.message, color: 'error' }),
})
const portal = useAutoApiEndpointQuery<{ url: string }>('/api/billing/portal', undefined, {
queryKey: ['billing', 'portal'],
unwrap: true,
enabled: false, // fetched on click
})
const pricing = computed(() => Object.entries(plans).map(([id, p]: [string, any]) => ({
title: p.label,
price: p.price,
billingCycle: '/month',
features: p.features.map((f: string) => ({ title: f, icon: 'i-lucide-check' })),
highlight: id === 'pro',
badge: id === planId.value ? 'Current plan' : undefined,
button: id === planId.value || !p.productId
? { label: id === planId.value ? 'Current plan' : 'Included', disabled: true, variant: 'outline' as const }
: { label: `Upgrade to ${p.label}`, loading: checkout.isPending.value, onClick: () => checkout.mutate({ planId: id }) },
})))
async function openPortal() {
const { data } = await portal.refetch()
if (data?.url) await navigateTo(data.url, { external: true })
}
// Back from checkout: the webhook may land a moment later.
onMounted(() => {
if (route.query.success) setTimeout(refetch, 2000)
})
</script>
<template>
<UContainer class="space-y-8 py-10">
<UAlert
v-if="isPastDue"
color="error"
icon="i-lucide-credit-card"
title="Payment failed"
description="Update your payment method to keep access to paid features."
:actions="[{ label: 'Update payment', onClick: openPortal }]"
/>
<UCard v-if="sub?.providerSubscriptionId">
<div class="flex items-center justify-between">
<div>
<p class="font-medium">{{ plans[planId]?.label }} plan</p>
<p v-if="sub.currentPeriodEnd" class="text-sm text-muted">
Renews {{ new Date(sub.currentPeriodEnd).toLocaleDateString() }}
</p>
</div>
<UButton label="Manage billing" variant="outline" icon="i-lucide-external-link" @click="openPortal" />
</div>
</UCard>
<UPricingPlans :plans="pricing" />
</UContainer>
</template>
Upgrade prompts where the feature lives
<!-- app/components/FeatureGate.vue -->
<script setup lang="ts">
const props = defineProps<{ resource: string, feature: string }>()
const { canRead, isLoading } = usePermissions(props.resource) // the server's decision, plan included
</script>
<template>
<USkeleton v-if="isLoading" class="h-32 w-full" />
<slot v-else-if="canRead" />
<UCard v-else>
<div class="flex items-center gap-4">
<UIcon name="i-lucide-sparkles" class="size-6 text-primary" />
<div class="flex-1">
<p class="font-medium">Unlock {{ feature }}</p>
<p class="text-sm text-muted">Available on the Pro plan and above.</p>
</div>
<UButton label="See plans" to="/app/billing" />
</div>
</UCard>
</template>
<FeatureGate resource="reports" feature="Reports">
<ReportsDashboard />
</FeatureGate>
Quota errors
const toast = useToast()
const { mutate } = useAutoApiCreate('projects', {
onError: (error: any) => {
const quota = error.data?.data?.reason === 'quota'
toast.add({
title: quota ? 'Plan limit reached' : 'Could not create project',
description: error.data?.message ?? error.message,
color: quota ? 'warning' : 'error',
actions: quota ? [{ label: 'Upgrade', to: '/app/billing' }] : undefined,
})
},
})
Checklist
- The subscription row is written only by the verified webhook (
create/update: falseon the resource). - Checkout and portal derive the customer from
ctx.tenant/ctx.user, never from the body. - The webhook is idempotent (event id table) and fast.
- Feature gates are permissions (so
/api/permissionsand the UI agree); quotas arebeforeCreatehooks. - A canceled / past-due subscription falls back to Free limits in the context extender.
- Test: a Free workspace gets 403 on a Pro resource and 402 past the quota; a webhook replay is a no-op.
Permissions Cookbook
Recipes for fine-grained access control, from "public blog" to "plan-gated, per-field, per-organization". The concepts behind them are in Authentication & Authorization.
Validation
@websideproject/nuxt-auto-api uses Zod for validation with automatic schema generation from Drizzle tables via drizzle-zod.