Hosted mode
The same build runs a single-tenant install and a multi-tenant service.
OTA_MODE=hosted is the switch. PUBLIC_URL becomes mandatory with it,
because OAuth metadata and checkout redirects are built from it.
Organisations, members and roles
Section titled “Organisations, members and roles”Every resource hangs off an organisation: projects, releases, devices, API
tokens. A token carries one orgId, and every project route funnels through
authorizeProject, which refuses a project belonging to another organisation
with a 404 rather than a 403 — a token cannot learn that another org’s project
exists.
Membership rows carry a role: owner, admin or member. Signup makes the
new user the owner of the organisation it creates. Billing routes call
requireOrgRole, which accepts owner and admin.
The self-hosted install uses the same schema. It just has one organisation, made on first boot, and nothing ever asks which one you mean.
Signup
Section titled “Signup”In hosted mode signup is open and self-serve:
POST /api/v1/auth/signupwith an email, a password of at least 10 characters, and an optional organisation name.- The account is created unverified, its organisation is created on the
freeplan with a 14-day trial, and a verification mail goes out with a token that lasts 24 hours. POST /api/v1/auth/verify-emailstampsemailVerifiedAt.POST /api/v1/auth/loginrefuses an unverified account until then.
An address that already exists gets the same “that address cannot be registered” conflict as an invalid one, so the endpoint does not confirm which addresses are registered.
In self mode the same endpoint accepts exactly one account. The first signup
is marked verified immediately — a fresh install has no way to receive mail —
and every later attempt is refused with “this server is self-hosted and already
has an account”. The dashboard reads signupEnabled from GET /api/v1/meta
and hides the form.
Plans and quotas
Section titled “Plans and quotas”Three plans are seeded on boot by the Node entry. Each carries the same three limits:
| Plan | Projects | Active devices | Storage | Price |
|---|---|---|---|---|
| free | 1 | 1,000 | 1 GB | 0 |
| pro | 5 | 50,000 | 20 GB | 49.00/month |
| scale | 50 | 1,000,000 | 200 GB | 249.00/month |
Active devices are counted over a 30-day window: rows in devices whose
lastSeenAt falls inside it. Storage is the sum of releases.size across the
organisation. GET /api/v1/orgs/:orgId/usage returns all three against their
limits plus an overQuota flag, and GET /api/v1/plans is public because
prices are not a secret.
In self mode getPlanFor returns a synthetic self-hosted plan whose limits
are Number.MAX_SAFE_INTEGER, and both quota checks return before doing any
work.
The product rule
Section titled “The product rule”Exceeding a quota blocks new publishes. It never blocks update-check or bundle downloads.
A customer’s end users must not have their app break because of a billing state
they cannot see. So assertCanPublish runs on prepare-upload and refuses
when the organisation is already over its device limit, or when the incoming
bundle would take it past its storage limit — and the error says so plainly:
“existing apps keep receiving updates — upgrade to publish new ones”.
assertCanCreateProject does the same for the project count. Nothing on the
Device API consults a plan at all.
Stripe
Section titled “Stripe”Billing turns on only when OTA_MODE=hosted and STRIPE_SECRET_KEY is set.
Everything below is off otherwise.
- Checkout —
POST /api/v1/billing/checkoutwith an org and a plan creates a subscription-mode session. The org id rides along inclient_reference_id,metadataandsubscription_data.metadata, because without it the webhook has no way back to the organisation that paid. A plan with nostripePriceIdis not purchasable. - Portal —
POST /api/v1/billing/portalopens the Stripe customer portal for the org’s existing customer, returning toSTRIPE_PORTAL_RETURN_URL. - Webhooks —
POST /api/v1/billing/webhookis public and verifies the signature over the exact received bytes, which are never re-serialised. The event id is then claimed instripe_eventswith an insert that does nothing on conflict; if the row already existed the handler returns{received: true, duplicate: true}without touching state. Redelivery is therefore free. Handled events:checkout.session.completed,customer.subscription.created,.updated,.deleted, andinvoice.payment_failed.
subscriptions is a mirror of Stripe and never leads it. The webhook and
reconcileSubscriptions are its only writers, and org.planId always comes
from the price actually on the subscription — never from what a client asked
for. A cancelled or expired subscription drops the org back to free.
What stays off in self mode
Section titled “What stays off in self mode”- Billing routes answer 400 with
billing_disabled. - Quotas are unlimited and never checked.
- Signup closes after the first account, which is verified immediately.
- Organisations get no trial period.
GET /api/v1/metareportshosted: false,billingEnabled: falseandsignupEnabled: false, and the dashboard adapts.
Remote MCP is not part of this split. /mcp and the OAuth endpoints are
available on every install regardless of mode — see
connect an agent.