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, andorg_app_accesstables 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
cloudandon_premhosting 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:
- Creates the local admin user +
ORG_ADMINmembership (AuthAPI's response carries the generatedadmin_userand a one-timetemporary_password). - Creates the org's
Defaultworkspace using its currently-granted app access. - 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" }
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": "..."
}
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" }