Skip to main content
Superseded — quint-provisiond never deployed. Tenant provisioning, approval, deprovisioning, observation and token minting now live on the deploy gateway. Write new clients against Tenant Lifecycle and Request a Tenant.What changed that matters: routes moved from /internal/provision/* to /v1/tenants/*; the single shared operator token became a per-operator WorkOS JWT; and creating or destroying a tenant now requires a second operator to approve. This page is kept for the design record and for the two-stage placement/database distinction, which still holds.
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 flow: an operator calls GET /internal/provision/cells then POST /internal/provision/tenants; invalid input or a taken slug returns 422 or 409 with nothing built; a valid request records intake, opens the flow row synchronously, returns 202 with a poll_url, and provisions the org, registry row, cell assignment and database_name in the background; the client polls GET /internal/provision/tenants/{slug} until terminal, waiting poll_after_ms between polls, and parks at state provisioned with schema_version NULL; an operator then runs quint-control db-provision out of band to create the database, 72 tables, tsvc_ role, secret and two KMS ARNs, after which the same poll endpoint reports a populated schema_version and it is safe to route traffic; both stages append to tenant_registry_events, which has no HTTP route.

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.
Anything that routes traffic must refuse a null schema_version, never default it. A UI that treats provisioned as “ready” will show a working workspace for a tenant with no database.

Base URL and authentication

Every route except /healthz requires the operator token:
The comparison is 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.
This is the flow state-machine journal. It is not the resource ledger — see below.

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.
Known gap for a monitoring UI. Deprovisioning deletes the tenant_registry row rather than marking it (lifecycle allows only trial, active, provisioned — there is no deprovisioned value). Every fleet read inner-joins that table, so churned tenants are invisible to quint-control fleet and to anything built on it, even though their history rows survive. Do not build a “tenant history” view on the fleet read; it can only ever show live tenants.
Also worth knowing when designing a console:
  • plan_tier never reaches the registry. It lives only in the intake record; organizations.plan is unrelated and will disagree. Read the tier from intake, 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.
db-provision reaches for real AWS and has no local-mode guard for Secrets Manager. KMS is guarded by QUINT_TENANT_KMS=fake; Secrets Manager is not, so the endpoint override is the only thing between a local run and a write to whatever account the ambient credentials resolve to. unset AWS_PROFILE is load-bearing — a profile outranks dummy keys.

Postman collection

Committed alongside this page at api/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 slug shadows every pm.collectionVariables.set the 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.iteration is not a poll counter. It is the Runner’s iteration index, so in a single-iteration run it is 0 forever and an iteration < N budget never fires. Count in a variable. And never setNextRequest(null) to leave a poll loop — it ends the entire run and silently skips every later request.