Hosted billing
Select Use Stripe when creating a project to enable the stripe Cargo feature. It adds a signed-in /billing page, Stripe-hosted
Checkout, customer portal, and a signed webhook at /stripe/webhook. Enable it in
app/Cargo.toml or run cargo run --features stripe. With the feature disabled,
billing routes and Stripe dependencies are omitted; source and migrations remain
available. Leaving every Stripe setting blank keeps the app runnable, with billing
unavailable. Partial configuration fails startup.
Configure your account
Start in a separate Stripe sandbox. Create a product and recurring price for subscriptions; optionally create a one-time price. This starter supports one price per mode and quantity one. Prices are selected on the server and validated at startup for their type, activity, and test/live mode.
Set these values in your ignored .env file or your host's environment:
STRIPE_SECRET_KEY=rk_test_your_restricted_key
STRIPE_WEBHOOK_SECRET=whsec_your_endpoint_secret
STRIPE_SUBSCRIPTION_PRICE_ID=price_your_recurring_price
STRIPE_PAYMENT_PRICE_ID=price_your_one_time_price
SITE_URL=http://localhost:3000
Configure either price or both. Use the same account and test/live mode for all values. Prefer a restricted key with Checkout Sessions write/read, Prices read, Subscriptions read, and Customer Portal write permissions (plus any dependent permissions Stripe requests). Keep keys and webhook secrets on the server. No publishable key or Stripe.js is needed: the browser follows a server-created Checkout URL. Payment methods come from your Stripe Dashboard settings.
Enable the customer portal in the same environment. Allow payment-method updates, invoice access and cancellation. Plan changes, quantities and multiple products require extending the starter's single-price validation; leave portal plan switching off until then.
Products, tiers, and billing intervals
The starter currently accepts one recurring price through
STRIPE_SUBSCRIPTION_PRICE_ID and one optional one-time price through
STRIPE_PAYMENT_PRICE_ID. It does not yet include a plan catalog, tier selection,
or a monthly/yearly selector. Products and prices are created in your Stripe
account; the app uses their price IDs rather than creating products itself.
For three tiers, such as Starter, Pro, and Business, create three Stripe products with two recurring prices each: one monthly and one yearly. That is six prices total. The product identifies the tier; the price determines the amount and billing interval. See Stripe's product and price model.
Supporting this in the app requires extending the starter:
- Replace the single recurring-price setting with a small server-side catalog mapping each tier and billing interval to an allowed Stripe price ID.
- Add tier and interval selection to
/billing. Resolve those choices through the catalog on the server before creating Checkout. - Store the subscription's current tier and price so product routes can apply the appropriate feature limits.
- Update webhook validation to accept the configured prices and update the stored tier when the subscription changes. Then configure the customer portal for the plan changes you support and test upgrades and downgrades.
The hosted Checkout flow, signature verification, durable checkout attempts, and most lifecycle handling can remain. Creating six prices in Stripe alone does not add tier support: the current webhook checks the subscription against its original checkout price. Switching to another price currently removes subscription access, so keep portal plan switching disabled until that validation is extended.
A simple Free / Starter / Pro / Business plan model is a useful extension.
Monthly and yearly prices for the same tier should normally grant the same
features; the interval changes billing, not permissions. This model is a suggested
customization, not functionality already included in the starter.
Configure webhooks
Register https://your-domain/stripe/webhook for your account, using API version
2026-08-26.dahlia, and copy that endpoint's signing secret. Subscribe to:
checkout.session.completedcheckout.session.async_payment_succeededcheckout.session.async_payment_failedcheckout.session.expiredcustomer.subscription.createdcustomer.subscription.updatedcustomer.subscription.deletedcustomer.subscription.pausedcustomer.subscription.resumedinvoice.paidinvoice.payment_failedinvoice.payment_action_required
For local testing, forward events with the Stripe CLI:
stripe listen --forward-to localhost:3000/stripe/webhook
Use the whsec_… printed by that listener locally. Sign up and use the actual
/billing buttons in your sandbox. Generic CLI fixture events don't carry the
application's checkout metadata and will not grant access. Test successful and
declined payments, delayed methods, renewal failure/recovery, cancellation at
period end, and immediate cancellation. Use Stripe test clocks for renewals.
The handler verifies the raw-body HMAC and a five-minute timestamp tolerance, then retrieves current Stripe objects. Account locks and canonical retrieval make duplicate and out-of-order deliveries safe. Transient failures return 5xx so Stripe can retry. The success URL never grants access. Pending delayed payments stay pending until Stripe confirms them.
Use billing in your product
app/src/billing.rs owns billing configuration, Checkout, database queries and
webhooks. Keep product-specific changes there.
Free and paid users
Billing state is stored separately from the user record in billing_accounts
and billing_subscriptions. The starter tracks whether a user has paid access,
but it does not automatically restrict product features. Signup, sign-in, and
the dashboard are available to free and paid users alike; /billing reflects
their billing status. No shipped product route calls the paid-access helper yet.
To protect a route you add, extract CurrentUser and call:
billing::require_access(&state.pool, user.id).await?;
The helper returns HTTP 402 (Payment Required) for users without access. The initial access policy recognizes:
- An active admin role: complimentary access to every feature using this gate, even when Stripe credentials are not configured.
- A confirmed one-time purchase: permanent access.
- A trialing subscription: access for the trial.
- An active subscription whose latest invoice is paid: subscription access.
An active subscription with an unsettled asynchronous payment does not grant access. Past-due, unpaid, incomplete, paused, and canceled subscriptions have no subscription access. Scheduled cancellation keeps access until Stripe changes the subscription status at period end. A confirmed one-time purchase continues to grant access independently of subscription status.
Admin access reads the current role from the database; existing sessions and API
keys lose the bypass as soon as the role is revoked. Actual purchase or active
subscription access survives revocation. Use BillingStatus::has_access() for
feature decisions; paid remains a truthful one-time-purchase flag, not an
entitlement check. API product handlers can call the same require_access helper
after extracting ApiUser; always scope resource queries by the authenticated
user ID. Admin access does not bypass ownership checks.
Admins are not offered new checkout sessions. Existing billing records and subscriptions are unchanged, and the customer portal remains available for existing customers. Granting admin access does not cancel recurring charges; cancel an existing subscription explicitly through the portal when desired. Projects with the Stripe feature disabled have no built-in paid-feature gates.
For conditional UI, read billing::status(&state.pool, user.id).await? and use
BillingStatus::has_access(). Do not use BillingStatus.paid as the general
paid/free check: that field only represents a one-time purchase;
has_access() also recognizes subscription and admin access. Hiding a button does not
protect a route, so enforce access in its server handler too.
The helper currently answers whether the user has any paid access, not which tier they have. Extend the plan model and server-side checks to enforce tier features or limits. Customize the policy for grace periods or per-product entitlements. Billing state is local; ordinary protected requests do not call Stripe.
Checkout recovery and operational boundaries
Repeated clicks reuse an existing Checkout. If its creation response is lost,
retries reuse the same idempotency key. Unknown outcomes older than 23 hours stop
retrying: locate the Session in Stripe by metadata.billing_checkout_id, then
resend its webhook or reconcile the billing_checkouts row before trying again.
Do not blindly clear an unresolved attempt: it could already have charged.
This starter does not automate taxes, prorations, refunds, disputes, email
notifications, or fulfillment beyond access. Enable Stripe's payment-failure
emails and review Dashboard notifications for refunds/disputes; add handlers and
an explicit access policy before automating them. Refunding a one-time purchase
does not clear billing_accounts.paid_at, and refunding an invoice does not cancel
a subscription. Preserve checkout metadata and old price records when evolving
billing. Monitor webhook delivery failures and replay failed events after outages.
The local test suite uses signed events, a fake Stripe HTTP server, and real Postgres. It never contacts Stripe or charges a card:
cargo test --workspace --features stripe
cargo test --workspace --no-default-features --features stripe