I work on Genesis, a multi-tenant developer platform on GCP. Customers design backend infrastructure on a visual canvas, the platform compiles that to Terraform and applies it inside the customer’s own Google Cloud project. The largest tenant runs 613 cloud resources from a 10,000 plus line generated config, applying in about three minutes. That tenant taught me how a copy drifts from its source, and subscription billing has the same shape: Stripe holds the subscription, your database holds a copy, and every bug lives in the gap between them.
I have not shipped a public Stripe subscription build I can link here. What I have shipped is the machinery underneath one: an alerting webhook service in Go that had to acknowledge fast and apply once, and a double entry ledger core in Node and TypeScript with Postgres constraints that refuse the forbidden write. This post pulls those two together into the subscription layer I would build next.
Stripe owns the lifecycle, your tables mirror the slice you render
Stripe owns trial, active, past due, canceled, the plan on each phase, quantity, and billing anchor. Your database mirrors the slice the product reads on every request: tenant id, Stripe customer id, Stripe subscription id, status, plan, quantity, period bounds. When the two disagree, Stripe wins, and the fix is a reconcile job that pages open subscriptions and rewrites the mirror, not a migration that edits history.
create table billing_subscriptions (
tenant_id uuid not null,
stripe_customer_id text not null,
stripe_subscription_id text not null unique,
status text not null,
plan text not null,
quantity integer not null default 1,
current_period_start timestamptz,
current_period_end timestamptz,
updated_at timestamptz not null default now(),
primary key (tenant_id, stripe_subscription_id)
);
The unique key on the Stripe subscription id is the control. A retry that inserts the same subscription twice must hit that key and stop, not create a second active row that grants twice the quota.
The endpoint persists and replies, the worker applies once
Stripe posts events over HTTP and expects a fast success response. Miss it and the sender retries, so a slow handler does not delay billing. It duplicates it. The useful work after a subscription event is slow: load the tenant, compare phases, post ledger rows, adjust quota, send the receipt. None of that belongs in the handler.
The handler does three fast things: verify the signature, persist the raw event keyed on the Stripe event id, and reply success. A worker reads unapplied events per subscription and applies each once. This is the split from my Go alerting service between Cloud Monitoring and on-call, where the endpoint validated, persisted, and acknowledged, and the slow analysis ran later. The failure it removed was double paging on redelivery. The billing version is double invoicing.
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY as string);
export async function handleWebhook(req: Req, res: Res) {
const sig = req.headers["stripe-signature"] as string;
const event = stripe.webhooks.constructEvent(
req.rawBody,
sig,
process.env.STRIPE_WEBHOOK_SECRET as string
);
const inserted = await db.insertBillingEventOnce({
stripeEventId: event.id,
type: event.type,
payload: event.data.object as object,
});
res.status(200).send("ok");
if (inserted) {
await queue.publish("billing.apply", { stripeEventId: event.id });
}
}
Signature verification stays in the handler because unsigned traffic should never reach storage. Dedupe stays in storage because only storage sees every attempt.
One invoice id gets one ledger entry, however many events mention it
Dedupe on the event id stops one delivery applying twice. It does not stop two different events describing one outcome, for example an invoice.payment_failed followed by a customer.subscription.updated for the same missed period. The business key for money is the Stripe invoice id, not the event id. The first event for an invoice writes the postings. The second finds the key and returns the existing rows.
create table ledger_entries (
id uuid primary key default gen_random_uuid(),
business_key text not null unique,
created_at timestamptz not null default now()
);
create table ledger_postings (
id uuid primary key default gen_random_uuid(),
entry_id uuid not null references ledger_entries(id),
tenant_id uuid not null,
account text not null,
amount_minor bigint not null check (amount_minor <> 0)
);
Amounts stay in integer minor units edge to edge with one explicit rounding step, and a deferred trigger refuses commits whose legs do not sum to zero. Display formats once at render from the integer. Types help readability, Postgres enforces, because every writer passes through it including the backfill someone runs by hand later.
Each control needs a test that tries the forbidden write: insert one Stripe event twice and assert one applied row, apply two events for one invoice and assert one entry, post an unbalanced fee and assert refusal. Happy path tests do not show the control exists.
Every billing row carries its tenant, plan changes append phases
The failure that dwarfs the rest in multi-tenant billing is the query that forgets the tenant. An admin overview without a tenant predicate leaks one customer into another, and a quota check without it grants the wrong tier. Every billing table carries tenant_id, keys lead with it, reads filter on it. The tenant is resolved once at the edge from session plus Stripe customer mapping and stored at insert, never re-derived later.
Usage for billing and usage for analytics then split. Billing keeps a small per tenant rollup the quota check reads in one seek. Analytics keeps the full event log for scans. A nightly job reconciles the two with an alert on the diff. Plan changes append phase rows with the Stripe version they came from, so current plan is the latest phase and history is the audit. Failed payments move status to past due without touching the plan.
Summaries read settled rows, never live Stripe calls
Usage summaries, churn flags, and a query box over the dashboard should read the mirror and the analytics store, never live Stripe at render time. Live calls add provider latency and rate limits to the page. A scheduled job writes each summary next to the period it describes, the dashboard renders stored text, and a model outage reads as stale text rather than a broken page. Prompts take the settled period bounds and plan phase as input so claims tie to rows, with golden prompts in CI to catch a trim that drops the grounding.
What I would tell someone building this
Rebuild your mirror from Stripe or you own two truths. The wipe and rebuild path is the reconcile path. Plan changes fork anything less within a quarter.
Acknowledge fast, apply once, key on their ids. Event id stops redelivery, invoice id stops double counting. Anything you mint on receipt names your transport, not their event.
Enforce money in Postgres. Integer minor units, one rounding step, deferred balance check, append only audit, each with a test that attempts the forbidden write.
Scope by tenant at insert and split billing counts from analytics. One rollup for quota, one log for scans, nightly reconcile with an alert. Summaries read the settled copy.