Skip to content

Organizations

An organization is the top-level tenant boundary in PlayTelly. Every user, workspace, and app grant belongs to exactly one organization. Organizations are split across two systems:

  • AuthAPI (auth slice) — owns the Zitadel org, the user's login identity, and invite/password-reset magic links.
  • CoreAPI (business slice) — owns the organizations, organization_members, and org_app_access tables that this domain manages.

A special platform organization (00000000-0000-0000-0000-000000000001) represents PlayTelly staff rather than a customer tenant, and is where platform-scope roles (PLATFORM_OWNER, PLATFORM_ADMIN, etc.) are assigned. Its app access is hardcoded to "all apps" and cannot be edited (PUT .../app-access returns 403 for this org).

Handles:

  • Org creation — platform admin, creates the org plus its first admin user in one call; supports cloud and on_prem hosting with a company code
  • Org self-signup — public application flow with email verification, deferred provisioning until a platform admin approves
  • Org listing & status — platform admin lists all orgs, approves/disables a pending or active org
  • Org member invitations — invite, reinvite, list
  • Org member role & account-status management — including custom, org-defined roles
  • Org app access — which apps (products) the org's users can use
  • Role catalog — list system + custom roles, view the permission matrix, create/update/delete custom roles, list a role's members
  • Permissions catalog — the full canonical list of platform/org/workspace permission strings
  • Internal sync endpoints — keep CoreAPI's copy of orgs/users in step with AuthAPI
  • Org self-service settings (billing, tier) — not yet exposed via API

See also Teams — org-scoped user groups that hold a role on a workspace as a unit, layered on top of the role system described here.


Architecture

graph LR
    Applicant["Self-signup applicant (public)"]
    AdminPortal["Platform / Org Admin Portal"]
    AuthAPI["AuthAPI (Zitadel + auth slice,\ncompany code + licensing)"]
    CoreAPI["CoreAPI (Tenancy — orgs)"]
    CoreDB["CoreAPI DB\n(organizations, organization_members,\norg_app_access, email_verification_codes)"]
    Redis["Redis\n(token blocklist)"]
    Zoho["Zoho Mail\n(verification + welcome emails)"]

    Applicant -->|verify-email, self-signup| CoreAPI
    AdminPortal -->|POST /tenancy/organizations| CoreAPI
    AdminPortal -->|invitations, roles, app-access, status| CoreAPI
    CoreAPI -->|create org + admin/invited user,\nhosting_type + company_code| AuthAPI
    CoreAPI --> CoreDB
    CoreAPI -->|blocklist on role/status change| Redis
    CoreAPI -->|verification code, welcome email| Zoho
    AuthAPI -->|internal sync: unknown org/user| CoreAPI

CoreAPI never talks to Zitadel directly — every identity operation (creating the org, creating or inviting a user, issuing invite/password-reset magic links) is delegated to AuthAPI over an internal, PSK-signed HTTP call. CoreAPI then mirrors the result into its own tables so that org/workspace/app-access queries stay local. company_code, hosting_type, and license keys are owned entirely by AuthAPI (see AuthAPI docs) — CoreAPI just proxies them through in its own responses and doesn't persist them.

The reverse sync exists too: when AuthAPI encounters an org or user it doesn't yet have a CoreAPI row for (e.g. during self-signup), it calls back into the /internal endpoints below to upsert it.


Data Model

erDiagram
    Organization {
        string id
        string name
        string legalName
        string website
        string timezone
        string status
        string tierId
    }

    OrganizationMember {
        string organizationId
        string userId
        string roleId
        string role
    }

    User {
        string id
        string organizationId
        string email
        string firstName
        string lastName
        string username
    }

    Role {
        string id
        string organizationId "null for system roles"
        string scope
        string name
        string description
        bool isSystem
    }

    RolePermission {
        string roleId
        string permission
    }

    OrgAppAccess {
        string organizationId
        string appId
    }

    EmailVerificationCode {
        string id
        string email
        string codeHash
        timestamp expiresAt
        timestamp verifiedAt
    }

    Organization ||--o{ OrganizationMember : has
    User ||--o{ OrganizationMember : "is a"
    OrganizationMember }o--|| Role : "role_id references"
    Role ||--o{ RolePermission : grants
    Organization ||--o{ OrgAppAccess : "app access"
    Organization ||--o{ Role : "owns (custom roles only)"

roles is a single table shared across scopes (platform, organization, workspace). System roles (is_system = true) have organization_id = null and are visible/assignable everywhere. Custom roles are always created with organization_id set to the calling org and are unique per (organization_id, scope, name) — a role name can be reused across different orgs. PATCH .../users/:userId/role accepts either a system organization-scope role (ORG_OWNER, ORG_ADMIN, ORG_MANAGER, ORG_OPERATOR, ORG_MEMBER) or a custom organization-scope role owned by that org. email_verification_codes is keyed by email (not user or org — self-signup applicants don't have accounts yet) and is used once by the self-signup flow below.


Endpoints

Platform admin

GET /api/v1/tenancy/organizations — requires platform:apps:platformconsole:access

Lists every organization on the platform, newest first. Used by the platform console's tenant directory.

Response 200

{
  "orgs": [
    { "id": "org_...", "name": "Acme Cinemas", "legal_name": "Acme Cinemas Sdn Bhd", "website": "https://acme.example.com", "timezone": "Asia/Kuala_Lumpur", "status": "active", "tier_id": "free", "created_at": "2026-08-01T00:00:00Z" }
  ]
}


POST /api/v1/tenancy/organizations — requires platform:org:create

Creates a new organization end-to-end: the Zitadel org (and, for cloud orgs, its first admin user) via AuthAPI, then the local organizations/users/organization_members rows, then grants the requested app access and creates a Default workspace (see Workspaces). The admin user is assigned ORG_ADMIN.

Request

{
  "name": "Acme Cinemas",
  "legal_name": "Acme Cinemas Sdn Bhd",
  "website": "https://acme.example.com",
  "timezone": "Asia/Kuala_Lumpur",
  "app_ids": ["ticketing", "signage"],
  "admin": {
    "username": "acme_admin",
    "given_name": "Ada",
    "family_name": "Lovelace",
    "email": "ada@acme.example.com",
    "password": "<initial-password>"
  },
  "hosting_type": "cloud",
  "company_code": "acme",
  "license_duration_days": 365
}

name and company_code are always required. hosting_type defaults to cloud. For cloud orgs, app_ids (at least one) and all admin.* fields except given_name/family_name are required, and unknown app IDs are rejected with 400. For on_prem orgs, app_ids and admin are ignored entirely — an on-prem deployment runs its own platform, so no local admin user or app-access grant is created (license_duration_days controls the minted license's validity instead; see AuthAPI's Company Code & Licensing).

Response 201

{
  "org": { "ID": "org_...", "Name": "Acme Cinemas", "Timezone": "Asia/Kuala_Lumpur", "Status": "active", "TierID": "free" },
  "app_ids": ["ticketing", "signage"],
  "company_code": "acme",
  "admin_user": { "ID": "usr_...", "Email": "ada@acme.example.com", "Username": "acme_admin" },
  "license_key": "<only present for on_prem>"
}

admin_user is omitted for on_prem orgs; license_key is omitted for cloud orgs.


PUT /api/v1/tenancy/organizations/:orgId/status — requires platform:org:manage

Approves or disables an organization.

Request

{ "status": "active" }

status must be active or disabled (400 otherwise). 404 if AuthAPI reports the org doesn't exist. If this call transitions a pending self-signup org to active, it does more than flip a flag:

  1. Creates the local admin user + ORG_ADMIN membership (AuthAPI's response carries the generated admin_user and a one-time temporary_password).
  2. Creates the org's Default workspace using its currently-granted app access.
  3. Emails the new admin their login details (username, company code, temporary password) via the org's welcome email template — best-effort; failure doesn't fail the request.

For any other transition (e.g. active → disabled), it's a plain status flip with no side effects.

Response 200

{ "org": { "id": "org_...", "name": "Acme Cinemas", "status": "active", "...": "..." } }


Self-signup — public, no authentication required

A prospective customer applies to create their own org. No Zitadel org or CoreAPI admin user is created until a platform admin approves via PUT .../status above — self-signup only stages the request.

POST /api/v1/tenancy/organizations/self-signup/verify-email

Sends a 6-digit verification code to the given email (10-minute expiry), via the org's welcome-email provider (Zoho Mail).

Request

{ "email": "ada@acme.example.com" }

Response — 204 No Content. 500 if the email fails to send.


POST /api/v1/tenancy/organizations/self-signup/verify-email/confirm

Request

{ "email": "ada@acme.example.com", "code": "482913" }

Response — 204 No Content. 400 if the code is invalid or expired ("verification code is invalid" / "verification code has expired").


POST /api/v1/tenancy/organizations/self-signup

Stages a new org application. Requires the admin's email to have been confirmed via the two endpoints above within the last 30 minutes (400 "email is not verified" otherwise — the verification record is single-use and is consumed on success).

Request

{
  "name": "Acme Cinemas",
  "legal_name": "Acme Cinemas Sdn Bhd",
  "website": "https://acme.example.com",
  "timezone": "Asia/Kuala_Lumpur",
  "app_ids": ["ticketing", "signage"],
  "admin": {
    "username": "acme_admin",
    "given_name": "Ada",
    "family_name": "Lovelace",
    "email": "ada@acme.example.com"
  },
  "company_code": "acme"
}

name, company_code, and admin.{username, email, given_name, family_name} are required — note there's no password; one is generated and emailed on approval. If app_ids is omitted, the org is granted every app in the catalog. App access is granted immediately even though the org starts pending.

Response 201

{ "org": { "id": "org_...", "status": "pending", "...": "..." }, "app_ids": ["ticketing", "signage"], "company_code": "acme" }


Org-scoped — requires a valid session; some further require an org permission

GET /api/v1/tenancy/organizations/:orgId/users

Paginated list of the org's users (page, limit query params, default 1/20, max limit 100). Sourced from AuthAPI (identity, account state) and enriched with the CoreAPI role and platform role where available.

Response 200

{
  "items": [
    { "userId": "usr_...", "name": "Ada Lovelace", "email": "ada@acme.example.com", "role": "ORG_ADMIN", "account_state": "active", "platform_role": null }
  ],
  "total": 1,
  "page": 1,
  "limit": 20,
  "hasNext": false
}


POST /api/v1/tenancy/organizations/:orgId/invitations — requires org:users:invite

Rewritten 2026-08-13 — this used to return a typed invite code. It now sends a magic-link sign-in email itself; no code is ever shown to the org admin. AuthAPI creates the user in invited state with a random, never-exposed internal password (the account only ever activates via the link), then hands CoreAPI a one-time magic-link token; CoreAPI builds the actual sign-in link from that token and sends it through the org's user_invite EmailProfile. If the org has no EmailProfile mapped to usage type user_invite (or fallback), this fails fast with 400 before AuthAPI is even called: "No email profile is configured for user invites. Set one up under Integrations before inviting users."

Request — no password field:

{ "username": "bob", "given_name": "Bob", "family_name": "Ng", "email": "bob@acme.example.com" }

Response 201 — does not include the magic-link token itself, only whether the email actually sent:

{ "user": { "ID": "usr_...", "Email": "bob@acme.example.com" }, "email_sent": true, "email_error": "" }


POST /api/v1/tenancy/organizations/:orgId/users/:userId/reinvite — requires org:users:invite

Issues a fresh magic link for a user still in invited state — the previous link is invalidated the moment the new one is issued. 404 if the user isn't in this org, 409 if they're no longer invited (already activated).

Response 200 — same shape as invite's response:

{ "email_sent": true, "email_error": "" }


PATCH /api/v1/tenancy/organizations/:orgId/users/:userId/role — requires org:users:manage

Changes a member's org role to either a system organization role (ORG_OWNER, ORG_ADMIN, ORG_MANAGER, ORG_OPERATOR, ORG_MEMBER) or a custom organization-scope role owned by this org (400 "invalid role: role must be a system organization role or a custom role defined for this org" otherwise). Callers cannot change their own role (403). On success, both role and role_id are updated and the target user's tokens are blocklisted so their JWT picks up the new role on next use.

Request

{ "role": "ORG_MANAGER" }

Response 200

{ "success": true, "user_id": "usr_...", "role": "ORG_MANAGER" }


PATCH /api/v1/tenancy/organizations/:orgId/users/:userId/status — requires org:users:manage

Sets a member's account status to active or disabled via AuthAPI. Disabling blocklists the user's tokens immediately.

Request

{ "status": "disabled" }


GET /api/v1/tenancy/organizations/:orgId/app-access

Returns the app IDs the org has been granted. Accessible to any member of the org, or to a caller with platform:org:manage.

Response 200

{ "org_id": "org_...", "app_ids": ["ticketing", "signage"] }


PUT /api/v1/tenancy/organizations/:orgId/app-access — requires platform:org:manage

Replaces the org's full app access list. At least one app is required; unknown app IDs return 400. Returns 403 for the platform organization, whose access is fixed to every app.

Request

{ "app_ids": ["ticketing", "signage", "menu-board"] }


GET /api/v1/tenancy/organizations/:orgId/roles

Returns every role usable in this org: system organization- and workspace-scope roles, plus this org's own custom roles. Platform-scope roles are never returned here. Intended for populating role-assignment dropdowns.

Response 200

[ { "id": "ORG_ADMIN", "scope": "organization", "name": "Org Admin" } ]


GET /api/v1/tenancy/organizations/:orgId/roles-matrix

Same role set as above (platform scope excluded), expanded with description, system flag, member count, and a permissions map of service -> highest level (admin > write > read, derived from each role's service:level permission strings). For workspace-scope roles, user_count only counts members of workspaces belonging to this org.

Response 200

[
  {
    "id": "ORG_ADMIN", "scope": "organization", "name": "Org Admin", "description": "Full organization control",
    "is_system": true, "user_count": 3,
    "permissions": { "user": "manage", "workspace": "manage", "media": "admin" }
  }
]


POST /api/v1/tenancy/organizations/:orgId/roles — requires org:roles:manage

Defines a new custom role and its permission set, always scoped to the calling org.

Request

{ "name": "Content Reviewer", "scope": "organization", "description": "Reviews media before publish", "permissions": ["media:read", "playlist:write"] }

scope must be organization or workspace (platform-scope custom roles aren't allowed). name must be unique within (this org, scope) — 409 on collision.

Response 201

{ "id": "b3f1..." }


PATCH /api/v1/tenancy/organizations/:orgId/roles/:roleId — requires org:roles:manage

Updates a custom role — same body shape as create (name, scope, description, permissions), full replace of the permission set. 403 if :roleId is a system role, 404 if it isn't a custom role owned by this org, 409 on a name collision within (org, scope).

Response 200

{ "id": "b3f1..." }


DELETE /api/v1/tenancy/organizations/:orgId/roles/:roleId — requires org:roles:manage

Deletes a custom role. 403 for system roles, 404 if not owned by this org, 409 if still assigned to any org or workspace member ("role is assigned to N user(s) and cannot be deleted" — remove the assignments first).

Response — 204 No Content.


GET /api/v1/tenancy/organizations/:orgId/roles/:roleId/members

Lists everyone currently holding a role — org members for an organization-scope role, or members of any of this org's workspaces for a workspace-scope role. 404 if :roleId is a custom role not owned by this org.

Response 200

[ { "id": "usr_...", "name": "Ada Lovelace", "email": "ada@acme.example.com" } ]


Email Profiles

Per-org email sending config — 5 real providers (Google, AWS SES, Postmark, Zoho, SMTP), AES-256-GCM-encrypted credentials, full CRUD, and a real test-send. See CLAUDE.md §5 for the full narrative (usage-type resolution, the legacy-stack fallback, known bugs) — this section is just the endpoint shapes, verified against domain/tenancy/handlers/email_profile_handler.go.

GET /api/v1/tenancy/organizations/:orgId/email-profiles — requires org:email-profiles:view

POST /api/v1/tenancy/organizations/:orgId/email-profiles — requires org:email-profiles:manage

Request (name/provider/fromAddress/credentials required; credentials' shape depends on provider). ⚠️ name must be letters and numbers only — no spaces, dashes, or punctuation (confirmed live: {"error":"email profile name must contain only letters and numbers"}, not documented anywhere before this):

{
  "name": "DocsTestMailtrap",
  "provider": "smtp",
  "fromAddress": "test@example.com",
  "fromName": "Docs Test",
  "credentials": {
    "host": "sandbox.smtp.mailtrap.io", "port": 2525,
    "username": "...", "password": "...", "useSsl": false
  }
}

GET /api/v1/tenancy/organizations/:orgId/email-profiles/:profileId

PATCH /api/v1/tenancy/organizations/:orgId/email-profiles/:profileId — requires org:email-profiles:manage

DELETE /api/v1/tenancy/organizations/:orgId/email-profiles/:profileId — requires org:email-profiles:manage

POST /api/v1/tenancy/organizations/:orgId/email-profiles/:profileId/test-send — requires org:email-profiles:manage

{ "to": "you@example.com" }
A 200 here means the provider accepted the send, not that it was delivered — check the actual inbox (or sandbox, e.g. Mailtrap) to confirm. A provider rejection (bad credentials, etc.) is a 400, not a 502 — verified directly against mapEmailProfileError's actual default case.

POST /api/v1/tenancy/organizations/:orgId/email-profiles/validate — requires org:email-profiles:manage

New — sends one real test email from caller-supplied credentials without creating, encrypting, or persisting a profile anywhere. Same provider construction code as CreateProfile/TestSend (providers.New(...).Send(...)), just skipping the database entirely. Confirmed live via a standalone script calling the exact same code path directly (no HTTP, no DB, no AuthAPI) — real send through a real Mailtrap sandbox, SUCCESS with zero rows ever written.

{
  "provider": "smtp",
  "fromAddress": "docs-test@example.com",
  "fromName": "Docs Test",
  "to": "you@example.com",
  "credentials": {
    "host": "sandbox.smtp.mailtrap.io", "port": 2525,
    "username": "...", "password": "...", "useSsl": false
  }
}

Use this instead of CreateProfile + test-send + DeleteProfile when you just want to know "do these credentials actually work" — same guarantee, no cleanup step needed.

GET /api/v1/tenancy/organizations/:orgId/email-usage-mappings

PUT /api/v1/tenancy/organizations/:orgId/email-usage-mappings — requires org:email-profiles:manage

DELETE /api/v1/tenancy/organizations/:orgId/email-usage-mappings/:usageType — requires org:email-profiles:manage

Only user_invite and password_reset are actually resolved by any code path today — order_confirmation/ticket_delivery/fallback are assignable but dead (see CLAUDE.md §5).


Payment / POS Profiles

New 2026-09-18, not previously documented anywhere under this domain even though the handlers/services/repositories live under domain/tenancy — the existing narrative for these lives only in the catalogue domain's docs (since that's where the consuming side, ticket_group_integration_config, is). Same org-owned, workspace-narrowable, AES-256-GCM-encrypted pattern as the pre-existing EmailProfile system (see CLAUDE.md §5 for the full narrative — its own dedicated page doesn't exist yet either), but deliberately minimal today — Create and List only, no GET/:id, PATCH, or DELETE on a profile yet (per the handlers' own doc comments: "enough to populate and verify real data end-to-end; update/delete follow the same pattern as EmailProfileHandler when this UI actually gets built out").

GET /api/v1/tenancy/organizations/:orgId/payment-profiles — requires org:payment-profiles:view

POST /api/v1/tenancy/organizations/:orgId/payment-profiles — requires org:payment-profiles:manage

Request (name/provider/gatewayUrl required):

{
  "name": "JohorPay Production", "workspaceId": null, "provider": "johorpay",
  "gatewayUrl": "https://gateway.johorpay.example",
  "paymentEndpoint": "/payment/process", "redflowEndpoint": "/payment/redflow",
  "bankListEndpoint": "/payment/banks", "apiKey": "...", "agToken": "..."
}

Response 201 — credentials never come back, not even encrypted:

{ "id": "<uuid>", "organizationId": "<uuid>", "workspaceId": null, "name": "JohorPay Production", "slug": "johorpay_production", "provider": "johorpay", "gatewayUrl": "https://gateway.johorpay.example", "isActive": true, "createdAt": "...", "updatedAt": "..." }

slug is derived server-side from name (lowercased, spaces → underscores) — it's not a request field, and (organizationId, slug) is unique per org. workspaceId is optional: null means org-wide (usable by every workspace under this org), or narrow it to exactly one workspace. A deactivated profile (isActive: false, no API to set this yet) is rejected at credential-resolve time — unlike EmailProfile, which still has the opposite bug (never checked at send time, see CLAUDE.md §5).

GET /api/v1/tenancy/organizations/:orgId/pos-profiles — requires org:pos-profiles:view

POST /api/v1/tenancy/organizations/:orgId/pos-profiles — requires org:pos-profiles:manage

Same shape, POS-flavored fields:

{
  "name": "Zoo KSM Production", "workspaceId": null, "provider": "zoo_ksm",
  "baseUrl": "https://ksm.example", "qrEndpoint": "/qr",
  "tokenEndpoint": "/oauth/token", "ticketEndpoint": "/tickets",
  "apiUsername": "...", "apiPassword": "..."
}
Response includes apiUsername (not secret) but never apiPassword.

All four permissions above are granted only to ORG_OWNER/ORG_ADMIN — worth noting they were unusable for a full week after the endpoints first shipped, since the permission constants existed in code before any role was actually seeded with them.


Testing this — try it yourself

Moved to its own page: Testing Email Integrations — both the Swagger "Try it out" walkthrough and a plain input form (no JSON editing), against a real Mailtrap sandbox, no local setup needed.


GET /api/v1/tenancy/permissions/catalog

The full canonical list of permission strings by scope — meant to replace any hand-maintained permission list in client code. Any authenticated caller can read it.

Response 200

{
  "platform": ["platform:org:manage", "platform:org:create", "platform:org:delete", "platform:apps:platformconsole:access"],
  "organization": ["org:users:view", "org:users:invite", "org:users:manage", "org:teams:manage", "org:roles:manage", "..."],
  "workspace": ["workspace:media:view", "workspace:playlists:create", "..."]
}


GET /api/v1/tenancy/apps

Lists the full app catalog (id + name) available on the platform, regardless of org.


Internal — PSK-protected, called by AuthAPI

Mounted at /api/v1/internal, gated by middleware.RequireInternalToken() (a pre-shared key in the X-Internal-Token header), never called by end-user clients.

POST /api/v1/internal/organizations

AuthAPI calls this when it discovers an org with no matching CoreAPI row (ON CONFLICT DO NOTHING, safe to retry).

{ "org_id": "org_...", "name": "Acme Cinemas" }

POST /api/v1/internal/users

Called during AuthAPI self-signup to create the local users row and an ORG_MEMBER organization_members row in the same transaction.

{ "user_id": "usr_...", "org_id": "org_...", "email": "...", "first_name": "...", "last_name": "...", "username": "..." }

GET /api/v1/internal/users/:userId/roles

Called during login so AuthAPI can embed the user's org role in the issued JWT's single org claim (org.role — each user belongs to exactly one org; see AuthAPI's Token Design). Returns an empty role gracefully if the user has no CoreAPI row yet.

Single-org lookup

This looks up the caller's organization_members row with LIMIT 1 and no org_id filter — it has no way to disambiguate a user who somehow belongs to more than one org row. Fine today since membership is 1:1, but would need an org_id parameter if that ever changes.

PUT /api/v1/internal/users/:userId/roles

Sets a user's org role directly (bypasses the self-role-change and system-role checks that the org-scoped PATCH .../role endpoint enforces — internal callers are trusted).

{ "org_id": "org_...", "role": "ORG_ADMIN" }