Skip to content

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:

  1. The X-Organization-ID header (set by the frontend proxy from the resolved hostname),
  2. the calling principal's humans.current_organization_id column (when the caller is a human),
  3. the principal's first membership in organization_memberships,
  4. 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 in services/api/migrations/core/000002_tenancy_rbac.up.sql:421-425 and constants live in services/api/internal/core/principal/permissions.go.
  • Errors: { "error": { "code": "...", "message": "..." } }. Validation errors add a fields map.
  • Audit: Every mutating endpoint writes a row to audit_log from the success path; the request returns 500 internal_error if the audit write fails.

Permission Catalog

The shipped catalog (everything else is planned, see Planned):

CodeGranted toUsed by
organizations.updateadminPATCH /v1/organizations/{id}
organizations.manage_membersadminGET/POST/DELETE /v1/organizations/{id}/members*
organizations.manage_domainsadminGET/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.pro style URLs.
  • domain=... — a custom hostname previously verified via the domains API.

Response: 200

json
{
  "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

json
{
  "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:

json
{
  "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):

json
{
  "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

json
{
  "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:

json
{
  "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

json
{
  "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

json
{
  "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

json
{
  "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:

json
{
  "domain": "clinic.example.com",
  "domain_type": "clinic"
}

domain_type is clinic or portal.

Response: 201

json
{
  "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:

CodeHTTPDescription
invalid_id400Path parameter is not a valid UUID
invalid_body400Request body is not valid JSON
validation_error400Body shape valid but a field is missing/invalid; fields map populated
unauthorized401Missing or invalid Bearer token
forbidden403Caller lacks the required permission, or attempted to access an org they aren't a member of
organization_not_found404Org with the given ID/slug/domain does not exist
conflict409Slug or domain already taken
internal_error500Unexpected error (audit failure, DB error) — never leaks internals