Skip to main content

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 the X-Api-Key header with a time-based HMAC key derived from the secret in ApiKeys:TenantCreate (see HMAC-time API key below).

Request (CreateTenantRequest)

FieldTypeRequiredNote
codestringyesMax 255, unique
namestringyesMax 255
adminEmailstringyesEmail, max 255, unique
licenseKeystring?noMax 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 Conflictcode or adminEmail already in use.
  • 503 Service UnavailableApiKeys:TenantCreate not configured.

POST /api/cloud/tenant/update

Updates the modifiable fields of a tenant (code is immutable).

Request (UpdateTenantRequest)

FieldTypeRequired
tenantIdGuidyes
namestring?no
adminEmailstring?no
licenseKeystring?no

Response 200 (UpdateTenantResponse): tenantId, code, name, adminEmail, licenseKey, updatedAt.

Errors

  • 404 Not Found — tenant not found.
  • 409 Conflict — tenant deleted, or adminEmail already 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 from Suspended state).

Purge is only available from Suspended state to prevent accidental deletion of active tenants. The correct flow is: suspendpurge.


POST /api/cloud/tenant/list

Lists tenants with optional filters and pagination.

Request (ListTenantsRequest)

FieldTypeDefault
includeDeletedboolean?false
statusCodeint16?
searchstring?
pageint?1
pageSizeint?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_codeMeaning
1Active
2Suspended

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:

EndpointRequired stateResulting state
suspendActive, not deletedSuspended
resumeSuspended, not deletedActive
purgeSuspended, not deleted(removed from DB)
deleteAny, not deleteddeleted=true
undeleteDeletedActive

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()
  1. The server computes the key for the current 60-second window and the previous window (±60 s clock skew tolerance).
  2. The caller must provide the key for the current window in the X-Api-Key header.
  3. A key captured at minute N is useless at minute N+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

KeyLocationUsage
Jwt:Key / Issuer / Audienceappsettings*.jsonBearer token signing and validation.
ApiKeys:TenantCreateappsettings.jsonHMAC secret used to derive the rotating X-Api-Key. See HMAC-time API key.
Cors:AllowedOriginsappsettings.Development.json (in dev)Allowed origins (includes http://localhost:3000 for frontend/, http://localhost:3001 for admin/).