Billing, Plans & Usage Reporting

Plans, prices and included volume, how Stripe events drive entitlement, how Enterprise overage reaches an invoice, and what happens while a subscription is unpaid.

Last reviewed September 18, 2026Fresh

Overview

Billing is plan-based with one metered dimension. A tenant's plan column is written from Stripe subscription events and is the only authority on entitlement; the included signal volume is written alongside it as signals_limit, in the same update, so the two cannot drift.

PlanPriceIncluded signals per periodOverage
Starterfree, no card100 per calendar monthnone; ingest refuses past the cap
Pro$99 per month10,000none; the dashboard prompts an upgrade
Enterprise$499 per month1,000,000$0.04 per signal, metered

Pro and Enterprise both offer a 7-day free trial, granted once per Stripe customer and decided from Stripe's own subscription history rather than a flag sent by the browser.

Two paid add-ons ride the same subscription as separate line items: the ML-DSA-87 post-quantum tier, which sets pqc_level, and the Predictive Escalation Ladder, which sets an entitlement column read by shield-predict. Both are independent of plan, and both revert when the line item is removed.

Four functions own the flow, and none of them is reachable from the public API host:

  • stripe-webhook receives signed Stripe events and synchronises plan, add-ons, subscription ids, billing-period bounds and the metered item.
  • shield-usage-billing runs hourly, folds counted usage into per-period ledgers and sends Enterprise overage to Stripe as billing meter events.
  • shield-billing-alerter runs hourly and raises provisioning failures, unbilled backlogs and stuck overage.
  • create-checkout and customer-portal are the dashboard's purchase and self-service paths, both requiring a tenant admin. One checkout intent runs per organization at a time: a second started while the first is still creating its session answers 409 checkout_in_progress and can be retried a few seconds later.

Where to look: Dashboard > Billing shows the plan, the signals used this period, a projected total and the invoice list, with a Manage subscription control that opens the Stripe portal.

Plans and signal limits

signals_limit is the included volume for one billing period, and it is written by the Stripe webhook on every plan change.

Plansignals_limit
starter100
pro10,000
enterprise1,000,000

Past the allotment the three plans behave differently.

Starter is card-free, so its cap is an abuse control. Signal ingest counts accepted signals month to date and refuses past 100 with 429 and an upgrade message. Test-mode and sandbox signals are exempt from the count and from the cap.

Pro keeps accepting signals past 10,000 with nothing metered. The dashboard prompts an upgrade to Enterprise.

Enterprise keeps accepting and meters the excess at $0.04 per signal through a dedicated Signal Usage subscription item, reported as Stripe billing meter events. That item is attached to Enterprise subscriptions and detached from every other plan by the webhook, so a downgrade stops the metering.

Dunning changes entitlement without changing the plan. billing_status mirrors the Stripe subscription: past_due while Stripe's retries run, which keeps full entitlement, and unpaid once retries are exhausted, which revokes paid tiers. The plan column is never rewritten during dunning, so signals_limit, the metered item and the add-on columns do not flap. While a tenant is unpaid, plan-gated endpoints answer 402 with code: payment_required and a billing_url, and signal ingest holds the tenant to the Starter allotment of 100 and answers 402 with x-pb-reason: payment-required once it is reached. Signals under the allotment are still processed.

The add-on is modelled the same way. When the ML-DSA-87 line item is present pqc_level is ml-dsa-87; when it is removed, or the subscription is deleted, it reverts to the platform default ml-dsa-65.

Stripe events handled

stripe-webhook accepts POST only, verifies the signature with Stripe's asynchronous constructor and deduplicates on the event id before any processing. Which secret verified the signature decides the trust lane: a live-secret event gets full processing and is ignored if it claims livemode: false, while a test-secret event may only touch the internal end-to-end test tenant.

EventEffect
checkout.session.completedSets plan, signals_limit, the subscription and item ids and the period bounds, reconciles add-on columns, attaches the metered item on Enterprise, and releases the billing gate. A one-time payment with no subscription is skipped. A session whose subscription is no longer active or trialing is skipped. A session for a tenant that already holds a different live subscription is remediated (the newcomer is cancelled and refunded) rather than adopted.
invoice.paidRe-confirms the plan from the price on the invoice, only while the subscription is active or trialing and only when it is the tenant's current subscription, then clears past_due or unpaid.
customer.subscription.updatedRe-fetches the live subscription, because payloads can arrive out of order, ignores events for a subscription other than the tenant's current one, then syncs plan, limit, add-ons, period bounds and the metered item, and drives the dunning transitions.
customer.subscription.deletedReverts the add-ons carried by that subscription and downgrades the plan to starter, but only when the deleted subscription is the tenant's current one, so cancelling a stale duplicate cannot kill a live plan. A tenant cancelled while in dunning is returned to a working card-free Starter state.

Any other event type is acknowledged with 200 and logged, so Stripe does not retry something that will never be processed.

Failure handling is deliberate. A missing stripe-signature header or a signature that verifies against neither secret answers 400 Invalid webhook signature. A body over 256 KB answers 413, which is terminal for Stripe. A recognised event carrying an unrecognised price is acknowledged with 200 and skipped, with a log line naming the price. Database errors are surfaced as 5xx so Stripe's retry ladder runs.

Deduplication has an in-flight state. The row is inserted as processing and promoted to processed only after the handler succeeds, so a concurrent duplicate delivery is answered 500 (retry later) instead of being acknowledged against work that may still roll back. A row stuck in processing for more than 10 minutes is reclaimed by exactly one retry. If the handler throws, the dedup row is deleted so the retry can reprocess.

Confirm in Dashboard > Billing: the plan and status update within a few seconds of the redirect, and every webhook write leaves an audit row prefixed billing..

Usage reporting pipeline

Signal volume is accumulated hourly. shield-usage-billing runs at minute 15 of every hour and works in two stages. It is not reachable from the public host: it requires the service-role bearer or the cron secret in x-cron-secret, both compared in constant time.

Stage 1, ledger fold. shield_claim_usage_into_periods claims unbilled rows in batches of 500, under an advisory lock and with row-level skip-locked semantics, and folds them into a per-tenant per-billing-period ledger in the same transaction, syncing the tenant's period-to-date counter as it goes. Because the claim and the ledger update commit together, a crash cannot lose counted usage.

Stage 1b, reconcile against the ledger. shield_list_unbilled_overage derives the rest of the work list from the ledger itself: every tenant and period pair that still owes a meter event, including closed periods and tenants that sent no usage this hour. Work is keyed by tenant and period, so a residual left by a crash cannot be starved by newer usage. Without this stage, a crash between the claim commit and the intent insert orphaned that delta until new usage happened to arrive.

Stage 2, Enterprise overage. For each Enterprise tenant and period past the included 1,000,000 signals, shield_open_overage_intent computes the unreported delta and records it as a write-ahead intent row before anything leaves the platform. The delta then goes to Stripe as a billing meter event keyed to the tenant's Stripe customer, and the intent is marked sent only after Stripe accepts it. Each intent carries a deterministic identifier, so a crash-retry re-sends the same identifier and Stripe collapses it into one event; a duplicate rejection from Stripe is treated as success, because the event is already recorded on their side.

The meter's event name and payload keys are read from the Stripe meter object at runtime rather than hard-coded, and cached per isolate.

Every successful run writes a cron heartbeat row, so a missing execution is detectable rather than silent.

Non-billable tenants

Some tenants are structurally non-billable: exempt internal tenants, quarantined tenants under grace-period review, unpaid tenants whose card retries are exhausted and which Stripe no longer invoices, cancelled tenants and deleted tenants.

Usage from every tenant is folded into the period ledger, because those counts drive the dashboard usage card on all plans. Overage evaluation is the part that is skipped: it applies only to Enterprise tenants whose billing_status is outside {exempt, quarantined, unpaid, cancelled, deleted}. A past_due tenant stays billable and keeps its full plan, because Stripe is still retrying the card.

Without this separation each cron tick would re-process the same rows forever, produce repeated warnings about a missing subscription item and inflate the queue depth that the alerter watches.

An Enterprise tenant past the allotment with no synced Stripe subscription cannot be invoiced. Meter events aggregate against the customer but never reach an invoice without a subscription carrying the metered price, so that state is skipped, counted in tenants_skipped_unbillable and written to the audit log as usage.overage_unbillable rather than sent.

Restoring a payment method in the billing portal clears unpaid as soon as Stripe confirms the payment, and the plan's full entitlement returns with it.

Billing alerts

shield-billing-alerter runs hourly on the same internal lane as the usage job (service-role bearer or the cron secret). It checks five conditions:

  • provisioning failures in the last 30 minutes,
  • any paying tenant with unbilled rows older than 6 hours,
  • more than 500 unbilled rows in total across all tenants,
  • overage intents still pending after 3 hours, which means meter-event sends are failing,
  • usage.overage_unbillable audit entries in the last 2 hours.

A paying tenant here means one with a synced Stripe subscription item whose billing_status is outside the non-billable set, so the structurally non-billable tenants above never raise an alert.

Immediate paging is off by default. Slack and email dispatch from this function run only when OPS_SLACK_IMMEDIATE_ENABLED=1 is set on the project. Otherwise every finding is still recorded: the cron heartbeat carries the full summary, and the finding is enqueued on the operator digest bus, which is delivered once a day. The switch exists so an incident can turn real-time paging back on without a deploy.

This gate is operator-only. Customer-facing billing email, including dunning reminders to tenant owners, is a separate function and is unaffected.

Run summary

Every shield-usage-billing invocation returns the same object, which is also written to the cron heartbeat so the trend is queryable:

{
  "signals_claimed": 12450,
  "enterprise_periods_evaluated": 3,
  "overage_signals_billed": 1830,
  "intents_sent": 1,
  "intents_failed": 0,
  "tenants_skipped_unbillable": 0,
  "lock_contended": false
}
FieldMeaning
signals_claimedSignals folded into period ledgers in this run.
enterprise_periods_evaluatedTenant and billing-period pairs checked for overage, including ledger-derived retries.
overage_signals_billedOverage signals accepted by Stripe as meter events this run.
intents_sentOverage intents Stripe accepted this run.
intents_failedMeter-event sends that failed. The intent stays pending and retries next tick with the same identifier.
tenants_skipped_unbillableEnterprise tenants past the allotment with no synced Stripe subscription.
lock_contendedTrue when another worker held the advisory lock and unclaimed rows were still waiting. An idle queue reports false.

A healthy run trends intents_failed and tenants_skipped_unbillable to zero and lock_contended to false. Sustained non-zero values on any of the three are what shield-billing-alerter escalates.

lock_contended is derived rather than assumed: the claim function returns nothing both when another run holds the lock and when the queue is empty, so the job probes for unclaimed rows before reporting contention.

Related from the blog