Organizations API
The complete reference for organization-related Core API endpoints. Source of truth: services/api/internal/core/server/routes.go, services/api/internal/core/domain/organization/handler.go, and services/api/internal/core/domain/organization/model.go.
Overview
Organizations are the root of multi-tenancy. Every authenticated request operates within an organization context resolved (in this order) from:
- The
X-Organization-IDheader (set by the frontend proxy from the resolved hostname), - the calling principal's
humans.current_organization_idcolumn (when the caller is a human), - the principal's first membership in
organization_memberships, uuid.Nil(no org scope — RLS naturally returns zero rows).
Base Conventions
- Base paths:
/v1/organizations(authenticated),/v1/public/organizations(public). - Authentication: Clerk session token in
Authorization: Bearer <token>header. - IDs: All resource IDs are UUIDv7 strings (canonical 36-char hyphenated form). There are no integer IDs anywhere on the wire.
- Permissions: Most mutating routes are gated by
RequirePermission(<code>). The codes are seeded inservices/api/migrations/core/000002_tenancy_rbac.up.sql:421-425and constants live inservices/api/internal/core/principal/permissions.go. - Errors:
{ "error": { "code": "...", "message": "..." } }. Validation errors add afieldsmap. - Audit: Every mutating endpoint writes a row to
audit_logfrom the success path; the request returns500 internal_errorif the audit write fails.
Permission Catalog
The shipped catalog (everything else is planned, see Planned):
| Code | Granted to | Used by |
|---|---|---|
organizations.update | admin | PATCH /v1/organizations/{id} |
organizations.manage_members | admin | GET/POST/DELETE /v1/organizations/{id}/members* |
organizations.manage_domains | admin | GET/POST/DELETE /v1/organizations/{id}/domains* |
Superadmin access is platform-level (platform_roles row), not a permission — it bypasses RLS via the admin pool.
Endpoints
GET /v1/public/organizations/resolve
Public endpoint used by the frontend proxy to translate a hostname into an organization. No authentication.
Query parameters (exactly one required):
slug=...— the org's slug, used for{slug}.clinic.restartix.prostyle URLs.domain=...— a custom hostname previously verified via the domains API.
Response: 200
{
"data": {
"id": "0190af3b-1c2e-7c00-8a4f-b2d9c4e5f100",
"name": "RestartiX",
"slug": "restartix",
"logo_url": "https://s3.../logo.png",
"icon_url": "https://s3.../icon.png",
"language_code": "en"
}
}Errors: 400 validation_error if neither slug nor domain is provided; 404 organization_not_found if no match.
GET /v1/organizations
List organizations the current user belongs to. Superadmins see all organizations.
Authentication: Bearer token. Permission: none — RLS scopes results to memberships.
Response: 200
{
"data": [
{
"id": "0190af3b-1c2e-7c00-8a4f-b2d9c4e5f100",
"name": "RestartiX",
"slug": "restartix",
"tagline": "Telemedicine platform",
"description": null,
"email": "[email protected]",
"phone": null,
"website": "https://restartix.com",
"location": "Amsterdam, Netherlands",
"logo_url": "https://s3.../logo.png",
"icon_url": "https://s3.../icon.png",
"language_code": "en",
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2026-04-26T10:30:00Z"
}
]
}POST /v1/organizations
Create a new organization. Superadmin only (gated by RequireSuperadmin()).
Request:
{
"name": "Demo Clinic",
"slug": "demo-clinic",
"tagline": "...",
"description": "...",
"email": "[email protected]",
"phone": "...",
"website": "https://demo-clinic.example",
"location": "Bucharest, RO",
"logo_url": "https://s3.../logo.png",
"icon_url": "https://s3.../icon.png",
"language_code": "ro"
}name and slug are required; everything else is optional. Slug must match [a-z0-9-]+ and be unique.
Response: 201 — full organization payload (same shape as GET /v1/organizations/{id}).
Errors: 400 validation_error (missing/invalid fields), 409 conflict (slug taken).
Audit: CREATE organization row scoped to the new org.
GET /v1/organizations/{id}
Get a single organization by UUID.
Authentication: Bearer token. Permission: none — RLS scopes to caller's org.
Response: 200 — full organization payload.
Errors: 400 invalid_id (malformed UUID), 404 organization_not_found.
PATCH /v1/organizations/{id}
Update mutable fields of an organization. Permission: organizations.update.
Request (all fields optional; only the fields present in the body are updated):
{
"name": "Demo Clinic NL",
"tagline": "...",
"description": "...",
"email": "[email protected]",
"phone": "...",
"website": "...",
"location": "...",
"logo_url": "...",
"icon_url": "...",
"language_code": "en"
}slug is immutable after creation and is not accepted in the request body.
Response: 200 — updated organization.
Audit: UPDATE organization row with field-level before/after diff.
Members
Members are principals with a role in this organization. There is no client-side allowlist of which roles can be assigned via this API — the backend accepts any role code defined for the target org (system clones AND custom per-org roles). Frontends populate role pickers from GET /v1/organizations/{id}/roles (below).
The patient role is also auto-assigned by the auth middleware on portal sign-up (which carries the org via X-Organization-ID); that flow is in addition to, not instead of, admin-driven member adds.
GET /v1/organizations/{id}/members
Permission: organizations.manage_members (no separate "view members" permission today).
Response: 200
{
"data": [
{
"principal_id": "0190af3b-1c2e-7c00-8a4f-b2d9c4e5f300",
"email": "[email protected]",
"role_id": "0190af3b-1c2e-7c00-8a4f-b2d9c4e5f400",
"role_code": "specialist",
"joined_at": "2026-04-20T09:00:00Z"
}
]
}POST /v1/organizations/{id}/members
Enrol an existing human (looked up by email) as a member with the given role, or change the role of an existing member. Upsert semantics: if the human is already a member, their role is changed to the requested one. For principals who already have a humans row on the platform — typically an existing member of another org being enrolled here, or a role update for someone already in this org. New staff onboarding goes through POST /v1/organizations/{id}/staff-invitations (Clerk Invitations API + bind-on-first-auth), not this endpoint — see the ADR "Why owner uses provisioning, staff and patients use invitation".
Returns 404 user_not_found if the email is unknown to the platform; the caller should switch to staff-invitations.
Permission: organizations.manage_members.
Request:
{
"email": "[email protected]",
"role": "specialist"
}role is the code of any role row defined for the target org — seeded system clones (patient, specialist, customer_support, admin) AND custom per-org roles. No client-side allowlist; look up valid values from GET /v1/organizations/{id}/roles.
Response: 200
{
"data": {
"principal_id": "0190af3b-1c2e-7c00-8a4f-b2d9c4e5f300",
"email": "[email protected]",
"organization_id": "0190af3b-1c2e-7c00-8a4f-b2d9c4e5f100",
"role_id": "0190af3b-1c2e-7c00-8a4f-b2d9c4e5f400",
"role_code": "specialist"
}
}Errors: 400 invalid_body/validation_error, 400 role_not_found if the role code does not match a role in the target org, 404 if the email does not match a known human.
Audit: CREATE principal_organization for new memberships, UPDATE principal_organization for role changes; nothing for no-op upserts.
DELETE /v1/organizations/{id}/members/{principalId}
Remove a member's organization membership. Idempotent — removing a non-member is a 204 with no audit row.
Permission: organizations.manage_members.
Response: 204 (no body).
Audit: DELETE principal_organization row when a row was actually removed.
GET /v1/organizations/{id}/roles
List every role defined for this organization — system clones (cloned from templates at org creation) AND any custom per-org roles created by an admin. Used by frontends to populate role pickers in invite/edit-member dialogs.
Permission: organizations.manage_members.
Response: 200
{
"data": [
{
"id": "0190af3b-1c2e-7c00-8a4f-b2d9c4e5f400",
"organization_id": "0190af3b-1c2e-7c00-8a4f-b2d9c4e5f100",
"code": "admin",
"name": "Admin",
"description": "Organization manager. Full management within the org.",
"is_system": true
}
]
}Custom Domains
Each org can map additional hostnames beyond {slug}.clinic.restartix.pro / {slug}.portal.restartix.pro. Each custom domain requires DNS-based verification before it routes traffic.
GET /v1/organizations/{id}/domains
Permission: organizations.manage_domains. The response includes each domain's verification_token, a persistent UUID secret used to prove DNS ownership; non-admin members have no need to read it.
Response: 200
{
"data": [
{
"id": "0190af3b-1c2e-7c00-8a4f-b2d9c4e5f500",
"organization_id": "0190af3b-1c2e-7c00-8a4f-b2d9c4e5f100",
"domain": "clinic.example.com",
"domain_type": "clinic",
"status": "verified",
"verification_token": "9b2d-…",
"verified_at": "2026-04-21T12:00:00Z",
"last_check_at": "2026-04-26T00:00:00Z",
"created_at": "2026-04-20T08:00:00Z",
"updated_at": "2026-04-21T12:00:00Z"
}
]
}POST /v1/organizations/{id}/domains
Add a custom domain. Returns the verification challenge the admin must publish at _restartix-verification.<domain> as a TXT record before calling verify.
Permission: organizations.manage_domains.
Request:
{
"domain": "clinic.example.com",
"domain_type": "clinic"
}domain_type is clinic or portal.
Response: 201
{
"data": {
"id": "0190af3b-1c2e-7c00-8a4f-b2d9c4e5f500",
"organization_id": "0190af3b-1c2e-7c00-8a4f-b2d9c4e5f100",
"domain": "clinic.example.com",
"domain_type": "clinic",
"status": "pending",
"verification_token": "9b2d-…",
"verified_at": null,
"last_check_at": null,
"created_at": "2026-04-26T10:00:00Z",
"updated_at": "2026-04-26T10:00:00Z"
},
"verification": {
"txt_host": "_restartix-verification.clinic.example.com",
"txt_value": "9b2d-…"
}
}Audit: CREATE organization_domain row.
POST /v1/organizations/{id}/domains/{domainId}/verify
Trigger a DNS lookup for the verification TXT record. On a match, status flips to verified and the domain begins routing.
Permission: organizations.manage_domains.
Response: 200 — updated domain payload.
Audit: UPDATE organization_domain with before/after.
DELETE /v1/organizations/{id}/domains/{domainId}
Remove a custom domain. The domain stops routing immediately.
Permission: organizations.manage_domains.
Response: 204 (no body).
Audit: DELETE organization_domain.
Error Code Reference
The full reference lives in apps/docs/reference/error-envelope.md; the codes most likely to appear on this surface:
| Code | HTTP | Description |
|---|---|---|
invalid_id | 400 | Path parameter is not a valid UUID |
invalid_body | 400 | Request body is not valid JSON |
validation_error | 400 | Body shape valid but a field is missing/invalid; fields map populated |
unauthorized | 401 | Missing or invalid Bearer token |
forbidden | 403 | Caller lacks the required permission, or attempted to access an org they aren't a member of |
organization_not_found | 404 | Org with the given ID/slug/domain does not exist |
conflict | 409 | Slug or domain already taken |
internal_error | 500 | Unexpected error (audit failure, DB error) — never leaks internals |