Skip to content

Organizations Feature

A multi-tenant container for clinic/practice data, branding, and integrations.

What this enables

Multi-clinic operations: Specialists and administrators can manage multiple clinics/practices from a single account, switching between them instantly.

Brand control: Each organization has its own logo, colors, and dedicated domains (e.g., healthcorp.clinic.restartix.app for staff, healthcorp.portal.restartix.app for patients). Custom domains are also supported (e.g., clinic.myclinic.com).

Data isolation: Appointments, forms, files, and settings are completely separate per organization — accidentally mixing clinics is impossible. The one deliberate exception is the patient's portable profile (patient_persons): demographics, blood type, allergies, and insurance travel with the patient across clinics, but only after the patient signs a per-org profile-sharing consent form. See Patient Profile Sharing Consent →.

Integration storage: Securely store API keys for payment processors, EHR systems, and other services without exposing secrets in code.

Platform model

RestartiX is a curated platform — clinics cannot self-register. Every organization is created and onboarded by the platform team (superadmin role). This ensures quality control, proper configuration, and compliance vetting before a clinic goes live. Self-service organization creation may be added in the future, but is explicitly out of scope for now.

How it works

  1. Superadmin creates organization: A platform operator (superadmin) creates the clinic/practice via POST /v1/organizations
  2. Users are connected: Admin connects existing users to the organization via POST /v1/organizations/{id}/connect-user, or users are auto-connected during onboarding
  3. Set organization context: All data operations work within that organization's boundary — determined by the domain the user visits
  4. Switch organizations (optional): If they work at multiple clinics, they can switch with one click — all data automatically updates to show the new clinic's patients and appointments
  5. Invites: Admin can add team members to the organization, giving them access to the same data
  6. API keys: Admin stores integration credentials per-org (e.g., Stripe for their clinic, NOT visible to other clinics)

Technical Reference

Overview

Organizations are the root of multi-tenancy in the RestartiX system. Every piece of tenant-scoped data belongs to an organization. Users can belong to multiple organizations and switch between them.

Key Principle: Clerk handles authentication (login, MFA, sessions). Organizations handle tenancy (data isolation, access control).

Key Concepts

Organizations as Multi-Tenancy Root

Every request operates within an organization context determined by the domain:

  1. User visits {slug}.clinic.restartix.app or a custom domain
  2. Frontend middleware resolves the hostname → organization via GET /v1/public/organizations/resolve?slug={slug}
  3. Organization ID is passed to the Core API via X-Organization-ID header
  4. All queries are automatically filtered by organization_id via Row-Level Security (RLS)

The Golden Rule: WHERE organization_id = current_app_org_id() — no sub-queries, no JOINs (except for the users table which supports multi-org membership).

Organization Slug

Every organization has a unique slug (URL-safe identifier) used for subdomain routing:

  • Clinic app: {slug}.clinic.restartix.app (e.g., healthcorp.clinic.restartix.app)
  • Patient Portal: {slug}.portal.restartix.app (e.g., healthcorp.portal.restartix.app)
  • Custom domains: Organizations can optionally configure their own domains (e.g., clinic.myclinic.com)
  • Public resolution: GET /v1/public/organizations/resolve?slug={slug} (no auth required)

Example slugs: restartix, healthcorp, medcenter-amsterdam

Organization Switching

Users who belong to multiple organizations switch between them by navigating to the other organization's domain. The OrgSwitcher component in the Clinic app redirects to {slug}.clinic.restartix.app.

The legacy PUT /v1/me/switch-organization endpoint is still available as a fallback but is no longer the primary switching mechanism.

Use cases:

  • Specialist working for multiple clinics
  • Admin managing multiple organizations
  • Support staff with cross-org access

API Keys for External Integrations

Organizations can store encrypted API keys for external services:

  • Encrypted storage: AES-256-GCM encryption at application level
  • Stored as bytea: Binary data in PostgreSQL
  • Admin-only access: Only organization admins can view/manage keys
  • Rate limited: Prevents brute force and excessive decryption
  • Audited: All access logged to audit_log

Supported services:

  • (none defined yet — extend the enum as integrations are added)

See api-keys.md for detailed documentation.

Database Schema

Tables

organizations

The root table for multi-tenancy.

ColumnTypeDescription
idBIGSERIALPrimary key
nameTEXTOrganization name (e.g., "RestartiX")
slugTEXTURL-safe identifier (unique)
taglineTEXTShort description
descriptionTEXTFull description (Markdown/HTML)
emailTEXTContact email
phoneTEXTContact phone
websiteTEXTWebsite URL
locationTEXTPhysical location
logo_urlTEXTLogo image (S3 key)
icon_urlTEXTIcon/favicon (S3 key)
created_atTIMESTAMPTZCreation timestamp
updated_atTIMESTAMPTZLast update timestamp (auto-updated)

Organization Lifecycle Workflows

Organizations use the Automations feature to configure event-driven workflows for patient and appointment lifecycles.

Example: New Patient Onboarding

When patient.onboarded event fires:

  1. Require Privacy Policy form (blocking)
  2. Require Terms of Service form (blocking)
  3. Send welcome email
  4. Show "Complete profile" notification

Example: First Video Appointment

When appointment.first_booked AND appointment.type = "video":

  1. Require video recording consent (blocking)
  2. Send "What to expect" email
  3. Create intake questionnaire

Example: Service Plan Enrollment

When service_plan.enrolled:

  1. Require biometric data consent (for exercise tracking)
  2. Send program welcome email
  3. Create baseline assessment form

See:

organization_integrations

Stores encrypted API keys for external services.

ColumnTypeDescription
idBIGSERIALPrimary key
organization_idBIGINTFK to organizations (CASCADE)
titleTEXTHuman-readable label
serviceintegration_serviceService enum (external service identifier)
api_key_encryptedBYTEAAES-256-GCM encrypted API key
created_atTIMESTAMPTZCreation timestamp
updated_atTIMESTAMPTZLast update timestamp (auto-updated)

Indexes

sql
-- Organizations
CREATE INDEX idx_orgs_slug ON organizations(slug);

-- Organization Integrations
CREATE INDEX idx_org_integrations_org ON organization_integrations(organization_id);
CREATE INDEX idx_org_integrations_service ON organization_integrations(organization_id, service);

Every organization_id column across the entire schema has an index for fast RLS checks.

Row-Level Security (RLS)

RLS policies enforce organization boundaries at the database level.

Organizations Table

PolicyOperationRule
org_selectSELECTSuperadmins see all; users see only their current org
org_insertINSERTSuperadmins only
org_updateUPDATESuperadmins OR org admins
org_deleteDELETESuperadmins only

Organization Integrations Table

PolicyOperationRule
org_integrations_allALLSuperadmins OR org admins

How RLS Works

Every authenticated request sets PostgreSQL session variables:

sql
SELECT set_config('app.current_user_id', '42', true);
SELECT set_config('app.current_org_id', '1', true);
SELECT set_config('app.current_role', 'specialist', true);

RLS policies use helper functions to read these variables:

sql
CREATE OR REPLACE FUNCTION current_app_org_id() RETURNS BIGINT AS $$
    SELECT NULLIF(current_setting('app.current_org_id', true), '')::BIGINT;
$$ LANGUAGE SQL STABLE;
-- Note: No is_superadmin() function. Superadmins bypass RLS via AdminPool (owner role).

Then policies check these values:

sql
CREATE POLICY org_select ON organizations FOR SELECT USING (
    id = current_app_org_id()
);

Result: Users on the AppPool can only see data for their current organization, automatically enforced by PostgreSQL. Superadmins use the AdminPool (owner role) which bypasses RLS entirely.

See schema.sql for complete schema definitions.

API Reference

Endpoints

MethodEndpointDescriptionAuth
GET/v1/public/organizations/resolveResolve org by slug or custom domain (public)None
GET/v1/organizationsList user's organizationsRequired
POST/v1/organizationsCreate organizationSuperadmin
GET/v1/organizations/{id}Get organization detailsRequired
PATCH/v1/organizations/{id}Update organizationAdmin
GET/v1/organizations/{id}/api-keysGet decrypted API keysAdmin
POST/v1/organizations/{id}/connect-userAdd user to orgAdmin
GET/v1/organizations/{id}/domainsList custom domainsRequired
POST/v1/organizations/{id}/domainsAdd custom domainAdmin
DELETE/v1/organizations/{id}/domains/{domainId}Remove custom domainAdmin
POST/v1/organizations/{id}/domains/{domainId}/verifyVerify domain DNSAdmin

Examples

Resolve organization by slug (public, for domain routing)

bash
GET /v1/public/organizations/resolve?slug=restartix

Response:
{
  "data": {
    "id": 1,
    "name": "RestartiX",
    "slug": "restartix",
    "logo_url": "https://s3.../logo.png",
    "icon_url": "https://s3.../icon.png",
    "language_code": "en"
  }
}

List user's organizations

bash
GET /v1/organizations
Authorization: Bearer <clerk_token>

Response:
{
  "data": [
    { "id": 1, "name": "RestartiX", "slug": "restartix" },
    { "id": 3, "name": "HealthCorp", "slug": "healthcorp" }
  ]
}

Switch active organization

bash
PUT /v1/me/switch-organization
Authorization: Bearer <clerk_token>
Content-Type: application/json

{
  "organization_id": 3
}

Response:
{
  "data": {
    "current_organization": {
      "id": 3,
      "name": "HealthCorp",
      "slug": "healthcorp"
    },
    "message": "Organization switched successfully"
  }
}

Update organization (admin only)

bash
PUT /v1/organizations/1
Authorization: Bearer <clerk_token>
Content-Type: application/json

{
  "name": "RestartiX NL",
  "email": "[email protected]"
}

Response:
{
  "data": {
    "id": 1,
    "name": "RestartiX NL",
    "slug": "restartix",
    "email": "[email protected]",
    "updated_at": "2025-01-15T14:22:00Z"
  }
}

Get decrypted API keys (admin only, rate limited)

bash
GET /v1/organizations/1/api-keys
Authorization: Bearer <clerk_token>

Response:
{
  "data": [
    {
      "id": 1,
      "title": "External Service Production",
      "service": "external_service",
      "api_key": "sk_live_abc123...",
      "created_at": "2024-01-01T00:00:00Z"
    }
  ]
}

See api.md for complete API documentation.

Authentication

  • Clerk handles all authentication (login, signup, MFA, sessions)
  • Users can belong to multiple organizations
  • Role-based access control (patient, specialist, admin, customer_support, superadmin)

See ../auth/README.md for authentication documentation.

User Organizations

The user_organizations table manages many-to-many relationships:

sql
CREATE TABLE user_organizations (
    user_id         BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    organization_id BIGINT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    PRIMARY KEY (user_id, organization_id)
);

Use cases:

  • Multi-clinic specialists
  • Consultants working across organizations
  • Support staff with cross-org access
  • Patients switching providers

Integrations

Organizations integrate with external services via encrypted API keys:

  • (No integrations defined yet — extend the integration_service enum as integrations are added, e.g. payment processors, EHR systems)

Each integration stores:

  • Service identifier (enum)
  • Human-readable title
  • Encrypted API key (AES-256-GCM, stored as bytea)

See api-keys.md for integration documentation.

Organization-Scoped Data

Every tenant-scoped table has organization_id:

TableDescription
specialtiesMedical specialties per org
specialistsDoctors/providers per org
patientsPatient records per org
appointmentsAppointments per org
form_templatesForm templates per org
formsForm instances per org
custom_fieldsCustom field definitions per org
custom_field_valuesCustom field values per org
segmentsPatient segments per org
appointment_templatesAppointment types per org
audit_logAudit events per org

All automatically filtered by RLS based on current_organization_id.

Design Principles

1. organization_id on Every Tenant-Scoped Table

Why: Direct RLS checks, no sub-queries, maximum performance.

Exceptions:

  • The users table doesn't have organization_id because users can belong to multiple organizations. User access is managed via user_organizations junction table.
  • patient_persons and patient_person_managers have no organization_id — they are patient-owned portable data that travels across orgs. Org access is gated by the patients.profile_shared consent flag (application-layer enforcement). See Patients →.

2. Clean Table Naming

Why: Human-readable, predictable, easy to understand.

  • organizations, users, appointments
  • auth_organizations, system_users, appt_v2

3. Explicit Foreign Keys with ON DELETE Behavior

Why: Referential integrity, no orphaned records, clear cascade behavior.

sql
organization_id BIGINT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE

If an organization is deleted, all related data is automatically deleted.

4. Encryption for High-Sensitivity Fields

Why: Defense in depth. API keys and phone numbers get application-level AES-256-GCM encryption in addition to infrastructure encryption (AWS RDS).

Not encrypted: Form values, custom field values (need to be queryable for segments).

5. Timestamps Everywhere

Why: Audit trail, debugging, analytics.

Every table has:

  • created_at (set once on INSERT)
  • updated_at (auto-updated on UPDATE via trigger)

6. UUIDs for External-Facing IDs

Why: Security (prevent enumeration), external API integration.

  • Internal IDs: bigserial (fast, indexed)
  • Public IDs: uid UUID (appointments, etc.)

Organizations use slug instead of UUID for human-friendly URLs.

7. Own Organizations, Not Clerk Orgs

Why: Clerk handles auth only. Organizations are a core domain concept with business logic (integrations, settings, branding, billing, etc.).

Clerk's organization feature is optional and not used. Our organizations table is the source of truth.

8. Local Audit + Telemetry Forwarding

Why: HIPAA safety net. All audit events written synchronously to local audit_log, then forwarded asynchronously to Telemetry service for enrichment.

Guarantees: Zero audit event loss, even if Telemetry service is down.

Architecture Decisions

Why Not Use Clerk Organizations?

Clerk offers an organizations feature, but we chose to build our own:

Reasons:

  1. Data ownership - Organizations are a core domain entity with complex business logic
  2. Integration data - Need to store encrypted API keys, settings, branding
  3. Medical compliance - HIPAA requires full control over data access and audit trail
  4. Flexibility - Can extend organizations with custom fields, features, billing
  5. RLS integration - Direct PostgreSQL integration for row-level security

Clerk handles: Authentication (login, MFA, sessions) Our organizations handle: Tenancy (data isolation, settings, integrations)

Why Denormalize organization_id?

Many tables have organization_id even when it could be derived via JOIN:

sql
-- Example: specialist_specialties has organization_id
-- Could derive from specialists.organization_id
-- But we store it directly for RLS performance
CREATE TABLE specialist_specialties (
    specialist_id   BIGINT NOT NULL REFERENCES specialists(id),
    specialty_id    BIGINT NOT NULL REFERENCES specialties(id),
    organization_id BIGINT NOT NULL REFERENCES organizations(id),  -- Denormalized!
    PRIMARY KEY (specialist_id, specialty_id)
);

Why:

  1. RLS performance - Direct column check is faster than sub-query
  2. Index usage - PostgreSQL can use the index on organization_id directly
  3. Simplicity - No complex sub-query logic in RLS policies

Trade-off: Slight denormalization for massive performance gain.

Why bytea for Encrypted API Keys?

API keys are encrypted at the application level (AES-256-GCM) and stored as binary data:

sql
api_key_encrypted BYTEA NOT NULL

Why bytea instead of TEXT:

  1. Binary format - Encryption output is binary, not text
  2. No encoding overhead - No need for base64 or hex encoding
  3. Integrity - Binary data can't be accidentally modified by text operations

Storage format: [nonce (12 bytes)][ciphertext][auth tag (16 bytes)]

See api-keys.md for encryption details.

Security Considerations

RLS Enforcement

RLS policies are enabled on all tenant-scoped tables. The AppPool connects as restartix_app role which does NOT bypass RLS. The AdminPool (restartix owner) is used only by auth middleware, superadmin requests, and system queries — it bypasses RLS by design.

Critical: Never grant BYPASSRLS to the restartix_app role.

API Key Security

  1. Encrypted at rest - AES-256-GCM application-level encryption
  2. Encrypted in transit - TLS 1.2+ for all database connections
  3. Admin-only access - RLS policies restrict to organization admins
  4. Rate limited - API endpoint limited to 10 requests/minute
  5. Audited - All access logged to audit_log
  6. No caching - Decrypted keys never cached, always decrypt on-demand

Organization Isolation

  1. RLS at database level - PostgreSQL enforces boundaries
  2. Session variables - Set per-request, transaction-scoped
  3. No cross-org queries - Users can only access data for current_organization_id
  4. Superadmin override - Superadmins can bypass for support (audited)
  5. Portable profile exception - patient_persons is readable by any org the patient is registered at, but extended fields (DOB, allergies, etc.) are only exposed to org staff after the patient signs a profile-sharing consent form (patients.profile_shared = true). Enforcement is application-level, not RLS.

Audit Trail

All organization operations are logged to audit_log:

  • Organization created/updated/deleted
  • User added/removed from organization
  • Organization switched by user
  • API keys accessed/rotated
  • Settings changed

Performance Considerations

Index Coverage

Every organization_id column has an index:

sql
CREATE INDEX idx_specialists_org ON specialists(organization_id);
CREATE INDEX idx_appointments_org ON appointments(organization_id);
CREATE INDEX idx_forms_org ON forms(organization_id);
-- ... etc.

Why: RLS policies filter by organization_id on every query. Indexes make this fast.

RLS Policy Design

RLS policies use direct column checks, not sub-queries:

sql
-- Fast (direct check)
CREATE POLICY specialists_select ON specialists FOR SELECT USING (
    organization_id = current_app_org_id()
);

-- Slow (sub-query - avoid!)
CREATE POLICY specialists_select ON specialists FOR SELECT USING (
    id IN (
        SELECT specialist_id FROM some_other_table
        WHERE organization_id = current_app_org_id()
    )
);

Exception: The users table uses a sub-query because users belong to multiple orgs:

sql
CREATE POLICY users_select ON users FOR SELECT USING (
    id = current_app_user_id()
    OR id IN (
        SELECT user_id FROM user_organizations
        WHERE organization_id = current_app_org_id()
    )
);

This is acceptable because user_organizations is small and has an index on organization_id.

Session Variable Overhead

Setting session variables per-request has minimal overhead:

sql
SELECT set_config('app.current_org_id', '1', true);  -- ~0.1ms

The true flag makes the setting transaction-scoped (not session-scoped), so it auto-resets after each request.

Migration Guide

Adding a New Table

When creating a new tenant-scoped table:

  1. Add organization_id column:

    sql
    CREATE TABLE new_table (
        id              BIGSERIAL PRIMARY KEY,
        organization_id BIGINT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
        -- ... other columns
        created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
        updated_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
    );
  2. Add index:

    sql
    CREATE INDEX idx_new_table_org ON new_table(organization_id);
  3. Add RLS policies:

    sql
    ALTER TABLE new_table ENABLE ROW LEVEL SECURITY;
    
    -- Superadmins bypass RLS via AdminPool (owner role) — no is_superadmin() needed
    CREATE POLICY new_table_select ON new_table FOR SELECT USING (
        organization_id = current_app_org_id()
    );
    
    CREATE POLICY new_table_modify ON new_table FOR ALL USING (
        organization_id = current_app_org_id() AND current_app_role() = 'admin'
    );
  4. Add auto-update trigger:

    sql
    CREATE TRIGGER set_updated_at BEFORE UPDATE ON new_table
        FOR EACH ROW EXECUTE FUNCTION trigger_set_updated_at();

Adding a New Integration Service

To add support for a new external service:

  1. Add to enum:

    sql
    ALTER TYPE integration_service ADD VALUE 'new_service';
  2. Update application code to handle the new service

  3. Document the service in api-keys.md

  4. Create integration via API:

    bash
    POST /v1/organizations/1/integrations
    {
      "title": "New Service Production",
      "service": "new_service",
      "api_key": "sk_live_..."
    }

Testing

RLS Policy Testing

Test RLS policies by setting session variables:

sql
-- Set user context (non-admin)
SELECT set_config('app.current_user_id', '42', true);
SELECT set_config('app.current_org_id', '1', true);
SELECT set_config('app.current_role', 'specialist', true);

-- Should only see org 1
SELECT * FROM organizations;

-- Should not be able to update
UPDATE organizations SET name = 'Hacked' WHERE id = 1;  -- Should fail

-- Switch to admin
SELECT set_config('app.current_role', 'admin', true);

-- Should now be able to update
UPDATE organizations SET name = 'Updated' WHERE id = 1;  -- Should succeed

Multi-Organization Testing

Test user switching between organizations:

sql
-- User belongs to orgs 1 and 3
INSERT INTO user_organizations (user_id, organization_id)
VALUES (42, 1), (42, 3);

-- Set to org 1
SELECT set_config('app.current_org_id', '1', true);
SELECT * FROM appointments;  -- Should see org 1 appointments only

-- Switch to org 3
SELECT set_config('app.current_org_id', '3', true);
SELECT * FROM appointments;  -- Should see org 3 appointments only

API Key Encryption Testing

Test encryption/decryption roundtrip:

go
func TestAPIKeyEncryption(t *testing.T) {
    orgID := int64(1)
    plaintext := "sk_live_test_key_123"

    // Encrypt
    encrypted, err := crypto.EncryptAPIKey(orgID, plaintext)
    require.NoError(t, err)

    // Decrypt
    decrypted, err := crypto.DecryptAPIKey(orgID, encrypted)
    require.NoError(t, err)

    // Verify
    assert.Equal(t, plaintext, decrypted)
}

Troubleshooting

Users Can't See Data After Switching Orgs

Symptom: User switches organization but sees empty lists.

Causes:

  1. User not connected to organization in user_organizations
  2. Session variable not updated correctly
  3. RLS policy blocking access

Debug:

sql
-- Check user's organization memberships
SELECT organization_id FROM user_organizations WHERE user_id = 42;

-- Check current session variable
SELECT current_setting('app.current_org_id', true);

-- Check RLS policy (run as superuser)
SET ROLE postgres;
SELECT * FROM appointments WHERE organization_id = 1;

API Keys Not Decrypting

Symptom: Error decrypting API key, invalid auth tag.

Causes:

  1. Encryption key changed in environment
  2. Associated data mismatch (wrong organization_id)
  3. Data corruption in database

Debug:

bash
# Check encryption key is set
echo $API_KEY_ENCRYPTION_KEY

# Check it's the same key used for encryption
# Re-encrypt if needed

See api-keys.md for more troubleshooting.

Domain Mapping

How Domain Resolution Works

Organization context is request-scoped — determined by the domain the user visits, not stored on the user record. This is the full flow for every page load:

  Browser ──► healthcorp.clinic.restartix.app    (subdomain)
         OR   clinic.myclinic.com                (custom domain)


  ┌──────────────────────────────────────────────────────────┐
  │  Next.js Proxy (proxy.ts)                                │
  │                                                          │
  │  extractSlugFromHostname(hostname, CLINIC_DOMAIN)        │
  │    ├── Match? slug = "healthcorp"                        │
  │    │   └── resolveOrganization(slug, CORE_API_URL)       │
  │    │       GET /v1/public/organizations/resolve           │
  │    │           ?slug=healthcorp                           │
  │    │                                                     │
  │    └── No match? (custom domain)                         │
  │        └── resolveOrganizationByDomain(hostname, ...)    │
  │            GET /v1/public/organizations/resolve           │
  │                ?domain=clinic.myclinic.com                │
  │                                                          │
  │  Set httpOnly cookies: org-id, org-slug, org-name        │
  │  Enforce Clerk auth for protected routes                 │
  └──────────────────────────┬───────────────────────────────┘


  ┌──────────────────────────────────────────────────────────┐
  │  Core API — Public Resolve Endpoint (no auth)            │
  │                                                          │
  │  ?slug=healthcorp                                        │
  │    ├── Redis cache hit? (org:resolve:slug:healthcorp)    │
  │    │   └── Return cached org                             │
  │    └── Cache miss?                                       │
  │        └── repo.FindBySlugPublic (pool, no RLS)          │
  │            └── Cache result in Redis (5 min TTL)         │
  │                                                          │
  │  ?domain=clinic.myclinic.com                             │
  │    ├── Redis cache hit? (org:resolve:domain:clinic.my..) │
  │    │   └── Return cached org                             │
  │    └── Cache miss?                                       │
  │        └── repo.FindByDomainPublic (pool, no RLS)        │
  │            JOIN organization_domains (status='verified')  │
  │            └── Cache result in Redis (5 min TTL)         │
  │                                                          │
  │  Returns: { id, name, slug, logo_url, icon_url, lang }  │
  └──────────────────────────────────────────────────────────┘


  ┌──────────────────────────────────────────────────────────┐
  │  Server Component / API Route                            │
  │                                                          │
  │  createApiClient() reads org-id cookie                   │
  │  ApiClient sends X-Organization-ID: {org-id} header      │
  └──────────────────────────┬───────────────────────────────┘


  ┌──────────────────────────────────────────────────────────┐
  │  Core API — Authenticated Request                        │
  │                                                          │
  │  1. ClerkAuth middleware — verify JWT                     │
  │  2. OrganizationContext middleware:                       │
  │     ├── Read X-Organization-ID header                    │
  │     ├── Validate user is member of this org              │
  │     └── SET app.current_org_id, app.current_role (RLS)   │
  │  3. Handler executes — all queries scoped to org via RLS │
  └──────────────────────────────────────────────────────────┘

Cache invalidation: When an org is updated (PATCH /v1/organizations/{id}), both the slug cache and all verified domain caches for that org are invalidated. Domain caches are also invalidated when a domain is added, removed, or its verification status changes.

Custom Domain Verification Flow

  Admin adds domain via POST /v1/organizations/{id}/domains
  ├── { "domain": "clinic.myclinic.com", "domain_type": "clinic" }
  ├── System generates UUID verification token
  └── Returns: status="pending", verification instructions


  Admin adds DNS TXT record:
    _restartix-verification.clinic.myclinic.com → {token}


  Admin triggers POST /v1/organizations/{id}/domains/{domainId}/verify
  ├── System calls net.LookupTXT("_restartix-verification.clinic.myclinic.com")
  ├── Match found? → status="verified", domain is now usable
  └── No match?    → status="failed", admin retries after fixing DNS

Roadmap

Subdomain Routing (Phase 1)

Each organization is accessed via its slug-based subdomain:

  • Clinic app: {slug}.clinic.restartix.app
  • Patient Portal: {slug}.portal.restartix.app
  • Local dev: {slug}.clinic.localhost:9100 / {slug}.portal.localhost:9200

How it works:

  1. Browser hits healthcorp.clinic.restartix.app
  2. Next.js proxy extracts slug healthcorp from hostname
  3. Proxy calls GET /v1/public/organizations/resolve?slug=healthcorp (public, no auth)
  4. Core API returns org ID, name, slug, logo, icon, language
  5. Proxy sets org-id, org-slug, org-name as httpOnly cookies
  6. createApiClient() reads org-id cookie and sends X-Organization-ID header
  7. OrganizationContext middleware uses the header to set RLS session variables
  8. All downstream queries are scoped to that organization

Organization switching: The OrgSwitcher component redirects to {slug}.{CLINIC_DOMAIN} — a full page navigation to the other org's subdomain. The legacy PUT /v1/me/switch-organization endpoint remains as a fallback.

Current: Custom Domain Support (Phase 2)

Organizations can configure their own domains in addition to the default subdomains.

Database: New organization_domains table:

sql
CREATE TYPE domain_type AS ENUM ('clinic', 'portal');
CREATE TYPE domain_status AS ENUM ('pending', 'verified', 'failed');

CREATE TABLE organization_domains (
    id              BIGSERIAL PRIMARY KEY,
    organization_id BIGINT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
    domain          TEXT NOT NULL UNIQUE,
    domain_type     domain_type NOT NULL,
    status          domain_status NOT NULL DEFAULT 'pending',
    verification_token TEXT NOT NULL,
    verified_at        TIMESTAMPTZ,
    last_check_at      TIMESTAMPTZ,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

RLS policies: admin + superadmin can manage domains for their organization.

API endpoints:

MethodPathAuthDescription
GET/v1/organizations/{id}/domainsRequiredList domains for an org
POST/v1/organizations/{id}/domainsAdminAdd a custom domain
DELETE/v1/organizations/{id}/domains/{domainId}AdminRemove a custom domain
POST/v1/organizations/{id}/domains/{domainId}/verifyAdminTrigger DNS verification

DNS verification flow:

  1. Admin adds custom domain clinic.myclinic.com via API
  2. System generates a UUID verification token
  3. Admin adds a DNS TXT record: _restartix-verification.clinic.myclinic.com{token}
  4. Admin triggers verification — system calls net.LookupTXT to check
  5. If TXT matches, domain status → verified, verified_at is set
  6. Daily cron re-verifies; revokes if TXT removed (statusfailed)

Resolve endpoint extension:

The existing GET /v1/public/organizations/resolve gains a domain parameter:

GET /v1/public/organizations/resolve?domain=clinic.myclinic.com

Looks up verified custom domains in organization_domains. Frontend proxy uses ?slug= for platform subdomains, ?domain= for custom domains.

Caching: Redis-based cache for domain resolution (5-minute TTL), invalidated on org update or domain status change.

Phase 3: Polish & Operations

  • Deprecate PUT /v1/me/switch-organization — keep current_organization_id as "last visited org" for redirect-on-login scenarios
  • Org-branded auth pages — pass org branding (logo, colors) via cookies to Clerk sign-in/sign-up pages for white-label login screens
  • Dynamic CORS — allow *.clinic.restartix.app, *.portal.restartix.app, and all verified custom domains
  • Clerk multi-domain SSO — configure Clerk to support auth across all org subdomains and custom domains
  • Wildcard DNS + SSL*.clinic.restartix.app / *.portal.restartix.app with wildcard certificates; Let's Encrypt for custom domains via DNS-01 or HTTP-01 challenge
  • Infrastructure — CloudFront with custom domain support, ACM for certificate management

Further Reading