Multi-tenancy
Scope notification state and delivery to an organization or workspace.
Two Levels
Every send, inbox item, delivery, and preference can carry two scope fields. Use them when one user can belong to several organizations, each with its own inbox and settings. A single-tenant app can ignore this page.
| Field | Use it for | Examples |
|---|---|---|
tenantId (alias organizationId) | The organization or team | A Slack workspace, a Linear team |
workspaceId | A context inside it with its own notification rules | A project, a repository, a board |
If you pass both tenantId and organizationId, tenantId wins.
Sending
await notify.send({
recipientId: user.id,
tenantId: org.id,
workspaceId: project.id, // optional
notificationId: "task_assigned",
payload: { taskTitle: "Fix auth bug", assignerName: "Rey" },
})The scope is stored on the notification record, its inbox items, and its deliveries. Rate limits and digests are counted per scope, so two organizations never share a counter or a batch. Deduplication and idempotency keys are not: they are matched by notification and recipient only, so make the key itself unique if the same event can happen in two organizations.
Recipients
A recipient is one record per id. Its tenantId and workspaceId are optional, and they decide how sends are scoped:
- User in one organization: set
tenantIdon the recipient. Sends that leave the scope out use it, and a send naming a different tenant throwsTENANT_MISMATCHinstead of crossing organizations. - User in several organizations: leave
tenantIdoff the recipient and pass it on every send, and fromidentify(). The same person then has a separate inbox and separate preferences in each organization.
// One person, many orgs: no tenant on the recipient itself
await notify.upsertRecipient({ id: user.id, email: user.email })
await notify.send({ recipientId: user.id, tenantId: "org_acme", /* ... */ })
await notify.send({ recipientId: user.id, tenantId: "org_globex", /* ... */ })In the Handler
Browser requests are scoped by what identify() returns. Scope fields sent by the client are never used.
export const { GET, POST, DELETE, OPTIONS, dynamic } = createRouteHandler({
notifykit: notify,
identify: async (request) => {
const session = await auth(request)
if (!session) return null
return {
recipientId: session.user.id,
tenantId: session.organizationId,
workspaceId: session.projectId, // optional
}
},
})Inbox lists, unread counts, preferences, and the realtime stream only include records in that scope. Marking read, archiving, or deleting an item outside it returns 403 or 404. See Security model.
| identify() returns | The inbox shows |
|---|---|
tenantId | Every item in that organization, across all its workspaces |
tenantId and workspaceId | Only that workspace's items |
Checklist
A missed scope rarely fails loudly; the records just land somewhere the scoped reads never look.
| Where | Pass the scope | If you forget |
|---|---|---|
send() | On every call, unless the recipient carries it | Items are stored unscoped and a scoped inbox never shows them |
identify() | From the session | Users can see every scope's items for their id |
preferences.update() | On server-side updates | The change applies to the unscoped preferences |
| Unsubscribe links | Nothing to do | The scope is signed into the token when the email is sent |
Preferences Per Scope
Preferences are stored with their exact scope, so a user can turn off email for one project and keep it everywhere else:
await notify.preferences.update({
recipientId: user.id,
tenantId: org.id,
workspaceId: "proj_backend",
notificationId: "task_assigned",
channels: { email: false },
})A preference only applies to sends with exactly the same scope. A workspace-scoped send does not fall back to the user’s organization-wide preference; if the workspace has none, the defaults below apply.
Muting a whole workspace
updateGlobal() sets a user’s preference for every notification at once, and takes a scope like everything else:
await notify.preferences.updateGlobal({
recipientId: user.id,
tenantId: org.id,
workspaceId: "proj_backend",
channels: { inbox: false, email: false },
})A per-notification preference in the same scope still wins over it, so a user can mute a project and keep one notification on.
Tenant Defaults
tenantDefaults lets each organization change the default channels for its members. It is called with the tenant id whenever preferences are resolved, and returns a channel map or null:
createNotifyKit({
// ...
defaults: { channels: { inbox: true, email: true } },
tenantDefaults: async (tenantId) => {
const org = await db.query.organizations.findFirst({
where: eq(organizations.id, tenantId),
})
return org?.plan === "free" ? { email: false } : null
},
})It sits above the app, notification, and category defaults and below everything the user sets, so a member can still turn email back on for themselves. The full order is on Preferences.