BILLING_ENABLED is true and STRIPE_SECRET_KEY is set. A self hosted instance never reaches any of this. See Configuration reference for the variables.
The catalog
Two products and four prices, created from a committed script rather than by hand, so a test account can be rebuilt exactly:STRIPE_PRICE_* values to put in the environment. It is idempotent: rerunning it reuses the products and prices it already created. A price id asobeast does not recognise is refused loudly rather than falling back to a free plan, because a silent fallback means a paying customer gets nothing.
The webhook endpoint
Stripe posts every subscription event to one endpoint, and the URL is the web origin followed by the same/api/backend/ prefix every other API call uses:
STRIPE_WEBHOOK_SECRET, and send one test delivery before you take a payment. Without the secret the endpoint refuses every delivery and checkout stays closed, so nobody pays for access that could never be provisioned.
The endpoint is the one billing route that is public, because Stripe has no session. It authenticates the delivery instead: each request carries a stripe-signature header computed over the exact bytes of the body, and a delivery whose signature does not verify is refused with 400. The web app forwards that header and the body untouched, so the API verifies what Stripe actually signed.
A refused delivery is logged. Look for rejected a webhook delivery in the API log, which names the reason, and check the endpoint’s own delivery history in the Stripe dashboard. Stripe retries a failed delivery, so a secret you fix within the retry window costs nothing.
States a workspace moves through
The plan in force is derived rather than trusted: a paid plan past its period end falls back to the trial if one is still running, and to free otherwise.
Every Stripe status lands in one of three outcomes. It entitles the workspace, it is recoverable, or it is gone. Recoverable is the row that used to strand a customer: an unpaid or paused subscription pays for nothing, and a second one would be a double charge, so the only way forward is the portal. A workspace in that state is told its subscription stopped collecting and is offered a resume, never a checkout that would be refused.
A trial that ends with no payment method on file pauses or cancels the subscription according to the trial setting on the Stripe side. Pausing keeps the same subscription and asks for a card; cancelling frees the customer to buy again. Either is coherent, so pick one in the dashboard and leave it alone.
Trial
Seven days, Indie limits, no card. With SMTP configured the trial starts when the customer confirms their email, not at registration. A workspace gets one trial ever, recorded bytrialStartedAt, so a returning customer resumes on a paid plan rather than a second free week.
Milestone emails go out on days 0, 3, 5, 7 and 8 through the same SMTP transport and delivery log the product alerts use. The day 3 message is the one that matters: three days of positions is the first point at which the keyword monitor shows movement rather than a single reading.
Dunning
A failed charge moves the subscription topast_due, which stays entitled. Revoking on the first decline turns an expired card into churn. asobeast emails the owner once, points them at the billing portal, and then follows Stripe’s own retry schedule. Recovery is automatic: the next invoice.paid clears the warning and restores the plan with no manual step.
Downgrade and cancellation
Both are self service through the Stripe customer portal, and both take effect at period end, so the customer keeps what they paid for. A plan change is a portal change, never a second checkout: a workspace that already names a live subscription is refused a checkout session with 409 and sent to the portal, so one workspace is never charged twice. A workspace also gets at most one checkout session it can complete. Opening a session claims a short lease, so two requests in flight together cannot both create one, and the previous session is expired at Stripe before a new one opens, so a page left open in another tab cannot be paid afterwards. Stripe itself is asked for the customer’s subscriptions before any session is created, which closes the window between a completed payment and the webhook that records it. A subscription found that way is reconciled onto the workspace before the checkout is refused, so the customer keeps what they already pay for, and a refusal that rests on stored state reconciles first as well rather than trusting a row a webhook may have missed. A live subscription on the customer whose metadata names a different workspace refuses the checkout without adopting it: one customer is never billed twice, and the mismatch is logged for support. Turn on Limit customers to one subscription in the Stripe dashboard as well: it is the only thing that stops two checkout tabs both completing. A refusal carries the recovery in the error body, not only in its message, so the web app can open the portal for the customer rather than showing them a dead end.- asobeast reads the scheduled phase from Stripe and records the plan the workspace will land on.
- Within a week of the effective date, if the workspace would be over the smaller plan’s limits, the owner is emailed with the exact numbers.
- At the effective date the daily run covers the first items in a stable order and leaves the rest untouched. Nothing is deleted.
Reconciliation
Webhooks are reliable but not guaranteed, and stale local state means either a paying customer locked out or a churned one still served. A daily job compares every workspace against Stripe, treats Stripe as authoritative, corrects the difference and logs every correction at error level, because a reconciliation that quietly fixes drift hides a webhook bug. It also reports subscriptions Stripe knows about that belong to no workspace. Reconciliation looks up a subscription two ways. The stored subscription id comes first, and when there is none, when Stripe has forgotten it, or when the one it names is already cancelled, the workspace customer is asked what it holds. A subscription there that names no other workspace in its metadata is adopted, which is what rescues a customer whose checkout completed and whose webhook never landed. Only a workspace that claims something Stripe cannot back is revoked, so a running trial is left to expire on its own rather than being reset every night. The same repair runs the moment a customer comes back from checkout: the return carries a marker the app reads once, and it reconciles the workspace there and then rather than waiting for a webhook that may never arrive. The marker is added toSTRIPE_PORTAL_RETURN_URL as well when that is set, so the repair does not depend on the default return page. It is only cleared once the reconciliation succeeds, so a return that arrives while Stripe is unreachable is retried on the next load.
Only an authoritative answer revokes a plan. A subscription Stripe reports as missing is gone and the workspace drops to free, but a timeout, a rate limit, a rejected key, an outage or a price the local catalog cannot name leaves the workspace exactly as it is and is reported as unreconciled, because a provider that cannot answer is not the same as a customer who stopped paying.
An owner can reconcile their own workspace immediately with POST /billing/reconcile, so support can resolve a single stuck customer without waiting for the next run. That call answers 503 rather than a report when Stripe cannot be reached.
Related
Authentication and accounts
Sessions, workspaces and what an unentitled workspace keeps.
Configuration reference
Every Stripe variable and its default.