Skip to content

Hosted nodes and membership

Everything in Payments and credits is about top-ups — a one-off purchase of credits. This page is about the other kind of charge: a membership, which is recurring, and which for one of the two plans buys a machine. See Hosted or self-hosted first if you haven’t decided which way you want a node; this page is the detail underneath the £29 door.

The two plans

Read from apps/api/src/modules/membership/config.ts, which is the only place these numbers are allowed to live:

PlanMonthlyAnnualCredits/monthPersonal machineHosted shape
Member£12/month£120/year (two months free)3,600No—
Node (“your own node”)£29/month£290/year (two months free)6,000Yeshome-standard

Unspent credits roll over, capped

A membership’s monthly allowance is not a use-it-or-lose-it grant. What you don’t spend carries into next month, up to three months’ worth (ROLLOVER_MONTHS, apps/api/src/modules/membership/config.ts) — 10,800 credits for Member, 18,000 for Node. When you’re already carrying that much, the next allowance only tops you up to the cap rather than going past it; your ledger states the withheld amount on the line when that happens. Nothing already in your wallet is ever taken away — the cap only ever makes a new grant smaller, and Credits you bought or earned are never counted against it at all.

This is a real, unusual commitment, not a rounding footnote: of roughly 35 comparable credit products surveyed for the decision, only 9 offer any rollover at all. Council 031 R12 (2026-09-20) is explicit about why a bound exists rather than unlimited carry — an unbounded rollover is a deferred liability nobody else in the market is carrying, because a light user would otherwise bank credits forever.

A plan is one thing and the intervals are a list under it. The same plan bought annually is not a different product — switching interval in the billing portal does not change plan and does not move your node. The credits column is per month either way: an annual invoice grants twelve monthly allowances (periodsCovered: 12), rather than a number worked out in proportion to the money received. That field is the whole reason the discount comes out of the price and not out of your credits — a naive proportional grant would have handed an annual member ten months of credits for a year of membership.

Member is not a cheaper Node. It buys a seat on the node Vocabotics itself runs — you think on the Hall’s key, same as any citizen registered on anyone’s install — and every pound of it is credit budget: 12 × 300 = 3,600. Node buys a machine of your own, on the home node profile (the same profile your own hardware would run — see Self-hosting), and the price splits: the infrastructure it actually costs, plus the rest as credit budget at the same rate that makes a standing commitment worth more than a one-off top-up (300 credits/£, against roughly 200–280 credits/£ for the packs in Payments and credits).

What the machine actually costs

estimateMonthlyCost('home-standard') (packages/contracts/src/provisioning.ts) measures it at Fly lhr list prices, excluding model spend:

StateCost
Running$8.72/month
Suspended (a lapsed membership; see below)$2.46/month

Call it £6.90 running. £29 − £6.90 ≈ £22 of credit budget, and 22 × 300 = 6,600 — deliberately more generous than the arithmetic requires, because the cost model’s own weakest line (the rootfs size) was never independently measured. This is written down because a price nobody can check is a price nobody can defend, and the working is the point.

home-standard — shared-cpu-1x, 1GB RAM, a 10GB volume — is also the only shape in HOSTED_PLANS that has been run under real load: it is exactly what novaterra.world itself runs (deploy/fly/fly.api.toml). Two other shapes (home-small, home-large) exist in the contract for future use and are explicitly labelled untested extrapolations; neither is sold today.

Checking whether hosting is switched on

GET /api/membership/plans
→ { currency, merchant, plans: [...], configured: true }

This is a public route — no session needed, because somebody deciding whether to pay should be able to read the price first. configured is false until the operator has finished wiring Stripe and created a dashboard Price for at least one plan; there is deliberately no inline-price fallback for a subscription (modules/membership/config.ts explains why: an inline recurring price creates a new anonymous Stripe product on every checkout, and Stripe’s automatic tax refuses a product with no tax code). A plan with no Price id configured is shown, priced, and marked unavailable rather than hidden — the honest state is “we haven’t finished selling this,” not silence.

From a click to a running node

novaterra.world/pricing → POST /api/membership/checkout → Stripe Checkout
→ signature-verified webhook (POST /api/payments/webhooks/stripe)
→ apps/api/src/modules/membership/events.ts
→ kickMembershipNodes() — a nudge, not a queue
→ the walker (apps/api/src/modules/membership/nodes.ts)
→ reconcile() DERIVES entitlement from the membership row + the plan catalogue
→ services/provisioner, over HTTP, bearer token, loopback only
→ eleven idempotent, resumable steps against Fly's HTTP API — never flyctl
→ handover: the owner access code is emailed, once

There is deliberately no provision_jobs table. reconcile() derives what should exist purely from the membership row and the plan catalogue every time it runs, rather than keeping a second record of the same fact that could drift from the first. The webhook’s whole job is a kick: two synchronous SQLite writes, then it returns, because Stripe retries an endpoint that doesn’t answer quickly and provisioning takes minutes. POST /provisioner/nodes/:id/provision is documented safe to call repeatedly, which is what makes a walker with no queue, no lock and no lease correct rather than lucky.

The eleven steps

In order, from packages/contracts/src/provisioning.ts and services/provisioner/src/steps.ts. Each is separately recorded and separately retryable against a remote API that can fail at any point, and each has a deterministic name derived from the node’s slug — never a random id — so a step can answer “did I already do this?” by looking at the world instead of at its own notes.

StepWhat it does
reserveChoose the slug, app name and hostname. Local only, no remote call — the root everything else derives from.
appCreate the Fly app.
volumeCreate the persistent disk, before the machine — a machine mounting a volume that doesn’t exist yet fails to start and leaves a machine to clean up, where a volume with nothing mounted is a visible, cheap ($0.15/GB/month) orphan.
secretsMint this node’s own SESSION_SECRET, ENCRYPTION_KEY and OWNER_ACCESS_CODE — never the Hall’s. Refuses outright if the machine already exists: a fresh ENCRYPTION_KEY over a live node would make every stored credential and every being’s private signing key on it permanently unreadable, and an identity has no reset link.
addressesA public IPv6 and a shared IPv4 — needed because a personal node serves its own web surface, unlike the Hall’s API, which has none.
machineCreate the machine, deliberately with force_https: false (see secure, below).
startedWait for the machine to actually reach started. Created is not running.
hostnameThe custom TLS certificate, only when a custom hostname was requested. Skipped entirely otherwise — a node is already reachable and fully TLS-protected at <app>.fly.dev under Fly’s own wildcard, no ACME challenge involved.
secureTurn force_https back on, once the certificate is issued. Skipped for the same reason as hostname.
verifyFetch the node’s own signed descriptor and check the signature — the only place HostedNode.nodeDid can come from.
handoverMark the owner access code deliverable, exactly once.

hostname and secure are skipped together — CUSTOM_HOSTNAME_STEPS in the contract names them as data, not a branch the walker has to remember, precisely so a half-provisioned node can answer “which steps did we skip, and why” months later. The far more common path (no custom hostname) never touches either.

Two details worth understanding rather than just trusting

  • The secrets refusal is permanent, not a retry. If a node’s ENCRYPTION_KEY needs replacing after the machine already exists, that is not this step’s job — the recovery is a separate, deliberate re-mint route (POST /provisioner/nodes/:id/owner-code) that touches only OWNER_ACCESS_CODE, never the encryption key.
  • The owner access code is never stored anywhere, on either side of the wire. It lives in the provisioner’s memory for the length of one handover, and in the email deliverOwnerCode (apps/api/src/modules/membership/nodes.ts) sends — nowhere else. If the process restarts between minting it and handing it over, the node is left failed on purpose: a node whose owner cannot get into it is not “provisioned,” whatever its machine is doing.

What a hosted node cannot do

A hosted node’s profile is home — the same one your own hardware would run — with one exception, stated in the contract rather than discovered: no code sandbox. PROFILE_FACILITIES.home includes sandbox, but the Fly container mounts no Docker socket and has no Docker CLI, so code.execute on a hosted node returns “sandbox unavailable” and the planner routes around it. See Sandboxing for what that skill does instead, and Self-hosting for the WASM sandbox a self-hosted node gets by default and a hosted one currently does not.

A lapsed membership suspends. It does not delete.

MembershipNodeIntent in the contract has two values, and destroy is not one of them — apps/api/src/modules/membership/nodes.ts exports no destroyNode. A lapsed subscription stops the machine (suspended, $2.46/month against $8.72 running) and leaves the volume untouched. Nothing derived from a payment event may delete a person’s data, and the vocabulary that would let it does not exist on this side of the code. Reinstating a membership resumes the same machine with the same volume — not a new node.

Environment variables

For the operator running the membership side of this (apps/api):

VariablePurposeDefault
STRIPE_PRICE_MEMBERSHIP_MEMBER, STRIPE_PRICE_MEMBERSHIP_NODEDashboard-managed recurring Stripe Price ids for each plan’s monthly price. Until set, that plan is shown, priced, and marked unavailable.unset
STRIPE_PRICE_MEMBERSHIP_MEMBER_ANNUAL, STRIPE_PRICE_MEMBERSHIP_NODE_ANNUALThe same for each plan’s yearly price.unset
MEMBERSHIP_SUCCESS_URL, MEMBERSHIP_CANCEL_URLWhere Stripe returns the browser after checkout.<WEB_ORIGIN>/settings?membership=started#billing, <WEB_ORIGIN>/pricing?membership=cancelled
MEMBERSHIP_PORTAL_RETURN_URLWhere Stripe’s customer portal sends a person back to.<WEB_ORIGIN>/settings#billing
PROVISIONER_ENDPOINT, PROVISIONER_TOKENHow apps/api reaches services/provisioner — a bearer token over loopback, a separate process with its own database. Without both set, apps/api cannot give a member a node of their own.unset — “This node has no provisioner configured”

There is one Price variable per plan per interval, and that is four, not two. The name is derived in apps/api/src/modules/membership/config.ts — STRIPE_PRICE_MEMBERSHIP_<ID> for the month, STRIPE_PRICE_MEMBERSHIP_<ID>_ANNUAL for the year — and the monthly ones keep their unsuffixed spelling so Prices created before annual billing existed keep working untouched. An interval with no Price id is not on sale, and is never inferred from the other one: a node with only the two monthly ids set sells monthly, shows the yearly tab, and says “Paying by the year is not on sale on this node yet” rather than offering a checkout that would 503. Each price reports its own variable name as MembershipPlanPriceDef.variable, which is what GET /api/membership/readiness prints when one is missing, and what scripts/hall-stripe-secrets.mjs sets on a Fly-hosted Hall.

services/provisioner has its own, larger environment (Fly API token, org, region, the hostname zone for custom domains, the relay it enrols new nodes at) — that is a separate service’s configuration, not apps/api’s, and is not repeated here.

A checkout for the Node plan is not blocked by a missing provisioner. Nothing on the checkout route reads PROVISIONER_ENDPOINT — a customer can pay before the operator has finished wiring the provisioner at all. The walker classifies the resulting failure as ProvisionerUnreachable, flags the node for operator attention, and retries every pass rather than giving up, so it is not a lost payment: setting the two variables later resumes the same node on its next walk instead of requiring a fresh checkout. GET /api/membership/readiness (an operator-only route) does not currently check this either — it reports on Stripe configuration only, not on whether the provisioner is reachable.

Watching it happen

In the app, Settings → Billing (apps/web/src/membership/BillingPanel.tsx) shows your own membership, its plan, and — when your plan buys one — your node and its provisioning state. The eleven steps above carry their own state and evidence, which is exactly what an owner watches when diagnosing a stuck node; the same rows are what a member sees while theirs is being built.

  • Hosted or self-hosted — the choice, stated plainly, before you commit to either.
  • Payments and credits — the one-off top-up path, credits, and the unit boundary between credits and real money.
  • Self-hosting — the free path, and what a home node runs when it’s on hardware you hold yourself.