Billing Sync with Stripe

Stripe is the system of record for money; your application is the system of record for tenants — billing sync is the disciplined, two-way mapping that keeps those two truths consistent. It sits inside the broader Tenant Billing & Usage Metering framework, downstream of the meters that count usage and upstream of the invoices a tenant actually pays.

The work is deceptively small in the happy path and unforgiving in the edges. Every tenant must map to exactly one Stripe customer; every paid plan must map to a subscription whose state you mirror locally; every unit of metered usage must reach Stripe exactly once, no more; and every webhook Stripe sends must be processed exactly once even though Stripe will, by design, sometimes send it twice. Get the identifiers, the idempotency, and the reconciliation right and billing runs unattended for months. Get any of them wrong and you get double charges, silent revenue leakage, or a tenant whose access does not match what they paid for.

Prerequisites

Confirm all of the following before you write a single line against the live Stripe API. Each missing item maps to a specific class of production incident later.

Step-by-Step Implementation

The flow has five ordered stages: provision the customer, attach the subscription, report metered usage, ingest webhooks idempotently, and reconcile on a schedule. Provision before you subscribe, and never report usage to a subscription item that does not exist yet.

1. Provision one Stripe customer per tenant

Create the customer at tenant signup, store the returned id immediately, and pass an idempotency key derived from the tenant id so a retried signup never creates a duplicate customer. Stamp the tenant id into metadata so any object in the Stripe dashboard traces back to your tenant.

import Stripe from "stripe";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: "2025-03-31.basil",
});

export async function provisionCustomer(tenantId: string, email: string) {
  const customer = await stripe.customers.create(
    {
      email,
      name: tenantId,
      metadata: { tenant_id: tenantId },
    },
    { idempotencyKey: `customer:create:${tenantId}` },
  );
  await db.tenants.update(tenantId, { stripe_customer_id: customer.id });
  return customer.id;
}

2. Attach a subscription and mirror its state locally

Create the subscription against the customer, then record its id and status. The local subscription_status is what your access checks read — never call Stripe synchronously on the request path to decide whether a tenant is active. Treat the subscription object Stripe returns here as the first webhook you will ever process for it.

export async function startSubscription(tenantId: string, priceId: string) {
  const tenant = await db.tenants.get(tenantId);
  const sub = await stripe.subscriptions.create(
    {
      customer: tenant.stripe_customer_id,
      items: [{ price: priceId }],
      metadata: { tenant_id: tenantId },
    },
    { idempotencyKey: `sub:create:${tenantId}:${priceId}` },
  );
  await db.tenants.update(tenantId, {
    stripe_subscription_id: sub.id,
    subscription_status: sub.status, // active | trialing | past_due | canceled
  });
  return sub;
}

3. Report metered usage as meter events

Stripe's modern metered billing uses the Billing Meters API: you send meter_events keyed by an event name and a stripe_customer_id, and Stripe aggregates them server-side against the meter attached to the price. Send a unique identifier per event so a retried report does not double-count. Aggregate locally first — push hourly or daily rollups, not one call per request.

export async function reportUsage(
  tenantId: string,
  meterEventName: string, // e.g. "api_requests"
  quantity: number,
  windowEnd: Date,
) {
  const tenant = await db.tenants.get(tenantId);
  await stripe.billing.meterEvents.create({
    event_name: meterEventName,
    // identifier makes the event idempotent at Stripe's edge.
    identifier: `${tenantId}:${meterEventName}:${windowEnd.toISOString()}`,
    payload: {
      stripe_customer_id: tenant.stripe_customer_id!,
      value: String(quantity),
    },
    timestamp: Math.floor(windowEnd.getTime() / 1000),
  });
}

4. Verify and dedup every inbound webhook

Stripe delivers state changes — invoice.paid, customer.subscription.updated, invoice.payment_failed — by webhook. Verify the signature with the raw body, then record the event id in a dedup ledger inside the same transaction that applies the change. If the insert hits the unique constraint, you have seen this event; ack and stop.

import type { Request, Response } from "express";

export async function handleWebhook(req: Request, res: Response) {
  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(
      req.body, // the raw Buffer, not parsed JSON
      req.headers["stripe-signature"] as string,
      process.env.STRIPE_WEBHOOK_SECRET!,
    );
  } catch {
    return res.status(400).send("invalid signature");
  }
  const applied = await db.tx(async (t) => {
    const inserted = await t.processedEvents.insertIfAbsent(event.id);
    if (!inserted) return false; // duplicate delivery, already applied
    await applyEvent(t, event);
    return true;
  });
  res.json({ received: true, applied });
}

5. Reconcile local state against Stripe on a schedule

Webhooks get dropped, replayed out of order, and occasionally missed during incidents. A nightly reconciliation job lists Stripe's view, compares it to yours, and repairs drift — Stripe is authoritative for subscription status and invoice totals. The detailed, per-tenant resolution rules live in reconciling Stripe webhooks per tenant.

export async function reconcileTenant(tenantId: string) {
  const tenant = await db.tenants.get(tenantId);
  const sub = await stripe.subscriptions.retrieve(tenant.stripe_subscription_id!);
  if (sub.status !== tenant.subscription_status) {
    await db.tenants.update(tenantId, { subscription_status: sub.status });
    await log.warn("billing.drift.repaired", {
      tenantId,
      local: tenant.subscription_status,
      stripe: sub.status,
    });
  }
}

Mapping Model: Tenants to Stripe Objects

The single most consequential decision is how tenants map onto Stripe's customer and subscription objects. The table below compares the three common shapes; the right answer depends on whether tenants buy plans independently and whether one paying account owns several tenants.

| Mapping | Stripe customer | Subscription | Best fit | Main risk | | :--- | :--- | :--- | :--- | | Tenant = customer | One per tenant | One per tenant | Self-serve SaaS, tenant pays directly | Many low-value customers | | Account = customer, tenant = subscription | One per billing account | One per tenant | Agencies, parent orgs with sub-tenants | Tenant-level metering needs subscription items | | Tenant = customer, usage on items | One per tenant | One, multi-item | Hybrid seat + usage pricing | Item bookkeeping per meter |

The default for most multi-tenant SaaS is tenant-equals-customer: it keeps the mapping one-to-one, makes per-tenant invoices trivial, and lets you store a single stripe_customer_id on the tenant row. Move to account-equals-customer only when one paying entity genuinely owns multiple tenants and wants a single invoice — and accept that per-tenant usage then has to ride on distinct subscription items.

The Exactly-Once Path

Billing correctness reduces to one property: every usage unit is counted once and every webhook is applied once, across retries on both sides. The figure traces that path and marks the two idempotency gates — the identifier on meter events going out, and the dedup ledger on webhooks coming in — that turn Stripe's at-least-once delivery into exactly-once effect.

Dynamic Query Scoping & Connection Handling

Every Stripe object you touch must be scoped to a tenant, and that scoping has to survive the asynchronous nature of webhooks. The inbound event carries Stripe identifiers, not your tenant id, so the handler resolves the tenant by stripe_customer_id (or, more reliably, by the tenant_id you wrote into metadata). Resolve once, then load the tenant record and run every subsequent write under that tenant's scope.

async function resolveTenant(event: Stripe.Event): Promise<string> {
  const obj = event.data.object as { customer?: string; metadata?: Record<string, string> };
  if (obj.metadata?.tenant_id) return obj.metadata.tenant_id;
  if (obj.customer) {
    const row = await db.tenants.findByStripeCustomer(obj.customer);
    if (row) return row.tenant_id;
  }
  throw new Error(`unmappable event ${event.id} (${event.type})`);
}

The dedup ledger and the tenant write must share one transaction. If you ack the webhook and then crash before committing the state change, Stripe will not redeliver — it saw a 2xx. Apply the change and record the event id atomically, or not at all. For database connections, the webhook worker is just another tenant-scoped write path: it acquires a pooled connection, sets the tenant context, and commits. Reuse the same per-transaction tenant context discipline you use everywhere else rather than building a separate billing-only path.

Security Enforcement & Access Control

Webhooks are unauthenticated HTTP from the public internet until you verify them. Signature verification is the boundary, and it requires the raw request body — any middleware that parses JSON before verification breaks the signature and must be disabled on the webhook route. Treat the Stripe secret key and the webhook signing secret as top-tier secrets; a leaked key can issue refunds and read every customer.

Layer Mechanism Enforced by Failure if absent
Inbound webhook constructEvent signature check on raw body App Forged events change billing state
Outbound write Idempotency key per logical operation App + Stripe Retries create duplicate customers / charges
Tenant scoping metadata.tenant_id + customer lookup App Event applied to the wrong tenant
Secret handling Restricted key, secret store, rotation Platform Key leak enables refunds and data read
Access decisions Local subscription_status, not live API App Stripe outage takes down your auth path

Use a restricted API key for the workload that only reports usage — it does not need permission to issue refunds or read full customer objects. Local subscription state is what gates feature access; this keeps your application available when Stripe is degraded and ties cleanly into subscription and plan enforcement, where the mirrored status actually decides what a tenant can do.

Operational Overhead & Scaling Metrics

Stripe sync is low-volume relative to product traffic, but it is high-stakes — every failure is a money or access bug. Watch these and act at the thresholds.

Metric Healthy Warning threshold Mitigation
Webhook handler latency <500 ms p95 >5 s (Stripe retries) Ack fast, process async via a queue
Webhook failure rate ~0% any sustained 4xx/5xx Fix handler; replay from Stripe dashboard
Reconciliation drift count 0 per night >0 recurring on same tenant Audit the missed webhook type
Meter event report lag < one window usage missing on invoice Backfill with the same identifier
Duplicate event applies 0 >0 Verify dedup ledger unique constraint
API rate-limit 429s rare sustained on usage reports Batch reports, add jitter + backoff

The highest-leverage controls are acking webhooks within a few hundred milliseconds — push the real work onto a queue so a slow downstream never triggers Stripe's retry storm — and keeping the reconciliation drift count at zero, because a non-zero count is the early-warning signal that a webhook type is being silently lost.

Pitfalls & Anti-Patterns

Parsing the body before verifying the signature. A JSON body-parser mutates the raw payload, so constructEvent fails or, worse, you skip verification and process forged events. Mount the raw-body parser only on the webhook route and verify before anything else reads the payload.

No idempotency on outbound writes. Without an idempotency key, a network retry on customers.create makes two customers and a retry on a usage report double-counts. Derive a deterministic key from the tenant id and the logical operation so retries are free.

Calling Stripe synchronously to gate access. Checking the live subscription on the request path couples your authentication to Stripe's availability and latency. Mirror status locally from webhooks and read the local copy; reconcile in the background.

Acking a webhook you did not actually apply. Returning 2xx before the state change commits means Stripe will never redeliver, and the change is lost forever. Record the event id and apply the change in one transaction, then ack.

Reporting raw per-request usage. One meter event per API call floods Stripe, hits rate limits, and makes idempotency identifiers unwieldy. Aggregate per window upstream and report rollups with a window-keyed identifier.

Frequently Asked Questions

Should each tenant be its own Stripe customer or a subscription under a shared customer? Default to one Stripe customer per tenant — it makes the mapping one-to-one, per-tenant invoices trivial, and the stored stripe_customer_id unambiguous. Use a shared customer with per-tenant subscriptions only when one paying entity genuinely owns several tenants and wants a single consolidated invoice.

How do I make sure metered usage is never double-counted? Send each meter event with a unique, deterministic identifier derived from the tenant, meter name, and aggregation window. Stripe deduplicates on that identifier, so a retried report is dropped server-side. Aggregate locally and report rollups rather than individual events.

How do I handle duplicate webhook deliveries? Stripe delivers at least once, so duplicates are expected. Record each processed event.id in a ledger with a unique constraint and apply the state change in the same transaction as the insert. If the insert conflicts, the event was already handled — ack and do nothing.

Why mirror subscription status locally instead of asking Stripe each time? Calling Stripe on the request path makes feature access depend on Stripe's uptime and adds latency to every check. Mirroring status from webhooks lets access decisions read a local column, keeping the product available during Stripe degradation, with reconciliation repairing any drift.

What does reconciliation actually fix? It repairs state that webhooks failed to deliver — dropped during an incident, replayed out of order, or never sent. A scheduled job lists Stripe's view, compares subscription status and invoice totals to your records, and corrects yours toward Stripe, which is authoritative for money.