> ## Documentation Index
> Fetch the complete documentation index at: https://quintsecurity.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Tenant Lifecycle

> The internal operator API for provisioning, approving, observing and deprovisioning a tenant, and the Postman collection that exercises it.

<Warning>
  **Internal operator API.** Served by the deploy gateway on the private control-plane origin,
  authenticated per named human operator through the operator WorkOS client. There is no
  self-service tenant creation and no customer-facing route on this surface. Do not proxy it from
  the product dashboard.
</Warning>

## What this API does

One API covers a tenant's whole life: request it, get it approved by a second operator, watch it
build, mint its first ingest token, and eventually deprovision it with a recorded justification.

Both mutating lifecycle transitions are **maker-checker**: the operator who requests a tenant
cannot approve it, and the operator who requests a deprovision cannot approve that either.

| Stage        | Route                                                                        | Who                                |
| ------------ | ---------------------------------------------------------------------------- | ---------------------------------- |
| Request      | `POST /v1/tenants`                                                           | requesting operator                |
| Observe      | `GET /v1/tenants`, `GET /v1/tenants/{slug}`, `GET /v1/tenants/{slug}/events` | any operator or viewer             |
| Decide       | `POST /v1/tenants/{slug}/{approve,reject}`                                   | **a second** operator              |
| Intervene    | `POST /v1/tenants/{slug}/{cancel,resume}`                                    | operator                           |
| Ingest setup | `POST /v1/tenants/{slug}/tokens`                                             | operator                           |
| Deprovision  | `POST /v1/tenants/{slug}/deprovision`                                        | operator, then a second to approve |

Full field-level reference for the entry point: [Request a Tenant](/api/endpoint/post-tenant-lifecycle).

## States

```
requested → pending-approval → provisioning → provisioned → db-ready
          → routed → awaiting-first-checkin → active
```

| State                    | Class     | Meaning                                                                              |
| ------------------------ | --------- | ------------------------------------------------------------------------------------ |
| `requested`              | running   | Intake accepted, flow open                                                           |
| `pending-approval`       | waiting   | Resting until a second operator decides. The engine will not wake it                 |
| `provisioning`           | running   | Placing the tenant: org record, registry row, cell assignment                        |
| `provisioned`            | running   | **Placed, not usable.** Has a cell and a `database_name`; the database may not exist |
| `db-ready`               | waiting   | The physical database exists and is migrated. `schema_version` is pinned             |
| `routed`                 | waiting   | Traffic routing configured                                                           |
| `awaiting-first-checkin` | waiting   | Waiting for the tenant's first sensor to report                                      |
| `active`                 | succeeded | Live                                                                                 |
| `failed`                 | waiting   | Retrying on a durable timer. **Not terminal** — reported as `backing-off`            |
| `paused`                 | paused    | Retries exhausted or a terminal error. Needs an explicit `resume`                    |
| `cancelled`              | cancelled | Cancelled pre-provision                                                              |

<Warning>
  **`provisioned` is the state that misleads.** It means the tenant has a home, not that it has a
  database. The one reliable signal is a non-null `placement.schema_version`. A console that
  treats `provisioned` as ready will show a working workspace for a tenant with no database
  behind it, and a token minted that early would authenticate and drop every event — which is why
  the mint route refuses with `409` before `db-ready`.
</Warning>

`failed` is not a resting state. A flow that failed with retries remaining sits on a timer and
re-enters `provisioning` on its own; the detail route derives `presentation: "backing-off"` for
exactly that case. Only `paused` needs a human.

## The integration contract

Three rules make a client correct across every state.

1. **Poll `poll_url`, don't guess.** The `202` hands back the path. Read `state` and
   `presentation`; `next_wake_at` tells you when the flow will next be tried, and is `null` when
   nothing is scheduled.
2. **Treat absent keys as absent, not as zero.** `placement` is missing until the registry row
   exists. `intake_summary` is missing once deprovisioning redacts the intake. Both must render
   without 500-ing.
3. **Never derive readiness from the state name.** Use `placement.schema_version`.

## Reading the detail route

`GET /v1/tenants/{slug}` returns the flow's state plus two optional blocks:

```json theme={null}
{
  "slug": "demo-tenant",
  "state": "db-ready",
  "attempts": 1,
  "presentation": "waiting",
  "next_wake_at": null,
  "placement": {
    "cell_id": "cell-use1-a",
    "database_name": "tenant_demotenant",
    "schema_version": "000160"
  },
  "intake_summary": {
    "org_name": "Demo Organization",
    "owner_email": "admin@demo.example",
    "region": "us-east-1",
    "plan_tier": "core",
    "isolation": "shared",
    "dev_endpoints": 0,
    "server_endpoints": 0,
    "security_profile_target": "monitor",
    "notes": "",
    "requested_by": "operator@example.com"
  }
}
```

`presentation` is derived from the state's class in the flow definition rather than read from a
stored column, so it cannot drift from the state it describes.

## Streaming

```bash theme={null}
curl -N -H "Authorization: Bearer $OPERATOR_JWT" \
  "https://<control-plane-host>/v1/tenants/demo-tenant/events"
```

Each frame is a JSON object carrying `state`, `attempts` and `reason`. Use `curl -N` rather than
Postman, which buffers SSE and renders it poorly.

## Ingest setup

```bash theme={null}
curl -X POST https://<control-plane-host>/v1/tenants/demo-tenant/tokens \
  -H "Authorization: Bearer $OPERATOR_JWT" \
  -H "Content-Type: application/json" \
  -d '{"name": "Initial sensor token"}'
```

```json 201 theme={null}
{
  "token": "qt_dk_...",
  "prefix": "qt_dk_a1b2c3",
  "scopes": ["ingest"],
  "name": "Initial sensor token"
}
```

<Warning>
  **`token` is returned exactly once.** It is never written to the flow journal, the tenant event
  log, or service logs — the stored record is a SHA-256 hash plus the prefix, and the event log
  records only the prefix and which operator minted it. There is no route that reveals it again.
  Capture it from this response or mint another.
</Warning>

Two refusals: minting before `db-ready` is `409`, and a duplicate token `name` for the same
tenant is `409` (names are unique per tenant so an operator can revoke by name).

## Deprovisioning

```bash theme={null}
curl -X POST https://<control-plane-host>/v1/tenants/demo-tenant/deprovision \
  -H "Authorization: Bearer $OPERATOR_JWT" \
  -H "Content-Type: application/json" \
  -d '{"justification": "Customer requested account closure via support ticket #12345. Data export verified by operator before requesting."}'
```

`justification` is required and must be **at least 20 characters** — 19 is a `422`. It is not
ceremony: the justification is rendered as the primary content of the approval card, so it is the
only thing the approving operator reads about why a tenant is being destroyed.

Deprovisioning exports before it drops, and redacts the intake record (which is why
`intake_summary` disappears from the detail route afterwards).

<Warning>
  **Approval reuses the onboarding route.** There is no separate offboard approve endpoint:
  `POST /v1/tenants/{slug}/approve` resolves whichever lifecycle flow is live from the slug. So
  while a deprovision is in flight, `reject`, `cancel`, `resume` and the event stream all act on
  the **offboard** flow, not on the completed provisioning flow.

  Once the offboard reaches a final class, the detail route falls back to the onboarding row.
  Confirm a completed deprovision by its export artifact, not by the detail route.
</Warning>

## Postman collection

Committed alongside this page:

* `api/collections/quint-deploy-gateway.postman_collection.json`
* `api/collections/quint-deploy-gateway.remote.postman_environment.json`

Source of truth is `docs/api/quint-deploy-gateway.postman_collection.json` in the platform repo.
Re-copy rather than edit the copy — with one deliberate difference: the published copy carries no
operator WorkOS client id, no control-plane hostname, and no runner URL, because this docs site is
public. Fill those in from the operator console's configuration.

Five folders:

| Folder                              | What it holds                                                                                                                                           |
| ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Session — operator tokens**       | The two-step WorkOS Magic Auth exchange, for both the operator and the second approver                                                                  |
| **Operator surface**                | Deploy routes (`/v1/deploys`)                                                                                                                           |
| **Tenant lifecycle**                | The ten requests documented on this page, numbered in execution order                                                                                   |
| **Runner surface**                  | The claim/step/notes/outcome routes the deploy runner uses                                                                                              |
| **Refusals — the contract's edges** | Asserts every documented error, including tenant self-approval `403`, the 19-character justification `422`, and a second provision of a live slug `409` |

Every secret-typed value in the committed environment ships empty. Fill `base_url`,
`workos_client_id`, `workos_client_secret` and the operator/approver emails for the gateway you
are pointing at.

### Getting a token

The Session folder runs a two-step exchange against WorkOS:

```
POST https://api.workos.com/user_management/magic_auth      → emails a code
POST https://api.workos.com/user_management/authenticate    → exchanges the code
     grant_type: urn:workos:oauth:grant-type:magic-auth:code
```

Run it twice — once as the requesting operator, once as the approver — because no single operator
can drive the collection end to end.

<Warning>
  **Use the operator WorkOS environment, not the product's.** These are separate WorkOS clients,
  and that separation is the security property: the gateway pins the token issuer to the operator
  client, so nothing the product mints verifies here.

  The failure is quiet. A product-environment API key returns a `200`-shaped success for a user it
  cannot see, so you get what looks like a valid token and a `403` at the gateway with no clue
  why. If the exchange succeeds and the gateway still refuses, check which client you
  authenticated against before anything else.
</Warning>

### Traps worth knowing

* **Postman environments must hold configuration only.** Environment scope resolves *before*
  collection variables, so an environment that also declares `slug` shadows anything the
  collection scripts set — the request then interpolates a stale value and fails for a reason
  absent from the request you are reading.
* **`pm.info.iteration` is not a poll counter.** It is the Runner's iteration index, so in a
  single-iteration run it stays `0` 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.
