This was the internal operator control-plane API, not a customer surface — served by
quint-provisiond on the private control-plane address under /internal/, authenticated by a
single shared operator token.What this API does
Provisioning a tenant is two stages. This API is stage 1 only.
Tenant provisioning — the two stages, and what each actually builds. Blue is stage 1 (this API); violet is the operator CLI; the amber state is where a correct stage-1 run parks.
state: "provisioned" means placed, not usable. A tenant can reach provisioned with no
database behind it. The one reliable signal that stage 2 has run is a non-null
placement.schema_version.
Base URL and authentication
/healthz requires the operator token:
subtle.ConstantTimeCompare, so a wrong token is byte-identical to a
missing one — both return the same 401 body. There is no per-user identity, no scopes, and no
audit of who called: the token is the whole authorization model.
401
The integration contract
Three rules. Follow them and the client is correct across every state.1
POST returns 202, never a finished tenant
The response carries
poll_url. It is always accepted-and-pending; there is no synchronous
success shape to handle.2
Obey terminal — do not re-derive it
Stop polling when the server says
terminal: true. The stop rule lives with the state machine
so it is not reimplemented differently in each client.Note the trap: failed with a next_retry_at is not terminal. The background sweep will
resume it. Only active, or failed with no scheduled retry, are terminal.3
Obey poll_after_ms for cadence
The server widens the interval as the flow goes quiet (1500 ms while moving, 3000 ms when
waiting on something external, 5000 ms between retries). Do not hardcode an interval.
States
Every state also carries a
customer_message — fixed, enumerated, neutral text, safe to show a
customer verbatim. It is generated from state at read time so the same words are never written
twice. Use it rather than composing your own copy.
GET /internal/provision/cells
Placement targets. Call this before a POST to pick a region that can actually accept a tenant.
Optional ?region= filters.
string
Cell id, e.g.
aws-use1-cell-1.string
The value to pass as
region on the POST.string
Derived from the cell id prefix:
aws, gcp, local, or unknown. A convention, not a schema column — cells has no provider column.integer
Configured maximum.
integer
Current occupancy.
integer
capacity - tenant_count, floored at 0. Capacity can be lowered below occupancy to close a cell to new placements, so a would-be negative is normal and reported as 0.boolean
Whether this cell accepts new placements. Filter on this, not on
free.cURL
Response
POST /internal/provision/tenants
Records the commercial intake, opens the flow row, and starts provisioning in the background.
Answers 202.
Unknown fields are rejected (400 malformed_json) — a misspelled field is a mistake, not a
default. Body limit 64 KB.
Required
string
required
DNS label: lowercase alphanumeric and hyphens, no leading or trailing hyphen, max 56
characters. Lowercased and trimmed server-side. This is the tenant’s permanent identifier and
the key for every other call.
string
required
Organization display name.
string
required
Owner’s email. Lowercased and trimmed server-side.
Optional
string
default:"local"
Resolves against
cells.region and so decides the cell — and the cloud. Take this from
GET /cells.string
default:"core"
One of
core, team, enterprise. Recorded, never enforced — it does not affect
provisioning or gate any feature. (growth is not a value and returns 422.)string
default:"shared"
Only
shared can be provisioned. dedicated and self-hosted are refused with 422, not
quietly downgraded — a dedicated stack is not reachable by terraform apply and self-hosted runs
in the customer’s own account. Recording either would put a claim in a durable field that
disagrees with what was built. Track upgrade intent with the deal instead.integer
default:"0"
0–100000. Recorded, never enforced.
integer
default:"0"
0–100000. Recorded, never enforced.
string
default:"monitor"
monitor or enforce. Recorded, never enforced.string
Free text for the operator’s terms.
string
Who took the order. Free text — not authenticated.
string[]
Accepted by the parser and then refused with 422. A name-constraint grant needs an AWS KMS
Ed25519 key, unavailable locally and uncreatable by the pinned Terraform provider. Provision
here, then issue the grant with
quint-control provision --gateway ... against a stack that has
the key. Send an empty array or omit the field.Response — 202
string
string
Always
requested at this point.string
The resolved region, after defaulting.
string
Path to poll. Always valid immediately — the flow row is opened synchronously, so there is no window where this 404s.
boolean
cURL
202
Errors
Intake is recorded before provisioning and its failure is fatal to the request: a provision
that later fails still leaves the operator’s terms on disk, which is when they matter most.
GET /internal/provision/tenants/{slug}
The poll endpoint, and the one read a monitoring UI should be built on. Returns 404
not_found when no flow row exists for the slug.
string
string | null
Null until the organization row exists.
string
See the state table above.
boolean
Stop polling when true. Do not re-derive this.
integer
Server-advised interval until the next poll.
integer
Provision attempts so far.
integer | null
quint-control exit code. Operator-only.datetime | null
When present on a
failed state, the sweep will retry and the flow is not terminal.datetime
string
Names the failing subsystem, e.g.
quint-control exit 4 (database). Operator-only — never proxy to a customer surface.string
Neutral text safe to show a customer. Included so an operator can see exactly what the customer is being told.
integer
Present once the flow has both endpoints. First
requested → first active. Derived from the journal, never stored.object | null
Null while
requested/provisioning (no registry row yet), and null after a full deprovision.
Not an error.object | null
Null for tenants provisioned by the CLI or before the intake table existed. Normal, not an error.
object[]
Per-transition timestamps, oldest first. Each entry:
from_state (null on the first), to_state, failed_code, at, is_note.200
200 means “a request was once made”, not “a tenant exists”. Deprovisioning drops the
registry row but leaves the flow row, so a fully deprovisioned tenant still answers 200 with
state: "provisioned" and a customer_message saying the workspace is ready. The tell is
placement: null. A monitoring UI must treat that combination as gone, not ready.GET /internal/provision/tenants/{slug}/events
The flow journal on its own, for a timeline view. Same 404 behaviour.
string
object[]
Same entries as the
journal field above, oldest first.Reading history and resource telemetry
Two ledgers exist, and they answer different questions. Only the first has an HTTP route.tenant_registry_events is append-only, indexed on (org_id, at DESC), carries a jsonb detail
and an actor, and is the audit trail for where a tenant’s data has lived. It is the right source
for resource-level telemetry — but there is currently no route, and no CLI subcommand, that reads
it. Reaching it means SQL against the control database.
Also worth knowing when designing a console:
plan_tiernever reaches the registry. It lives only in the intake record;organizations.planis unrelated and will disagree. Read the tier fromintake, not from the org.- The per-tenant secret id is not in any event, but it is derivable:
quint/tenant/<slug>/db. - Provisioning writes nothing to
admin_audit_log. Only offboarding does. If that is the compliance evidence surface, tenant lifecycle is not currently in it.
Stage 2 and teardown (CLI only)
Neither has an HTTP route. A UI can display these as the operator’s next action but cannot invoke them.db-deprovision exports the database, drops it, retires the role, schedules the secret and both
KMS keys for deletion, and frees the cell slot — offboarding strictly last, and idempotent. It does
not delete the flow row, which is why the poll endpoint keeps answering 200.
Postman collection
Committed alongside this page atapi/collections/quint-provisioning.postman_collection.json
(source of truth: docs/api/quint-provisioning.postman_collection.json in the platform repo — the
two are byte-identical, so re-copy rather than edit the copy). It ships a runnable
folder, End-to-end — provision a tenant and watch it progress, which picks a real open cell,
mints a unique slug per run, self-chains the poll loop on poll_after_ms, asserts the flow parks
at provisioned with a null schema_version, and then asserts the flip after stage 2. Verified
green: 20 requests, 43 assertions, 0 failures.
To get a daemon to point at:
up prints the exact newman invocation with the port and token it chose.
Only the remote environment is committed here, and every secret-typed value in it ships
empty — fill base_url and operator_token for the daemon you are pointing at. The platform repo
also carries a local environment preset for 127.0.0.1:8090; it is deliberately not published,
because a committed environment is the easiest place for a real token to end up by accident.
Two traps that cost real time
- Postman environments must hold config only. Environment scope resolves before collection
variables, so an environment that also declares
slugshadows everypm.collectionVariables.setthe scripts make — the POST then interpolates a stale value and answers 422 for a reason absent from the request you are reading. The collection now fails loudly on this. pm.info.iterationis not a poll counter. It is the Runner’s iteration index, so in a single-iteration run it is0forever and aniteration < Nbudget never fires. Count in a variable. And neversetNextRequest(null)to leave a poll loop — it ends the entire run and silently skips every later request.