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:
- An explicit slug in the URL (e.g.
/landing/{tenantSlug},/api/public/legal-terms/{slug}/...). - A custom domain — if the request's
Hostheader matches an entry inWhiteLabelConfig.CustomDomainfor some tenant, that tenant is selected. This is how branded vanity URLs work (loyalty.brand.com→ Brand's tenant). - The
Abp-TenantIdheader — 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.