Skip to content

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.

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.


GET /api/v1/tenants?cursor=<uuid>&limit=50

Query 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
}

POST /api/v1/tenants

Body:

{
"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 /api/v1/tenants/:id

Response: 200 OK. Returns the tenant object.


PATCH /api/v1/tenants/:id

Body (all fields optional):

{
"name": "New Name",
"slug": "new_slug",
"config": {"key": "value"},
"metadata": {"key": "value"}
}

Response: 200 OK. Returns the updated tenant.


DELETE /api/v1/tenants/:id

Soft-deletes the tenant (sets status = 'archived'). Fails with 409 HAS_CHILDREN if the tenant has active children.

Response: 204 No Content


POST /api/v1/tenants/:id/move

Body:

{
"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.


POST /api/v1/tenants/batch

Creates 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 /api/v1/tenants/:id/ancestors

Response: 200 OK with an array of tenant objects from root to parent.


GET /api/v1/tenants/:id/descendants

Response: 200 OK with all descendants (uses ltree for efficient subtree query).


GET /api/v1/tenants/:id/children

Response: 200 OK with direct children only.


GET /api/v1/tenants/:id/context

Returns 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
}
}
}

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.