Tenants API
Base URL: http://localhost:3001 (default, configurable via PORT env var)
All endpoints require authentication via X-API-Key or Authorization: Bearer header.
Endpoints
Section titled “Endpoints”| Method | Path | Description |
|---|---|---|
GET |
/api/v1/tenants |
List tenants (cursor pagination) |
POST |
/api/v1/tenants |
Create tenant |
POST |
/api/v1/tenants/batch |
Batch create (up to 100, atomic) |
GET |
/api/v1/tenants/:id |
Get tenant |
PATCH |
/api/v1/tenants/:id |
Update tenant |
DELETE |
/api/v1/tenants/:id |
Archive tenant (soft delete) |
POST |
/api/v1/tenants/:id/move |
Move tenant in hierarchy |
GET |
/api/v1/tenants/:id/ancestors |
Get ancestors (root to parent) |
GET |
/api/v1/tenants/:id/descendants |
Get all descendants |
GET |
/api/v1/tenants/:id/children |
Get direct children |
GET |
/api/v1/tenants/:id/context |
Resolve full context (read) |
GET |
/api/v1/tenants/:id/export |
Export tenant data (GDPR, admin) |
POST |
/api/v1/tenants/:id/purge |
Hard-delete tenant data (GDPR, admin) |
POST |
/api/v1/tenants/:id/migrate-region |
Migrate to region (admin) |
ancestry_path lists the IDs of a tenant’s ancestors, root first, and not the tenant’s own ID. A root tenant has /, its child has /<root-id>, and a grandchild has /<root-id>/<child-id>. depth is the number of ancestors.
Suspending, resuming and activating a tenant have no route. Call suspendTenant, resumeTenant and activateTenant of @stratum-hq/lib instead (see Tenant lifecycle). POST /api/v1/tenants activates a tenant with its own schema or database itself, once it has provisioned the storage.
List tenants
Section titled “List tenants”GET /api/v1/tenants?cursor=<uuid>&limit=50Query Parameters:
| Param | Type | Default | Description |
|---|---|---|---|
cursor |
UUID | – | Cursor for pagination (tenant ID) |
limit |
number | 50 | Max results (1-100) |
Response: 200 OK
{ "data": [ { "id": "550e8400-e29b-41d4-a716-446655440000", "parent_id": null, "name": "Acme Corp", "slug": "acme_corp", "ancestry_path": "/", "depth": 0, "config": {}, "metadata": {}, "isolation_strategy": "SHARED_RLS", "status": "active", "deleted_at": null, "created_at": "2024-01-01T00:00:00.000Z", "updated_at": "2024-01-01T00:00:00.000Z" } ], "next_cursor": "uuid | null", "has_more": false}Create tenant
Section titled “Create tenant”POST /api/v1/tenantsBody:
{ "name": "Acme Corp", "slug": "acme_corp", "parent_id": "uuid (optional)", "config": {}, "metadata": {}, "isolation_strategy": "SHARED_RLS"}| Field | Type | Required | Description |
|---|---|---|---|
name |
string | Yes | Display name (1-255 chars) |
slug |
string | Yes | URL-safe identifier (^[a-z][a-z0-9_]{0,62}$) |
parent_id |
UUID | No | Parent tenant ID (null for root) |
isolation_strategy |
enum | No | SHARED_RLS, SCHEMA_PER_TENANT, DB_PER_TENANT |
config |
object | No | Initial config key-value pairs |
metadata |
object | No | Arbitrary metadata |
Response: 201 Created. Returns the tenant object.
Get tenant
Section titled “Get tenant”GET /api/v1/tenants/:idResponse: 200 OK. Returns the tenant object.
Update tenant
Section titled “Update tenant”PATCH /api/v1/tenants/:idBody (all fields optional):
{ "name": "New Name", "slug": "new_slug", "config": {"key": "value"}, "metadata": {"key": "value"}}Response: 200 OK. Returns the updated tenant.
Archive tenant
Section titled “Archive tenant”DELETE /api/v1/tenants/:idSoft-deletes the tenant (sets status = 'archived'). Fails with 409 HAS_CHILDREN if the tenant has active children.
Response: 204 No Content
Move tenant
Section titled “Move tenant”POST /api/v1/tenants/:id/moveBody:
{ "new_parent_id": "uuid"}Moves the tenant (and all descendants) under a new parent. Performs cycle detection and acquires advisory locks.
Response: 200 OK. Returns the updated tenant.
Batch create tenants
Section titled “Batch create tenants”POST /api/v1/tenants/batchCreates up to 100 tenants in a single atomic transaction. If any tenant fails validation, the entire batch rolls back.
Body:
{ "tenants": [ {"name": "Client A", "slug": "client_a", "parent_id": "uuid"}, {"name": "Client B", "slug": "client_b", "parent_id": "uuid"} ]}Response: 201 Created
{ "created": [], "errors": []}Get ancestors
Section titled “Get ancestors”GET /api/v1/tenants/:id/ancestorsResponse: 200 OK with an array of tenant objects from root to parent.
Get descendants
Section titled “Get descendants”GET /api/v1/tenants/:id/descendantsResponse: 200 OK with all descendants (uses ltree for efficient subtree query).
Get children
Section titled “Get children”GET /api/v1/tenants/:id/childrenResponse: 200 OK with direct children only.
Resolve context
Section titled “Resolve context”GET /api/v1/tenants/:id/contextReturns the full tenant context including resolved config and permissions.
Scope: read. A tenant-scoped key can resolve its own tenant and its descendants. A global key can resolve any tenant. The SDK middleware, the NestJS guard and the Hono resolver call this route, so an app server needs only a read key.
resolved_config follows the same masking rule as GET /tenants/:id/config: a sensitive value inherited from an ancestor comes back as "value": null with "sensitive": true and "masked": true, unless the caller’s key belongs to the tenant that set it.
Response: 200 OK
{ "tenant_id": "uuid", "ancestry_path": "/parent-uuid", "depth": 1, "isolation_strategy": "SHARED_RLS", "resolved_config": { "max_users": { "key": "max_users", "value": 500, "source_tenant_id": "uuid", "inherited": true, "locked": false } }, "resolved_permissions": { "manage_users": { "key": "manage_users", "value": true, "mode": "LOCKED", "source_tenant_id": "uuid", "locked": true, "delegated": false } }}Error responses
Section titled “Error responses”| Code | Status | Description |
|---|---|---|
TENANT_NOT_FOUND |
404 | Tenant ID does not exist |
TENANT_ARCHIVED |
410 | Tenant has been archived |
TENANT_HAS_CHILDREN |
409 | Cannot archive tenant with active children |
TENANT_CYCLE_DETECTED |
409 | Move would create a cycle |
TENANT_ALREADY_EXISTS |
409 | Slug already exists |
REGION_NOT_FOUND |
404 | The target region of migrate-region does not exist |
REGION_NOT_ACTIVE |
409 | The target region of migrate-region is not active |
VALIDATION_ERROR |
400 | Invalid request body |
All errors follow this format:
{ "error": { "code": "ERROR_CODE", "message": "Human-readable message", "details": { "region_id": "uuid" } }}error.details is optional. When it is present, it holds more data about the error. For example, a REGION_NOT_ACTIVE error has the region_id.
A path id that is not a UUID, such as the :id of /api/v1/tenants/:id, gets 400 VALIDATION_ERROR with an issue that names the bad id, for example { "path": ["params", "id"], "message": "Invalid UUID", "code": "invalid_format" }. This holds on every route with an id in its path.
A path that matches no route gets 404 NOT_FOUND in the same format, once the caller is authenticated. A caller without valid credentials gets 401 UNAUTHORIZED first.
An unexpected server error gets 500 INTERNAL_SERVER_ERROR with a generic message and no detail of the cause. The control plane logs the error, with its request id, through its logger in every environment.
An error below 500 that has no Stratum code gets a code from its status: BAD_REQUEST (400), UNAUTHORIZED (401), FORBIDDEN (403), NOT_FOUND (404), CONFLICT (409), PAYLOAD_TOO_LARGE (413), UNSUPPORTED_MEDIA_TYPE (415), or RATE_LIMITED (429). Any other status below 500 gets BAD_REQUEST. For example, a request body that is not valid JSON gets 400 BAD_REQUEST. Only a failed field check gets VALIDATION_ERROR.
A VALIDATION_ERROR about the request fields lists each failed field in error.details.issues:
{ "error": { "code": "VALIDATION_ERROR", "message": "Validation failed", "details": { "issues": [ { "path": ["slug"], "message": "Invalid input: expected string, received undefined", "code": "invalid_type" } ] } }}The response also has error.issues, a copy of error.details.issues. It is deprecated and goes away in the next major release, so read error.details.issues.