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.

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 }
})
Answer within the provider's timeout (Polar: a few seconds). If processing grows, acknowledge first and finish in the background (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: false on 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/permissions and the UI agree); quotas are beforeCreate hooks.
  • 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.

Need a Landing Page?

Modern landing pages with optional modules (blog, docs, forms, i18n). Let's discuss your project.

Build Your MVP

Full-stack SaaS development. Expert in database design, multi-tenancy, and scalable architecture.

Deployment Help

Dockerize your backend, set up CI/CD pipelines, deploy to Cloudflare or Hetzner. Early-stage setup.

Suggest a SaaS Tool

Missing a calculator or tool? Suggest what you'd like to see on our site.