> ## 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.

# Request a Tenant

> Request a new tenant on the control plane. Returns 202 with a poll URL — the tenant does not exist yet, a flow that will create it does.

<Warning>
  **Internal operator API.** This route is served by the deploy gateway on the private
  control-plane origin, not by `api.quintai.dev`. It is not a customer surface: there is no
  self-service tenant creation. Every call is authenticated as a named human operator holding
  the `operator` role, and creating a tenant requires a **second** operator to approve it.

  Do not confuse this with [Create Tenant](/api/endpoint/post-tenant)
  (`POST /v1/tenant`, singular), which is the product API's organization record.
</Warning>

## Why 202 and not 201

The response is `202 Accepted`. Nothing has been created when it returns — an onboarding flow
has been opened in state `requested`, and that flow is still subject to approval or rejection.

Answering `201` with a `Location` header pointing at a tenant that a second operator may still
reject would be a lie the console renders as success. The `202` body hands back a `poll_url`
instead, and the tenant becomes real only after approval.

## Base URL and authentication

```
https://<control-plane-host>
```

```
Authorization: Bearer <operator JWT>
```

The token is a WorkOS Magic Auth JWT minted against the **operator** WorkOS client, which is a
different client from the product's. The gateway pins the token issuer to that client id, so a
token minted by the product client does not verify here regardless of the user's roles. The
operator's role must be `operator`; a `viewer` token is refused on every write route.

<Note>
  A missing token and a malformed token both return the same byte-pinned `403`. There is no `401`
  on this surface, and no response body distinguishes "no credential" from "bad credential".
</Note>

## Request body

<ParamField body="slug" type="string" required>
  URL-safe tenant identifier, and the tenant's permanent handle. **Maximum 56 characters** — the
  physical database is named `tenant_<slug>` and Postgres truncates identifiers at 63 bytes
  (`NAMEDATALEN`), so a longer slug would silently collide with another tenant's database.
</ParamField>

<ParamField body="name" type="string" required>
  Human-readable organization name, shown on the approval card.
</ParamField>

<ParamField body="owner_email" type="string" required>
  Email of the tenant's owner. Must contain `@`.
</ParamField>

<ParamField body="region" type="string" required>
  Placement region. A cell in this region must exist **and have spare capacity**, or the flow
  fails placement with `no_placeable_cell`. Check placement before requesting.
</ParamField>

<ParamField body="plan_tier" type="string" required>
  One of `core`, `team`, `enterprise`.

  These three are the whole set — the value is enforced by a database `CHECK` constraint as well
  as by the API, so an unrecognised tier is refused rather than stored. `trial` is **not** a
  tier; a trial is a time window on a tenant, not a plan.
</ParamField>

<ParamField body="isolation" type="string" required>
  Must be `shared`. The schema also names `dedicated` and `self-hosted`, but only `shared` is
  implemented today and anything else is refused with `422`.
</ParamField>

<ParamField body="security_profile_target" type="string" default="monitor">
  `monitor` or `enforce`. Omitted means `monitor` — a new tenant observes before it blocks.
</ParamField>

<ParamField body="dev_endpoints" type="integer">
  Expected developer endpoint count, for capacity planning. Recorded, not enforced.
</ParamField>

<ParamField body="server_endpoints" type="integer">
  Expected server endpoint count. Recorded, not enforced.
</ParamField>

<ParamField body="notes" type="string">
  Free-text context for the approver. Maximum 2000 characters.
</ParamField>

<Warning>
  **Unknown fields are rejected, not ignored.** The decoder refuses any key it does not
  recognise with a `400`, and refuses a second JSON document in the body. A typo in a field name
  fails loudly instead of silently dropping the value — so `plan_teir` is an error, not a tenant
  provisioned on a default plan.

  `gateways` is refused with `422` specifically: gateway wiring is not part of intake.
</Warning>

## Response

<ResponseField name="slug" type="string">
  The slug you requested, echoed back.
</ResponseField>

<ResponseField name="state" type="string">
  Always `requested` on a fresh request.
</ResponseField>

<ResponseField name="poll_url" type="string">
  Path to the detail route. Poll this for progress.
</ResponseField>

<ResponseField name="stream_url" type="string">
  Path to the SSE event stream for the same flow.
</ResponseField>

<RequestExample>
  ```bash cURL theme={null}
  curl -X POST https://<control-plane-host>/v1/tenants \
    -H "Authorization: Bearer $OPERATOR_JWT" \
    -H "Content-Type: application/json" \
    -d '{
      "slug": "demo-tenant",
      "name": "Demo Organization",
      "owner_email": "admin@demo.example",
      "region": "us-east-1",
      "plan_tier": "core",
      "isolation": "shared"
    }'
  ```

  ```json Minimal body theme={null}
  {
    "slug": "demo-tenant",
    "name": "Demo Organization",
    "owner_email": "admin@demo.example",
    "region": "us-east-1",
    "plan_tier": "core",
    "isolation": "shared"
  }
  ```

  ```json Full body theme={null}
  {
    "slug": "acme-prod",
    "name": "Acme Corporation",
    "owner_email": "security@acme.example",
    "region": "us-east-1",
    "plan_tier": "enterprise",
    "isolation": "shared",
    "security_profile_target": "monitor",
    "dev_endpoints": 120,
    "server_endpoints": 40,
    "notes": "Signed 2026-08-28. Rollout starts with the platform team (12 seats) before the wider fleet."
  }
  ```
</RequestExample>

<ResponseExample>
  ```json 202 theme={null}
  {
    "slug": "demo-tenant",
    "state": "requested",
    "poll_url": "/v1/tenants/demo-tenant",
    "stream_url": "/v1/tenants/demo-tenant/events"
  }
  ```

  ```json 422 invalid plan_tier theme={null}
  {
    "error": "unprocessable",
    "detail": "plan_tier must be one of core, team, enterprise"
  }
  ```

  ```json 400 unknown field theme={null}
  {
    "error": "bad_request",
    "detail": "unknown field \"plan_teir\""
  }
  ```

  ```json 409 slug already live theme={null}
  {
    "error": "conflict",
    "detail": "a live flow already exists for this slug"
  }
  ```
</ResponseExample>

## Errors

| Status | When                                                                                                                                    |
| ------ | --------------------------------------------------------------------------------------------------------------------------------------- |
| `400`  | Unknown field, malformed JSON, or a second JSON document in the body                                                                    |
| `403`  | Missing token, invalid token, or a `viewer` token on this write route                                                                   |
| `409`  | A live flow already exists for this slug                                                                                                |
| `422`  | Failed validation: bad `plan_tier`, non-`shared` `isolation`, slug over 56 characters, `owner_email` without `@`, or `gateways` present |

## After the 202

The `202` is the start of a pipeline, not the end of a request. The states a tenant moves
through:

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

Two states rest until a human acts: `pending-approval` waits for a second operator, and the
flow engine deliberately will not wake it, because nothing but a decision can move it.
`paused` holds a flow that exhausted its retries, and needs an explicit resume.

Poll `GET /v1/tenants/{slug}` for the current state, or stream
`GET /v1/tenants/{slug}/events`. The detail route's `presentation` field is derived from the
flow definition rather than stored, and reports `backing-off` for a flow retrying on a timer —
which is why a state of `failed` is not terminal.

<Warning>
  **`provisioned` means placed, not usable.** A tenant reaches `provisioned` when it has a cell
  and a `database_name`; the physical database may not exist yet. The signal that the database
  is real and migrated is a non-null `placement.schema_version`, which is the `db-ready` state.

  Anything that routes traffic must refuse a null `schema_version` rather than default it, and
  minting an ingest token before `db-ready` is refused with `409` — a token for a tenant with no
  database would authenticate and then drop every event.
</Warning>

### Approval is a separate call, by a different person

```
POST /v1/tenants/{slug}/approve
```

The approver must not be the requester. Self-approval returns `403` — the rule working, not a
misconfiguration.

<Note>
  **The refusal code differs by surface.** Self-approval on a **tenant** route returns `403`; on
  a **deploy** route it returns `409`. A console that treats every `403` as "session expired"
  will tell operators to log in again when the real answer is "ask a colleague to approve."
</Note>

To refuse instead, `POST /v1/tenants/{slug}/reject` with a `reason` — an empty or very short
reason is `422`, because the reason is what an auditor reads later. `cancel` and `resume` take
the same path shape.

### The full lifecycle

| Step                 | Call                                                                  |
| -------------------- | --------------------------------------------------------------------- |
| 1. Request           | `POST /v1/tenants`                                                    |
| 2. Watch             | `GET /v1/tenants/{slug}` or `.../events`                              |
| 3. Approve           | `POST /v1/tenants/{slug}/approve` *(second operator)*                 |
| 4. Mint ingest token | `POST /v1/tenants/{slug}/tokens` *(after `db-ready`)*                 |
| 5. Deprovision       | `POST /v1/tenants/{slug}/deprovision` *(justification, min 20 chars)* |

The token mint returns the raw token **exactly once**. It is never written to the flow journal,
the tenant event log, or the service logs — only a SHA-256 hash and a short prefix are stored.
If it is not captured from that one response body, mint another; there is no route that reveals
it again.

Deprovisioning requires a `justification` of at least 20 characters, and is approved through
**the same** `/approve` route — the flow type is resolved from the slug, not from the path. While
a deprovision is live, `reject`, `cancel`, `resume` and the event stream all act on the offboard
flow rather than on the completed provisioning flow.

## Postman

Every call above ships as a runnable request in the
[deploy gateway collection](/api/tenant-lifecycle#postman-collection), including a
**Refusals** folder that asserts each error in the table above.
