Docs
Billing
How runs, replicas and stores become an invoice, and what happens at a limit.
Keel bills plans through Stripe monthly or annually, and settles usage above the plan allowance monthly through Stripe Billing Meters. Everything Stripe specific lives behind ports in packages/application/src/billing/ports; nothing outside the billing module knows there are two subscriptions.
Monthly or annual
Every plan carries both prices (packages/config/src/plans): pricing.annualPriceCents and pricing.monthlyPriceCents, which is null for a plan that cannot be bought monthly. An annual term costs ten monthly payments, so two months are free, and planSchema refuses a catalog where an annual term costs more than twelve monthly payments or where a contractual plan carries a monthly price.
| Plan | Monthly | Annual | Default spend cap |
|---|---|---|---|
| Starter | 4,900 cents | 49,000 cents | 9,800 cents |
| Business | 49,900 cents | 499,000 cents | 99,800 cents |
| Enterprise | not sold monthly | 2,500,000 cents | contractual (null) |
BillingInterval ("monthly" | "annual", packages/domain/src/billing/interval.ts) is the value object, priceCentsFor(pricing, interval) reads the price of an interval, sellsInterval says whether a plan is sold on it, and annualSavingCents / annualSavingMonths give the saving an annual term carries (998 EUR and two months on Business). termMonthsFor maps the interval to the length of the term Stripe is asked for: one month or twelve.
subscriptions.interval records the interval the organization is on and subscriptions.scheduled_interval the interval a switch at the end of the term will move it to (null when nothing is scheduled, and never equal to interval, which a check constraint enforces). Migration 0034 adds both columns, defaults every existing row to annual and writes monthlyPriceCents into the plans rows (4,900 on Starter, 49,900 on Business, null on Enterprise).
The spend cap is the same number either way. defaultSpendCapFor is two months of the plan's monthly price, which for an annual plan is the same 9,800 and 99,800 cents it was before, because the annual price is exactly ten monthly payments. A monthly customer is therefore never handed twelve months of headroom, and an annual customer is not given less: both get two months of metered spend above the allowance, and ENTERPRISE_SPEND_CEILING_CENTS still caps everyone.
Two subscriptions per organization
Stripe allows one billing interval per subscription, so each organization has:
| Subscription | Interval | Price | Purpose |
|---|---|---|---|
| Plan | yearly or monthly, as the customer chose | STRIPE_PRICE_<PLAN> or STRIPE_PRICE_<PLAN>_MONTHLY |
The paid term. Carries the renewal date, mid-term upgrades, the interval switch and cancel at period end. |
| Usage | monthly | STRIPE_PRICE_RUN_OVERAGE, STRIPE_PRICE_RUN_MINUTE_OVERAGE, STRIPE_PRICE_SERVICE_HOUR, STRIPE_PRICE_APP_SERVICE_HOUR, STRIPE_PRICE_POSTGRES_HOUR and STRIPE_PRICE_VALKEY_HOUR |
Metered run overage, always-on agent and app service hours and managed data store hours, invoiced monthly from the six meters. |
The usage subscription is anchored at the next UTC midnight after signup (billing_cycle_anchor) so its monthly periods start at 00:00 UTC. org_quota_state is aligned to that period whenever the usage subscription renews (customer.subscription.updated on the usage subscription), which keeps the dispatch admission window, the rollup ledger, and Stripe's invoice period on the same boundaries.
Period anchor and snapshots
org_quota_state.anchor_day records the day of month the period cycle is anchored to. When a period rolls locally, the next one starts where the previous ended and ends on the anchor day of the following month, clamped to that month's last day, so an anchor of 31 gives Jan 31, Feb 28, Mar 31, Apr 30 instead of drifting to the 28th forever. Alignment from Stripe keeps the anchor when Stripe's start date is the anchor clamped to that month and adopts Stripe's day otherwise.
Every time a period opens (a local roll, an alignment from Stripe, or the first quota row), and every time its allowance changes (a plan change), the period is written to billing_periods(organization_id, period_id, starts_at, ends_at, included_runs, included_weighted_minutes, anchor_day). The rollup computes closed periods against that snapshot, so a plan change in October never recomputes September's overage against October's allowance. Periods without a snapshot (created before this table existed) fall back to the current allowance.
What resets when
The usage subscription is monthly on both intervals, so the quota period is monthly on both intervals. org_quota_state holds one period at a time and includedRunsPerPeriod and includedWeightedMinutesPerPeriod are that period's allowance, so the run and run-minute allowances reset every month whether the plan is paid monthly or annually; the monthly allowance is the same number on both intervals, and the pricing page says so. The plan term is the only thing the interval changes: a monthly subscription renews every month and an annual one every twelve months, and subscriptions.term_starts_at / term_ends_at follow the plan subscription Stripe reports. Service hours, app service hours and data store hours have no allowance on either interval, and the overage ledger keys on the monthly period id exactly as before.
Self-serve Checkout
A card organization buys its plan itself. CreateCheckoutSession (POST /v1/billing/checkout, billing.manage) creates the Stripe customer once (recorded on a pending subscription row with no Stripe subscription ids, so a retry or a later plan choice reuses it) and opens a Checkout Session in subscription mode for the price of the plan and the interval the body asks for (interval, default annual) with automatic_tax enabled, tax_id_collection enabled and required where supported, customer_update.address and customer_update.name set to auto, billing_address_collection required, client_reference_id and metadata.keel_organization_id set to the organization, subscription_data.billing_mode flexible and branding_settings fixed to the dashboard's dark theme (background #111316, button #43D5DC, rounded corners). Enterprise is refused with enterprise_is_invoiced, an invoiced organization with organization_is_invoiced, a live subscription with already_subscribed, a checkout whose payment Stripe is still confirming with checkout_in_progress, and a monthly checkout for a plan the catalog does not sell monthly with interval_not_available. The session carries the interval in metadata.keel_billing_interval for the audit trail; the completion reads the interval back from the price, not from the metadata. The pending row records the interval the customer picked, so a retry reuses it.
The body's uiMode picks the presentation. embedded (the default) creates the session with ui_mode: embedded_page and return_url set to DASHBOARD_PUBLIC_URL/settings/billing?checkout=return&session_id={CHECKOUT_SESSION_ID} and answers { uiMode: "embedded", clientSecret }; the dashboard mounts Stripe's embedded Checkout inside the billing page with @stripe/stripe-js and the publishable key that GET /v1/billing reports, so the customer never leaves the app, and Stripe redirects to the return url once the payment is confirmed. hosted creates a hosted_page session with success_url and cancel_url (?checkout=success, ?checkout=cancelled) and answers { uiMode: "hosted", url }; the CLI uses it to print a payment link, and the dashboard falls back to it when no publishable key is configured. Either way the return page polls the billing status until the subscription is active.
checkout.session.completed then activates the plan: the handler retrieves the plan subscription Checkout created (its price maps back to a plan and an interval through the price catalog, planForPrice), creates the usage subscription with the six metered prices through StripeBilling, writes the customer and both subscription ids on the row, sets the organization's plan, applies the plan limits to the quota state, aligns the quota period to the usage subscription and upserts billing_profiles (company name, address, tax id, tax exempt status as collected by Checkout). A session paid asynchronously (payment_status: unpaid) leaves the row pending with its ids until invoice.paid moves it to active. Sessions that are not subscription checkouts are ignored; sessions without an organization or with an unknown price are reported as unknown_organization and unknown_plan. The pending state covers both a customer without a checkout yet and a checkout awaiting payment; needsCheckout in the domain tells them apart (needsCheckout(subscription) is true when there is no row, the row is canceled, or it is pending without a plan subscription id).
CreatePortalSession (POST /v1/billing/portal) opens the Stripe Customer Portal on the Keel configuration (ensureBillingPortalConfiguration, found by keel_portal=default metadata or named by STRIPE_PORTAL_CONFIGURATION_ID): payment method updates, invoice history, billing detail and tax id updates, cancel at period end; plan switching is off because upgrades go through ChangePlan. A cancellation made in the portal arrives as customer.subscription.updated with cancel_at_period_end (or a cancel_at, which flexible billing mode reports instead) and the handler mirrors it onto the usage subscription (cancel_at = termEndsAt), and clears it again when the customer renews before the term ends.
GetBillingStatus (GET /v1/billing) answers the plan, the purchasable plan catalog with both prices per plan (monthlyPriceCents is null on a plan that is annual only), the subscription with its interval, its scheduledInterval and renewsAt (the end of the paid term while the subscription grants access and is not set to cancel), the billing profile, the next invoice date (the end of the current usage period while the subscription grants access) and checkoutRequired, which is true only when billing is enabled, the organization pays by card and needsCheckout holds. The dashboard shows a banner on every page for owners of a starter organization that still needs to check out, and lands a freshly created organization on the billing page.
Card versus invoiced
StartSubscription first writes a pending row (no Stripe ids yet), then creates the Stripe objects, then activates the row. A crash between the two leaves the pending row behind as evidence; the next attempt reuses it, and the Stripe idempotency keys (keel:customer:{org}, keel:plan-subscription:{customer}, keel:usage-subscription:{customer}) make the retry safe. A pending row is not billable and does not grant access.
What gets created depends on organization.billingMode:
card: creates the customer, the plan subscription withcollection_method=charge_automatically, and the usage subscription. Status follows Stripe (active,past_due,unpaid,canceled).invoiced(Enterprise): creates the customer, the plan subscription and the usage subscription, all withcollection_method=send_invoiceanddays_until_due=STRIPE_INVOICE_DAYS_UNTIL_DUE(default 30). Overage is invoiced monthly from the usage subscription like everyone else. The local status isinvoiced_pendingand access is granted immediately;invoice.paidmoves it toactive.
Subscription statuses and their transitions are in packages/domain/src/billing/subscription.ts:
| Local status | Meaning | Access |
|---|---|---|
pending |
Customer created but no paid checkout yet, or a checkout whose payment Stripe is still confirming | no |
active |
Paid and current | yes |
past_due |
A payment failed; Stripe is retrying | yes |
unpaid |
Stripe exhausted its retries | no |
invoiced_pending |
Invoice sent, payment not settled | yes |
canceled |
Ended, or Stripe deleted the subscription | no |
Admission gate
Dispatch admission evaluates a BillingAccessPolicy under the quota row lock, before the spend cap and concurrency checks. SubscriptionBillingAccess denies when the organization has no subscription row or its status does not grant access; the run is rejected with the subscription_inactive error class and the terminal listeners are notified like any other rejection. DisabledBillingAccess grants everything and is composed when BILLING=disabled, so local development and test environments dispatch without a subscription. Nothing else bypasses the gate.
What a run-minute is
A run-minute is one minute of wall clock at the smallest Fargate task (256 CPU units, 512 MB). What a run is billed at is the task Fargate actually launches, not the memory the manifest asks for: the agent's memory plus the supervisor's 256 MB is rounded up to the next size Fargate offers for that CPU (fargateTaskSize in packages/domain/src/billing/fargate-sizing.ts, the same table the executor uses). A manifest asking for 256 CPU and 512 MB therefore runs, and is billed, as a 256 CPU, 1 GB task. Tasks are weighted by their hourly cost relative to the smallest task (sizeWeight in packages/domain/src/billing/weighting.ts), and weightedMinutes = ceil(billableSeconds / 60) * sizeWeight rounded to four decimals, where billableSeconds = min(runSeconds, ceilingSeconds). Usage records store the billed task size. Plan allowances are expressed in weighted minutes. Peak memory is recorded but not billed.
A reservation is refunded, not settled, when an attempt fails for a platform reason before the supervisor registered (dispatch_lost, provisioning_timeout, image_pull_failed, platform_error, platform_lost): the run count, the concurrency slot and the inflight minutes go back and no usage record is written. A queued run that waits longer than the plan's queueTimeoutSeconds is rejected by the reaper with queue_timeout; it never held a reservation, so nothing is released.
Services are billed per replica-month, by kind
An always-on service (triggerSource = service, see Services and APIs) is a flat product, not run capacity, and comes in two kinds (Agent.serviceKind, serviceKinds = ["agent", "app"], migration 0028): an agent is an AI agent or bot and costs pricing.servicePriceCentsPerMonth (49 EUR on Business and Enterprise, null on Starter, which has no services); an app is a regular backend (an API, a web app, a worker) and costs pricing.appServicePriceCentsPerMonth (19 EUR on Business and Enterprise, null on Starter). Both are charged per replica and pro-rated by the hour the replica is up; servicePriceCentsPerMonth(pricing, kind) picks the price. The kind defaults to agent, is set with service.kind in keel.yaml (serviceKind on POST /v1/agents) and may be changed with PATCH /v1/agents/{id} only while the service is stopped: while a service run is active or service_status.state is not stopped the API answers 409 service_kind_locked, so no billing hour is ever split between two prices. Service hours never consume the run-minute allowance and never count as run overage; a fleet of eleven bots costs eleven agent replica-months, not ten times a generic host.
- A plan offers services exactly when it prices both kinds (
planSchemarefuses a priced plan without the feature and a feature without both prices) and a plan without services hasmaxServices = 0.maxServicescovers both kinds together. Business allows as many services as agents (15), Enterprise is unlimited. - Static sites (Static sites) are included in the plan at no extra price:
features.sitesis on for Business and Enterprise and off for Starter (maxSites = 0),limits.maxSitescaps live sites (10 on Business, unlimited on Enterprise) andlimits.siteMaxBytes/limits.siteMaxFilescap one deployment (250 MB, 10,000 files). The bytes of every ready deployment are kept per organization insite_usage.site_bytes(migration0024, refreshed after each upload and by thesite-retentionworker) so a storage price can be metered later without a data migration; nothing bills them today. - Replicas are billed as services: a service running N replicas costs N replica-months at the kind's price (49 EUR for agents and bots, 19 EUR for app services on Business and Enterprise), because every replica is its own service run with its own attempt, its own concurrency slot and its own hourly
servicekind usage windows. Theservice_hoursmeter carries the hours of every agent replica, theapp_service_hoursmeter the hours of every app replica.plan.limits.maxReplicasPerService(Starter 1, Business 3, Enterprise 10; migration0026writes it into theplansrows) capsservice.replicasin the manifest andPOST /v1/agents/{id}/scale, and the scale endpoint refuses a count the organization's freeconcurrentRunsslots cannot hold (concurrency_exhausted). A rolling deploy briefly runs N+1 replicas, so the settlement may bill one extra partial hour per rollout; the spend cap projection sees that hour through the reservation of the extra run. - Dispatch admits a service with a
servicereservation (serviceReservationFor(agent.serviceKind) = { kind: "service", expectedHours: 1, serviceKind }): it takes one concurrency slot and projects one hour at the hourly price of that kind against the spend cap, but reserves no run and no inflight minutes. A run reservation ({ kind: "run", expectedWeightedMinutes }) behaves as before. usage_records.kind(migration0021,runorservice, defaultrun, checked so that exactly theservicekind carries awindow_start) flags every record, andusage_records.service_kind(migration0028,agentorapp, checked so that exactly theservicekind carries one) says which price it was settled at; the settlement and the terminal write read it from the agent, and the migration backfills every existing service record asagent. Run mode records keep exactly one record per attempt through a partial unique index onattempt_id where window_start is null; service records carry the UTC clock hour they cover and are unique on(attempt_id, window_start) where window_start is not null.insertIfAbsentpicks the matching conflict target, so both paths keep their once-only semantics.findForAttemptreturns the most recent record of an attempt, which the settlement uses as its high water mark. Service records still storeweighted_minutesfor cost analysis; nothing bills them.service-settlementruns every five minutes (SettleServiceUsage). Per organization, under the quota row lock, for everyrunningservice run with a registered attempt it computes the completed clock hours since the last settled window (completedServiceWindows), inserts oneservicekind record per window stamped with the agent'sserviceKind(the first window covers only the seconds after the attempt started, later ones a full hour; seconds are rounded up), adds the hours of the inserted windows (serviceWindowHours, a full window is one hour) toorg_quota_state.period_service_hoursfor agents orperiod_app_service_hoursfor apps (settleServiceHours(state, hours, kind)), and then recomputes the spend cap projection. Records are attributed to the quota period current at settlement. The slot stays taken while the service is up.- Over the effective cap, every running service of the organization is stopped: the run fails with
spend_cap_exceeded, the attempt's stop reason isspend_cap_exceeded, the executor is stopped,service_status.statebecomesstoppedwith a five minute hold, and the organization is notified (ServiceNotifier.serviceStoppedForSpendper service, plus the once-per-periodspendCapExceedednotification). The reconciler will not start a service again while the projection is over the cap (spend_cap_hold). - Terminal (
finalizeAttemptfor a service run) writes every still unsettled completed window plus the last partial window from the top of the current hour to the stop time (terminalServiceWindow; a stop exactly on the hour writes an empty keyed record), adds their hours to the period and frees the slot once (releaseServiceReservation), guarded by the insertion of that terminal window. A platform failure before the attempt registered gives the slot back (refundReservationwith a service reservation touches nothing else). - The rollup, the reconciler and the usage endpoint exclude
servicekind records from the run count (usageRunCount) and from the weighted minutes (usageTotals); agent hours go to theservice_hoursmeter and toperiod_service_hours, app hours to theapp_service_hoursmeter and toperiod_app_service_hours(serviceHoursByKind).
After upgrading, running services still hold the inflight minutes their old reservation took; ReconcileQuotaState zeroes the inflight counter once no run mode run is reserved and recomputes period_runs, period_service_hours and period_app_service_hours from the records, so the counters settle within one reconcile tick. Migration 0021 backfills kind from window_start, moves the service minutes out of period_weighted_minutes_finalized, fills period_service_hours from the existing windows and writes the service price into the plans rows and the quota rows of Business and Enterprise organizations. Migration 0028 adds agents.service_kind (default agent), usage_records.service_kind (backfilled to agent for every service record), org_quota_state.period_app_service_hours and org_quota_state.app_service_price_cents_per_month, writes appServicePriceCentsPerMonth (1900, null on Starter) into the plans rows and the app price into the quota rows of Business and Enterprise organizations. Usage subscriptions created before 0028 carry five metered prices; the rollup detects that and leaves their app service hours unreported until the keel_app_service_hour price is attached, see "Usage subscriptions created before a price existed". New subscriptions and Checkout completions include all six prices.
Managed data stores are billed per store-month
A managed data store (Databases) is a flat product like a service: pricing.postgresPriceCentsPerMonth (19 EUR) and pricing.valkeyPriceCentsPerMonth (9 EUR) on Business and Enterprise, null on Starter, pro-rated by the hour the store exists. The data-store-settlement role (every five minutes, SettleDataStoreUsage) inserts one data_store kind usage record per completed clock hour since the store's settled_window_start, carrying data_store_id and data_store_kind and no run; a deleted store is settled through its deletion time including the partial hour, and the seven day soft delete window is not billed. Migration 0025 makes run_id and attempt_id nullable for that kind, adds the two columns, the (data_store_id, window_start) unique index and the usage_records_subject_check constraint that ties each kind to its subject.
Data store hours never touch the run count, the weighted minutes or period_service_hours. They are not part of the spend cap projection either: a store is an explicit, fixed price opt in bounded by limits.maxDataStores, so the cap keeps guarding metered run and service spend while the usage endpoint shows the data store projection per kind under dataStores.
Meters and the rollup
Six Stripe Billing Meters exist, all sum aggregations keyed by stripe_customer_id:
Meter event_name |
Unit | Source |
|---|---|---|
run_overage |
runs above includedRuns |
distinct attempts of the finalized run kind usage records in the period |
run_minute_overage |
weighted minutes above includedWeightedMinutes |
sum of weighted_minutes over the same records |
service_hours |
agent service hours, no allowance | sum of billable_seconds over the service kind records with service_kind = agent in the period, in hours, one per full window |
app_service_hours |
app service hours, no allowance | same for service_kind = app |
postgres_hours |
Postgres store hours, no allowance | sum of billable_seconds over the data_store kind records with data_store_kind = postgres |
valkey_hours |
Valkey store hours, no allowance | same for data_store_kind = valkey |
The service_hours price is servicePriceCentsPerMonth / (30 * 24) per hour, created with unit_amount_decimal (twelve decimals, 6.805555555556 cents for 49 EUR) so a full month of one service comes to the flat price and Stripe rounds the invoice line to whole cents. The app_service_hours price is appServicePriceCentsPerMonth / 720 (2.638888888889 cents for 19 EUR), and the data store prices follow the same rule: 2.638888888889 cents per Postgres hour and 1.250000000000 cents per Valkey hour. Invoiced organizations carry the same prices on their usage subscription. The invoice lines are named after the products: Keel usage overage carries the metered prices, whose display names are Keel runs above allowance, Keel run minutes above allowance, Keel always-on service hours, Keel app service hours, Keel managed Postgres hours and Keel managed Valkey hours, so an invoice shows agents and app services on separate lines.
RollupUsage runs every five minutes per billable organization (meter-rollup worker role). The ledger rows in usage_reports (one per organization, period, meter) are an outbox: reported_quantity is the cumulative overage Stripe has accepted, and pending_identifier, pending_from, pending_to, pending_at describe a report that has been decided but not yet confirmed sent.
Stage, in one tenant transaction, for the current quota period and every period that still has records inside Stripe's 35 day backdating window:
- Read the finalized usage records for the period and the period's allowance snapshot from
billing_periods. - Lock or create the ledger row for each meter (
select ... for update). - If the row already has a pending report, that report is resent as is.
- Otherwise compute
overage = max(0, used - included)anddelta = max(0, overage - reported_quantity)(rollupDeltasin the domain;service_hours,app_service_hours,postgres_hoursandvalkey_hourshave an allowance of zero, so every settled hour is reported), and when the delta is positive persist the pending report{identifier, from: reported_quantity, to: overage, at: now}.
Then, outside that transaction, send each pending report as one meter event with quantity = to - from and the persisted identifier, and mark it sent (reported_quantity = to, pending cleared) in a second transaction guarded by the identifier.
The identifier is {organizationId}:{periodId}:{meter}:{newTotal}. Whatever crashes, the same identifier is sent again on the next tick: a failure before Stripe accepted the event simply retries, and a failure after Stripe accepted but before the ledger was marked sent resends an identifier Stripe deduplicates (uniqueness is enforced for at least 24 hours), so overage is never counted twice. Usage that arrived while a report was pending is reported by the tick after the pending one settles. The event timestamp is the latest stopped_at in the period, clamped into the period window and to the present, so the usage lands in the Stripe period it belongs to.
Usage subscriptions created before a price existed
A meter event only lands on an invoice when the customer's usage subscription carries a price bound to that meter. Stripe accepts the event either way, so reporting a meter the subscription does not carry would mark the quantity as reported in the ledger and never bill it. Before the rollup claims anything it therefore reads the usage subscription's items (StripeUsagePriceCoverage, one retrieveSubscription per usage subscription, remembered for the life of the process once every price is attached) and compares them with the six configured price ids. A meter whose price is missing is left unclaimed: the ledger row stays where it is, the outcome lists it under unbilled and the meter-rollup role logs an error per meter naming the quantity that waits. Once the price is attached, the next tick reports the whole outstanding delta with the usual identifier. A subscription row without a usage subscription id covers nothing.
Usage subscriptions created before app_service_hours existed carry five prices. Attach the sixth with the worker script, which reads the same STRIPE_PRICE_* variables the platform runs with, lists every subscription that carries at least one of them, prints what each one lacks and, only with --apply, adds the missing items without proration:
STRIPE_SECRET_KEY=... STRIPE_PRICE_RUN_OVERAGE=... STRIPE_PRICE_RUN_MINUTE_OVERAGE=... STRIPE_PRICE_SERVICE_HOUR=... STRIPE_PRICE_APP_SERVICE_HOUR=... STRIPE_PRICE_POSTGRES_HOUR=... STRIPE_PRICE_VALKEY_HOUR=... pnpm --filter @keel/worker stripe:attach-usage-prices --apply
The same step applies to any metered price added later. Doing it by hand in the Stripe dashboard (subscription, add product, the Keel usage overage price for the meter, no proration) is equivalent.
The row lock serialises two rollups of the same organization: the second one sees the pending report the first staged and resends the same identifier. Running one meter-rollup worker is still the recommendation. A worker that stays down for longer than Stripe's dedupe window and then resends a pending report is the one case the outbox cannot cover; the nightly drift check catches it.
Quota reconciliation and spend caps
quota-reconcile runs every ten minutes:
ReconcileQuotaStatelocks eachorg_quota_staterow and, inside that same tenant transaction, counts the organization's runs indispatching,provisioning, orrunning(runs.countByStatus). It recomputesconcurrent_runningfrom that count,period_runsfrom the distinct attempts of therunkind usage records plus the run mode share of the count (countByStatuswithtriggerSource: servicegives the services),period_weighted_minutes_finalizedfrom therunkind records,period_service_hoursfrom theservicekind records of agents andperiod_app_service_hoursfrom those of apps. Inflight minutes are zeroed only when no run mode run is reserved under the lock, so a run admitted while the reconciler works can never have its reservation erased. Every correction is logged.- Service settlement (
service-settlement, every five minutes) adds each completed clock hour of every running service to the period's service hours under the same lock; see "Services are billed per replica-month, by kind". CheckSpendCapNotificationsnotifies once per period for organizations whose projected overage is over their effective cap and who have not been told yet (capNotificationDue). Dispatch already notifies at rejection time; this covers caps lowered between dispatches.
Spend cap semantics: every plan carries pricing.defaultSpendCapCents (Starter and Business: twice the plan's monthly price, the same number on both billing intervals; Enterprise: null, meaning contractual). The default is written to org_quota_state.spend_cap_cents when the quota row is created and again on every plan change. An owner with billing.manage changes it through SetSpendCap (an integer number of cents, at most the platform ceiling; only a contractual plan may clear it back to null). On top of every cap sits ENTERPRISE_SPEND_CEILING_CENTS, a platform-wide ceiling from configuration: the effective cap is min(spend_cap_cents, ceiling), or the ceiling alone when the cap is null, so no organization is ever uncapped. The cap bounds the projected spend for the period (projectedOverageCents): run overage above the included runs, minute overage over finalized minutes plus inflight reservations plus the run being admitted at its ceiling, and the service spend, period_service_hours times the hourly share of servicePriceCentsPerMonth plus period_app_service_hours times the hourly share of appServicePriceCentsPerMonth (plus the one hour a service being admitted projects at its kind's price), each rounded to whole cents (serviceSpendByKindCents). The shape of the projection and of spendCap on the usage endpoint is unchanged. A run is rejected with spend_cap_exceeded when admitting it would push the projection over the effective cap. Admitted runs are never killed for spend; the overshoot is bounded by the reservations already made. Services are the exception: their spend grows while they are up, so the settlement stops every running service once the projection crosses the cap.
Plan changes and proration
ChangePlan compares plans by annual price, and stays on the interval the subscription is already on: the target price is the price of that plan for that interval, a plan that is not sold on it is refused with interval_not_available, and prorationForUpgrade estimates from the prices of that interval, so a monthly customer upgrading Starter to Business sees the difference between the monthly prices, not the annual ones.
- Upgrade: any pending subscription schedule (a scheduled downgrade) is released first so it cannot undo the upgrade at renewal, then
subscriptions.updateon the plan subscription with the new price andproration_behavior=create_prorations. Stripe computes the actual proration;prorationForUpgradegives the estimate shown to the customer (difference times the remaining fraction of the term). The organization'splan_idand the quota limits (concurrent_limit, allowances, overage prices, default spend cap) change immediately and the current period snapshot is updated. Running counters are untouched, so in-flight runs are never affected. - Downgrade: a subscription schedule keeps the current phase and adds a phase with the new price lasting exactly one term of that price (its
recurringinterval, read from Stripe so the annual assumption stays in the price object), withend_behavior=releaseso the subscription continues on the new price afterwards. Nothing changes locally until Stripe renews; thecustomer.subscription.updatedevent then carries the new price, the price catalog maps it back to a plan, and the same plan change is applied.
CancelAtPeriodEnd sets cancel_at_period_end on the plan subscription and cancel_at=termEndsAt on the usage subscription.
Switching the billing interval
ChangeBillingInterval (POST /v1/billing/change-interval, billing.manage) moves the plan subscription between the two prices of the same plan. The usage subscription is untouched: it stays monthly, keeps its anchor and keeps carrying the six metered prices.
- Monthly to annual takes effect immediately. The plan subscription is updated to the annual price with
proration_behavior=create_prorations, so Stripe invoices the annual price now and credits the unused part of the month already paid. The customer is charged the annual price minus that credit, the new term starts today and runs twelve months, andchargeForSwitchToAnnualCentsgives the estimate the dashboard and the CLI show before the customer confirms (annual price minus the monthly price times the unused fraction of the current month). The storedinterval,term_starts_atandterm_ends_atare updated from the subscription Stripe answers, and any scheduled switch is cleared. - Annual to monthly takes effect at the end of the paid term, and nothing is refunded. A Stripe subscription schedule keeps the current phase and adds a phase on the monthly price lasting one month with
end_behavior=release, exactly as a scheduled downgrade does. Nothing is charged today, the annual term the customer paid for runs toterm_ends_at, andscheduled_intervalrecords that monthly billing starts on that date. The renewal that day arrives ascustomer.subscription.updatedcarrying the monthly price, which is whenintervalchanges andscheduled_intervalis cleared.
Guards, all refused before Stripe is called: same_interval when the subscription is already on that interval, interval_not_available when the plan carries no price for it, plan_not_priced when the plan has no Stripe price id configured for it, and interval_change_not_allowed with reason: "not_active" while the subscription is pending, past_due, unpaid, canceled or invoiced_pending, or with reason: "canceling" while it is set to end at the term. A past due subscription therefore cannot escape or extend its debt by switching interval. Stripe holds one schedule per subscription, so a scheduled interval switch and a scheduled plan downgrade replace each other; the last one scheduled is the one that happens at renewal.
The dashboard's billing page shows the current interval, the renewal date, a monthly and annual toggle on the plan cards with the saving an annual term carries, and a confirmation naming exactly what is charged and when before the switch is made. keel billing prints the interval and the renewal date, keel billing --interval monthly switches the current subscription and prints what was charged, and keel billing --plan business --interval monthly opens Checkout on the monthly price.
Webhooks
The API must expose a Stripe webhook endpoint that passes the raw body and the Stripe-Signature header to HandleStripeWebhook. Signature verification happens in StripeWebhookVerifierAdapter (stripe.webhooks.constructEventAsync with STRIPE_WEBHOOK_SECRET). Events are claimed in billing_events by Stripe event id before processing, so redeliveries are reported as duplicates; a claim is released when processing throws so Stripe's retry can succeed, and an unprocessed claim expires after ten minutes.
The endpoint is POST /v1/billing/stripe/webhook (public, raw body). Handled event types: checkout.session.completed, customer.subscription.updated, customer.subscription.deleted, invoice.paid, invoice.payment_failed. Everything else is acknowledged and ignored.
customer.subscription.updated on the plan subscription also reconciles the interval: the price the subscription now carries maps back to a plan and an interval, so a renewal onto the monthly price, a switch made in the Stripe dashboard and a switch made through Keel all end with the stored interval, term_starts_at and term_ends_at matching Stripe. scheduled_interval is cleared once the event carries the interval it named, and kept while the paid term runs on. An event whose prices this platform does not know leaves the stored interval alone.
Stripe does not guarantee delivery order. Subscription events are snapshots, so the handler stores the event's created time on the subscription, separately for the plan and the usage subscription (plan_event_at, usage_event_at), and reports an event older than the last one applied to that subscription as stale without touching anything. Invoice events are transitions and are applied in the order they arrive. A provider status of unpaid (retries exhausted) maps to the local unpaid status, which withdraws access until an invoice is paid.
Nightly drift check
billing-nightly runs the drift check once per day (a billing_job_markers row records the last run). For every billable organization and every meter (meterNames, including service_hours, app_service_hours, postgres_hours and valkey_hours) it compares Stripe's meter event summary over the current period with the ledger total and raises a BillingAlerts.meterDrift alert above one percent. Zero drift is the expected steady state.
Prices are data
Plan limits and prices live in the plans table and are read through PlanCatalog. SeedPlansFromConfig upserts the config catalog (packages/config/src/plans) into that table; the worker and API fall back to the config catalog for any plan the table lacks (FallbackPlanCatalog). Change a price by updating the row (and the matching Stripe price), not by editing code. The service prices are pricing.servicePriceCentsPerMonth and pricing.appServicePriceCentsPerMonth on the same row and are copied to org_quota_state.service_price_cents_per_month and app_service_price_cents_per_month when the quota row is created and on every plan change, so the spend projection prices services at the plan the organization was on when the period ran.
Stripe objects and the bootstrap script
Required objects in the Stripe account:
- Products
Keel Starter,Keel Business,Keel Enterprise, each with a yearly price (lookup keyskeel_plan_<plan>_annual), plus a monthly price for every plan the catalog prices monthly (lookup keyskeel_plan_<plan>_monthly; Enterprise has none). - Product
Keel usage overagewith six monthly metered prices (lookup keyskeel_run_overage,keel_run_minute_overage,keel_service_hour,keel_app_service_hour,keel_postgres_hour,keel_valkey_hour) bound to the meters; the hourly prices useunit_amount_decimal. - Meters with
event_namerun_overage,run_minute_overage,service_hours,app_service_hours,postgres_hoursandvalkey_hours,sumaggregation, customer mapping bystripe_customer_id, value keyvalue. - A Customer Portal configuration with
keel_portal=defaultmetadata (payment method update, invoice history, customer updates including the tax id, cancel at period end, no plan switching). - A webhook endpoint pointing at
https://api.<domain>/v1/billing/stripe/webhookwith the five event types above.
apps/worker/scripts/stripe-bootstrap.ts creates the products, prices (both intervals of every plan the catalog prices), meters and the portal configuration idempotently in a test account from the plan catalog and prints the environment variables to set:
STRIPE_SECRET_KEY=sk_test_... DASHBOARD_PUBLIC_URL=https://app.example.test node --experimental-strip-types apps/worker/scripts/stripe-bootstrap.ts
It refuses live keys. Create the webhook endpoint in the dashboard and copy its signing secret into STRIPE_WEBHOOK_SECRET. deploy/single-host/README.md walks through the host side.
Environment
| Variable | Meaning |
|---|---|
BILLING |
stripe or disabled (default). Disabled means the billing worker roles log once and exit. |
STRIPE_SECRET_KEY |
Required when BILLING=stripe. |
STRIPE_PUBLISHABLE_KEY |
Optional pk_test_... or pk_live_... key. Reported on GET /v1/billing as publishableKey; the dashboard mounts embedded Checkout with it and falls back to the hosted Checkout page without it. |
STRIPE_WEBHOOK_SECRET |
Signing secret of the webhook endpoint; required when BILLING=stripe. |
DASHBOARD_PUBLIC_URL |
Where Checkout and the Customer Portal send the customer back (/settings/billing); required when BILLING=stripe. |
STRIPE_PORTAL_CONFIGURATION_ID |
Optional id of the Keel portal configuration; otherwise looked up by metadata once per process. |
STRIPE_PRICE_STARTER, STRIPE_PRICE_BUSINESS, STRIPE_PRICE_ENTERPRISE |
Yearly plan price ids. |
STRIPE_PRICE_STARTER_MONTHLY, STRIPE_PRICE_BUSINESS_MONTHLY |
Monthly plan price ids, required when BILLING=stripe for every plan the catalog sells monthly. Enterprise has no monthly variable because it is invoiced by hand. |
STRIPE_PRICE_RUN_OVERAGE, STRIPE_PRICE_RUN_MINUTE_OVERAGE, STRIPE_PRICE_SERVICE_HOUR, STRIPE_PRICE_APP_SERVICE_HOUR, STRIPE_PRICE_POSTGRES_HOUR, STRIPE_PRICE_VALKEY_HOUR |
Metered price ids shared by every plan, all required when BILLING=stripe; the hourly prices are the monthly price divided by 720. |
STRIPE_INVOICE_DAYS_UNTIL_DUE |
Payment terms for invoiced customers (default 30). |
ENTERPRISE_SPEND_CEILING_CENTS |
Platform-wide ceiling on the monthly overage projection, applied to every organization and the only cap for contractual plans (default 1,000,000). |
The Stripe SDK is pinned to stripe@22.6.2, which targets API version 2026-08-26.dahlia. Subscription periods are read from subscription items (current_period_start and current_period_end moved off the subscription object in this API version), and invoice subscriptions from invoice.parent.subscription_details.