Skip to main content

Multi-Tenancy

The platform is multi-tenant — many independent loyalty programs share one deployment without ever seeing each other's data. The same admin URL, member portal, and public endpoints serve every tenant; what changes is which tenant is selected for the request.

This page is a mental model, not a configuration guide.

Host vs tenant

Two scopes:

  • Host scope — the platform operator. The host user has no tenant id (it's null). Host admins see the list of tenants, manage their lifecycle, and own platform-wide resources (external-game definitions, global feature toggles).
  • Tenant scope — each tenant is one loyalty program (e.g. Acme Rewards). Tenant admins see only their tenant's data: members, campaigns, channels, integrations.

Login form behaviour:

  • Leave the tenant field empty → host login.
  • Type a tenant slug → tenant login.

The MustHaveTenant filter

Every tenant-scoped table has a TenantId column. The platform applies a server-side filter (MustHaveTenant) that automatically appends WHERE TenantId = current to any query. This is on by default for every request — there's no way to "forget" to filter; you'd have to explicitly disable the filter.

What this means in practice:

  • A campaign that belongs to tenant A is invisible to tenant B. Asking for it by id returns null.
  • A search across members never crosses the boundary — tenant B searches return zero rows for tenant A members.
  • Workers that legitimately need cross-tenant access (e.g. the Meta-health probe sweeping every WhatsApp channel) must explicitly disable the filter and set the tenant id per row. This is rare and visible.

For partner impersonation, see Tenant impersonation — the partner inherits a synthetic tenant-scoped identity.

Public surfaces and tenant resolution

Public URLs (landing pages, public APIs, web chat) need a way to figure out which tenant is being addressed. The platform resolves the tenant from one of these signals, in order:

  1. An explicit slug in the URL (e.g. /landing/{tenantSlug}, /api/public/legal-terms/{slug}/...).
  2. A custom domain — if the request's Host header matches an entry in WhiteLabelConfig.CustomDomain for some tenant, that tenant is selected. This is how branded vanity URLs work (loyalty.brand.com → Brand's tenant).
  3. The Abp-TenantId header — used by the admin SPA to switch tenants in preview mode.

Campaign-scoped URLs (/landing/c/{slugOrId}) accept either a vanity slug (a human-readable string you set on the campaign) or a numeric id. Both resolve to the same campaign — useful when you rename the slug but old links must keep working.

Unique constraints across tenants

Database constraints that would be globally unique in a single-tenant system are scoped to (TenantId, ...). For example, "phone numbers are unique" really means "phone numbers are unique within a tenant" — the same phone can be a member of tenant A and tenant B.

This affects how you reason about merging or moving members between tenants: you can't (and shouldn't). A member belongs to exactly one tenant.

Soft-delete and tenant scope

Soft-deleted rows still belong to a tenant. The MustHaveTenant filter applies before the ISoftDelete filter, so deleted rows in tenant A are still invisible to tenant B. GDPR erasure (see GDPR erasure) uses both filters together — the row goes soft-deleted and gets PII-scrubbed, but it's still owned by the originating tenant for accounting purposes.