In the last post I walked through the multi-tenant auth layer running in front of my Fastify API on AWS Lambda. JWT tokens, hashed API keys, tenant context decorated onto every request. That solved the "who is this and which tenant do they belong to" problem.
Then my second customer asked about pricing tiers. The third one wanted to upgrade themselves without emailing me. That's when I hit the wall every SaaS hits eventually. You need billing. Not someday, right now. And it has to know which plan a tenant is on before the request even reaches your handler, because some routes are pro-only and some are free-forever.
So I bolted Stripe onto the existing auth layer. One Stripe Customer per tenant. Plans modeled as Products and Prices in Stripe. Subscription state mirrored locally so the auth-then-billing pipeline can decide in microseconds whether this request is allowed. Webhooks for the source of truth. Checkout sessions for upgrades. The customer portal for everything else.
This post walks through the whole thing. The Fastify billing plugin that sits next to the auth plugin, the webhook handler with the raw-body gotcha that ate two hours of my life, the checkout flow, and the bits I got wrong before I got them right. The code is sanitized but it's the same code running in my production stack.

Photo by Towfiqu barbhuiya on Pexels
Architecture overview
I kept the model boring on purpose. One Stripe Customer per tenant, one active subscription per customer, plans stored as Stripe Products with one or more Prices. The local database mirrors the subscription state so every request can read the plan without calling Stripe.
┌──────────────────────┐ │ Tenant frontend │ └──────────┬───────────┘ │ ▼ ┌────────────────────────────────────────┐ │ API Gateway / Lambda URL │ │ ┌──────────────────────────────────┐ │ │ │ Fastify app │ │ │ │ 1. auth-plugin (tenantId) │ │ │ │ 2. billing-plugin (plan) │ │ │ │ 3. routes (request.plan check) │ │ │ └──────────────────────────────────┘ │ └────────────┬───────────────┬───────────┘ │ │ ▼ ▼ ┌────────────┐ ┌────────────┐ │ Postgres │ │ Stripe │ │ hrr_tenants│◀──│ webhooks │ └────────────┘ └────────────┘ Stripe is the source of truth for subscription status. The local hrr_tenants row is a cache that gets reconciled on every webhook event. The auth plugin runs first and resolves request.tenantId. The billing plugin runs right after and decorates request.plan and request.billingPeriodEnd. By the time a route handler runs, both pieces of context are already sitting on the request.
The reason billing comes after auth is obvious once you say it out loud. You can't load a tenant's plan if you don't know which tenant. But I'll spell it out because Fastify plugin order is load-bearing here. Get it backwards and the billing plugin reads undefined for the tenant ID, then silently treats every request as anonymous. Ask me how I know.
Database schema
The hrr_tenants table from the auth post grows a few new columns. Stripe gives you stable identifiers (cus_*, sub_*) that I store directly. I reuse the plan column as the cached subscription tier. billing_period_end lets the API show "renews on" without a Stripe round trip.
ALTER TABLE hrr_tenants ADD COLUMN stripe_customer_id VARCHAR(64), ADD COLUMN stripe_subscription_id VARCHAR(64), ADD COLUMN billing_period_end TIMESTAMPTZ, ADD COLUMN billing_status VARCHAR(32) NOT NULL DEFAULT 'inactive' CHECK (billing_status IN ('inactive', 'active', 'past_due', 'canceled', 'trialing')); -- The plan column already exists from the auth post, -- but widen the check constraint to match Stripe products. ALTER TABLE hrr_tenants DROP CONSTRAINT hrr_tenants_plan_check; ALTER TABLE hrr_tenants ADD CONSTRAINT hrr_tenants_plan_check CHECK (plan IN ('free', 'starter', 'pro')); -- Webhook idempotency table. One row per Stripe event ID we've processed. CREATE TABLE hrr_billing_events ( event_id VARCHAR(64) PRIMARY KEY, type VARCHAR(64) NOT NULL, tenant_id UUID REFERENCES hrr_tenants(id), processed_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX idx_hrr_tenants_stripe_customer ON hrr_tenants(stripe_customer_id); CREATE INDEX idx_hrr_tenants_stripe_subscription ON hrr_tenants(stripe_subscription_id); The two indexes matter. Webhook handlers look up tenants by stripe_subscription_id (when a subscription updates) or by stripe_customer_id (when a customer event fires). Without indexes you're scanning the whole tenants table on every webhook. Sounds fine until you have ten thousand tenants and Stripe replays a backlog.
The hrr_billing_events table is my idempotency log. Stripe will absolutely deliver the same event twice. Sometimes more. The PRIMARY KEY on event_id turns "did I already process this" into a single insert that either succeeds (first time) or fails with a duplicate key error (replay). I'll show how that's wired up in the webhook section.
Free is the odd one out. Free tenants have no stripe_subscription_id and no stripe_customer_id until they upgrade for the first time. I still create the customer at signup though. More on why in the next section.

Photo by Tima Miroshnichenko on Pexels
Creating a Stripe customer on signup
I went back and forth on this one. Lazy customer creation (only call Stripe when the tenant first tries to upgrade) feels cleaner. But it means the upgrade path has an extra failure mode and an extra latency hit at the worst possible time. Eager creation (call Stripe at signup) costs one API call per signup and removes a whole class of problems later.
I went eager. The signup handler creates the Stripe customer right after creating the tenant row, in the same request.
import Stripe from 'stripe'; const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { apiVersion: '2024-06-20', // Stripe SDK retries on its own. Lambda timeouts are short, // so cap the timeout per attempt. timeout: 8_000, maxNetworkRetries: 2, }); interface SignupBody { tenantName: string; ownerEmail: string; } app.post<{ Body: SignupBody }>('/api/signup', async (request, reply) => { const { tenantName, ownerEmail } = request.body; // 1. Create the tenant row first so we have a tenant ID. const tenant = await fastify.pg.one<{ id: string }>( `INSERT INTO hrr_tenants (name, plan, billing_status) VALUES ($1, 'free', 'inactive') RETURNING id`, [tenantName], ); // 2. Create the Stripe customer. The idempotency key is keyed on // the tenant ID so retries from the client (or Lambda) // don't create duplicate customers in Stripe. const customer = await stripe.customers.create( { email: ownerEmail, name: tenantName, metadata: { tenant_id: tenant.id }, }, { idempotencyKey: `hrr_signup_${tenant.id}` }, ); // 3. Persist the customer ID back on the tenant. await fastify.pg.query( `UPDATE hrr_tenants SET stripe_customer_id = $1 WHERE id = $2`, [customer.id, tenant.id], ); return reply.status(201).send({ tenantId: tenant.id }); }); Two things worth pointing out. The idempotencyKey on stripe.customers.create is the unsexy hero here. If the Lambda times out after Stripe creates the customer but before I save the ID, the client retries, and I'd end up with a second customer in Stripe and no way to clean it up. With the idempotency key, Stripe returns the same customer object on retry. Free duplicate protection.
The metadata.tenant_id is also load-bearing. Every Stripe object I create from this point on (customers, subscriptions, checkout sessions, invoices) gets stamped with tenant_id in metadata. When a webhook arrives, that metadata gives me a backup path to resolve the tenant if the customer ID lookup ever misses for whatever reason.
The order matters. Tenant row first, then Stripe customer, then update the row. If step 2 fails, I have a tenant with no Stripe customer, which is a recoverable state (the next signup retry or a backfill job can fix it). If I'd created the Stripe customer first and then the tenant row failed, I'd have an orphaned Stripe customer with no local tenant to bind it to. That's harder to clean up.
The billing Fastify plugin
This piece does the real work on every request. It runs after the auth plugin, reads request.tenantId, and decorates request.plan plus request.billingPeriodEnd so route handlers can do plan checks without writing any boilerplate.
import fp from 'fastify-plugin'; import type { FastifyInstance, FastifyRequest } from 'fastify'; type Plan = 'free' | 'starter' | 'pro'; declare module 'fastify' { interface FastifyRequest { plan: Plan; billingPeriodEnd: Date | null; billingStatus: 'inactive' | 'active' | 'past_due' | 'canceled' | 'trialing'; } } interface CachedBilling { plan: Plan; billingPeriodEnd: Date | null; billingStatus: FastifyRequest['billingStatus']; cachedAt: number; } // Per-container cache. Lambda reuses warm containers, so this gives us // near-zero overhead for repeat requests from the same tenant. const billingCache = new Map<string, CachedBilling>(); const CACHE_TTL_MS = 30_000; async function billingPlugin(fastify: FastifyInstance) { fastify.decorateRequest('plan', 'free'); fastify.decorateRequest('billingPeriodEnd', null); fastify.decorateRequest('billingStatus', 'inactive'); fastify.addHook('preHandler', async (request: FastifyRequest) => { // Skip routes that didn't run the auth plugin (e.g. /health, webhooks). if (!request.tenantId) return; const cached = billingCache.get(request.tenantId); if (cached && Date.now() - cached.cachedAt < CACHE_TTL_MS) { request.plan = cached.plan; request.billingPeriodEnd = cached.billingPeriodEnd; request.billingStatus = cached.billingStatus; return; } const row = await fastify.pg.oneOrNone<{ plan: Plan; billing_period_end: Date | null; billing_status: FastifyRequest['billingStatus']; }>( `SELECT plan, billing_period_end, billing_status FROM hrr_tenants WHERE id = $1`, [request.tenantId], ); const plan = row?.plan ?? 'free'; const billingPeriodEnd = row?.billing_period_end ?? null; const billingStatus = row?.billing_status ?? 'inactive'; request.plan = plan; request.billingPeriodEnd = billingPeriodEnd; request.billingStatus = billingStatus; billingCache.set(request.tenantId, { plan, billingPeriodEnd, billingStatus, cachedAt: Date.now(), }); }); } export default fp(billingPlugin, { name: 'billing-plugin', dependencies: ['auth-plugin', 'database-plugin'], }); The dependencies array is what enforces plugin ordering. Fastify will refuse to start if auth-plugin isn't registered first. That's exactly the kind of bug I'd rather catch at boot than at 3am via a "why is every request unauthenticated" alert.
The cache is a per-Lambda-container Map with a 30 second TTL. Not Redis. Not DynamoDB. Just a Map. Lambda reuses warm containers for several minutes, so a busy tenant hits the cache for almost every request after the first one. When the webhook fires and a tenant's plan changes, the worst case is 30 seconds of stale data before the next read pulls fresh state. For billing that's totally fine. If you need stricter consistency you can bust the cache from the webhook handler, but I haven't needed to.
I deliberately do NOT call Stripe from this plugin. Every request would hit Stripe's API otherwise, which blows your rate limit and adds 100ms of latency to every authenticated request. The webhook handler updates the local row, and the plugin reads from the local row. Stripe is the source of truth, the database is the read path.

Photo by Onur Yumlu on Pexels
Enforcing plan limits in route handlers
With request.plan on every authenticated request, plan gating becomes a one-liner. I wrap it in a tiny helper so the intent is obvious in route code.
import type { FastifyRequest, FastifyReply } from 'fastify'; type Plan = 'free' | 'starter' | 'pro'; const PLAN_RANK: Record<Plan, number> = { free: 0, starter: 1, pro: 2, }; export function requirePlan(minimum: Plan) { return async (request: FastifyRequest, reply: FastifyReply) => { if (PLAN_RANK[request.plan] < PLAN_RANK[minimum]) { return reply.status(402).send({ error: 'payment_required', message: `This endpoint requires the ${minimum} plan or higher.`, currentPlan: request.plan, upgradeUrl: '/api/billing/checkout', }); } }; } Then in a pro-only route.
app.post('/api/exports/csv', { preHandler: [app.authenticate, requirePlan('pro')], handler: async (request, reply) => { const csv = await generateExport(request.tenantId); return reply .header('content-type', 'text/csv') .send(csv); }, }); I return 402 Payment Required deliberately. It's an underused HTTP status that means exactly this: the request is well-formed and authenticated, but your account doesn't have access. Frontend code can branch on 402 specifically and route the user straight to the upgrade page. Way better UX than a generic 403.
The upgradeUrl in the body is a tiny detail that paid for itself. Frontend devs working on this API don't have to remember which endpoint creates a checkout session. The error response tells them. One less thing to look up.

Photo by Quentin Guiot on Pexels
Stripe webhooks on Lambda
Webhooks are the source of truth for subscription state. When a tenant upgrades, downgrades, fails a payment, or cancels, Stripe sends an event to my webhook endpoint. The handler updates hrr_tenants and that's it. Everything downstream reads from the local row.
The first thing that bit me, and I mean really bit me, is that Stripe's signature verification needs the raw request body. Not the parsed JSON. The exact bytes Stripe sent. Fastify's default content-type parser eats the body and hands you a parsed object, which means stripe.webhooks.constructEvent() throws "No signatures found matching the expected signature for payload" no matter what you do.
The fix is registering a custom content-type parser that captures the raw buffer for the webhook route only.
import Stripe from 'stripe'; import type { FastifyInstance, FastifyRequest } from 'fastify'; const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { apiVersion: '2024-06-20', }); const WEBHOOK_SECRET = process.env.STRIPE_WEBHOOK_SECRET!; declare module 'fastify' { interface FastifyRequest { rawBody?: Buffer; } } export async function registerWebhookRoute(app: FastifyInstance) { // Custom parser that keeps the raw buffer alongside the parsed JSON. app.addContentTypeParser( 'application/json', { parseAs: 'buffer' }, (request, body, done) => { (request as FastifyRequest).rawBody = body as Buffer; try { const json = body.length ? JSON.parse(body.toString('utf8')) : {}; done(null, json); } catch (err) { done(err as Error, undefined); } }, ); app.post('/webhooks/stripe', async (request, reply) => { const signature = request.headers['stripe-signature']; if (typeof signature !== 'string' || !request.rawBody) { return reply.status(400).send({ error: 'missing_signature' }); } let event: Stripe.Event; try { event = stripe.webhooks.constructEvent( request.rawBody, signature, WEBHOOK_SECRET, ); } catch (err) { request.log.warn({ err }, 'webhook signature verification failed'); return reply.status(400).send({ error: 'invalid_signature' }); } // Idempotency. Insert before processing. If the event was already // handled, the unique violation tells us to skip. try { await app.pg.query( `INSERT INTO hrr_billing_events (event_id, type, tenant_id) VALUES (\(1, \)2, $3)`, [event.id, event.type, extractTenantId(event)], ); } catch (err: any) { if (err.code === '23505') { // duplicate event, already processed return reply.status(200).send({ received: trueSource:https://dev.to/harundotdev/building-a-multi-tenant-billing-system-on-aws-lambda-with-stripe-35gf
