Billing
Billing uses Stripe-hosted Checkout and the customer portal. Organization keys withapi_key_create permission can read billing status and start management
sessions; resource-scoped keys are rejected. Missing setup keeps online billing
unavailable and the console provides a support path.
Operator prerequisites
Apply all API migrations (including billing tables), bind D1, and configure:
For a live restricted key, grant Write to Customers, Checkout Sessions and
Customer portal, and Read to Prices and Invoices. Grant Subscriptions Write if overage consent must attach metered items. The production
activation check additionally reads Balance and Webhook Endpoints, so grant
Read to those resources as well. Leave other resources and connected-account
permissions at None. This reflects the endpoints used by Lupin; see
Stripe restricted API keys.
The webhook signing secret (
whsec_) is separate from the API key.
If the Stripe account has no Lupin catalog or webhook yet, automatic initial
setup additionally needs temporary Products: Write, Prices: Write and
Webhook Endpoints / Event Destinations: Write. After creating the three monthly
prices and signed webhook, remove Products access and return Prices and Webhook
Endpoints to Read. Subscription reconciliation requires read access; enabling metered items on an existing subscription additionally requires Write.
The production account uses Stripe Managed Payments. Lupin subscription products use the
operator-confirmed Platform as a service (PaaS) — business use tax code
txcd_10102000. Set the code on each product before testing Checkout; leaving it
empty causes Stripe to reject session creation. The bootstrap includes this code;
infra/configure-stripe-tax.mjs can add it to verified, previously created Lupin
products without changing prices or the account’s Managed Payments setting.
The category appears in Stripe’s
eligible Managed Payments tax codes.
Configure the Stripe customer portal to expose the intended plan changes,
cancellation, payment methods and invoice history. Portal features depend on
Stripe portal settings.
Register the API’s POST /v0/billing/webhooks in Stripe for
customer.subscription.created, customer.subscription.updated, and
customer.subscription.deleted.
Subscription webhooks
carry asynchronous status changes; Lupin verifies the raw body using the
stripe-signature HMAC and a five-minute timestamp window. The webhook endpoint
uses its signing secret rather than the customer API-key gate. Preserve the raw
body at any reverse proxy. A checkout success return URL does not grant a plan;
the signed subscription event updates durable account state and entitlements.
Customer workflow
billing.status(), billing.checkout("Developer" | "Growth" | "Startup")
and billing.portal(). An active subscription blocks duplicate checkout; use
the portal to change it. An organization without a Stripe customer receives
billing_account_missing when opening the portal. Provider failures return an
explicit error; no plan change is inferred from redirect navigation.
billing.status().usage reads the permanent usage ledger. Sent email counts
recipients; deleting a message or resource does not remove recorded usage.
usage_summary separates provider-confirmed USD costs, configured estimates and
unpriced activity, and reports the coverage start. Historical usage before ledger
activation is not backfilled, so these remain partial totals rather than an invoice
counter. Billing period timestamps come from subscription state, or the current UTC
calendar month for a free workspace. Inspect provider invoices for actual charges.
See budgets for admission limits and coverage boundaries.
Test checkout, plan change, cancellation, webhook retry and portal access in
Stripe test mode before enabling live keys. Deployment configuration alone does
not verify a payment flow. No real payment or subscription was created by the
local regression tests.
Published catalog and usage controls
Catalog2026-10-25 has five plans: Free ($0), Developer ($20/month), Growth
($79/month), Startup ($200/month), and Enterprise (contracted). The canonical
allowances, limits, and rational micro-USD retail rates live in
packages/shared/src/plans.ts and packages/shared/src/billing.ts; the console,
landing pricing, and API use that catalog. Existing Developer subscriptions with
catalog legacy-developer-10000 retain their 10,000-recipient allowance when enabling metered overages and across renewals. New Developer subscriptions include 5,000 recipients. Use the account entitlement catalog version for consent; the public catalog does not replace a grandfathered allowance.
Use GET /v0/billing/catalog for published plans and rates,
GET /v0/billing/status for the billing account’s plan, payment and cancellation
state, and GET /v0/billing/usage for the retail accounting overview. The overview
separates included limits, settled quantities, reserved quantities, remaining
allowances, billable overage quantities, retail estimates, and reserved retail
estimates. Accounting starts at coverage_start; earlier usage is not backfilled.
These estimates exclude subscription charges, taxes and provider adjustments.
Provider-confirmed costs and configured provider estimates in usage_summary
remain separate from customer retail charges. They are not the customer invoice.
Free has hard limits and cannot enable paid overages. Paid overages are disabled
by default. An active paid account must explicitly accept the catalog, authorize
overages, and set a positive monthly USD cap. The cap covers metered retail
overages, not the subscription or separately quoted purchases. The settings API
accepts an integer number of micro-USD (1 USD = 1,000,000 micro-USD):
billing.catalog(), billing.usage(), billing.settings(),
billing.updateSettings({overage_enabled, spend_limit_micros, catalog_version}),
and billing.checkout("Developer" | "Growth" | "Startup"). The settings write
returns verified persisted settings and the updated metering overview. The
console retains edits and shows an error if subscription reconciliation or
settings storage fails; it does not infer success from a provider redirect.
Already incurred charges remain payable after overages are disabled.
enforcement_enabled: false means this overview does not establish that
quotas, retention, or overage admission are active. Provider capability and
retention/storage readiness must also be configured before operators advertise
those controls as operational. Phone usage needs destination-specific quotes;
proxy allocation is separately purchased. No phone rate is inferred from the
email or browser catalog. Enterprise allowances require an explicit contract.
Invoices and recovery
billing.invoices({limit, starting_after}) and
billing.upcoming(). Invoice list results provide has_more and next_cursor.
Amounts are in the invoice currency’s minor units (USD cents); dates are ISO
strings or null. The upcoming invoice is an estimated provider preview and
can report no_subscription or no_upcoming_invoice. Permission or provider
failures remain errors rather than empty successful history.
Hosted invoice URLs are restricted to HTTPS invoice.stripe.com and
pay.stripe.com; PDF URLs may additionally use files.stripe.com. Credentials,
custom ports, and lookalike hosts are rejected. Links open with noopener and
noreferrer. Payment recovery and scheduled cancellation management use the
Stripe customer portal; a payment issue does not silently switch plans or enable
overages. Configure Invoices: Read for invoice history and previews, and
Subscriptions: Write when consent attaches metered items to an existing
subscription. Configure STRIPE_GROWTH_PRICE_ID alongside the Developer and
Startup recurring prices. Full metered-catalog configuration and
BILLING_ENFORCEMENT=1 are required before activating retail metering.
All local console, SDK, and CLI billing regression tests use mocked provider
responses. They do not create invoices, charge a card, or modify subscriptions.
Explicit enrollment and plan changes
Older workspaces without an explicit billing entitlement are not assigned a paid plan by matching their resource limits. An owner may enroll them in Free by accepting the current catalog:POST /v0/billing/enroll accepts exactly
{ "plan": "Free", "catalog_version": "2026-10-25" }. The SDK equivalent is
billing.enroll(...). Existing paid or already enrolled accounts cannot be
silently replaced by Free. Online paid checkout and explicit Free enrollment are
separate operations.
Existing paid subscriptions can preview a change before accepting proration:
billing.previewPlanChange("Growth") and
billing.confirmPlanChange(intentId) in the SDK. Preview responses include the
provider invoice estimate, expiry, and allowance_effective_at. The current
cycle’s included usage allowance remains unchanged, including on upgrades;
subscription proration and resource capacity are separate from included usage.
billing.status().pending_change survives a console reload and reports the
original intent, target plan and pending/payment state. Recovery uses
POST /v0/billing/change-plan with the same intent_id, never a new plan request.
A verified Stripe invoice payment URL may be returned when payment is required.
An uncertain result, expired preview, or unresolved payment does not grant new
allowances. Use the subscription portal for payment-method recovery.
Retained content and expiry
GET /v0/billing/storage (SDK billing.storage(), CLI billing storage)
separates stored, reserved, deleting, and available bytes from the storage limit.
Reservations and unfinished deletions continue consuming capacity. It reports
activation time, current retention days, and whether historical-content expiry
was explicitly activated. enforced: false does not establish automatic expiry.
Storage overages are currently unavailable: retained storage has a hard cap.
Additional browser concurrency and nondefault sandbox sizes also have no
self-service purchase flow; confirm availability with support before relying on
those optional add-ons.
Changing the retention window defaults to future content. Applying it to
existing retained content requires a distinct confirmation; it assigns a full
new retention window rather than deleting old content immediately:
billing.updateRetention({retention_days, apply_to_existing, confirmed}).
POST /v0/billing/storage/retention rejects values above the plan maximum and
rejects apply_to_existing: true without confirmed: true. Expired content can
be permanently deleted; extending retention cannot restore data already deleted.
Retained content and bounded reads
The workspace storage limit covers retained message and draft content, raw messages, call transcripts, event payloads and binary attachments. Usage is measured in UTF-8 or binary bytes. Database indexes and metadata overhead are excluded. Provider operation journals keep reconciliation and idempotency information, including some response or message copies; they are retained separately and are not covered by content expiry. A storage total is not a measurement of your provider’s invoice. New email and draft bodies use immutable R2 versions. Exact reads verify the stored content; a missing or unavailable body returns503 content_body_unavailable, rather than a successful empty message. Earlier inline
bodies remain readable. Attachment downloads and complete outbound attachment
sets are limited to 30 MiB. A retained message body is limited to 10 MiB; /v0
request bodies are limited to 32 MiB (413 request_too_large). Use attachment
URLs when base64 encoding would exceed the request limit.
Message lists, searches and batch reads are paginated metadata responses. They
include body_loaded: false and a body_url. Fetch that URL for the complete
message (body_loaded: true); append /raw for the original RFC822 content.
Draft lists use the same metadata/detail distinction. Lists do not download all
workspace bodies into a worker’s memory.
Thread details keep their existing routes, including
/v0/emails/threads/:thread_id,
/v0/emails/inboxes/:inbox_id/emails/threads/:thread_id, and
/v0/projects/:project_id/emails/threads/:thread_id. The response contains
paginated message metadata, messages_limit and, when another page exists,
messages_next_page_token. Pass that token as message_page_token; choose
message_limit up to 50. Fetch each message’s body_url when its body is needed.
Search covers message metadata and the first 8,192 Unicode characters of plain
text. Responses expose this limit through search_coverage. HTML-only content
and text beyond the indexed excerpt are not promised as searchable. Search
never downloads every R2 body.
GET /v0/billing/storage shows stored, reserved and deletion-pending bytes,
available capacity, retention days and activation dates. Unknown writes keep
their reservation. Deleting an R2 object frees capacity only after deletion is
confirmed. Storage overage billing is currently unavailable: the included byte
limit is a hard cap even when other product overages are enabled.
Newly stored content follows the active retention policy. Existing content has
no new expiry by default. An owner or administrator can use
POST /v0/billing/storage/retention with retention_days within the current
plan’s maximum. Applying it to existing content additionally requires
apply_to_existing: true and confirmed: true. The effective date starts a full
retention window; it does not immediately remove old content. Expired bodies
are removed while message metadata and permanent usage records remain.
Legacy Custom workspaces without an explicit billing enrollment keep their
existing resource limits. A separate compatibility storage policy captures their
known retained bytes and grants one-time 250 MB of additional headroom. It does
not enroll a plan, reset monthly, authorize paid actions or create usage charges.
Content under that legacy policy receives no automatic expiry. The storage
response identifies it as policy_kind: legacy_grandfathered. After explicit
plan enrollment, the plan’s byte cap and retention for new content apply;
historical expiry still requires the separate confirmation above.
Complimentary plans
An operator can explicitly grant a complimentary plan independently of Stripe. Billing responses identify it throughentitlements.source: complimentary;
the console shows $0 / month · Complimentary. Included allowances renew on
calendar months. Usage stays metered against those allowances, while paid
overages, Stripe meter exports and automatic subscription charges remain disabled.
The complimentary-startup workflow is restricted to the designated verified
administrator’s owned workspace. It expires any unused paid checkout, refuses
an existing Stripe subscription, and records the grant in the console audit log.
Replaying it does not reset consumed allowances. A subsequently purchased
subscription returns the workspace to verified Stripe billing periods.