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:
| Plan | Monthly | Annual | Credits/month | Personal machine | Hosted shape |
|---|---|---|---|---|---|
| Member | £12/month | £120/year (two months free) | 3,600 | No | — |
| Node (“your own node”) | £29/month | £290/year (two months free) | 6,000 | Yes | home-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:
| State | Cost |
|---|---|
| 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, onceThere 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.
| Step | What it does |
|---|---|
reserve | Choose the slug, app name and hostname. Local only, no remote call — the root everything else derives from. |
app | Create the Fly app. |
volume | Create 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. |
secrets | Mint 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. |
addresses | A public IPv6 and a shared IPv4 — needed because a personal node serves its own web surface, unlike the Hall’s API, which has none. |
machine | Create the machine, deliberately with force_https: false (see secure, below). |
started | Wait for the machine to actually reach started. Created is not running. |
hostname | The 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. |
secure | Turn force_https back on, once the certificate is issued. Skipped for the same reason as hostname. |
verify | Fetch the node’s own signed descriptor and check the signature — the only place HostedNode.nodeDid can come from. |
handover | Mark 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
secretsrefusal is permanent, not a retry. If a node’sENCRYPTION_KEYneeds 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 onlyOWNER_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 leftfailedon 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):
| Variable | Purpose | Default |
|---|---|---|
STRIPE_PRICE_MEMBERSHIP_MEMBER, STRIPE_PRICE_MEMBERSHIP_NODE | Dashboard-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_ANNUAL | The same for each plan’s yearly price. | unset |
MEMBERSHIP_SUCCESS_URL, MEMBERSHIP_CANCEL_URL | Where Stripe returns the browser after checkout. | <WEB_ORIGIN>/settings?membership=started#billing, <WEB_ORIGIN>/pricing?membership=cancelled |
MEMBERSHIP_PORTAL_RETURN_URL | Where Stripe’s customer portal sends a person back to. | <WEB_ORIGIN>/settings#billing |
PROVISIONER_ENDPOINT, PROVISIONER_TOKEN | How 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.
Related
- 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
homenode runs when it’s on hardware you hold yourself.