Cloud — Tenant API
Endpoints for managing platform tenants.
Route prefix: /api/cloud/tenant
Reference DB schema: cloud.tenants
Default policy: SuperAdmin (claim user_type = "1") — applied at controller level.
All endpoints accept and return application/json except those with an empty body (204).
POST /api/cloud/tenant/create
Creates a new tenant and the initial manager user (user type 2) with a temporary password valid for 7 days.
- Auth:
[AllowAnonymous] + [RequireApiKey]— requires theX-Api-Keyheader with a time-based HMAC key derived from the secret inApiKeys:TenantCreate(see HMAC-time API key below).
Request (CreateTenantRequest)
| Field | Type | Required | Note |
|---|---|---|---|
code | string | yes | Max 255, unique |
name | string | yes | Max 255 |
adminEmail | string | yes | Email, max 255, unique |
licenseKey | string? | no | Max 255 |
Response 201 (CreateTenantResponse)
{
"tenantId": "uuid",
"code": "ACME-INC",
"name": "ACME Inc.",
"adminEmail": "admin@acme.example",
"managerUserId": "uuid",
"managerTempPassword": "string"
}
managerTempPassword is the only time the password is exposed in plain text: it must be communicated to the manager out-of-band.
The password is generated according to the configuration system rules (keys auth.password.*, resolved at global-only level since the tenant does not exist yet): min_length, require_uppercase, require_digit, require_special. See api/config.md to modify them.
Errors
401 Unauthorized— API key missing or invalid.409 Conflict—codeoradminEmailalready in use.503 Service Unavailable—ApiKeys:TenantCreatenot configured.
POST /api/cloud/tenant/update
Updates the modifiable fields of a tenant (code is immutable).
Request (UpdateTenantRequest)
| Field | Type | Required |
|---|---|---|
tenantId | Guid | yes |
name | string? | no |
adminEmail | string? | no |
licenseKey | string? | no |
Response 200 (UpdateTenantResponse): tenantId, code, name, adminEmail, licenseKey, updatedAt.
Errors
404 Not Found— tenant not found.409 Conflict— tenant deleted, oradminEmailalready used by another tenant.
POST /api/cloud/tenant/suspend
Sets the tenant to suspended status (ten_status_code = 2).
Request (TenantActionRequest): { "tenantId": "uuid" }.
Response 204 No Content
Errors
404 Not Found— tenant not found.409 Conflict— tenant deleted or already suspended.
POST /api/cloud/tenant/delete
Soft delete: sets ten_deleted = true. The tenant is not removed from the DB.
Request (TenantActionRequest): { "tenantId": "uuid" }.
Response 204 No Content
Errors
404 Not Found— tenant not found.409 Conflict— tenant already deleted.
POST /api/cloud/tenant/undelete
Restores a soft-deleted tenant (ten_deleted = false).
Request (TenantActionRequest): { "tenantId": "uuid" }.
Response 204 No Content
Errors
404 Not Found— tenant not found.409 Conflict— tenant not deleted.
POST /api/cloud/tenant/resume
Reactivates a suspended tenant (ten_status_code = 1).
Request (TenantActionRequest): { "tenantId": "uuid" }.
Response 204 No Content
Errors
404 Not Found— tenant not found.409 Conflict— tenant deleted or not suspended.
POST /api/cloud/tenant/purge
Permanently deletes a suspended tenant: removes the record from cloud.tenants and all related data via FK cascade. This operation is irreversible.
Request (TenantActionRequest): { "tenantId": "uuid" }.
Response 204 No Content
Errors
404 Not Found— tenant not found.409 Conflict— tenant not suspended (purge is only allowed fromSuspendedstate).
Purge is only available from
Suspendedstate to prevent accidental deletion of active tenants. The correct flow is:suspend→purge.
POST /api/cloud/tenant/list
Lists tenants with optional filters and pagination.
Request (ListTenantsRequest)
| Field | Type | Default |
|---|---|---|
includeDeleted | boolean? | false |
statusCode | int16? | — |
search | string? | — |
page | int? | 1 |
pageSize | int? | 50 (max 200) |
search is case-insensitive and applies ILIKE %value% on code, name, adminEmail.
Response 200 (ListTenantsResponse)
{
"items": [
{
"tenantId": "uuid",
"code": "ACME-INC",
"name": "ACME Inc.",
"adminEmail": "admin@acme.example",
"fiscalCode": "IT12345678901",
"statusCode": 1,
"deleted": false,
"createdAt": "2026-01-15T08:30:00Z",
"updatedAt": null
}
],
"totalCount": 1,
"page": 1,
"pageSize": 50
}
licenseKey is not included in TenantSummary to avoid exposing it in lists. Use get to retrieve it.
POST /api/cloud/tenant/get
Full details of a single tenant.
Request (TenantActionRequest): { "tenantId": "uuid" }.
Response 200 (GetTenantResponse): tenantId, code, name, adminEmail, fiscalCode, licenseKey, statusCode, deleted, createdAt, updatedAt.
Errors
404 Not Found— tenant not found.
Status codes in use
ten_status_code | Meaning |
|---|---|
1 | Active |
2 | Suspended |
The logical deletion flag is separate (ten_deleted: bool).
Tenant lifecycle
[create] ──▶ Active (1)
│
suspend│
▼
Suspended (2) ──purge──▶ [permanently deleted]
│
resume│
▼
Active (1)
│
delete│
▼
Deleted (deleted=true)
│
undelete│
▼
Active (1)
Allowed transitions for each endpoint:
| Endpoint | Required state | Resulting state |
|---|---|---|
suspend | Active, not deleted | Suspended |
resume | Suspended, not deleted | Active |
purge | Suspended, not deleted | (removed from DB) |
delete | Any, not deleted | deleted=true |
undelete | Deleted | Active |
HMAC-time API key
The tenant/create endpoint is protected by a rotating HMAC key instead of a static string. This prevents replay attacks: a captured key is valid for at most one minute.
How it works
key = HMAC-SHA256(secret, floor(unix_epoch_seconds / 60))[0..16].toLower()
- The server computes the key for the current 60-second window and the previous window (±60 s clock skew tolerance).
- The caller must provide the key for the current window in the
X-Api-Keyheader. - A key captured at minute
Nis useless at minuteN+2.
Server configuration
appsettings.json (or the environment-specific override):
"ApiKeys": {
"TenantCreate": "your-32-char-random-secret-here"
}
ApiKeys:TenantCreate is the HMAC secret, not the literal key value. Keep it secret; rotate it by updating appsettings.json (or the equivalent environment variable ApiKeys__TenantCreate).
Computing the key (client / M2M caller)
// C# (integration tests / backend tooling)
static string GetCurrentKey(string secret)
{
var window = DateTimeOffset.UtcNow.ToUnixTimeSeconds() / 60;
var hash = HMACSHA256.HashData(
Encoding.UTF8.GetBytes(secret),
Encoding.UTF8.GetBytes(window.ToString()));
return Convert.ToHexString(hash)[..16].ToLowerInvariant();
}
// TypeScript (Node.js / server-side only — never expose the secret in the browser)
import { createHmac } from "node:crypto";
function getCurrentKey(secret: string): string {
const window = Math.floor(Date.now() / 1000 / 60).toString();
return createHmac("sha256", secret).update(window).digest("hex").slice(0, 16);
}
Example request
POST /api/cloud/tenant/create
X-Api-Key: 3fa8c2b1e4d09f56
Content-Type: application/json
{ "code": "ACME-INC", "name": "ACME Inc.", "adminEmail": "admin@acme.example" }
Configuration
| Key | Location | Usage |
|---|---|---|
Jwt:Key / Issuer / Audience | appsettings*.json | Bearer token signing and validation. |
ApiKeys:TenantCreate | appsettings.json | HMAC secret used to derive the rotating X-Api-Key. See HMAC-time API key. |
Cors:AllowedOrigins | appsettings.Development.json (in dev) | Allowed origins (includes http://localhost:3000 for frontend/, http://localhost:3001 for admin/). |