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
- Superadmin creates organization: A platform operator (superadmin) creates the clinic/practice via
POST /v1/organizations - Users are connected: Admin connects existing users to the organization via
POST /v1/organizations/{id}/connect-user, or users are auto-connected during onboarding - Set organization context: All data operations work within that organization's boundary — determined by the domain the user visits
- 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
- Invites: Admin can add team members to the organization, giving them access to the same data
- 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:
- User visits
{slug}.clinic.restartix.appor a custom domain - Frontend middleware resolves the hostname → organization via
GET /v1/public/organizations/resolve?slug={slug} - Organization ID is passed to the Core API via
X-Organization-IDheader - All queries are automatically filtered by
organization_idvia 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.
| Column | Type | Description |
|---|---|---|
id | BIGSERIAL | Primary key |
name | TEXT | Organization name (e.g., "RestartiX") |
slug | TEXT | URL-safe identifier (unique) |
tagline | TEXT | Short description |
description | TEXT | Full description (Markdown/HTML) |
email | TEXT | Contact email |
phone | TEXT | Contact phone |
website | TEXT | Website URL |
location | TEXT | Physical location |
logo_url | TEXT | Logo image (S3 key) |
icon_url | TEXT | Icon/favicon (S3 key) |
created_at | TIMESTAMPTZ | Creation timestamp |
updated_at | TIMESTAMPTZ | Last 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:
- Require Privacy Policy form (blocking)
- Require Terms of Service form (blocking)
- Send welcome email
- Show "Complete profile" notification
Example: First Video Appointment
When appointment.first_booked AND appointment.type = "video":
- Require video recording consent (blocking)
- Send "What to expect" email
- Create intake questionnaire
Example: Service Plan Enrollment
When service_plan.enrolled:
- Require biometric data consent (for exercise tracking)
- Send program welcome email
- Create baseline assessment form
See:
- Automations feature - Complete lifecycle workflow system with 15+ triggers and 13+ action types
- Patient onboarding - How automation rules execute during onboarding
- Forms feature - Disclaimer forms with
consent_types - GDPR compliance - Two-layer consent architecture
organization_integrations
Stores encrypted API keys for external services.
| Column | Type | Description |
|---|---|---|
id | BIGSERIAL | Primary key |
organization_id | BIGINT | FK to organizations (CASCADE) |
title | TEXT | Human-readable label |
service | integration_service | Service enum (external service identifier) |
api_key_encrypted | BYTEA | AES-256-GCM encrypted API key |
created_at | TIMESTAMPTZ | Creation timestamp |
updated_at | TIMESTAMPTZ | Last update timestamp (auto-updated) |
Indexes
-- 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
| Policy | Operation | Rule |
|---|---|---|
org_select | SELECT | Superadmins see all; users see only their current org |
org_insert | INSERT | Superadmins only |
org_update | UPDATE | Superadmins OR org admins |
org_delete | DELETE | Superadmins only |
Organization Integrations Table
| Policy | Operation | Rule |
|---|---|---|
org_integrations_all | ALL | Superadmins OR org admins |
How RLS Works
Every authenticated request sets PostgreSQL session variables:
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:
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:
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
| Method | Endpoint | Description | Auth |
|---|---|---|---|
| GET | /v1/public/organizations/resolve | Resolve org by slug or custom domain (public) | None |
| GET | /v1/organizations | List user's organizations | Required |
| POST | /v1/organizations | Create organization | Superadmin |
| GET | /v1/organizations/{id} | Get organization details | Required |
| PATCH | /v1/organizations/{id} | Update organization | Admin |
| GET | /v1/organizations/{id}/api-keys | Get decrypted API keys | Admin |
| POST | /v1/organizations/{id}/connect-user | Add user to org | Admin |
| GET | /v1/organizations/{id}/domains | List custom domains | Required |
| POST | /v1/organizations/{id}/domains | Add custom domain | Admin |
| DELETE | /v1/organizations/{id}/domains/{domainId} | Remove custom domain | Admin |
| POST | /v1/organizations/{id}/domains/{domainId}/verify | Verify domain DNS | Admin |
Examples
Resolve organization by slug (public, for domain routing)
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
GET /v1/organizations
Authorization: Bearer <clerk_token>
Response:
{
"data": [
{ "id": 1, "name": "RestartiX", "slug": "restartix" },
{ "id": 3, "name": "HealthCorp", "slug": "healthcorp" }
]
}Switch active organization
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)
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)
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.
Related Features
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:
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_serviceenum 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:
| Table | Description |
|---|---|
specialties | Medical specialties per org |
specialists | Doctors/providers per org |
patients | Patient records per org |
appointments | Appointments per org |
form_templates | Form templates per org |
forms | Form instances per org |
custom_fields | Custom field definitions per org |
custom_field_values | Custom field values per org |
segments | Patient segments per org |
appointment_templates | Appointment types per org |
audit_log | Audit 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
userstable doesn't haveorganization_idbecause users can belong to multiple organizations. User access is managed viauser_organizationsjunction table. patient_personsandpatient_person_managershave noorganization_id— they are patient-owned portable data that travels across orgs. Org access is gated by thepatients.profile_sharedconsent 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.
organization_id BIGINT NOT NULL REFERENCES organizations(id) ON DELETE CASCADEIf 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:
- Data ownership - Organizations are a core domain entity with complex business logic
- Integration data - Need to store encrypted API keys, settings, branding
- Medical compliance - HIPAA requires full control over data access and audit trail
- Flexibility - Can extend organizations with custom fields, features, billing
- 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:
-- 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:
- RLS performance - Direct column check is faster than sub-query
- Index usage - PostgreSQL can use the index on
organization_iddirectly - 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:
api_key_encrypted BYTEA NOT NULLWhy bytea instead of TEXT:
- Binary format - Encryption output is binary, not text
- No encoding overhead - No need for base64 or hex encoding
- 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
- Encrypted at rest - AES-256-GCM application-level encryption
- Encrypted in transit - TLS 1.2+ for all database connections
- Admin-only access - RLS policies restrict to organization admins
- Rate limited - API endpoint limited to 10 requests/minute
- Audited - All access logged to
audit_log - No caching - Decrypted keys never cached, always decrypt on-demand
Organization Isolation
- RLS at database level - PostgreSQL enforces boundaries
- Session variables - Set per-request, transaction-scoped
- No cross-org queries - Users can only access data for
current_organization_id - Superadmin override - Superadmins can bypass for support (audited)
- Portable profile exception -
patient_personsis 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:
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:
-- 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:
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:
SELECT set_config('app.current_org_id', '1', true); -- ~0.1msThe 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:
Add organization_id column:
sqlCREATE 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() );Add index:
sqlCREATE INDEX idx_new_table_org ON new_table(organization_id);Add RLS policies:
sqlALTER 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' );Add auto-update trigger:
sqlCREATE 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:
Add to enum:
sqlALTER TYPE integration_service ADD VALUE 'new_service';Update application code to handle the new service
Document the service in api-keys.md
Create integration via API:
bashPOST /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:
-- 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 succeedMulti-Organization Testing
Test user switching between organizations:
-- 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 onlyAPI Key Encryption Testing
Test encryption/decryption roundtrip:
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:
- User not connected to organization in
user_organizations - Session variable not updated correctly
- RLS policy blocking access
Debug:
-- 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:
- Encryption key changed in environment
- Associated data mismatch (wrong organization_id)
- Data corruption in database
Debug:
# Check encryption key is set
echo $API_KEY_ENCRYPTION_KEY
# Check it's the same key used for encryption
# Re-encrypt if neededSee 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 DNSRoadmap
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:
- Browser hits
healthcorp.clinic.restartix.app - Next.js proxy extracts slug
healthcorpfrom hostname - Proxy calls
GET /v1/public/organizations/resolve?slug=healthcorp(public, no auth) - Core API returns org ID, name, slug, logo, icon, language
- Proxy sets
org-id,org-slug,org-nameas httpOnly cookies createApiClient()readsorg-idcookie and sendsX-Organization-IDheaderOrganizationContextmiddleware uses the header to set RLS session variables- 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:
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:
| Method | Path | Auth | Description |
|---|---|---|---|
GET | /v1/organizations/{id}/domains | Required | List domains for an org |
POST | /v1/organizations/{id}/domains | Admin | Add a custom domain |
DELETE | /v1/organizations/{id}/domains/{domainId} | Admin | Remove a custom domain |
POST | /v1/organizations/{id}/domains/{domainId}/verify | Admin | Trigger DNS verification |
DNS verification flow:
- Admin adds custom domain
clinic.myclinic.comvia API - System generates a UUID verification token
- Admin adds a DNS TXT record:
_restartix-verification.clinic.myclinic.com→{token} - Admin triggers verification — system calls
net.LookupTXTto check - If TXT matches, domain status →
verified,verified_atis set - Daily cron re-verifies; revokes if TXT removed (
status→failed)
Resolve endpoint extension:
The existing GET /v1/public/organizations/resolve gains a domain parameter:
GET /v1/public/organizations/resolve?domain=clinic.myclinic.comLooks 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— keepcurrent_organization_idas "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.appwith 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
- Database Schema - Complete SQL schema with indexes and RLS
- API Documentation - All organization API endpoints
- API Keys Guide - Encryption, rotation, and usage
- Authentication - Clerk integration and user management
- Database Schema Overview - Full system schema