Appearance
Billing (SaaS only)
Self-hosted Aictiq has no billing: Billing:Mode defaults to self_hosted, every organization is unlimited, the Plan page shows usage only, no part of the product is gated, there is no upgrade call to action anywhere, and nothing here needs configuring - the free tier settings below included, which a self-hosted instance ignores. Retention and safety limits an operator configures - Retention:*, Automation:*, Analytics:RetentionDays, the rate limits - keep working exactly as configured; feature parity is not a promise to disable them. This page is for running the hosted service.
The hosted offer is one flat plan: USD $79 per organization per month (plan code hosted) - unlimited humans, agent identities, teams, projects and work items, all implemented core features. New hosted organizations start a 30-day evaluation: no card, no automatic charge at expiry, explicit checkout to become paid. A founding price of $29/month for the first 12 paid billing periods can be granted server-side to the pilot cohort. There are no seat charges and no overage charges.
When the free tier is switched on (Billing:FreeTier:Enabled), an evaluation that ends without a checkout - and a paid subscription that is cancelled - drops the organization to Free (plan code hosted_free) instead of making it read-only. Free is the product too, with a few limits that belong to a person rather than to an organization; see The free tier. With the switch off, the default, an evaluation ends in read-only exactly as it always has.
The free tier
Hosted only, and only with Billing:Mode=saas and Billing:FreeTier:Enabled=true.
The limits belong to a person, not to an organization. For every Owner, Aictiq counts across all the unpaid organizations they own - evaluating or Free; a paid Hosted organization and its members count toward nobody:
| Limit | Default | Counted as |
|---|---|---|
| People | 3, the Owner included ("you plus 2") | Distinct humans: members of every role, Stakeholders and Guests included, plus pending invitations (so 50 open invitations cannot get around it). Agents never count. A person in two of the Owner's organizations counts once, and an invitation to an address that already has an account counts as that account. |
| Attachments | 200 MiB (209,715,200 bytes) | Committed attachments, pooled across the same organizations - ten organizations are not ten times the storage. |
| Registered runners | 2 per Free organization | Disabled runners included; deleting one frees the slot. |
| Finished-run raw logs | 30 days | Through IPlanAllowances, like Hosted's 90. |
An organization with several Owners counts toward every one of them, and being a member of someone else's organization never counts against you. Everything else on Free matches Hosted: projects, items, agents and features are unlimited, runs are allowed, and analytics history is 365 days.
What a limit does.
- While on Free, inviting someone, accepting an invitation, or making someone an Owner that would take any Owner over the people limit is refused with
402 plan-limitandlimit: "free_people". Committing an attachment over the pooled allowance is refused withlimit: "storage_bytes", and a third runner withlimit: "runners". During the evaluation none of these apply - the organization is Hosted until it ends. - Over the people limit, a Free organization is read-only through the same
RequireProjectWritablepath as an expired evaluation, until the Owner removes someone, revokes an invitation or pays. Nobody is removed automatically. The check is made on every write, so getting back under the limit, or paying, makes it writable again with nothing to run. That includes going over because the Owner's other, still-evaluating organization took people on: they are in the Owner's count from the moment they join. - Over the attachment pool only new uploads stop; reads, downloads and exports keep working, and nothing is deleted.
Upgrade prompts. On hosted, a refusal caused by a free limit carries an upgradeUrl, and the app shows Upgrade to Hosted linking to the organization's billing page, and the shell banner for a Free organization over the people limit says the same. That is the only upgrade call to action in the product: a paid Hosted organization over its 10 GiB is told to delete attachments, and self-hosted shows none.
Plan codes. hosted_free is its own plan row. The legacy free row is untouched and still reached by the old code paths with the switch off; with it on, cancelling lands on hosted_free, and checkout to free means the same.
Allowances
Hosted evaluation and paid Hosted organizations get the same allowances. They are service allowances, not a smaller product: nothing on the list below unlocks a feature.
| Allowance | Value | What it does not touch |
|---|---|---|
| Committed attachments | 10 GiB per organization | Pending and deleted attachments do not count. Over the line, reads, downloads and exports keep working; deleting attachments frees space. |
| Finished-run raw logs | 90 days | Run records, outcome summaries, failure reasons, prompt snapshots, playbook revision references, linked pull requests and item history are kept. |
| Analytics history | 365 days | A report window, enforced in queries. Source work-item history and audit records are never deleted to enforce it. |
Two things follow from that and are worth stating plainly:
- Expired run logs cannot be recovered by purchasing a subscription later. The chunks are deleted; buying Hosted afterwards does not bring them back. Everything else about those runs survives.
- The 8 MiB per-run log cap (
Automation:MaxLogBytes) is unchanged and independent of retention; a capped log is visibly marked truncated. Runner compute and model usage are supplied and paid for by the customer, and Aictiq adds no token markup.
Automation and Analytics learn these numbers through IPlanAllowances (SharedKernel/Contracts), which Billing implements: run-log retention days and analytics history days, per organization. Neither module reads a billing table. A null answer means "no entitlement opinion" and the module keeps its operator-configured value - which is the self-hosted answer, and every legacy plan's.
Billing is still Stripe subscriptions: Stripe Checkout starts a subscription, the Customer Portal handles payment methods, invoices and cancellation, and Stripe webhooks keep Aictiq's copy (billing.subscriptions) current. The browser never talks to Stripe directly - the API returns a Checkout or Portal URL and the page navigates to it - so there is no Stripe script, no publishable key and no CSP change.
Configuration
| Key | Environment | Notes |
|---|---|---|
Billing:Mode | Billing__Mode | saas to bill. Anything else bills nothing, whatever keys are set. |
Stripe:SecretKey | Stripe__SecretKey | sk_test_… / sk_live_…. Secret. |
Stripe:WebhookSecret | Stripe__WebhookSecret | whsec_… of the webhook endpoint. Secret. |
Stripe:Prices:hosted_organization | Stripe__Prices__hosted_organization | Price id (price_…) of the $79/month organization subscription, recurring monthly, quantity 1. Required to sell Hosted. |
Stripe:Prices:hosted_founding | Stripe__Prices__hosted_founding | Price id of the founding price ($29/month). Optional; selling the founding offer needs it and Billing:FoundingPrice. |
Stripe:Prices:<plan>_human / <plan>_agent | Stripe__Prices__starter_human | Legacy per-human and extra-agent prices. Still read for subscriptions created before the flat plan; never sold to anyone new. |
Billing:EvaluationDays | Billing__EvaluationDays | Default 30. Length of the hosted evaluation. |
Billing:FoundingPrice | Billing__FoundingPrice | USD amount of the founding price. Unset - the default - means the offer is not running on this instance. |
Billing:FoundingPeriods | Billing__FoundingPeriods | Default 12. Discounted monthly billing periods before the plan's own price returns. |
Billing:StorageAllowanceBytes | Billing__StorageAllowanceBytes | Optional operational cap on committed attachments for this deployment. It only ever narrows the plan's allowance - the smaller of the two wins - and null defers to the plan (10 GiB on Hosted). |
Billing:FreeTier:Enabled | Billing__FreeTier__Enabled | Default false. Turns on the free tier: evaluations and cancellations end on hosted_free instead of read-only. Read only in SaaS mode. |
Billing:FreeTier:MaxPeople | Billing__FreeTier__MaxPeople | Default 3. Distinct humans per Owner across their unpaid organizations, the Owner included. |
Billing:FreeTier:StorageBytes | Billing__FreeTier__StorageBytes | Default 209715200 (200 MiB). Committed attachments per Owner across their unpaid organizations. Billing:StorageAllowanceBytes narrows it, never widens it. |
Billing:FreeTier:RunLogDays | Billing__FreeTier__RunLogDays | Default 30. Raw run-log retention on Free. |
Billing:FreeTier:MaxRunners | Billing__FreeTier__MaxRunners | Default 2. Registered runners per Free organization. |
Billing:GracePeriodDays | Default 14. How long an organization keeps writing after a failed payment. | |
Billing:SeatSyncInterval | Default 1.00:00:00. The nightly billing reconciliation (seat re-derivation for legacy subscriptions; founding-transition safety net). | |
Billing:StripeEventRetentionDays | Default 30. How long processed Stripe event ids are kept for de-duplication. |
The free tier numbers are configuration so they can be tuned on a running deployment without a release; deploy/.env.example lists them, commented out. Give them to the API and Workers alike - Workers ask the same questions when they prune run logs and pause runs.
Both the API and Workers need the Stripe keys and prices: the API creates sessions and takes webhooks, Workers run the nightly reconciliation. Nothing is validated on start - without both keys a SaaS instance boots, the billing endpoints answer 409 billing-unavailable, and /webhooks/stripe answers 404.
Price keys are Stripe:Prices:{plan}_{kind}. The kinds are organization (the flat hosted line), founding (the same plan's discounted price) and the legacy human / agent seat kinds, which only the retired plans' surviving subscriptions use. Hosted is sold when hosted_organization has a price configured.
Local development (Aspire)
Secrets are Aspire parameters, declared only when they have a value, so a stack without them runs exactly as before. In the AppHost's user secrets:
bash
cd backend/src/AppHost
dotnet user-secrets set "Parameters:stripe-secret-key" "sk_test_..."
dotnet user-secrets set "Parameters:stripe-webhook-secret" "whsec_..." # from `stripe listen`, below
dotnet user-secrets set "Billing:Mode" "saas"
dotnet user-secrets set "Stripe:Prices:hosted_organization" "price_..."
dotnet user-secrets set "Stripe:Prices:hosted_founding" "price_..."
dotnet user-secrets set "Billing:FoundingPrice" "29"Stripe test mode, end to end
- In the Stripe dashboard (test mode) create the hosted product with one recurring monthly price at $79 per organization (quantity 1), plus - if you are selling the founding offer - a second recurring monthly price at $29. Put the price ids in the configuration above.
- Configure the Customer Portal (Settings → Billing → Customer portal): allow updating the payment method, viewing invoices and cancelling. Do not allow plan switching there - plan changes go through Aictiq's checkout, so the offer rules and entitlement checks apply. A plan changed in the Stripe dashboard is still applied when its webhook arrives; limits then refuse new over-allowance writes but remove nothing.
- Forward webhooks to the API (not through Vite -
/webhooksis not proxied):bashIt prints thestripe listen --forward-to http://localhost:<api-port>/webhooks/stripe \ --events checkout.session.completed,customer.subscription.created,customer.subscription.updated,customer.subscription.deleted,invoice.payment_failed,invoice.payment_succeededwhsec_…signing secret to use asParameters:stripe-webhook-secret. In production, create a webhook endpoint athttps://<host>/webhooks/stripewith the same events. - Sign up and create an organization - the evaluation starts with it. Open Settings → Billing as its Owner and check out for Hosted. Pay with
4242 4242 4242 4242. After the redirect the page says Stripe has the payment; within a few seconds the organization's plan ishosted. - Payment failure:
stripe trigger invoice.payment_failedagainst a test customer, or attach card4000 0000 0000 0341and advance a test clock. The shell shows the grace banner; afterGracePeriodDaysproject writes answer409 org-read-only.
How it works
- Endpoints.
GET /orgs/{slug}/billing/subscription(any member - it drives the banner) returns the evaluation (startedAt,endsAt,expired), the founding terms when the subscription is on them (price,periods,periodsBilled,renewalPrice,convertedAt), the plan'sorganizationPrice,readOnlyReason(payment_failed,evaluation_endedorfree_people; null while writable) and, while the free tier runs,freeTier: its limits and this organization's Owners' usage against them (people,storedBytes- the worst of its Owners, zero when it is paid).POST …/billing/checkout {plan}andPOST …/billing/portal(Owner,adminscope). Checkout acceptshosted, orfree(hosted_freewhile the free tier runs) to cancel; the legacy plan codes are refused for new subscriptions. With no subscription, checkout returns a Stripe Checkout URL; with one, it changes the subscription in place with proration (or, forfree, cancels at period end) and the webhook confirms it. - Evaluation. When a hosted organization is created, Billing receives the
OrganizationCreatedoutbox event and starts one evaluation, persisted inbilling.evaluations.ux_evaluations_organization_id- a unique index on the organization - is what makes "exactly one" a database fact, so a redelivered event or two racing deliveries cannot mint a second window, andck_evaluations_window(ends_at > started_at) refuses a degenerate one. Nothing writes to the row after the day it is born: restarts, invitations, new members and plan requests never move it. There is no trial-to-paid conversion. With the free tier on, expiry is the move tohosted_free(read-only only while an Owner is over the people limit, as above). With it off, at expiry the organization becomes read-only through the same mechanism as a failed payment - reads, downloads, exports, the Portal and checkout keep working, while ordinary mutations, new agent dispatches and rule-triggered runs stop; runs still queued are cancelled and in-flight runs finish within their existing deadlines, including outcome writes and credential cleanup. Explicit checkout is required to become paid. - Founding offer. Granted server-side to the pilot cohort; the browser cannot grant itself the discount. Each
invoice.payment_succeededon the founding price counts a discounted period; afterBilling:FoundingPeriodsthe Stripe subscription is switched to the standard price automatically, with the nightly reconciliation as the safety net. Entitlement never changes - the renewal is Hosted, not a different plan. - Flat charge. A hosted subscription is one organization at quantity one. Membership changes never touch it, and the nightly reconciliation NEVER changes a hosted subscription's charge: how many humans or agents belong to the organization is irrelevant to what it pays. (Seat re-derivation still runs for legacy subscriptions.)
- Over-allowance. Committing an attachment that would exceed the committed storage allowance answers
402 plan-limitwithlimit: "storage_bytes"and a message that explains deleting attachments frees space - on Hosted not an upgrade to a nonexistent higher tier, and noupgradeUrl. On Free the message also offers Hosted, and the problem carries anupgradeUrl. Reads, downloads and exports keep working when over. - Legacy plans.
free,starter,teamandenterpriseare closed to new subscriptions and retained for existing ones. A subscription already on one keeps working unchanged - its per-seat quantities are still re-derived nightly and its legacy{plan}_human/{plan}_agentprice keys are still read. Nothing is silently migrated, repriced, given a fresh evaluation, or deleted; moving to Hosted is an explicit checkout by the organization's Owner.freeis the exception that is still reachable while the free tier is off, because it is where cancelling lands an organization then - it is a cancellation target, not an offer. It is not sold, not marketed, and it still carries its old caps; the hosted free tier ishosted_free, and nothing should present the legacyfreeas it. - Webhooks (
POST /webhooks/stripe) are anonymous, signature-verified with Stripe'sEventUtilitybefore anything is parsed, exempt from the per-IP rate limiter (Stripe sends from few addresses) and outside the CSRF rule (no cookie). Each event id is inserted intobilling.stripe_eventswithON CONFLICT DO NOTHINGin the same transaction as its effect, so a redelivery is a 200 no-op. Events are applied only if newer (Stripe'screated) than the last one applied, and a cancelled subscription is never revived. - Plan. Tenancy owns
organizations.plan. Billing raisesOrganizationBillingChangedthrough the outbox; Tenancy's handler asks Billing for the entitled plan and stores it, so replays and reordering converge on the newest state. - Grace and read-only. The first
invoice.payment_failedstarts a grace period anchored to Stripe's timestamp (retries do not extend it). When it ends the organization is read-only throughRequireProjectWritable- reads, the Portal and checkout keep working - until acustomer.subscription.updatedshows the subscription active again. Evaluation expiry uses the same read-only mechanism.
Operating it
- Watch Workers logs for nightly billing reconciliation failures and for outbox dead letters of
OrganizationBillingChanged/OrganizationMemberAdded(seeoperations.md). For a hosted organization neither is a charge problem - membership cannot change the bill - but it means Aictiq's copy of the subscription state may be stale. - A webhook Stripe reports as failing with 400 is a signature problem - almost always a rotated or mistyped
Stripe:WebhookSecret. billing.subscriptionsis Aictiq's copy; Stripe is the truth. To re-sync one organization after fixing something by hand in Stripe, trigger any subscription update there (the webhook applies it) or wait for the nightly run.
Changing the Hosted price
Stripe prices are immutable, so a new price is a new price id. The move from $49 to $79 was: create a new recurring monthly $79 price on the hosted product, point Stripe:Prices:hosted_organization at it on the API and Workers, and restart them. The billing.plans row's organization_price is what the Plan page shows; the FreeTierAndHostedPrice migration moved it to 79. There were no subscribers, so nothing was migrated - an existing subscription would otherwise keep the price it was sold at until it is changed. The $29 founding price is a separate price id and did not change.
Legacy customers at the time of the change
There are none. Aictiq is pre-launch: no hosted instance has ever sold a subscription, and this repository contains no production billing data - billing.plans is seeded, and billing.subscriptions, billing.evaluations and billing.stripe_events are empty on every environment that exists (development, CI and the test containers, which create a fresh database per test class).
The migration to the flat offer asked for an inventory of existing hosted organizations and subscriptions. That inventory is this paragraph: the cohort is empty, so there is no migration to perform and none was invented. The legacy plan rows stay in the schema because the code and the historical tests still reference them, not because anyone is on them.
If that ever stops being true - a hosted instance sells a subscription - run the inventory for real before changing plan rows or Stripe prices, and treat the migration, the entitlement change and the Stripe price change as three separate, separately reversible operations.