Organizations and billing

The shape copies the one people already know. A personal namespace is free: you get one when you sign up, your public repositories live at /you/repo, and the only limit is the free-tier repository cap. It holds public repositories only. Private repositories live in an organization with a subscription, billed per person; a solo developer is a one-seat organization, and pays for one seat.

An organization is free while everything in it is public. Members, teams, forks, review and landing cost nothing on public work. The first private repository starts a subscription, billed per seat, at the price the pricing page quotes. Hosted CI minutes come with the plan: a fixed allowance for a free namespace, a larger one per paid seat.

On a self-hosted Weft, none of this applies. With no payment provider configured, organizations are not something we are selling, so nothing is gated and the billing screen says there is nothing to buy.

Creating one

curl -X POST https://api.weft.sh/v1/orgs \
  -b "$COOKIE_JAR" -H "Content-Type: application/json" \
  -d '{ "name": "acme" }'
{ "id": "…", "name": "acme", "plan": "free", "billable_seats": 1,
  "detail": "Ready. Public repositories and members are free; the first private repository starts the per-seat subscription." }

Organizations belong to people, so this needs a signed-in session with a confirmed address — an API token has nobody to own the result. The name goes through the same rules as a personal handle: valid shape, not reserved, not already taken, case-folded.

The organization is free the moment it exists: public repositories, any number of members, the free hosted-minute allowance. No card is asked for. Stripe is the merchant of record here — it handles sales tax, VAT and disputes — and a merchant-of-record account has no “save a card, charge nothing” page: the card is met on the subscription page, together with the price and a promotion code, the first time the organization wants something private. Nothing at the provider exists for an organization until then.

curl -X POST https://api.weft.sh/v1/orgs/acme/billing \
  -b "$COOKIE_JAR" -H "Content-Type: application/json" -d '{}'
# → { "url": "https://billing.stripe.com/…", "kind": "portal" }

Once a subscription exists this route opens the provider’s own portal — cards, invoices and cancellation live there, because building that here would mean handling card details. Before one does it answers 402 (card: nothing to manage yet … subscribe first).

Going private

Creating a private repository in a personal namespace is refused, and the refusal says where private work lives:

{ "error": "quota: private repositories live in an organization — create one from the dashboard; a personal namespace holds public repositories" }

Creating a private repository on a free organization is refused:

{ "error": "quota: private repositories need a paid plan — public repositories are free; subscribe from Billing to make this one private" }

The dashboard answers that 402 with the price for the seats in use today and a button. The button is this call, and it is a redirect to the provider’s own subscription page rather than a confirmation, because a promotion code can only be redeemed there: the page carries the seat price for today’s seat count, a box for a code, and the card fields — the merchant of record collects the card and billing address there.

curl -X POST https://api.weft.sh/v1/orgs/acme/billing/subscribe \
  -b "$COOKIE_JAR"
# → { "url": "https://checkout.stripe.com/…", "kind": "checkout" }

Nothing is written by this call. The subscription the page opens arrives on the provider’s customer.subscription.created webhook, and that is what moves the plan to paid; the page comes back to the organization’s billing screen with ?subscribed=done or ?subscribed=cancelled, and the screen keeps asking for a few seconds while the webhook lands rather than telling somebody who just paid that they have not. The repository creation that was refused is offered there, by name, and one click finishes it. A card is accepted or declined on the provider’s page now, never here; a cancelled or expired page leaves the organization free and nothing charged.

A redeemed code is a discount on the subscription at the provider, and the billing view does not show it — the invoice does, in the portal. One edge to know about: a second tab that was left on the provider’s page can finish a second Checkout after the first opened a subscription; the newer one is the one recorded here, and the older one is cancelled from the portal. Calling it on an organization that already pays returns its billing view and opens nothing; the provider is keyed on the organization id, so a double click cannot open two subscriptions.

What a seat is

Every member of the organization, plus every outside collaborator who can reach at least one private repository.

Somebody you added to a public repository is free — that is the open-source case. Somebody who is both a member and a collaborator is one seat, not two. Team grants add nobody: team membership requires org membership, so anyone a team can reach is already counted.

curl https://api.weft.sh/v1/orgs/acme/billing -b "$COOKIE_JAR"
{ "org": "acme", "plan": "paid", "billable_seats": 7, "paid_seats": 7,
  "status": "active", "current_period_end": 1800000000000,
  "may_create_public": true, "may_create_private": true, "may_add_people": true,
  "price_per_seat_cents": 400, "paid_minutes_per_seat": 2000, "free_minutes": 500,
  "ci_minutes_limit": 14000, "ci_minutes_used": 1284, "ci_minutes_remaining": 12716,
  "ci_suspended_reason": null, "ci_suspended_at": null,
  "private_repos": 3 }

plan is one of free (public repositories and people), paid, and past_due (see below). billable_seats is what we would charge for right now. paid_seats is what the provider has been told. They differ for a moment between a membership change and the push that follows it — if they stay different, a push did not land, and this screen is where you find out. private_repos counts the active private repositories here, and is only interesting on free: that is where a cancelled subscription lands as well as a new organization, so the number is how the screen tells “nothing private yet, the first one starts a subscription” apart from “three private repositories are read-only until you subscribe again”.

The three may_* flags are what a screen has to decide before it draws, already decided. price_per_seat_cents and paid_minutes_per_seat are said in numbers so nothing rendering this hard-codes a price it then disagrees with the site about; they are null where nothing is for sale — a personal namespace, or a deployment with no payment provider. On a personal namespace may_create_private is false wherever the deployment sells: private work lives in an organization, and a private create there is refused with

{ "error": "quota: private repositories live in an organization — create one from the dashboard; a personal namespace holds public repositories" }

Hosted CI minutes

The last five fields are hosted workflows, and they are here rather than on a page of their own because it is the same question as the seat count — what has this organization used, and what is it allowed — and somebody whose builds have just started saying “out of minutes” comes looking at billing first.

What the plan includes is free_minutes per rolling month for a personal namespace or a free organization, and paid_minutes_per_seat times the seats billed for a paid one — the example above is seven seats at 2,000. A past_due organization keeps counting per seat: the seats were paid for until the invoice failed, and shrinking the allowance underneath running builds would be a second punishment for one declined card.

ci_minutes_used is the last thirty days, rolling — counted per job and rounded up, with a job still running counting from the moment it started, so the number moves while builds run. There is no reset date and no first-of-the-month: the oldest minutes fall out of the window as they age, which is why the dashboard reads “used in the last 30 days” rather than naming a date to wait for. ci_minutes_limit and ci_minutes_remaining are null when there is no limit at all — not zero. “0 minutes left” and “no limit” are opposite facts, and anything rendering these has to tell them apart. ci_minutes_limit is never 0: a budget of zero is read as no budget, so an unmetered organization reports null in both. The zero that means metered and out is ci_minutes_remaining: 0, and it is floored there rather than going negative — a job already running can take an organization past its limit, because nothing is killed for budget — so ci_minutes_used may exceed ci_minutes_limit while ci_minutes_remaining reads 0.

ci_suspended_reason is set when hosted workflows have been switched off for the organization — today, only a runner catching a job mining cryptocurrency does that. While it is set, every push’s workflow runs are blocked with that reason. Clearing it is an operator action, not a button: see Workflows.

Jobs that ran on your own machines are not counted. These five fields are about hosted runners — our compute, our bill. A job with runs-on: [self-hosted] costs no minutes, is not stopped when the organisation’s budget runs out, and is not stopped by a suspension either; only a workflow file containing at least one hosted job is. See self-hosted runners for what that does and does not carry over.

Minutes are counted on a self-hosted deployment too — the one part of this page that is not about a payment provider. A fleet applied from this repository’s terraform is metered from the first apply; an operator can set any budget, or turn metering off entirely.

Seats are elastic upward and sticky downward

Adding somebody to a paid organization is never refused: the quantity goes up and so does the bill, the moment they join, prorated.

Removing somebody does not silently shrink what you have paid for. A seat bought for this period is one you should be able to refill without paying twice, so releasing it is a deliberate act in the billing portal — next to where you would cancel. Refilling a paid seat costs nothing more and pushes nothing.

When a payment fails

The organization goes to past_due. Everything in it stays exactly where it is and stays readable — clones, fetches, the API, the dashboard. What stops is creating: no new private repositories, no new people, and hosted workflows on private repositories are paused. Public repositories keep building, as they would on a free organization; jobs on self-hosted runners are not affected at all.

{ "error": "quota: this organization's last payment failed — everything here is still readable, and private repositories resume as soon as it is settled" }

Settling the invoice — the “Settle the payment” button on the billing screen opens the provider’s portal — restores it in full, with nothing lost. Cancelling drops the organization back to free, which keeps its public repositories and people and refuses new private ones. No billing state ever deletes a repository. Taking somebody’s work away over a declined card is not a dunning strategy.

The private repositories it already holds stay, readable, and become read-only: a push is refused with

quota: this repository is private and the organization's subscription has ended — everything here is still readable; subscribe from Billing, or make the repository public, to write to it again

That sentence is on the repository row as write_blocked — null whenever writes are allowed, and folded into viewer_write, which is false while it is set — so a page can say so before somebody clones. The forge and the dashboard both show it as a read-only banner over the repository, the repository table marks those rows Private · read-only, and the billing screen names the count rather than offering the first private repository to an organization already holding several. Making the repository public is the other way out and needs no subscription.

Webhooks

POST /webhooks/stripe verifies the timestamped HMAC in Stripe-Signature within a five-minute tolerance — the timestamp is inside the signed material, so a captured delivery cannot be replayed tomorrow. Configure STRATUM_STRIPE_WEBHOOK_SECRET with the signing secret from the provider’s dashboard.

Deliveries are idempotent: providers retry until they see a 200, and a retry that lands after the first one succeeded does nothing twice. The converse holds too: a delivery answered 5xx — the card webhook arriving while Stripe itself is down, say — is not remembered as seen, so the retry is acted on rather than waved through as a duplicate. Events we do not act on, and subscriptions we have never seen, are acknowledged and ignored — an endpoint that errors on an event type somebody enabled in a dashboard is one the provider eventually stops calling, and then subscription state silently stops tracking.

Configuration

Variable What it is
STRATUM_STRIPE_KEY secret API key; with the two below absent, this deployment sells nothing
STRATUM_STRIPE_PRICE the recurring price each seat is billed at
STRATUM_STRIPE_WEBHOOK_SECRET signing secret for POST /webhooks/stripe
STRATUM_STRIPE_BASE API base, for pointing at a test double
STRATUM_PRICE_PER_SEAT_CENTS what the dashboard and site quote per seat per month (default 400) — display only; the Stripe price is what is charged, and the two have to agree
STRATUM_FREE_CI_MINUTES hosted minutes per rolling month for a personal namespace or free organization (default 500; 0 is unlimited)
STRATUM_PAID_CI_MINUTES_PER_SEAT hosted minutes per rolling month per paid seat (default 2000; 0 is unlimited)

The first three are all-or-nothing: setting some of them and not the rest is a boot error, not a warning. Each half fails a different way and all fail quietly — with no price every subscribe is a 502 from the provider rejecting an empty price, with no webhook secret every delivery fails its signature check and no subscription ever lands, with no key every call is a 401 — and none of them says “misconfigured” anywhere a person would look before the first customer does. A blank value counts as unset, because a task definition cannot unset a variable, only empty it.