Home/Blog

SaaS Pricing Implementation: Metering, Entitlements, Billing

How SaaS teams implement pricing in code: a versioned plan catalog, idempotent usage metering, an entitlement service, Stripe webhooks and dunning.

SaaS Pricing Implementation: Metering, Entitlements, Billing

Implement SaaS pricing as four separate pieces: a versioned catalog that defines plans as data, a metering pipeline that records usage as idempotent append-only events, an entitlement service the application asks before every gated action, and a billing provider that turns the catalog and usage into invoices. Application code checks entitlements, never plan names, and billing state reaches your system through an idempotent webhook handler. Built this way, a pricing change is a data change, not a refactor.

Key takeaways

  • Check features and limits, not plan names. if (plan === "pro") scattered through the codebase is why pricing changes take a quarter.
  • Treat plans as versioned data, so existing customers can stay on old terms while new customers get new ones.
  • Every usage event needs a stable unique ID, an append-only store and a reconciliation job. Double counting is a billing incident, and customers find it before you do.
  • Webhooks arrive at least once and in no guaranteed order. Deduplicate on event ID and re-fetch current state from the provider instead of trusting delivery order.
  • Model failed payments as a state machine with grace periods, not an instant lockout.
  • Billing is not accounting. Tax, invoice rules and revenue recognition need clean data from day one, or a provider that handles them.

What each pricing model demands from engineering

Pricing strategy is a business decision. Each model, though, has a predictable engineering bill, and it is worth knowing before the pricing page is designed.

ModelYou charge forWhat you must buildThe hard part
Flat rateAccessSubscription state, one entitlement setAlmost nothing; also the least flexible
Per seatUsersSeat counting, quantity sync to the provider, prorationDefining a seat: invited, active, SSO-provisioned, deactivated
Tiered plansFeature bundles and limitsEntitlement service, upgrade and downgrade pathsDowngrades when usage already exceeds the lower plan's limits
Usage-basedUnits consumedMetering pipeline, aggregation, live usage view, threshold alertsAccuracy, late events, disputes, surprise bills
HybridA base fee plus usage, or an allowance plus overageAll of the aboveAllowance resets and overage notifications
Prepaid creditsCredits drawn downA credit ledger with balances, expiry and top-upsConcurrent draws on one balance, refunds, expiry rules

The architecture

Loading diagram...

Each component has one job, and each owns one kind of truth:

  • The plan catalog (your repository) defines plans, versions, features and limits. It references the provider's price IDs instead of copying amounts.
  • The metering pipeline (your database) owns raw usage: what happened, for whom, when.
  • The billing provider owns payment state: subscriptions, invoices, payment methods, tax.
  • The entitlement service (your code) combines catalog, subscription state and per-customer overrides into one answer: what this organization can do right now.

When something disagrees, you know which system to believe. That is most of the value of the split.

If billing is shared across a multi-tenant product, the tenancy model matters here too; our guide to SaaS architecture patterns covers how tenant boundaries shape data like this.

Model the catalog as versioned data

plans:
  - id: team
    version: 3
    billing:
      provider_price_ids:
        monthly: price_team_monthly_v3
        yearly: price_team_yearly_v3
    features: [api_access, custom_domains, audit_log]
    limits:
      seats: 20
      api_requests_per_month: 500000
      storage_gb: 100
 
  - id: enterprise
    version: 1
    billing:
      provider_price_ids: {}
    features: [api_access, custom_domains, audit_log, sso, scim]
    limits:
      seats: unlimited
      api_requests_per_month: unlimited
      storage_gb: 1000

Rules that keep it maintainable:

  • A subscription points at a plan ID and a version. New customers get the new version. Existing customers stay on theirs until you migrate them on purpose.
  • Enterprise deals are overrides, not new plans. Store per-customer overrides as a separate record. Effective entitlements are the plan version plus overrides.
  • Amounts live in exactly one place. The provider holds prices; the catalog holds price IDs. Two copies of a price drift.
  • The free plan is a plan. Give it an entry in the catalog. "No subscription means free" turns into special cases everywhere.

The entitlement service

type Feature = "api_access" | "custom_domains" | "audit_log" | "sso" | "scim";
type Limit = "seats" | "api_requests_per_month" | "storage_gb";
type Status = "active" | "trialing" | "grace" | "restricted" | "suspended";
 
interface Entitlements {
  orgId: string;
  status: Status;
  features: Set<Feature>;
  limits: Record<Limit, number | "unlimited">;
}
 
export class EntitlementError extends Error {
  constructor(
    public code: "feature_not_in_plan" | "limit_reached" | "billing_action_required",
    public detail: string,
  ) {
    super(detail);
  }
}
 
// Call before any write action that a plan can gate
export async function requireFeature(orgId: string, feature: Feature) {
  const e = await entitlements.get(orgId); // cached per org, invalidated by billing webhooks
  if (e.status === "restricted" || e.status === "suspended") {
    throw new EntitlementError("billing_action_required", "update payment method");
  }
  if (!e.features.has(feature)) {
    throw new EntitlementError("feature_not_in_plan", feature);
  }
}
 
export async function requireCapacity(orgId: string, limit: Limit, used: number, adding = 1) {
  const e = await entitlements.get(orgId);
  const max = e.limits[limit];
  if (max !== "unlimited" && used + adding > max) {
    throw new EntitlementError("limit_reached", `${limit} ${used}/${max}`);
  }
}

Design decisions that matter:

  • Enforce on the server. Hiding a button is user experience, not enforcement. Every gated API path calls the entitlement service.
  • Return a machine-readable error. A 403 with a code such as limit_reached lets the frontend show exactly what the upgrade unlocks, at the moment of friction.
  • Cache per organization and invalidate on billing events. A short TTL keeps checks fast; webhook-driven invalidation keeps them correct after an upgrade.
  • Decide failure behavior per check. When the entitlement store is unreachable, there is no single right default.
CheckIf entitlements are unavailableWhy
Core product access for paying customersUse the last known valueLocking paying customers out during your own outage is worse than brief over-access
Limits on cheap resourcesAllow, reconcile laterOverage can be billed or forgiven
Expensive resources (GPU jobs, LLM calls, outbound SMS)DenyCost is unbounded
Security features (SSO enforcement, IP allowlists)Use the last known value, never default to offSilently disabling SSO enforcement is a security incident

Metering usage without double counting

Under-counting loses revenue. Over-counting loses customers. Both come from the same few mistakes: retries without idempotency, mutable records and confusion about which timestamp counts.

create table usage_events (
  event_id      text primary key,       -- generated by the producer, stable across retries
  org_id        text not null,
  meter         text not null,          -- for example 'api_requests'
  quantity      bigint not null,        -- negative only for corrections
  occurred_at   timestamptz not null,   -- when the usage happened: this decides the period
  received_at   timestamptz not null default now(),
  correction_of text references usage_events (event_id)
);
create index on usage_events (org_id, meter, occurred_at);
 
-- Producers insert; retries are harmless
insert into usage_events (event_id, org_id, meter, quantity, occurred_at)
values ($1, $2, $3, $4, $5)
on conflict (event_id) do nothing;
 
-- Billable totals for a period
select org_id, meter, sum(quantity) as billable
from usage_events
where occurred_at >= $1 and occurred_at < $2
group by org_id, meter;

The principles behind the schema:

  1. Derive the event ID from the business action, such as the request ID or job ID. A retry of the same action produces the same ID, and the insert does nothing.
  2. Never update or delete usage. Corrections are new events with a negative quantity that reference the original. The audit trail survives disputes.
  3. Bill on occurred_at, not received_at. Decide what happens to events that arrive after a period closes: hold the period open for a short grace window, or roll late events into the next invoice. Write the rule down.
  4. Pre-aggregate at high volume. Counting every request as a row is expensive. Aggregate in process per organization and minute, and emit one event whose ID is derived from organization, meter and window. Retries stay idempotent.
  5. Show customers their usage. A live usage view and alerts as usage approaches the allowance prevent the angriest support tickets: the surprise bill.
  6. Reconcile. After each period, compare your totals with the provider's per customer and meter. Any difference is an alert, not a curiosity.

Integrating Stripe (or any billing provider)

The patterns here apply to any provider. Stripe is used for the examples because it is common. Usage-focused platforms such as Orb, Metronome and the open-source Lago, and subscription platforms such as Chargebee, follow the same shape. A merchant of record such as Paddle goes further: it becomes the legal seller and handles sales tax and VAT for you. That trade is worth considering if you sell globally with a small finance team.

Creating subscriptions

Use a hosted checkout to collect payment details and create the subscription, and carry your own IDs in metadata so every later event can be tied back to an organization.

import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
 
const session = await stripe.checkout.sessions.create({
  mode: "subscription",
  customer: org.stripeCustomerId,
  client_reference_id: org.id,
  line_items: [
    { price: plan.billing.provider_price_ids.monthly, quantity: seatCount },
    { price: apiRequestsMeteredPriceId }, // usage price backed by a meter: no quantity
  ],
  subscription_data: {
    metadata: { org_id: org.id, plan_id: plan.id, plan_version: String(plan.version) },
  },
  success_url: `${APP_URL}/billing/done?session_id={CHECKOUT_SESSION_ID}`,
  cancel_url: `${APP_URL}/billing`,
});

Reporting usage

Stripe's older usage-records API has been superseded by Billing Meters, and usage-based prices are now built on meters. Send aggregated meter events with an identifier that is stable across retries:

await stripe.billing.meterEvents.create({
  event_name: "api_requests",
  identifier: `${org.id}:api_requests:${windowStartMs}`, // same ID on retry; Stripe enforces uniqueness for at least 24 hours
  timestamp: Math.floor(windowStartMs / 1000),
  payload: {
    stripe_customer_id: org.stripeCustomerId,
    value: String(requestCount),
  },
});

Handling webhooks

Webhooks are delivered at least once, can arrive out of order and can be replayed. The handler should verify the signature, record the event, acknowledge quickly and let a worker do the real work.

app.post("/webhooks/stripe", express.raw({ type: "application/json" }), async (req, res) => {
  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(
      req.body,
      req.headers["stripe-signature"] as string,
      process.env.STRIPE_WEBHOOK_SECRET!,
    );
  } catch {
    return res.status(400).send("invalid signature");
  }
 
  const objectId = (event.data.object as { id: string }).id;
  await db.query(
    `insert into webhook_inbox (event_id, type, object_id, received_at)
     values ($1, $2, $3, now())
     on conflict (event_id) do nothing`,
    [event.id, event.type, objectId],
  );
  res.sendStatus(200); // a worker processes unhandled inbox rows
});
 
// Worker: do not trust the payload snapshot or the delivery order
async function handleSubscriptionEvent(subscriptionId: string) {
  const sub = await stripe.subscriptions.retrieve(subscriptionId);
  await entitlements.syncFromSubscription(sub); // recompute and invalidate the cache
}

Re-fetching the subscription means an older event processed late cannot overwrite newer state. The events most products need:

EventWhat to do
checkout.session.completedLink the customer and subscription to the organization, then provision
customer.subscription.created and customer.subscription.updatedRe-fetch the subscription and recompute entitlements
customer.subscription.deletedMove to the free plan or a suspended state, per your policy
invoice.paidClear any grace or restricted state
invoice.payment_failedEnter the dunning state machine and notify billing contacts
invoice.payment_action_requiredAsk the customer to complete card authentication
customer.subscription.trial_will_endSend the conversion reminder

Seats and proration

When seats change, update the quantity on the subscription item and choose a proration behavior deliberately: create_prorations adds the difference to the next invoice, always_invoice bills it now, and none skips it. A common policy is to bill added seats promptly and apply removals at the end of the period, which avoids a stream of small credits. Use the provider's hosted customer portal for card updates and invoice downloads; it is one less screen to build and secure.

Trials, freemium and downgrades

Trials. Decide whether a trial requires a card, and configure what happens when it ends without one: cancel, pause or invoice. Give trialing organizations a trialing status in entitlements so the product can show days remaining.

Freemium. The free plan has entitlements like any other plan. Abuse controls (email verification, rate limits, limits on expensive resources) matter more here than anywhere else.

Downgrades over the limit. A customer on 30 seats downgrades to a plan with 20. Do not delete anything. Block new additions over the limit, keep existing data readable, and show the customer exactly what to remove or which plan fits.

Failed payments as a state machine

Failed payments are the most common cause of involuntary churn. Treat recovery as a product flow, not a cron job that locks accounts.

StateEntered whenEntitlementsCustomer sees
activeInvoice paidFullNothing
graceinvoice.payment_failedFullBanner and email with a link to update payment
restrictedGrace period ends unpaidRead-only; exports allowedWrite actions blocked with a billing prompt
suspendedRestricted period endsBilling page only; data retainedBilling page on login
closedRetention period endsNone; data deleted per policyFinal notice before deletion

Keep the timings in configuration, not code. Example values, to tune for your billing cycle and contracts:

dunning:
  grace_days: 7
  restricted_days: 14
  retention_days_after_suspension: 60
  reminder_days_after_failure: [0, 3, 6]
  exempt_payment_terms: [invoice, bank_transfer]

Let the provider handle retry timing (Stripe's Smart Retries does this), send a reminder before cards expire, and exempt customers on invoiced payment terms from card dunning entirely. An enterprise customer paying by bank transfer on agreed terms should never see a "payment failed" banner.

Tax, invoices and revenue recognition

Billing engineers are not accountants, but the system has to produce data accountants can use.

  • Sales tax, VAT and GST depend on where the customer is and what you sell. Use the provider's tax product or a merchant of record rather than hand-built tax tables.
  • Invoice rules differ by country: sequential numbering, customer tax IDs and required legal text. Let the provider generate invoices.
  • Revenue recognition under ASC 606 or IFRS 15 needs invoice lines with clear service periods. Annual prepayments, credits and mid-period upgrades are where messy data hurts.

Changing prices safely

Prices in Stripe cannot have their amount edited after creation; you create a new price. Treat that as the right model everywhere:

  1. Add a new plan version to the catalog with new price IDs.
  2. Point new signups at the new version.
  3. Decide the policy for existing customers: keep them on old terms indefinitely, migrate at renewal with notice, or migrate with a transition discount.
  4. Migrate with a script that runs in dry-run mode first and prints every affected subscription, then schedules changes at each customer's period end.

Test packaging and pricing-page presentation with the experimentation tools from our growth engineering guide. Be careful about showing different prices to otherwise identical customers; the trust and legal costs outweigh what you learn.

Whether to build any of this yourself is a classic build versus buy decision. For most companies the answer is: buy payment processing, invoicing and tax; build the catalog, entitlements and the metering that is specific to your product.

What goes wrong in production

These are the billing incidents that page someone, usually at the start of a month.

Duplicate webhooks double-provision. A retry delivers the same invoice.paid twice and the customer gets two credit grants. Fix: the inbox table with a unique event ID.

Out-of-order events. customer.subscription.updated arrives before checkout.session.completed, and the handler cannot find the organization. Fix: carry your organization ID in metadata and client_reference_id, and re-fetch state instead of applying deltas.

Paid but still locked. The customer updates their card, the invoice is paid, and the product still shows a billing wall because the entitlement cache was not invalidated. Fix: invalidate on webhook, and re-sync when the customer returns from the billing page.

Retried usage is counted twice. A producer retries after a timeout and the event lands twice. Fix: deterministic event IDs and on conflict do nothing.

Period boundary bugs. Usage at the end of the month lands in the wrong invoice because one service used local time or received_at. Fix: UTC everywhere and bill on occurred_at.

Test and live mode mixed up. A test price ID ships in production configuration. Fix: separate keys per environment and a startup check that the key prefix (sk_live_ or sk_test_) matches the environment.

Manual changes in the provider dashboard. Someone cancels a subscription by hand and the application never hears about it because the webhook failed. Fix: a nightly reconciliation of subscription status between the provider and your database.

Money in floating point. Amounts are integers in minor units, and some currencies (the Japanese yen, for example) have no minor unit at all. Use the provider's amount fields as they are.

A rotated webhook secret. Signature checks start failing and every event returns 400. Fix: alert on the webhook endpoint's error rate, since nobody looks at it otherwise.

Frequently Asked Questions

How do you implement usage-based billing in a SaaS product?

Record every billable action as an event with a stable unique ID in an append-only store, aggregate it per customer and billing period, and send the totals to your billing provider's metering API. Bill on the time the usage happened, not when the event arrived, and use compensating events for corrections. Show customers their live usage and reconcile your totals with the provider after every period.

What is an entitlement service?

An entitlement service answers one question for the rest of the application: what this customer is allowed to do right now. It combines the plan catalog, the subscription status from the billing provider and any per-customer overrides into a set of features and limits. Application code checks entitlements instead of plan names, which lets you change pricing without changing code.

Should you build or buy a SaaS billing system?

Buy payment processing, invoicing and tax calculation, because they are regulated, global and not what makes your product different. Build the parts that are specific to you: the plan catalog, the entitlement service and the metering of your product's own usage. A dedicated usage-billing platform can replace the aggregation layer if your metering is complex.

How do you handle Stripe webhooks reliably?

Verify the signature on the raw request body, store the event in an inbox table keyed by event ID, return 200 quickly and process the event in a background worker. Because events can repeat and arrive out of order, deduplicate on the event ID and re-fetch the current subscription from the API instead of applying the event payload directly. Alert on webhook error rates and run a nightly reconciliation.

How do you grandfather existing customers when prices change?

Create a new plan version with new price IDs and point only new signups at it, leaving existing subscriptions on their current version. Then choose a policy for existing customers: keep them indefinitely, migrate them at renewal with notice, or migrate them with a transition discount. Run migrations as scripts with a dry-run mode and schedule changes at each customer's period end.

How do you prevent double counting of usage events?

Give every usage event an ID derived from the business action that caused it, such as a request or job ID, so retries produce the same ID. Store events with a unique constraint on that ID and insert with an on-conflict-do-nothing clause. When sending aggregates to a billing provider, use an idempotency identifier derived from the customer, meter and time window.

Need help implementing pricing and billing?

We are senior engineers who build and then operate what we build: if we built it, we are on the pager when the month-end invoices run. We can design the catalog, entitlements and metering, integrate the billing provider, and add the reconciliation that catches problems before customers do.