Tenant & Whitelabel Configuration
Overview
Creatiq supports multi-tenancy with full whitelabel capabilities. Each tenant can have custom branding (colors, logo, app name, CSS) and an optional custom domain. Whitelabel features are restricted to the Premium plan.
Source files:
server/services/tenant/tenant-types.tsserver/services/tenant/tenant-service.tsserver/middleware/tenant-resolver.tsserver/routes/tenant.tsserver/middleware/feature-gate.tsserver/config/features.ts
Multi-Tenancy Model
Database Schema
Tenants are stored in the tenants table:
| Column | Type | Description |
|---|---|---|
id | UUID (PK) | Auto-generated tenant ID |
slug | VARCHAR(63), UNIQUE | URL-safe identifier (e.g., acme-school) |
name | VARCHAR(255) | Display name |
domain | VARCHAR(500) | Primary domain (e.g., acme.creatiq.app) |
custom_domain | VARCHAR(500), UNIQUE, nullable | Custom domain (e.g., content.acme.edu) |
brand_config | JSONB | Branding configuration |
smtp_config | JSONB, nullable | Tenant-specific SMTP settings |
plan | VARCHAR(50) | Subscription plan (free, pro, premium) |
active | BOOLEAN | Whether the tenant is active |
created_at | TIMESTAMP | Creation timestamp |
updated_at | TIMESTAMP | Last update timestamp |
Indexes:
idx_tenants_slugonslugidx_tenants_domainondomainidx_tenants_custom_domainoncustom_domain
Tenant Interface
interface Tenant {
id: string;
slug: string;
name: string;
domain: string;
customDomain: string | null;
brandConfig: BrandConfig;
smtpConfig: TenantSmtpConfig | null;
plan: string;
active: boolean;
createdAt: Date;
updatedAt: Date;
}
User-Tenant Association
Users are linked to tenants via the tenant_id column in creatiq_users. This association is established during JIT provisioning from the Keycloak token's tenant_id claim. A composite unique index ensures one external user ID per tenant:
CREATE UNIQUE INDEX idx_creatiq_users_tenant_external
ON creatiq_users (tenant_id, external_user_id)
WHERE tenant_id IS NOT NULL AND external_user_id IS NOT NULL;
Tenant Resolution
Tenant resolution is handled by the tenantResolver middleware, which runs on every request.
Resolution Strategy
The middleware resolves tenants in this order:
-
Exact domain match: Looks up
req.hostnameagainst bothtenants.domainandtenants.custom_domaincolumns. Only active tenants are matched. -
Subdomain extraction: If no exact match, the middleware extracts a subdomain from
{slug}.{BASE_DOMAIN}(whereBASE_DOMAINdefaults tocreatiq.app) and looks up by slug. -
Default tenant: If no tenant is resolved,
req.tenantremainsundefinedand the default Creatiq branding is used.
Subdomain Validation
Subdomains must match the slug pattern: ^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$
- Lowercase alphanumeric characters and hyphens only
- Must start and end with alphanumeric character
- Maximum 63 characters
- Nested subdomains (containing dots) are rejected
Configuration
| Variable | Description | Default |
|---|---|---|
BASE_DOMAIN | Base domain for subdomain resolution | creatiq.app |
Examples
| Hostname | Resolution |
|---|---|
acme.creatiq.app | Subdomain acme -> lookup by slug |
content.acme.edu | Exact match on custom_domain |
creatiq.app | No match -> default tenant |
localhost | No match -> default tenant |
Branding Configuration
BrandConfig Interface
interface BrandConfig {
primaryColor: string; // Hex color (e.g., "#6366f1")
logoUrl: string | null; // URL to logo image
faviconUrl: string | null; // URL to favicon
appName: string; // Application display name
customCss: string | null; // Custom CSS (max 50,000 characters)
}
Default Values
When no tenant-specific branding is configured, these defaults are used:
| Property | Default Value |
|---|---|
primaryColor | #6366f1 |
logoUrl | null |
faviconUrl | null |
appName | Creatiq |
customCss | null |
Get Current Tenant Branding
Endpoint: GET /api/tenant/current
No authentication required. Returns the resolved tenant's branding or the default config.
Response when a tenant is resolved (200):
{
"success": true,
"data": {
"isDefault": false,
"id": "uuid",
"slug": "acme-school",
"name": "Acme School",
"brandConfig": {
"primaryColor": "#2563eb",
"logoUrl": "https://cdn.acme.edu/logo.png",
"faviconUrl": null,
"appName": "Acme Learn",
"customCss": null
},
"plan": "premium"
}
}
Response when no tenant is resolved (200):
{
"success": true,
"data": {
"isDefault": true,
"brandConfig": {
"primaryColor": "#6366f1",
"logoUrl": null,
"faviconUrl": null,
"appName": "Creatiq",
"customCss": null
}
}
}
Update Brand Configuration
Endpoint: PUT /api/tenant/brand
Requires authentication and the whitelabel feature (Premium plan only).
Request:
{
"primaryColor": "#2563eb",
"logoUrl": "https://cdn.acme.edu/logo.png",
"faviconUrl": "https://cdn.acme.edu/favicon.ico",
"appName": "Acme Learn",
"customCss": ".header { background: navy; }"
}
All fields are optional. Only provided fields are updated (JSONB merge via || operator).
Validation rules:
primaryColor: Must be a 6-digit hex color (^#[0-9a-fA-F]{6}$)logoUrl: Valid URL, max 1000 characters, nullablefaviconUrl: Valid URL, max 1000 characters, nullableappName: 1-100 characterscustomCss: Max 50,000 characters, nullable
Response (200):
{
"success": true,
"data": {
"brandConfig": {
"primaryColor": "#2563eb",
"logoUrl": "https://cdn.acme.edu/logo.png",
"faviconUrl": "https://cdn.acme.edu/favicon.ico",
"appName": "Acme Learn",
"customCss": ".header { background: navy; }"
}
}
}
Custom Domains
Update Custom Domain
Endpoint: PUT /api/tenant/domain
Requires authentication and the whitelabel feature (Premium plan only).
Request:
{
"customDomain": "content.acme.edu"
}
Validation rules:
- 3-500 characters
- Must match
^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$ - Lowercase alphanumeric with dots and hyphens
- Must start and end with alphanumeric character
Response (200):
{
"success": true,
"data": {
"customDomain": "content.acme.edu"
}
}
The custom domain column has a UNIQUE constraint, so no two tenants can use the same domain.
DNS setup: After setting a custom domain, the tenant must create a CNAME record pointing to the Creatiq platform domain. Domain verification is handled at the infrastructure level.
SMTP Configuration
Each tenant can optionally have its own SMTP configuration for sending emails:
interface TenantSmtpConfig {
host: string;
port: number;
user: string;
pass: string;
from: string;
}
This field is stored as JSONB in the smtp_config column. There is currently no API endpoint to update SMTP config -- it is managed at the database level.
Whitelabel Feature Gate
All branding and custom domain endpoints are protected by the requireFeature('whitelabel') middleware, which restricts access to the Premium plan.
If a user on the Free or Pro plan attempts to update branding or domains, they receive:
{
"success": false,
"error": "feature_not_available",
"requiredPlan": "premium",
"message": "This feature requires the premium plan or higher"
}
Plan Requirements Summary
| Capability | Free | Pro | Premium |
|---|---|---|---|
| View current tenant branding | yes | yes | yes |
| Update brand configuration | - | - | yes |
| Set custom domain | - | - | yes |
| Custom CSS injection | - | - | yes |
Tenant Service API
The tenantService object in server/services/tenant/tenant-service.ts provides the following methods:
| Method | Description |
|---|---|
getTenantByDomain(domain) | Look up active tenant by domain or custom_domain |
getTenantBySlug(slug) | Look up active tenant by slug |
getTenantById(id) | Look up tenant by UUID (includes inactive) |
createTenant(data) | Create a new tenant with slug, name, domain, optional branding and plan |
updateTenant(id, data) | Update tenant fields (name, domain, customDomain, plan, active) |
updateBrandConfig(id, config) | Merge partial brand config into existing config |
Slug Validation
Tenant slugs are validated against the pattern ^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$ during creation. Invalid slugs are rejected with an error.
Environment Variables
| Variable | Description | Default |
|---|---|---|
BASE_DOMAIN | Base domain for subdomain-based tenant resolution | creatiq.app |