Skip to main content

tenant-service — API_CONTRACTS

Cross-cutting conventions live in 05 API Design. This file lists service-specific endpoints, schemas, and error mappings. Defer to the platform doc for envelope, pagination, idempotency, error structure, and headers.

Base path: /api/v1. All endpoints are mounted on the internal tenant-service Cloud Run revision and proxied through the gateway. The backoffice surface uses bff-backoffice-service which composes these endpoints; the BFF does not duplicate logic, only orchestrates.

Common headers (per 05 API Design §3.1):

  • Authorization: Bearer <jwt>
  • X-Tenant-Id: tnt_<ulid> (required on tenant-scoped endpoints; absent only on platform-admin endpoints)
  • X-Request-Id, X-Idempotency-Key (writes), If-Match (PATCH on versioned resources), traceparent

Error response: application/problem+json with code from ERROR_CODES.md, namespaces MELMASTOON.TENANT.*, MELMASTOON.MEMBERSHIP.*, MELMASTOON.COMMON.*.


1. Tenant CRUD (super-admin only)

1.1 POST /api/v1/tenants

Provision a new tenant. Restricted to platform.super_admin. Triggers melmastoon.tenant.created.v1, …organization_unit.created.v1 (chain root), and …membership.created.v1 (owner).

Request:

{
"slug": "asia-hotel",
"legalName": "Asia Hotel Co. Ltd.",
"country": "AF",
"residencyRegion": "asia-south1",
"ownerUserId": "usr_01H8YN7Q2P7GZ4F8Y5CK4MV3DT",
"ownerDisplayName": "Mohammad Daud",
"billingContact": {
"fullName": "Sara Ahmadi",
"email": "sara@asiahotel.af",
"address": { "country": "AF", "city": "Kabul", "line1": "Shar-e-Naw" }
}
}

Response 201 Created with Location: /api/v1/tenants/tnt_… and the tenant resource (see §1.3 shape).

Errors: MELMASTOON.TENANT.SLUG_TAKEN, MELMASTOON.COMMON.VALIDATION_FAILED, MELMASTOON.AUTH.RBAC_DENIED.

1.2 GET /api/v1/tenants/{tenantId}

Returns the tenant resource. Accessible to platform.super_admin, platform.support (audited), and the tenant's own tenant.owner (returns a sanitized projection without internal flags).

Response 200:

{
"data": {
"id": "tnt_01H7…",
"slug": "asia-hotel",
"legalName": "Asia Hotel Co. Ltd.",
"country": "AF",
"residencyRegion": "asia-south1",
"status": "active",
"planRef": "plan_chain_pro_v2",
"createdAt": "2026-04-01T08:00:00Z",
"version": 7
},
"meta": { "etag": "\"v7\"", "...": "..." }
}

1.3 PATCH /api/v1/tenants/{tenantId}

Partial update of mutable tenant fields (legalName, country if no folios), under If-Match. Errors include MELMASTOON.COMMON.STALE_VERSION (412), MELMASTOON.TENANT.IMMUTABLE_FIELD.

1.4 POST /api/v1/tenants/{tenantId}/suspend

Body: { "reason": "policy.payment_overdue" }. Restricted to platform.super_admin. Idempotent on (tenantId, reason) if already suspended. Emits melmastoon.tenant.suspended.v1.

1.5 POST /api/v1/tenants/{tenantId}/reactivate

Symmetric. Body { "note": "manual_reinstatement" }.

1.6 DELETE /api/v1/tenants/{tenantId}

Initiates the close saga (see APPLICATION_LOGIC §3.4). Requires platform.super_admin and a 24-hour two-person approval window (request must include X-Approval-Token issued by a second platform admin). Returns 202 Accepted:

{ "data": { "sagaId": "sag_01H…", "status": "started", "expectedAcksFrom": ["billing","reservation","..."] } }

Errors: MELMASTOON.TENANT.APPROVAL_REQUIRED, MELMASTOON.TENANT.ALREADY_CLOSED.


2. Tenant Configuration

2.1 GET /api/v1/tenants/{tenantId}/config

Returns full TenantConfig. Read-through cache (60 s). Accessible to any active member of the tenant.

{
"data": {
"id": "tcg_01H…",
"tenantId": "tnt_01H…",
"currencies": ["AFN", "USD"],
"locales": [{ "value": "fa-AF", "isRtl": true }, { "value": "en-US", "isRtl": false }],
"timeZone": "Asia/Kabul",
"taxModel": { "inclusive": false, "defaultRateBasisPoints": 1500, "registrationNumber": null },
"defaultCheckIn": "14:00",
"defaultCheckOut": "12:00",
"breakfastIncludedDefault": true,
"smokingPolicy": "designated",
"childPolicy": { "minAge": 0, "cribsAvailable": true },
"cancellationDefault": { "windowHours": 48, "chargeOnLateCancelMicro": "0", "noShowChargeMicro": "1000000" },
"businessHours": null,
"version": 12
},
"meta": { "etag": "\"v12\"" }
}

2.2 PATCH /api/v1/tenants/{tenantId}/config

Partial update. Required role: tenant.owner or tenant.gm. Requires If-Match: "v12".

Errors: MELMASTOON.COMMON.STALE_VERSION, MELMASTOON.TENANT.CONFIG_INVALID (e.g. unknown IANA TZ, empty currencies).


3. Memberships

3.1 GET /api/v1/memberships?tenantId=…&status=active&cursor=…&limit=50

Lists memberships for a tenant. Cursor pagination. Required role: tenant.owner, tenant.gm, chain.operator, or platform.support.

Item shape:

{
"id": "mbr_01H…",
"tenantId": "tnt_01H…",
"userId": "usr_01H…",
"displayName": "Sara Ahmadi",
"status": "active",
"propertyScope": ["org_01H…"],
"roles": [{ "id": "rol_01H…", "code": "tenant.front_desk" }],
"invitedBy": "usr_01H…",
"invitedAt": "2026-04-01T09:00:00Z",
"joinedAt": "2026-04-02T10:13:00Z",
"version": 3
}

3.2 GET /api/v1/memberships/{membershipId}

Single membership.

3.3 PATCH /api/v1/memberships/{membershipId}

Partial update (display name, propertyScope). Requires If-Match. Cannot change userId or tenantId. Changing roles uses §5 (RoleAssignments).

3.4 POST /api/v1/memberships/{membershipId}/suspend

Body: { "reason": "policy.disciplinary" }. Required role: tenant.owner or tenant.gm. Emits melmastoon.tenant.membership.suspended.v1 (which iam-service consumes to revoke active sessions).

3.5 POST /api/v1/memberships/{membershipId}/reinstate

Symmetric.

3.6 DELETE /api/v1/memberships/{membershipId}

Soft remove. Required role: tenant.owner or tenant.gm. Domain rejects last-owner removal with 409 MELMASTOON.TENANT.LAST_OWNER_REMOVAL.

3.7 GET /api/v1/me/tenants and GET /api/v1/me/memberships

User-context endpoints used by bff-backoffice-service after login. Returns tenants the caller is a member of, plus their memberships and resolved property scope. No X-Tenant-Id header required.

3.8 GET /api/v1/me/properties

Backed by the ListPropertiesForUser query handler. Returns the per-property switcher payload:

{ "data": [
{ "tenantId": "tnt_01H…", "organizationUnitId": "org_01H…", "propertyId": "ppt_01H…", "name": "Hotel Asia Kabul" }
] }

4. Invitations

4.1 POST /api/v1/invitations

Required role: tenant.owner or tenant.gm. Idempotency key required. Emits melmastoon.tenant.invitation.sent.v1. Triggers notification-service to send the email.

Request:

{
"tenantId": "tnt_01H…",
"email": "newhire@asiahotel.af",
"rolesProposed": ["rol_01H…", "rol_01H…"],
"propertyScope": ["org_01H…"],
"locale": "fa-AF"
}

Response 201:

{ "data": { "id": "inv_01H…", "status": "pending", "expiresAt": "2026-05-06T08:00:00Z" } }

Errors: MELMASTOON.TENANT.ROLE_ESCALATION, MELMASTOON.TENANT.INVITATION_RATE_LIMITED, MELMASTOON.COMMON.VALIDATION_FAILED.

4.2 GET /api/v1/invitations?tenantId=…&status=pending

Lists invitations. Same RBAC as POST.

4.3 POST /api/v1/invitations/{invitationId}/accept

Public (anonymous-tolerant): the caller has not necessarily authenticated as a member yet. The body carries the raw token; the route is rate-limited per IP and per invitationId.

{ "rawToken": "9c3e…", "actorUserId": "usr_01H…" }

Response 200 returns the new membershipId. Errors: MELMASTOON.TENANT.INVITATION_REUSED, MELMASTOON.TENANT.INVITATION_EXPIRED, MELMASTOON.TENANT.INVITATION_TOKEN_INVALID.

4.4 POST /api/v1/invitations/{invitationId}/revoke

Required role: tenant.owner or tenant.gm. Idempotent.

4.5 POST /api/v1/invitations/{invitationId}/resend

Re-renders + re-delivers the same invite (does not extend TTL). Idempotency key required. Emits melmastoon.tenant.invitation.sent.v1 again with attempt: 2.


5. Roles & Role Assignments

5.1 GET /api/v1/roles?tenantId=…

Lists role catalog (system + custom). Item shape:

{ "id": "rol_01H…", "tenantId": "tnt_01H…", "code": "tenant.front_desk", "displayName": "Front Desk", "system": true, "permissions": ["reservation:create", "reservation:check_in", "folio:read"] }

5.2 POST /api/v1/roles

Create custom role. Required role: tenant.owner. System codes (tenant.*, chain.*) are reserved.

{ "tenantId": "tnt_01H…", "code": "custom.night_auditor", "displayName": "Night Auditor", "permissions": ["folio:read", "report:run"] }

Errors: MELMASTOON.TENANT.ROLE_CODE_RESERVED, MELMASTOON.TENANT.UNKNOWN_PERMISSION.

5.3 PATCH /api/v1/roles/{roleId}

Mutate non-system roles (display name, permissions). System roles return 409 MELMASTOON.TENANT.ROLE_IMMUTABLE.

5.4 DELETE /api/v1/roles/{roleId}

Delete custom role. Rejected with 409 MELMASTOON.TENANT.ROLE_IN_USE if any active assignments reference it.

5.5 POST /api/v1/memberships/{membershipId}/role-assignments

Assign a role to a membership. Server enforces RoleEscalationGuard — actor must possess every permission in the role being granted. Request:

{ "roleId": "rol_01H…", "propertyScope": ["org_01H…"] }

Response 201 with the RoleAssignment shape.

5.6 DELETE /api/v1/role-assignments/{assignmentId}

Remove an assignment. Domain rejects with MELMASTOON.TENANT.LAST_OWNER_REMOVAL if removing the last tenant.owner assignment in the tenant.


6. Organization Units

6.1 GET /api/v1/organization-units?tenantId=…

Returns the full org tree. Cached (300 s). Each item:

{ "id": "org_01H…", "tenantId": "tnt_01H…", "kind": "property", "parentId": "org_01H…", "path": "chain_root.kabul.asia_hotel", "name": "Hotel Asia Kabul", "propertyId": "ppt_01H…", "archived": false, "version": 4 }

6.2 POST /api/v1/organization-units

Create unit. kind validated against parent (chain → region|property, region → property). propertyId required if kind = property; that ID is allocated by property-service first and passed in.

Body:

{ "tenantId": "tnt_01H…", "kind": "region", "parentId": "org_01H…", "name": "Kabul" }

Errors: MELMASTOON.TENANT.ORG_INVALID_PARENT, MELMASTOON.TENANT.ORG_DEPTH_EXCEEDED.

6.3 PATCH /api/v1/organization-units/{id}

Rename or archive (soft). Move uses §6.4.

6.4 POST /api/v1/organization-units/{id}/move

Initiates the chain-restructure saga. Body: { "newParentId": "org_01H…", "approvalToken": "atk_…" }. Returns 202 Accepted with sagaId.

6.5 DELETE /api/v1/organization-units/{id}

Archive (no hard delete). Rejected with MELMASTOON.TENANT.ORG_ARCHIVE_BLOCKED if active children or open reservations (cross-aggregate check).


7. Feature Flags

7.1 GET /api/v1/tenants/{tenantId}/feature-flags

Returns the resolved flag set: platform default + per-tenant override. Cached.

{ "data": [
{ "key": "aiEnabled", "enabled": true, "rolloutBasisPoints": 10000, "source": "tenant" },
{ "key": "lockIntegrationEnabled", "enabled": false, "source": "platform" }
] }

7.2 PUT /api/v1/tenants/{tenantId}/feature-flags/{key}

Sets an override. Required role: tenant.owner. Body: { "enabled": true, "rolloutBasisPoints": 5000 }. Emits melmastoon.tenant.feature_flag.toggled.v1.

7.3 DELETE /api/v1/tenants/{tenantId}/feature-flags/{key}

Clears the override (revert to platform default).


8. Billing Contact

8.1 GET /api/v1/tenants/{tenantId}/billing-contact

Required role: tenant.owner or tenant.finance. Returns the contact resource (no PCI data lives here).

8.2 PUT /api/v1/tenants/{tenantId}/billing-contact

Replace the billing contact. Emits melmastoon.tenant.billing_contact_updated.v1 consumed by billing-service.


9. Authorization Endpoint (PDP)

9.1 POST /api/v1/authz/check

Used by gateway and other services to resolve an ABAC decision when local cache misses. Request:

{
"principal": { "userId": "usr_01H…", "tenantId": "tnt_01H…" },
"action": "reservation:check_in",
"resource": { "type": "reservation", "id": "rsv_01H…", "tenantId": "tnt_01H…", "propertyId": "ppt_01H…" },
"context": { "stepUpRecent": true }
}

Response (cacheable for 10 s, key includes membership version):

{
"data": { "allowed": true, "obligations": [], "decisionId": "dec_01H…", "matchedRoleId": "rol_01H…" },
"meta": { "...": "..." }
}

Failure modes are fail-closed: a 5xx or timeout from this endpoint is treated by callers as denied with the error surfaced as MELMASTOON.AUTH.PDP_UNAVAILABLE.


10. Guest Erasure (DSAR fan-out)

10.1 POST /api/v1/tenants/{tenantId}/guest-erasure-requests

Required role: tenant.owner or platform.compliance_officer. Body:

{ "guestId": "gst_01H…", "reason": "gdpr_request", "requestId": "req_01H…" }

Response 202 with requestId. Emits melmastoon.tenant.guest.erasure_requested.v1 consumed by every service that holds guest data. tenant-service itself stores no guest content — this is a coordination endpoint.


11. Sync Surface

The desktop calls /sync/v1/pull?scope=tenant and /sync/v1/push?scope=tenant. The full protocol is in SYNC_CONTRACT. Push-side mutations are limited to the non-RBAC aggregates (TenantConfig partial, FeatureFlagOverride); role and membership writes return 409 MELMASTOON.SYNC.ONLINE_REQUIRED.


12. Health & Admin

PathPurpose
GET /healthzLiveness; never reads DB
GET /readyzReadiness; checks Postgres + Memorystore + Pub/Sub publisher
GET /metricsPrometheus scrape (auth-gated)
GET /__/admin/outbox?status=pendingOperator view; platform.super_admin only

13. Error Code Map (subset)

HTTPCodeMeaning
400MELMASTOON.COMMON.VALIDATION_FAILEDBody or query failed schema
401MELMASTOON.AUTH.UNAUTHENTICATEDMissing/expired JWT
403MELMASTOON.AUTH.RBAC_DENIEDRole insufficient
403MELMASTOON.AUTH.TENANT_MISMATCHX-Tenant-Id ≠ JWT tid
404MELMASTOON.COMMON.NOT_FOUNDResource absent or hidden by RLS
409MELMASTOON.TENANT.SLUG_TAKEN§1.1
409MELMASTOON.TENANT.ILLEGAL_STATE_TRANSITIONTenant or membership state machine
409MELMASTOON.TENANT.LAST_OWNER_REMOVAL§3.6, §5.6
409MELMASTOON.TENANT.ROLE_ESCALATION§4.1, §5.5
409MELMASTOON.TENANT.ROLE_IMMUTABLE§5.3 on system role
409MELMASTOON.TENANT.ROLE_IN_USE§5.4
409MELMASTOON.TENANT.INVITATION_REUSED§4.3 single-use violation
409MELMASTOON.TENANT.INVITATION_EXPIRED§4.3 TTL elapsed
409MELMASTOON.TENANT.ORG_DEPTH_EXCEEDED§6.2
412MELMASTOON.COMMON.STALE_VERSIONIf-Match mismatch
422MELMASTOON.TENANT.CONFIG_INVALID§2.2
422MELMASTOON.COMMON.CROSS_TENANT_REFERENCEscope refers to another tenant's org unit
423MELMASTOON.TENANT.SUSPENDEDtenant suspended; write blocked
429MELMASTOON.TENANT.INVITATION_RATE_LIMITED§4.1
502MELMASTOON.AUTH.PDP_UNAVAILABLE§9 fail-closed

The full registry: ERROR_CODES.md.