Billing (self-serve plans)
While impersonating ("View as User"), the billing and wallet endpoints are refused with
403impersonation_forbidden— an administrator viewing as a user cannot read or act on the user's billing, wallet balance, top-ups or payment methods. The plan a user is on is still visible viaGET /admin/auth/me. See Admin impersonation.
Workspaces on the self-serve paid plan (self_serve_custom) manage their
subscription through these endpoints. They are part of the pay-first self-sign-up flow: a
workspace only exists after a successful Stripe payment. The first registrant is the
account owner (role tenant_admin) and may invite teammates up to the plan's seat cap
(see the Seats note below).
These endpoints are unavailable to non-self-serve (manually provisioned) workspaces — a
member of such a workspace receives 403 forbidden. They are also owner-only: an
invited member (non-owner) of a self-serve workspace receives 403 forbidden, so only the
account owner can view or change the subscription. The three session-authenticated
endpoints share this gate: a self-serve workspace that has no subscription row yet (a
provisioning race) receives 409 no_subscription.
Settings tab visibility (interface affordance, not an authorization boundary)
The Billing & plans tab in Settings is shown to every customer-tenant role and tenant — a free trial, a paid self-serve workspace, and a manually provisioned (enterprise) workspace; owner and member alike. What each sees on the page differs by persona:
- Trial — the trial credit balance and the trial countdown; no top-up (a trial has no payment method).
- Paid self-serve owner — the full page: Your plan, the upgrade funnel (Upgrade / Contact us), Wallet, Invoices.
- Paid member (of any tenant) — the Your plan tab only. The upgrade funnel (Upgrade / Contact us), the Wallet top-up surfaces and Invoices are absent (not greyed out) — the workspace owner drives billing.
- Trial member — as the Trial row above: Your plan and Invoices, plus the read-only Wallet balance when one is present (no top-up). The upgrade funnel is still absent — only the trial owner sees Upgrade / Contact us.
- Manual / enterprise tenant admin — a read-only "managed plan" state: the workspace is
billed directly by Myra, with no upgrade or checkout path. The Your plan tab shows the
plan name, the seats (in use / cap) and its live prepaid wallet balance (AGF-2844 — the same
tenant_walletbalance the Wallet tab shows, not the organisation's configured budget); the Wallet tab shows that same wallet balance as a read-only balance (no top-up); the Invoices tab shows the awaiting/empty state until Myra uploads invoices. These figures come fromGET /billing/overview, the managed admin's own-tenant read path — not fromGET /billing/summary(which403s a manual tenant). The read is owner-only (a member/viewer of a managed workspace sees only the "Your plan" tab landing, no figures).
This visibility is a client affordance only — never the authorization boundary. The
session-authenticated self-serve billing endpoints stay owner + self-serve gated regardless
of who can see the tab: a member, and a manually provisioned/enterprise tenant, still receive
403 forbidden from GET /billing/summary, POST /billing/portal and POST /billing/reactivate,
and from the wallet endpoints (GET/POST/PUT/DELETE /billing/wallet*) — all share the same
require_self_serve_owner gate. Broadening the tab does not broaden data access. The separate
GET /billing/overview (below) is the managed admin's own-tenant read path, owner-gated and
scoped to the caller's own tenant only.
GET /admin/v1/billing/overview (managed read)
The manual / enterprise tenant admin's own-tenant billing read. It exists because such
a workspace has no other read path: /me carries the billing snapshot only for a self-serve tenant,
and GET /billing/summary 403s a non-self-serve caller. It reuses the same underlying authorities as
the self-serve surfaces — the effective seat cap, the active-user count, and the live prepaid
tenant_wallet balance (AGF-2844 — the same spendable figure /me and /billing/summary carry, via
storage.get_wallet_balance_micro) — so there is no second source of truth.
Authorization (fail closed). Session-authenticated and owner-only (admin or tenant_admin,
via is_owner_role); a member/viewer/ki_manager/finance, an absent session and a nil/garbage role all
receive 403 forbidden (ABSENT and MALFORMED share the deny outcome). The tenant is always the
caller's own tenant_id from the session — there is no tenant identifier in the request body,
query or headers, so the endpoint can only ever return the caller's own tenant (no IDOR surface).
Unlike the self-serve endpoints it does not require a subscription row, and it does not 403 a
manual/enterprise tenant — a managed owner is exactly its intended caller.
Response 200:
plan— the tenant's plan value (enterprise/standard/free/ a self-serve slug), ornullif the row carries no plan.seats— the effective seat cap (the admin-set entitlement override, else the plan floor).null= uncapped (an ordinary enterprise tenant with no seat override) or a transient read blip — both render as "no seat limit" (a display cap ofnulldefeats no control; the real seat gate is the server-side capped insert).seats_used— active member count (the same count enforcement uses);nullon a read blip (a dash).wallet_usd— the live prepaidtenant_walletbalance in USD (AGF-2844) — the same spendable figure/meand/billing/summarycarry (storage.get_wallet_balance_micro:0for a disputed or unfunded wallet). This is the money figure the managed Your plan tile and Wallet tab display.null= a wallet read fault only — rendered as a dash, and it must never fall back to the org budget (that would reintroduce the bug this replaced).0= a real, funded-to-zero balance, shown as a money figure. Emitted ungated (unlike/summary'sview_enabled-gatedwallet_usd): the managed persona has no self-serve top-up offer to gate on. The organisation'stenant.budget_usd/ effective budget is a pure cost-control/enforcement input now and is no longer emitted or displayed anywhere (AGF-2841). An Enterprise tenant is funded through the admin Seats & Budget save: raisingbudget_usdon the tenant PATCH writes an admin-grant wallet credit of the increase (AGF-2845 — see Tenants & gateways, and theadmin_grantledger kind under wallet), so this figure reflects the admin-set budget once a budget has been saved (a brand-new, never-budgeted tenant reads0).
Rejected: 403 forbidden (no session / not an owner / not the caller's own tenant — there is no
cross-tenant parameter to attempt); 500 billing_lookup_failed (the caller's own tenant row could not
be read — fail closed, never an invented empty overview). Secondary figures (seats, seats_used,
wallet_usd) degrade to null on their own read blip rather than failing the whole response.
Prices, seats and intervals
Every price the workspace shows comes from the product catalog, which mirrors Stripe exactly — what is shown is what is charged. A charge only ever happens in a Stripe Checkout the owner completes (card entry and Stripe's confirmation); nothing is billed automatically without it, except the opt-in wallet auto top-up.
- Interval. The paid plan can be bought monthly or annually (the annual price and its saving are whatever the catalog's annual price is — the sign-up page computes the saving from the two prices). The interval does not change the entitlement (models, allowance). The usage allowance is "per month" for both intervals (see the allowance note below).
- Seats. The customer picks the number of seats at checkout (1 … the plan's seat maximum,
max_seatsin the catalog feed; for a trial conversion at least the workspace's active users). The Stripe Checkout charges exactly that quantity × the seat price, and after payment the workspace's seat limit is the purchased number. It is written only when a checkout completes (sign-up, trial conversion, reactivation) — never by a renewal.
Changing plan, interval or seats
Not available for paying customers (owner decision): there is no self-serve plan, interval or seat change for a workspace that already pays, until a mechanism that correctly accounts for what was already paid (proration) is designed. The Stripe Billing Portal therefore offers no subscription changes — only invoices, the payment method and cancellation (at period end). The upgrade view in the workspace offers checkout only to a free trial (and reactivation to a lapsed workspace).
Entitlement enforcement
A self-serve plan is enforced server-side, independent of anything stored in the gateway configuration:
-
Tier position.
GET /admin/auth/mereportsplan_rankandplan_rank_max, which describe where the workspace's plan sits in the commercial tier order. They let an interface decide whether there is anything above the current plan to upgrade into without hardcoding which plan is the highest. A workspace withplan_rank == plan_rank_maxis on the top self-serve tier. Ranks are ordered but not contiguous, and only their order is meaningful — they are not for display. Both fields appear together or not at all; they are omitted whenever the plan carries no self-serve rank, including an unrecognised plan value. Absent means unknown, not lowest — treat it as "cannot determine", and do not offer an upgrade you cannot confirm is available. Like the rest of the billing snapshot on/me, the pair follows the workspace-owner gate: a trial member receives it (owner or invited), whereas an invited member of a paid workspace receives none of the billing snapshot, this pair included. It is not reported onGET /admin/v1/billing/summary, which drives a panel with no upgrade decision to make. -
Model list. Each plan includes a fixed list of models. A request for a model outside the plan's list is refused with
403 plan_model_not_allowedbefore any upstream call. The list is also reported to the interface asplan_modelsonGET /admin/auth/me, so the model picker offers only models the plan includes rather than letting the choice fail at send time. The field is present for every member of a self-serve workspace — including one whose plan the platform does not recognise, where it is an empty list, matching the enforcement, which denies every model for such a workspace — and absent for a manual workspace, which is unrestricted. It is reporting only — the refusal above remains the sole enforcement, so a client that ignores it still gets the 403. The list governs every model, including one served by a provider key you added yourself: a third-party key on a self-serve plan is stored and shown as configured, but requests using its models are refused until the workspace is upgraded. While the allowance is in the degraded state (see below), the effective list narrows to the plan's efficient subset: an in-plan efficient model passes unchanged, an in-plan premium model is transparently served by the plan's degrade model (the response reports the model that actually answered), and an out-of-plan model is still refused with403 plan_model_not_allowed. A degraded request served by a swapped model may hit that model's own capability limits (for example native document handling) exactly as if the efficient model had been selected directly. - Subscription status. While a subscription is active, inference works normally. After a
failed payment the subscription enters a grace period during which inference still works.
Once the grace period ends (or the subscription is cancelled or disputed) inference is
blocked with
402 subscription_inactive; the account and billing pages stay reachable so the subscription can be reactivated. - Allowance. Each plan has an included monthly usage allowance (a spend cap). Usage
is reported as
allowance_used_pctand refills every ~30 days for both monthly and annual subscriptions — the refill is decoupled from the Stripe invoice cadence (a scheduled reset drives it), so an annual customer gets a fresh monthly allowance every month, not once a year. The refill date is not tied to your invoice date. What happens at 100% depends on the deployment: with cap soft-degrade configured, requests keep working on the plan's efficient models (statedegraded) up to a generous secondary cap; without it, or past the secondary cap, requests are refused with429 quota_exceeded(stateblocked). The current over-cap state is reported asallowance_state("degraded","blocked", or — with a funded wallet —"wallet": the allowance is used up but the prepaid balance carries the workspace at its full entitlement, nothing is degraded or blocked) on bothGET /admin/auth/meandGET /admin/v1/billing/summary; the field is absent while the allowance is not used up. See Budgets — self-serve cap soft-degrade. - Free trial. A no-card 7-day trial workspace is on the
self_serve_trialplan with a small credit (a lifetimetotal-period cap). It is provisioned with two pre-configured gateways —internal(Myra fleet) andexternal(Anthropic Haiku only) — each with its own per-gateway budget (the tenant credit split ⅔ internal / ⅓ external); see Budgets. While on the trial plan the per-gateway budgets and model restrictions are locked (aPATCHthat changes them, aPOSTthat replaces a gateway, or aDELETEof a gateway is403; see Tenants & gateways), so the split cannot be collapsed onto real vendor spend. (Resetting a gateway/tenant spend counter is403too, but for a broader reason — it is platform-admin only for every plan — so that 403 does not lift on upgrade for a tenant admin.) A trial ends in one of two distinct ways, both blocking inference with402while the account/billing pages stay reachable to upgrade:402 trial_expiredonce the 7 days pass, and402 trial_budget_exhaustedonce the credit is spent (whichever comes first). A trial has no soft-degrade allowance — the credit is a hard cap. Upgrading throughPOST /admin/v1/billing/checkoutconverts the workspace to a paid plan, clears both limits, and resets both gateways in place — the per-gateway caps and theexternalmodel restriction are lifted (Sonnet unlocks if the paid plan entitles it; Haiku stays) and the edit lock releases, without deleting or re-creating either gateway. - Seats. A paying workspace's seat cap is the number of seats it bought at checkout (a
per-workspace entitlement written when the checkout completes);
plan_config.max_seatsis the most a customer can buy on that plan (a trial uses the plan's cap). The first registrant is seeded as thetenant_adminowner. The owner invites teammates viaPOST /admin/v1/tenants/:id/users; an add that would exceed the cap is rejected with403 seat_limit_reached. Restoring a soft-deleted user re-activates a seat, so it is capped the same way —POST /admin/v1/users/:id/restoreat the cap is refused403 seat_limit_reachedand the user stays deleted. Only the owner (roleadminortenant_admin) reaches the billing endpoints on this page.
POST /admin/v1/billing/portal
Opens a Stripe Billing Portal session where the owner can update the payment method, view invoices, or cancel (at the end of the period). The portal offers no plan, interval or seat change (see Changing plan, interval or seats). Session-authenticated.
Response 200
Redirect the browser to url. Returns 403 forbidden for a non-self-serve workspace,
500 portal_not_configured if the deployment has no portal configuration, and
502 portal_unavailable on a Stripe error.
GET /admin/v1/billing/summary
Returns the current subscription snapshot for the workspace. Session-authenticated.
Response 200
{
"plan": "self_serve_custom",
"subscription_status": 100,
"period_end": 1701000000,
"allowance_renewal_date": 1699500000,
"cancel_at_period_end": false,
"dispute_flag": false,
"reactivatable": false,
"allowance_used_pct": 42.5,
"allowance_state": "degraded",
"spent_usd": 2.13,
"cap_usd": 5,
"wallet_usd": 12.50,
"invoices": [
{ "id": "in_1", "total": 1900, "status": "paid",
"invoice_pdf": "https://...", "created": 1700000000, "billing_reason": "subscription_cycle" }
]
}
subscription_status is 100 (active), 50 (grace — payment overdue, inference still
works), or 0 (inactive — inference blocked). Two clocks, unix seconds: period_end
is the subscription period end (the next invoice; up to 12 months out on an annual plan),
allowance_renewal_date is when the monthly allowance next resets (~30 days after its
last reset, clamped to the period end when that comes first — the same instant the
gateway's Retry-After on a 429 quota_exceeded and the allowance e-mails use). It is
absent when no reset is scheduled: the subscription is in grace or inactive (the reset
sweep only runs for active subscriptions), or it cancels at the period end and no reset
falls before that. If a reset is pending — the hourly sweep has not run yet, or the
subscription period just rolled over and the renewal invoice is still being paid (Stripe
finalises it about an hour after the boundary) — the value is typically within the next
hour (one sweep interval). The signed-in user's /admin/auth/me
carries the same pair as renewal_date (subscription) and allowance_renewal_date
(allowance); every "your allowance renews on …" copy in the workspace reads the allowance
one and, when it is absent, says nothing about a reset (the 429 body and the allowance
e-mails likewise drop their "renews monthly" sentence). allowance_state appears only when the
allowance is fully used: "degraded" (requests continue on the plan's efficient models),
"blocked" (requests are refused until the allowance resets or the plan is upgraded) or "wallet" (the prepaid wallet
carries the workspace at its full entitlement — neither degraded nor blocked). wallet_usd
(the prepaid balance, USD) is returned for the self-serve owner while the platform wallet is offered (the wallet_enabled toggle on), defaulting to 0 when the wallet is unfunded, and is absent on a failed balance read. this matches /admin/auth/me, which has always gated the field the same way — so across both endpoints the field's PRESENCE means "the wallet is on offer", not merely "a balance row exists". /me additionally carries it for a free trial (owner and member) so a trial SEES its read-only balance though it cannot top up. The balance is READ either way: it feeds allowance_state, so a funded wallet still carries the workspace past its allowance (allowance_state: "wallet") even while the offer toggle is off — enforcement never depends on whether the figure is displayed. AGF-2844: wallet_usd is the money figure the Your plan
card now displays for a Custom workspace (the live wallet balance), replacing the old budget-derived
allowance meter — so tenant.budget_usd / effective_tenant_budget no longer feeds any figure shown on
"Your plan". spent_usd and cap_usd are the USD spend and cap that define allowance_used_pct
(its numerator and denominator); they remain on the wire for enforcement and the depletion/degraded
banners (BillingBanner / CapNotice, which read /me) but no longer drive the displayed money
figure. cap_usd is 0 when the workspace has no/unlimited budget (then allowance_used_pct
is 0). invoices is always an array (empty if the invoice history could not be loaded).
reactivatable is true exactly when POST /reactivate
would accept: the subscription has lapsed (Stripe status canceled or incomplete_expired) and the
account is not dispute-locked. A client offers Reactivate only then; a past_due, unpaid or
paused subscription is fixed by updating the payment method in the portal instead.
When this endpoint refuses — the workspace UI branches on these, so they are part of the contract, not incidental error handling:
| Status | Meaning |
|---|---|
403 forbidden |
The workspace is not on a self-serve plan (it is billed by an account manager), or the caller is a self-serve workspace member rather than the account owner. Only the owner may drive the subscription. |
409 no_subscription |
The workspace has no subscription row. This is every free-trial workspace, by design — a trial is created without one. The remedy is not this endpoint; it is the trial-to-paid conversion (POST /admin/v1/billing/checkout). |
500 billing_lookup_failed |
The subscription lookup failed. Transient; retry. |
Because a trial legitimately gets 409 and a member legitimately gets 403, a client must not
present either as a generic "could not load" — the workspace UI shows a distinct explanation for
each, and offers the conversion action for the trial case.
Better still, a client should not send a trial workspace to this endpoint to begin with: the plan
is already known from /admin/auth/me, so the conversion action can be offered directly. The
workspace UI does exactly that — Settings → Billing & plans renders a trial's Your plan page from
/admin/auth/me alone and never calls this endpoint for a trial; the trial pill and banner navigate to
the Upgrade tab, and only its Continue to payment calls POST /admin/v1/billing/checkout.
POST /admin/v1/billing/reactivate
Starts a new Stripe Checkout session for a lapsed subscription, reusing the workspace's existing Stripe customer, at the catalog's current price for the workspace's plan, in the lapsed subscription's own interval (monthly when unknown), for the larger of the last purchased seats and the workspace's active users — the Stripe Checkout shows exactly that before the card is taken. It never creates a second live subscription. No body. Session-authenticated, owner-only.
Response 200
{ "checkout_url": "https://checkout.stripe.com/c/pay/...", "session_id": "cs_...",
"url": "https://checkout.stripe.com/c/pay/..." }
(url equals checkout_url, kept for existing clients.)
Status / error |
Meaning |
|---|---|
409 subscription_active |
The subscription is not lapsed (still active, past_due, unpaid, …) — re-checked live with Stripe before refusing. Use the portal to fix payment instead. |
409 dispute_flagged |
The account is locked pending dispute review. |
409 seats_below_active_users |
The workspace has more active users than the plan's seat maximum (min_seats, max_seats in the body). |
409 checkout_in_progress / checkout_pending |
Another checkout for this workspace is being created (retry shortly) / is already paid and being provisioned (wait). |
403 forbidden |
Not the owner, or not a self-serve workspace. |
500 billing_lookup_failed |
A lookup failed — transient, retry. |
502 / 503 checkout_unavailable |
Stripe unreachable / the catalog cannot offer a price. |
POST /admin/v1/billing/checkout — trial → paid conversion
Starts a Stripe Checkout session that attaches a workspace's first paid subscription — the upgrade path for a free-trial workspace. Session-authenticated and owner-only; usable only by a workspace on the trial plan (self_serve_trial). A workspace that already pays cannot change plan, interval or seats (see Changing plan, interval or seats).
Accepted body — the same choice as sign-up checkout:
| Field | Rules |
|---|---|
plan |
Required. A public, purchasable catalog plan slug (^[a-z0-9_-]{1,64}$), e.g. custom. The former starter/pro values are gone (400 unknown_plan). |
interval |
Required. "month" or "year". |
seats |
Required. Integer ≥ the workspace's active users and ≤ the plan's max_seats. The Checkout charges exactly this quantity; after payment the workspace's seat limit is this number. |
expect |
Required. { "unit_amount", "currency", "tax_behavior" } — the price the page showed; a change → 409 price_changed with the current offer. |
The plan is authoritatively re-derived from the charged Stripe price — a body cannot buy a plan it did not pay for. The Checkout Session reuses the workspace's Stripe customer when the trial already topped up its wallet (else a fresh one) and is tagged metadata.kind=conversion + metadata.tenant_id (the authenticated session's workspace, never a body value). One open checkout per workspace: a new choice retires the previous open session first. When the checkout completes, the webhook attaches the subscription, flips the plan, writes the purchased seats and clears the trial deadline; a confirmation email is sent.
Response 200
Status / error |
Meaning |
|---|---|
400 missing_field / invalid_body / invalid_plan / invalid_interval / invalid_seats / invalid_expect |
Absent vs malformed field (invalid_seats also above the plan maximum, with max_seats). |
400 unknown_plan |
Not a public, purchasable plan (the trial plan itself is not one). |
403 forbidden |
Not the owner, or not a trial workspace. |
409 seats_below_active_users |
seats is below the workspace's active users (min_seats in the body). |
409 price_changed |
The price changed since it was shown (offer in the body) — show it and ask again. |
409 checkout_in_progress / checkout_pending |
A checkout is being created right now (retry shortly) / is already paid and being provisioned (wait). |
500 billing_lookup_failed |
A lookup failed — transient, retry. |
502 / 503 checkout_unavailable |
Stripe unreachable / the catalog cannot offer a price. |
GET /admin/v1/billing/catalog
The product catalog for the signed-in workspace UI (upgrade view, compare plans, trial screens,
plan names) — the same body as the public GET /admin/auth/signup/catalog
(public rows, prices = what checkout charges, no Stripe ids). Session-authenticated; any user with a
workspace (members too — the trial-ended screen compares plans for everyone); not gated on
self-serve. There is no per-caller field — the caller's own plan comes from /admin/auth/me.
| Status | Meaning | Caching |
|---|---|---|
200 |
the feed | Cache-Control: private, max-age=60 |
401 |
not signed in | — |
403 no_tenant |
the caller has no workspace (a platform admin not impersonating) | no-store |
503 catalog_unavailable |
the catalog cannot be read | no-store |
Wallet — prepaid credit
A self-serve workspace on the paid plan (Custom) has one prepaid wallet, at the workspace level — never per gateway. The wallet sits beside the monthly allowance: while the allowance is under its cap, requests draw on the allowance; once it is exhausted, requests continue on the wallet with the plan's full model entitlement (no reduced-model window), and the part of each turn's cost that lands above the allowance cap is debited from the wallet. When the wallet reaches zero, the ordinary over-cap behaviour resumes (see Budgets — Wallet). A workspace on a manual/enterprise plan is sales-managed and receives 403 forbidden on every self-serve wallet endpoint (/billing/wallet*) — it cannot run a Stripe top-up checkout. Its Wallet tab and "Your plan" tile instead show its live tenant_wallet balance as a read-only figure (AGF-2844 — not the organisation-configured budget, which is no longer displayed), sourced from GET /billing/overview's wallet_usd. A managed/Enterprise wallet is funded by the admin Seats & Budget save, not by Stripe checkout: raising the tenant's budget_usd on PATCH /admin/v1/tenants/{id} writes an admin-grant wallet credit of the increase (a decrease never debits), recorded on wallet_ledger under the admin_grant kind with the granting admin's id and NULL Stripe fields (AGF-2845). The credit fires only when the budget change actually applies — on immediate apply, or on the four-eyes config-approval approval, never at submission of a held change — and is idempotent (a re-apply / replayed approval does not double-credit). A brand-new, never-budgeted Enterprise tenant reads 0 until its first budget is saved.
A free trial cannot top up the wallet (CEO ruling 2026-09-15) — neither a manual top-up nor an automatic one. A trial evaluates its allowance for free and converts to a paid plan via POST /admin/v1/billing/checkout; it does not load a prepaid balance. The mutating endpoints (/topup, and /auto-topup when switching on) answer 409 account_blocked with reason: "trial_no_topup". The refusal is server-enforced at one chokepoint the manual top-up, the auto-top-up arming and the auto-top-up sweep all share. (Enforcement is unaffected: a workspace that already holds a balance is still served on it — the balance is read directly on the request path, never through this gate.)
A trial SEES its wallet balance, but has no top-up control. A trial owner and its members see the Wallet tab and a read-only balance card (the balance VIEW), while the platform wallet is switched on (the wallet_enabled master toggle — which, is also the sole offer term). Every top-up / auto-top-up / Add-budget control is removed (not disabled) for a trial. /admin/auth/me emits wallet_usd for a trial owner and member accordingly; the SPA reads it from /me (never the owner-only wallet summary endpoint, which a member would 403).
All four endpoints are session-authenticated and owner-only (the same gate as the endpoints above), but — unlike them — do not require a subscription row. The read (GET) is reachable by any self-serve owner (a trial included — the SPA is what hides the surface from a trial); the mutating endpoints additionally refuse a trial (above).
Money is stored in USD (like the allowance, budget_usd); amounts in requests and responses that carry no _usd suffix are in the workspace's currency (its budget_currency, USD for every self-serve workspace today), converted once at the workspace's pinned reference rate — the identity for USD. Top-up amounts are catalog configuration, not Stripe prices (step 1b): the component-bound wallet_topup row of the product catalog holds the top-up currency (EUR or USD), a min_minor / max_minor range and 1–12 display presets (all in minor units of that currency). An owner tops up any integer amount within the range; every top-up — manual or automatic — is charged as an inline amount on the ONE tagged "Wallet top-up" Stripe product (which carries the tax code). The client names an amount and the currency it showed it in — never a price, a product or the charged currency. Top-up amounts are not in the workspace currency: they are in the top-up currency (see each field below).
The wallet is offered as soon as a platform admin switches on the wallet_enabled toggle (from the platform-admin Feature Flags screen); until then enabled is false and the mutating endpoints answer 409 wallet_disabled. The toggle defaults off, so a fresh environment offers no wallet until an admin turns it on. The balance cap has been retired. A tenant with the toggle on but no sellable top-up (the catalog config missing / invalid, or the Stripe product not bound — see topup.reason below) still SEES its wallet — there is simply nothing to buy.
Top-up refusal reasons (one closed vocabulary). Every refusal carries a bounded token; a client must treat an unknown token as generic "unavailable", never as a parse error.
reason |
Class | POST /topup |
PUT /auto-topup (on) |
Sweep (auto_topup.status / last_error) |
GET topup.reason |
|---|---|---|---|---|---|
catalog_unavailable, Stripe unreachable |
transient | 503 topup_unavailable (reason: "transient") |
same | no charge this tick, retried | transient |
wallet_disabled |
gate | 409 wallet_disabled |
409 wallet_disabled |
not listed | wallet_disabled |
not_configured, no_topup_row, ambiguous_topup_row, not_public, no_product, ambiguous_product, product_inactive, product_untagged, product_missing, product_tax_code |
platform | 409 topup_unavailable + reason |
same | unavailable / topup_unavailable:<reason> — self-healing, re-evaluated every 10 min |
the reason |
unconvertible |
platform | — (the POST/PUT answer an unconvertible amount with 400 amount_invalid) |
— | unavailable / topup_unavailable:unconvertible — self-healing, re-evaluated every 10 min |
— |
currency_changed (the configured currency is no longer the stored / shown one) |
owner | 409 currency_changed (+ currency) |
same | unavailable / currency_changed |
— |
amount_out_of_range |
owner | 400 + min_minor, max_minor, currency |
same | unavailable / amount_out_of_range |
— |
settings_diverged (settings written by a previous release) |
owner | — | — | unavailable / settings_diverged — save the auto top-up again. No longer written: an earlier migration cleared every such row (its stored amount was reset, so it shows needs_amount instead); the token is only still recognised for display |
— |
tax_location_missing (no billing address for tax yet) |
owner | — | — | unavailable / tax_location_missing — make one manual top-up |
— |
tax_unavailable, tax_mismatch |
platform | — | — | unavailable / the token (+ an ops alert) |
— |
The owner-class states currency_changed / amount_out_of_range also heal on their own if an admin reverts the config; re-saving the auto top-up always clears them.
GET /admin/v1/billing/wallet
Response 200
{
"enabled": true,
"currency": "USD",
"balance_usd": 27.17,
"balance": 27.17,
"topup": {
"available": true, "reason": null, "currency": "EUR",
"min_minor": 100, "max_minor": 100000, "presets": [2500, 5000, 10000, 25000]
},
"payment_method": { "saved": true, "brand": "visa", "last4": "4242" },
"dispute_flag": false,
"auto_topup": {
"enabled": true, "threshold": 5, "monthly_cap": 100,
"amount_minor": 2500, "amount_currency": "EUR", "needs_amount": false,
"status": "idle", "last_error": null, "last_attempt_at": null, "pending": false,
"month_charged": 0
},
"movements": [
{ "kind": "topup", "stripe_payment_intent_id": "pi_…", "paid_minor": 2500, "paid_currency": "EUR",
"fx_rate": 0.92, "requested_usd": 27.173913, "credited_usd": 27.173913, "capped": false,
"balance_after_usd": 27.173913, "created_at": 1757790000 }
]
}
balanceisnullwhen the workspace's stored currency tag is unreadable (the figure is omitted, never guessed). This endpoint shows the raw balance together withdispute_flag;wallet_usdonGET /admin/auth/meandGET /admin/v1/billing/summaryis the spendable figure (0while the wallet is disputed). OnGET /admin/auth/me,wallet_usdis present: for a self-serve owner on a paid plan while the platform wallet is enabled (thewallet_enabledtoggle on — the top-up offer, toggle-only); and for a trial owner and member while thewallet_enabledmaster toggle is on (the balance view — a trial cannot top up but still sees its balance). It is omitted while the platform wallet is off (so a client can tell "wallet not configured" apart from a genuine0balance), and — by the current safe default (an undecided policy point) — for a member of a paid workspace (only the tenant admin sees a paid workspace's balance).wallet_usd's presence on/meis the SPA's per-session balance signal (canSeeWalletBalance); the separatecanManageWalletpredicate — a paid owner only — gates every top-up control.topup(step 1b) is what can be bought right now:availableistrueonly while the wallet is switched on, the catalog config is valid and the one "Wallet top-up" Stripe product is bound and confirmed live (active, tagged, the expected tax code). Thencurrency,min_minor,max_minor(the accepted range, minor units ofcurrency) andpresets(1–12 ascending suggestions within the range — shortcuts, not a whitelist) are set. Otherwiseavailableisfalse,reasonis a refusal token (wallet_disabled,transient, or a platform reason) and the other fields arenull/[].- An earlier release removed the one-release compatibility fields
topup_products,products_partialandauto_topup.price_id. payment_method.brand/last4are read from Stripe (60 s cache) andnullwhen unavailable; the platform stores only the payment-method id, never card data.auto_topup.amount_minor+auto_topup.amount_currencyare the stored amount of one automatic top-up and its currency (the top-up currency — the only money field inauto_topupthat is not in the workspacecurrency;threshold,monthly_capandmonth_chargedare in the workspacecurrency). Bothnullwhen unset.needs_amountistruewhen automatic top-up is switched on but no amount is stored (a setting saved before variable amounts existed whose price could not be translated, or one armed under a previous release) — such a row is never charged until the owner saves an amount.auto_topup.statusis one ofidle,ok,declined,capped,unavailable,error;pendingistruewhile a charge is in flight (a charge already in flight is replayed verbatim at its own amount even if the owner changes or disables the setting meanwhile — a replay can never mint a second charge).last_errorcarries the reason for the current status — and, whatever the current status, it may readaccount_blocked:<reason>(the sweep found the workspace blocked and charged nothing; it is replaced by the next status the sweep writes, and stays while ticks pass without a write).month_chargedis the sum of this calendar month's automatic top-ups, incurrency.movements(newest first, at most 20) holds credits (topup,auto_topup),refund/disputerows (whosepaid_minoris the cumulative amount Stripe returned and whosecredited_usdis the cumulative amount debited from the wallet so far) andunreconciledrows (a charge that could not be credited — see the runbook). Consumption is not ledgered here: spend shows in the usage views; the wallet is beside the allowance ledger, not instead of it. Always an array. (Theadmin_grantkind — an Enterprise budget-save credit, AGF-2845 — is a separate, no-Stripe ledger kind that never appears in this list: this self-serve endpoint403s the Enterprise tenants that alone carryadmin_grantrows. Such a row records the granting admin's id (granted_by) and an internal idempotency token in place of the Stripe PaymentIntent id, with NULLstripe_event_id/fx_rate.)
POST /admin/v1/billing/wallet/topup
Starts a Stripe Checkout Session in payment mode for one top-up; completion is delivered by the webhook and credits the wallet. The session saves the card for the automatic top-up (setup_future_usage=off_session, with Stripe's own mandate text on the payment page). Top-up is tenant-admin only (the owner gate); a member receives 403 forbidden.
Accepted body: { "amount_minor": <integer>, "amount_currency": "EUR" | "USD" } (step 1b) — the amount in minor units (cents) of the currency the client showed it in. The server builds the one Checkout line item itself: an inline price on the bound "Wallet top-up" Stripe product (its tax code), in the configured currency, tax_behavior = inclusive. JSON null counts as absent; other unknown keys are ignored.
amount_minormust be a finite integer≥ 1(at most99999999, Stripe's per-charge ceiling) andamount_currencyexactlyEURorUSD(case-sensitive), and the two come as a pair.- The amount must lie within the configured
[min_minor, max_minor]andamount_currencymust equal the configured currency — a shown currency that no longer matches is refused, never re-stamped (the SPA re-reads the wallet).
Response 200: { "checkout_url": "https://checkout.stripe.com/…", "session_id": "cs_…" }. A second call for the same top-up returns the same open session for as long as no top-up has completed since: the idempotency key is wallet_topup:<workspace>: followed by a SHA-256 over the amount, currency, product, number of ledger movements, Stripe customer and owner locale (an unambiguous JSON-array encoding) — deterministic, so a double-click never mints two; a completed top-up changes it, an abandoned session is replayed for Stripe's 24-hour window.
Rejected (in this order):
400 body_must_be_object— a JSON array body (invalid JSON answers400 malformed_body).400 field_retired(+field) — the retiredprice_idoramountfield is present (a stale client fails loudly, never silently).400 amount_required— no amount pair at all (absent).400 amount_invalid— only half of the pair, a non-number / string / fractional / zero / negative / NaN / Infinity / over-ceiling amount, or a currency that is not exactlyEUR/USD(malformed — never degrades to the absent path); also an amount the platform cannot convert.409 wallet_disabled— thewallet_enabledplatform toggle is off.503 topup_unavailable(reason: "transient") — the catalog or Stripe could not be read; retry.409 topup_unavailable+reason— top-ups are paused on the platform side (see the reason table).409 currency_changed(+currency, the configured one).400 amount_out_of_range(+min_minor,max_minor,currency) — the SPA uses these bounds.409 account_blockedwithreason(trial_no_topup— a free trial cannot top up, expired or not;disputed,subscription_inactive,deleted,not_self_serve,frozen— the workspace's allowance cap is not a positive number: zeroed by dunning / cancellation or an operator, or unknown; a wallet can only fund a workspace with a positive cap, so money is never taken for one an earlier gate would refuse to serve).429 rate_limited— more than 10 started top-ups per workspace within 60 s. Counted only for requests that passed validation (trying amounts is never throttled); the counter is per server node, so the effective limit is 10 × nodes.503 fx_unavailable— no usable reference rate to convert the top-up amount.409 topup_unavailable(reason: "product_inactive") — Stripe refused the bound product between the check and the session (archived meanwhile);502 checkout_unavailable— any other Stripe failure.500 wallet_lookup_failed/500 no_owner_email(the workspace's own records could not be read — retry);403 forbidden.
Every wallet endpoint may answer 500 wallet_lookup_failed (read) or 500 wallet_write_failed (write) on a database fault — retry.
PUT /admin/v1/billing/wallet/auto-topup
Configures the automatic off-session top-up: when the balance drops below threshold, the platform charges the saved card for amount_minor (in amount_currency, the top-up currency; no Checkout, nobody present), at most monthly_cap per calendar month (threshold and monthly_cap in the workspace currency).
Accepted body: { "enabled": true|false, "threshold"?: number, "monthly_cap"?: number, "amount_minor"?: integer, "amount_currency"?: "EUR"|"USD" }.
- The body must be a JSON object (
400 body_must_be_objectfor an array; a scalar ornullbody reads as "no fields" and answersenabled_required; invalid JSON answers400 malformed_body).enabledis required and must be a JSON boolean — absent,null, or a string such as"true"is400 enabled_required(absent and malformed are different answers; malformed never degrades to a default). The retiredprice_idfield →400 field_retired. amount_minorandamount_currencycome as a pair with the same shape rules as the top-up body (400 amount_invalidfor one half or a malformed value).threshold/monthly_capare finite numbers, greater than0and at most1000000(400 threshold_invalid/monthly_cap_invalid).- With
enabled: trueall settings are required (400 threshold_required/monthly_cap_required/amount_required). The amount is then checked against the live top-up offer exactly like a manual top-up —409 wallet_disabled,503/409 topup_unavailable,409 currency_changed,400 amount_out_of_range(+ bounds) — and one charge must fit the monthly cap (400 cap_below_amount: the amount, converted, exceedsmonthly_cap, which would park the settingcappedforever). Switching on is refused with409 account_blockedand itsreasonfor an account the sweep would not charge — a free trial →trial_no_topup, adisputed/subscription_inactive/deleted/frozenworkspace → its reason — the SAME gate the automatic sweep applies before each charge. A saved card is required (409 no_payment_method— make one manual top-up first). While a charge is in flight the switch-on is refused (409 pending_charge). - With
enabled: falsenothing is checked against Stripe or the catalog — only the shape of fields that are present (a malformed value is still rejected). Switching off therefore always works, including during a Stripe outage, a pending charge or a paused offer; the response does not read the offer either. Absent fields keep their stored values (applied atomically in the database), so a toggle never loses configuration. - Every
enabled: truesave re-arms adeclinedorunavailablestate (status → idle) — the only re-arm: a later manual top-up does not re-arm by itself. Anerrorstate keeps the charge it could not resolve, so it answers409 pending_chargeuntil an operator has reconciled it (internal runbook); it is never re-armed from the client. - A workspace whose currency is EUR with no usable pinned rate cannot store an amount it would have to guess:
503 fx_unavailable; a workspace whose currency tag is unreadable answers500 currency_unresolved.403 forbiddenfor a non-owner.
Response 200: { "auto_topup": { … } } — the block exactly as GET returns it.
The sweep runs once a minute. Before every charge it re-validates the stored amount against the current top-up config and the live Stripe product (status unavailable with a reason token otherwise — never a charge in another currency or outside the range). It never exceeds the tenant's monthly cap (status capped; The platform balance cap is retired, so the monthly cap is the only ceiling now; the threshold and the settings are re-checked atomically at the moment the charge is armed, so a manual top-up or a settings change landing in between cancels the charge rather than duplicating it), spaces consecutive charges ten minutes apart, and stops after a decline (declined, with the card network's decline code in last_error — a short token such as insufficient_funds (letters, digits, _, ., -, at most 64 characters); anything else Stripe sends is reported as card_declined — and one email to the owner; a failed email send is logged, not retried) until the setting is saved again. A charge whose outcome could not be learned is never repeated blindly: it is replayed under the same Stripe idempotency key, and after 24 hours it becomes error for an operator to reconcile.
Tax on automatic top-ups (step 1b). A manual top-up is taxed by Stripe Checkout. An automatic top-up is an off-session charge; while the platform setting wallet_autotopup_tax is on, the sweep first asks Stripe Tax for a calculation on the workspace's Stripe customer (the billing address and tax ids the first manual top-up's Checkout saved) and attaches it to the charge, so Stripe records the tax transaction and reverses it on a refund. The amount charged does not change (top-ups are tax-inclusive). A customer without a usable tax address parks the setting unavailable / tax_location_missing (no charge) until a manual top-up has collected one.
DELETE /admin/v1/billing/wallet/payment-method
Forgets the saved card and switches automatic top-up off. For a workspace without a subscription (a trial) the card is also detached at Stripe; for a subscribed workspace it is not (a subscription's renewal card is managed in the Stripe Billing Portal), so the response says which.
Response 200: { "detached": true|false } — false also when there was no saved card (idempotent).
Rejected: 409 pending_charge while a charge is in flight (the replay needs the card it was built with — checked again atomically when the card is forgotten); 502 stripe_unavailable (the card is already forgotten on the platform side at that point — a remembered-but-detached card would decline forever, a forgotten-but-attached one is harmless; call again once Stripe answers); 403 forbidden.
How a credit is computed
A completed top-up credits the amount charged before tax is split out — the Checkout session's amount_subtotal (a manual top-up) or the PaymentIntent's amount_received (an automatic one); top-up amounts are tax-inclusive, so this is the amount the owner chose — converted once into USD at the workspace's pinned rate when it has one, else the platform's cached reference rate, else a freshly fetched one — never a baked constant; the rate is recorded on the movement. The platform balance cap has been retired, so every credit lands in full — there is no clipping and no wallet.credit_capped audit for new credits (historical capped ledger rows keep their recorded values). Credits are idempotent twice over: a redelivered Stripe event is ignored, and the two events Stripe fires for one payment (checkout.session.completed and payment_intent.succeeded) credit the same PaymentIntent once. A Stripe refund debits the wallet by the refunded share of what was credited (attributed to any clipped overflow first, cumulative across partial refunds) and leaves the subscription untouched; a charge-back debits likewise, flags the wallet (it funds nothing and accepts no top-up until an operator clears it) and locks the subscription when one exists.
POST /public/billing/cancel — cancellation button (public)
A public, login-free cancellation endpoint (Germany's § 312k BGB Kündigungsbutton requirement). The contract holder cancels by email without signing in. The endpoint is reachable same-origin on the app host (the cancellation page posts it directly) as well as on the API host — both routes serve the identical handler.
A cancellation is applied only after the requester enters a confirmation code mailed to the account's address — proof of control of that mailbox. Both steps use this ONE url; the body selects the step.
Step 1 — request the code
Response 200 — always the same generic body, whether or not the email matches an
account (anti-enumeration; no account existence is ever revealed in the 200 response — see the
transient-fault 503 note below for the one narrow caveat):
Nothing is cancelled by this step and Stripe is never called. What happens depends on the address, and is told only by email, to that address:
| The address… | The mail |
|---|---|
| is the account owner of a self-service workspace with an active (or payment-overdue) subscription | a 6-digit confirmation code (valid for the platform's one-time-code lifetime, 15 minutes by default) |
| belongs to a member who is not the account owner | a status mail: only the account holder can cancel |
| belongs to a workspace on a contract agreed directly with Myra (not self-service) | a status mail: cancel through your account manager |
| is a free trial | a status mail: a trial is not a contract and ends by itself (with the date). Because the generic screen tells everyone a code was mailed, this mail also states plainly that no confirmation code will be sent for a trial and that the recipient can close the code-entry window — otherwise a trial recipient is stranded on the code-entry step waiting for a code that never comes |
| has no subscription, or one that already ended, or one already scheduled to cancel | a status mail saying so (with the end date where one is known) |
| matches no account | nothing (there is no mailbox to write to) |
A disabled owner account can still request and confirm (a disabled customer may still cancel their contract). Requesting a code never invalidates an earlier, still-valid code for the same address (a third party who knows the address cannot kill the owner's code by requesting codes); every wrong guess counts against every live code of the address, a code whose five guesses are spent is dead, and confirming with any one of them retires the others. Stated residual: a guess is tested against every live code, so with the five codes an hour the request cap allows, one guess has five chances in a million instead of one — still far below any practical bound, and each extra code is a mail the owner sees.
Step 2 — confirm
Response 200 { "ok": true } — the subscription is set to cancel at the end of the
current billing period at Stripe and a written (Textform) confirmation email is sent stating the
receipt date, the termination date (Stripe's answer for the current period end — a date earlier
than the receipt date is never sent; the confirmation is withheld and an operator alert fires
instead) and a reactivation link. Access continues until the termination date; no further
payments are charged after the current period. Where the account cannot be cancelled
automatically (an operator alert fires and support completes it by hand) the customer receives
a status mail saying so instead of the confirmation. If the subscription's state changed since
the code was requested (it ended, or is already cancelling) the matching status mail is sent and
nothing is cancelled twice. A "code": null is the same as an absent code — the request step.
Rejected:
| Status | error |
Meaning |
|---|---|---|
401 |
code_invalid |
the code is wrong, expired, already used, or there is no live code for the address — byte-identical for a known address with a live code, a known address without one, and an unknown address |
429 |
code_attempt_cap |
this address's five guesses are spent — request a new code (a new code is a new budget, and requesting one also clears the no-code counter) |
429 |
code_failure_cap |
twenty rejected attempts for the address within an hour (wrong guesses and code_attempt_cap answers alike) — wait; a new code does not lift it; it is cleared by a successful confirm. A transient database fault answers 503 with nothing counted or consumed; the one exception is a fault between the two guess-counting statements after a wrong guess, which can leave that wrong guess counted on the code row — the guess was wrong, so the next answer may be code_attempt_cap instead of code_invalid |
On ONE node the sequence of answers for six wrong guesses is the same for every address
(401 ×5, then 429 code_attempt_cap), so the confirm step is not an account-existence oracle.
Stated residuals: the guess budget is site-global (the code row) while the no-code counter is
per node, so across two nodes of an active-active deployment a known address WITH a live code
can be told apart from an unknown one within the code window (the same residual the sign-in
route documents); a known address performs more reads before the check than an unknown one
(a timing difference of a few database round-trips); and twenty wrong guesses from anyone lock
the confirm step for that address for the hour, and five code requests from anyone spend the
address's hourly request cap so the owner gets no further code that hour (both caps, like the
no-code counter, are per node: on an active-active deployment the bounds — and the mail lever —
are multiplied by the number of nodes) — as on the sign-in
route, a per-IP bound is not possible while the client controls X-Forwarded-For. Neither
touches the contract (before this change the same five requests cancelled it); a code already
mailed stays valid through the request cap, so the owner enters it on the confirmation page
(the page opens it even when a request was refused); the login-gated Cancel subscription
action on the Billing page is unaffected. The per-IP bucket is keyed on the client address the
front layer forwards, so where that header is forgeable, twenty forged requests can hold the
button for one shared egress address for an hour (the sign-in route has no per-IP cap for that
reason; this route keeps its documented one). The per-address bound is also the bound on mail:
five mails an hour per node to any registered address, members included.
Accepted input / rejected input (both steps)
email goes through the one address boundary: a non-empty ASCII string of at most 254 bytes,
trimmed, with exactly one @ and no inner whitespace or control bytes; it is canonicalised to
lower case, so a case or padding variant shares the address's limits. code is optional; when
present it must be a string or a JSON number that reads as exactly six ASCII digits after
trimming ("123456" or 123456).
| Status | error |
Input |
|---|---|---|
400 |
email_required |
email absent, null, empty, or not a string |
400 |
invalid_email |
email malformed (non-ASCII, control byte, inner whitespace, over-long, not exactly one @) |
400 |
invalid_code |
code present but not a string/number, or not exactly six digits — never hashed, never counted as a guess |
400 |
malformed_body |
unparseable JSON, or a non-object body (a non-empty array; an empty array is indistinguishable from {} after decoding and answers email_required) |
405 |
method_not_allowed |
not a POST |
413 |
payload_too_large |
body over 16 KB |
429 |
rate_limited |
20 requests per hour per client IP — both steps share this bucket (checked first, before anything else) |
429 |
code_request_cap |
5 code requests per hour per address (request step only; checked before any lookup, so it never signals account existence). A code already mailed stays valid and the confirm step is not gated by this cap — the page opens the confirmation step on it. This cap is also the bound on how much mail the endpoint sends to any registered address |
Transient backend fault → 503. If a transient database/backend fault prevents the step
from being processed (the account, subscription or code could not be read), the endpoint
returns a retryable 503 with body { "ok": false, "error": "temporarily unavailable, try again" }
— not the generic 200. A 200 {ok:true} here would falsely confirm a § 312k cancellation
(or a mailed code) that never happened. On the confirm step a fault before the code check leaves
the code live, so the retry succeeds. The response body is fixed text and never echoes the
error. The dominant identity-lookup fault fires independently of whether the email is
registered; a billing-store-only partial fault is the sole narrow case where the 503 is
reachable only for a registered email — an accepted tradeoff over falsely confirming a
cancellation that never ran.
Retention and deletion after cancellation (GDPR)
When a self-serve subscription has fully ended (the Stripe subscription is cancelled and any grace period has elapsed), the workspace enters a 90-day retention window. During this window the account stays reactivatable: signing in and reactivating the subscription restores full service with all data intact.
After the window, the workspace is permanently and irreversibly deleted by an automated retention job — the account, all conversations, projects, uploaded documents, settings, and the stored billing identifiers. The deletion is recorded in an audit Löschprotokoll (category counts only, never content), including evidence of the warnings below.
- Two warning emails are sent to the workspace owner before deletion: 14 days and 1 day
before the deletion date. Each email states the earliest possible deletion date; the actual
deletion never happens before the date stated in an email. Both emails point at the
self-service data export (
GET /admin/v1/me/export) so a copy of all personal data can be downloaded first. - The retention window is configurable per deployment (default 90 days, minimum 15 — the warnings need their runway).
- Disputed accounts are exempt: a workspace with an open payment dispute is retained until the dispute is resolved (GDPR Art. 17(3)(e) — establishment, exercise or defence of legal claims) and only then enters the normal deletion schedule.
- Stripe-side records: the Stripe customer object and invoices are retained by Myra for the statutory commercial/tax retention periods (GDPR Art. 17(3)(b)); they are not part of the workspace deletion.
- Expired, never-completed sign-up attempts (
signup_intentrows) are deleted automatically 14 days after creation; webhook-ledger entries are deleted after 90 days.