Multi-tenancy
Facteur supports multi-tenancy out of the box. This is useful for SaaS applications where you need to isolate notifications and preferences per tenant (organization, workspace, team, etc.).
Use case
Imagine a SaaS application like Slack or Notion where users belong to different organizations. Each user might have different notification preferences per organization:
- In Organization A, a user wants email + Slack notifications
- In Organization B, the same user only wants in-app notifications
Facteur carries a tenantId through notification delivery and preference resolution. This context is not an authorization boundary: your application must validate tenant access, and the built-in API has the filtering limitations described below.
Sending notifications for a tenant
Use the .tenant() method when sending a notification:
await facteur
.notification(InvoicePaidNotification)
.params({ amount: 100 })
.to(user)
.tenant('org-123') // Scope to this tenant
.send()
The tenantId is passed through the entire pipeline. Your notification can access it if needed:
import { Notification, type NotificationOptions } from '@facteurjs/core/types'
import { TwilioMessage } from '@facteurjs/core/channels/twilio'
import { DatabaseMessage } from '@facteurjs/core/database'
import type { User } from './user.js'
export class InvoicePaidNotification extends Notification<User, { amount: number }> {
static options: NotificationOptions<User> = {
name: 'Invoice Paid',
deliverBy: { twilio: true, database: true },
}
asTwilioMessage() {
return TwilioMessage.create().setBody(
`Invoice of $${this.params.amount} paid for organization ${this.tenantId}.`,
)
}
asDatabaseMessage() {
return DatabaseMessage.create().setContent({
message: `Invoice of $${this.params.amount} paid`,
})
}
}
User preferences per tenant
Facteur supports a hierarchical preference system with 4 levels (from most to least specific):
- Notification-specific tenant preference - "Disable email for Invoice Paid in org-123"
- Tenant global preference - "Disable email for all notifications in org-123"
- Notification-specific global preference - "Disable email for Invoice Paid everywhere"
- Global preference - "Disable email for everything"
The resolver orders sources this way, but generated default values can mask lower-priority settings. See User Preferences for the current limitations.
Example
// User preferences structure in database
{
global: {
global: { channels: { email: true, sms: true } },
notifications: []
},
tenants: {
'org-123': {
global: { channels: { email: false } }, // Email disabled for this tenant
notifications: [
{
notification: { identifier: 'invoice-paid' },
channels: { email: true } // But re-enabled for Invoice Paid
}
]
}
}
}
With these already-resolved channel values, the priority order for a notification scoped to org-123 is:
- Invoice Paid: Email enabled (notification-specific tenant preference wins)
- Other notifications: Email disabled (tenant global preference applies)
In-app notifications with tenants
When using the database channel, notifications are stored with the tenant_id:
SELECT * FROM notifications WHERE notifiable_id = 'user-123' AND tenant_id = 'org-123';
Supplying a tenant to list/mark-all operations filters by that tenant. Omitting it does not filter to null-tenant notifications: the built-in adapters list or update notifications across the user's tenants. Validate tenant membership in your API authorization. Single-notification mark-as updates match the notification ID, owner and exact tenant; omitting tenantId matches only notifications without a tenant. See Server API.
API and SDK
All API endpoints accept a tenantId parameter:
// Backend - using Facteur routes
GET /notifications/notifiable/:notifiableId/notifications?tenantId=org-123
GET /notifications/notifiable/:notifiableId/preferences?tenantId=org-123
POST /notifications/notifiable/:notifiableId/preferences
{ tenantId: 'org-123', preferences: { email: false } }
// Frontend - using the SDK
import { FacteurProvider, useNotifications } from '@facteurjs/react'
// User identity belongs in the provider; tenant filters belong in calls.
;<FacteurProvider apiUrl={import.meta.env.VITE_API_URL} notifiableId={user.id}>
<App />
</FacteurProvider>
const { data: notifications } = useNotifications({ tenantId: currentOrganization.id })
// Also pass tenantId to preferences and mark-all calls.
Without a tenant
If you don't use .tenant() and neither the database message nor target provides a tenant, new notifications have no tenant. Preference reads without a tenant return global preferences only; notification list/mark-all operations without a tenant cover all of the user's tenant scopes. This is fine for single-tenant applications but is not an isolation boundary for multi-tenant apps.
// No tenant - works for single-tenant apps
await facteur.notification(WelcomeNotification).to(user).send()