BackProduction

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.

FieldUse it forExamples
tenantId (alias organizationId)The organization or teamA Slack workspace, a Linear team
workspaceIdA context inside it with its own notification rulesA 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 tenantId on the recipient. Sends that leave the scope out use it, and a send naming a different tenant throws TENANT_MISMATCH instead of crossing organizations.
  • User in several organizations: leave tenantId off the recipient and pass it on every send, and from identify(). 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.

app/api/notifykit/[...notifykit]/route.ts
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() returnsThe inbox shows
tenantIdEvery item in that organization, across all its workspaces
tenantId and workspaceIdOnly that workspace's items

Checklist

A missed scope rarely fails loudly; the records just land somewhere the scoped reads never look.

WherePass the scopeIf you forget
send()On every call, unless the recipient carries itItems are stored unscoped and a scoped inbox never shows them
identify()From the sessionUsers can see every scope's items for their id
preferences.update()On server-side updatesThe change applies to the unscoped preferences
Unsubscribe linksNothing to doThe 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:

lib/notifykit.ts
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.