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
| Path | Purpose |
|---|---|
GET /healthz | Liveness; never reads DB |
GET /readyz | Readiness; checks Postgres + Memorystore + Pub/Sub publisher |
GET /metrics | Prometheus scrape (auth-gated) |
GET /__/admin/outbox?status=pending | Operator view; platform.super_admin only |
13. Error Code Map (subset)
| HTTP | Code | Meaning |
|---|---|---|
| 400 | MELMASTOON.COMMON.VALIDATION_FAILED | Body or query failed schema |
| 401 | MELMASTOON.AUTH.UNAUTHENTICATED | Missing/expired JWT |
| 403 | MELMASTOON.AUTH.RBAC_DENIED | Role insufficient |
| 403 | MELMASTOON.AUTH.TENANT_MISMATCH | X-Tenant-Id ≠ JWT tid |
| 404 | MELMASTOON.COMMON.NOT_FOUND | Resource absent or hidden by RLS |
| 409 | MELMASTOON.TENANT.SLUG_TAKEN | §1.1 |
| 409 | MELMASTOON.TENANT.ILLEGAL_STATE_TRANSITION | Tenant or membership state machine |
| 409 | MELMASTOON.TENANT.LAST_OWNER_REMOVAL | §3.6, §5.6 |
| 409 | MELMASTOON.TENANT.ROLE_ESCALATION | §4.1, §5.5 |
| 409 | MELMASTOON.TENANT.ROLE_IMMUTABLE | §5.3 on system role |
| 409 | MELMASTOON.TENANT.ROLE_IN_USE | §5.4 |
| 409 | MELMASTOON.TENANT.INVITATION_REUSED | §4.3 single-use violation |
| 409 | MELMASTOON.TENANT.INVITATION_EXPIRED | §4.3 TTL elapsed |
| 409 | MELMASTOON.TENANT.ORG_DEPTH_EXCEEDED | §6.2 |
| 412 | MELMASTOON.COMMON.STALE_VERSION | If-Match mismatch |
| 422 | MELMASTOON.TENANT.CONFIG_INVALID | §2.2 |
| 422 | MELMASTOON.COMMON.CROSS_TENANT_REFERENCE | scope refers to another tenant's org unit |
| 423 | MELMASTOON.TENANT.SUSPENDED | tenant suspended; write blocked |
| 429 | MELMASTOON.TENANT.INVITATION_RATE_LIMITED | §4.1 |
| 502 | MELMASTOON.AUTH.PDP_UNAVAILABLE | §9 fail-closed |
The full registry: ERROR_CODES.md.