Multi-tenancy

NotifyKit has first-class support for multi-tenant applications. Every operation can be scoped by tenantId (or its alias organizationId) and workspaceId. Data isolation is enforced at the framework level — not just by convention.

When do you need this? If your app has organizations, teams, or workspaces where one user can belong to multiple — and each should have separate notification state (different inbox, different preferences). If you have a single-tenant app, you can skip this page.
1
Scope on send

Pass tenantId / workspaceId with every send() call.

2
Scope on read

Return the scope from identify() in your handler — all queries filter automatically.

3
Isolation enforced

Cross-tenant access returns 403 or filters to empty. No shared state between orgs.

Data isolation

Every inbox, preference, and delivery is scoped by tenant. No shared state between organizations.

Workspace hierarchies

Two-level scoping (org + project) for apps with nested contexts like repos, channels, or boards.

Tenant-level defaults

Org admins control default channel states for their members. Users can still override individually.

Safe migration path

Move from single-tenant to multi-tenant without losing existing data or disrupting active users.

Scoping sends

Pass the tenant/workspace scope with every send:

await notify.send({
  recipientId: user.id,
  tenantId: org.id,          // or organizationId: org.id
  workspaceId: workspace.id, // optional
  notificationId: "comment_mentioned",
  payload: { actorName: "Rey", postUrl: "/posts/42" },
})

The scope is stored on the notification record, inbox items, and delivery rows. All subsequent reads are filtered by it.

Scoping the handler

In the client-facing handler, return the scope from identify():

app/api/notifykit/[...notifykit]/route.ts
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.workspaceId,
    }
  },
})

The handler enforces these scopes on every operation. A user in org A cannot see inbox items or preferences belonging to org B. Cross-tenant requests return 403 or filter to an empty set.

Integration checklist

Every place tenantId must appear. Miss one and you get silent cross-tenant leaks:

WhereHowIf you miss it
send()Pass tenantId on every callRecords are unscoped — visible to all orgs or invisible to the correct one
identify()Return tenantId from sessionHandler queries are unscoped — users see cross-org data
upsertRecipient()Include tenantId per-orgPreferences and inbox are shared across orgs for the same user
preferences.update()Include tenantId in scopePreference changes in one org bleed into another
Unsubscribe configScope is encoded in the HMAC token automaticallyN/A — handled by the engine if send() has the scope
Realtime (SSE)Scoped via identify() returnUsers receive events from other orgs in their stream
The most common bug: forgetting tenantId on send(). The handler scopes reads via identify(), so the inbox appears to work in testing. But without scope on writes, production data lands in an unscoped bucket — invisible to the correctly-scoped handler.

Tenant-level preference defaults

Different tenants may want different default channel states. Use tenantDefaults to override app-level defaults per tenant:

lib/notifykit.ts
const notify = createNotifyKit({
  // ...
  defaults: {
    channels: { inbox: true, email: true },
  },
  tenantDefaults: async (tenantId) => {
    const org = await db.query.organizations.findFirst({
      where: eq(organizations.id, tenantId),
    })
    if (org?.plan === "free") {
      return { email: false } // free plans don't get email by default
    }
    return null // use app defaults
  },
})

Preference resolution order

The tenant layer sits between notification defaults and user preferences. Tenant admins can override app defaults, but users can still override their tenant's choice:

LayerSet byExample
1. App defaultDeveloperEmail on for all notifications
2. Category/NotificationDeveloperMarketing emails off by default
3. TenantTenant adminFree plans: email off
4. User preferenceEnd userUser enables email anyway
5. Required overrideDeveloperPassword resets always deliver
Most specific wins. Each layer overrides the one above. See Preferences & unsubscribe for the full breakdown with resolution trails.

Mapping your app to tenancy

Use these questions to decide how many scope levels you need:

Can a user be in multiple organizations?

Yes → you need tenantId so each org gets its own inbox and preferences. No → you can skip tenancy entirely.

Are there sub-contexts with separate notification rules?

Yes (e.g. projects, channels, repos) → add workspaceId. No → tenantId alone is enough.

Should users manage preferences per sub-context?

Yes (e.g. mute one project but not another) → workspaceId is essential. No → keep it simple with just tenantId.

Your app hasMap toExample
Organizations/companiestenantIdSlack workspaces, Linear teams
Projects within an orgworkspaceIdVercel projects, GitHub repos
Single-tenant (no orgs)Omit bothPersonal apps, single-team tools
Both orgs and projectstenantId + workspaceIdNotion (team → workspace), Figma (org → project)

Scoping recipients

Recipients can belong to a tenant. This enables the common pattern where one user belongs to multiple organizations with separate notification state per org:

// Same user, two orgs — separate inboxes and preferences
await notify.upsertRecipient({ id: user.id, tenantId: "org_acme", email: user.email })
await notify.upsertRecipient({ id: user.id, tenantId: "org_globex", email: user.email })
One user, multiple orgs. When Alice switches from Acme to Globex in your app, she sees a different inbox, different preferences, and different unsubscribe state. The tenantId from identify() controls which org's data she sees.

Workspace scoping in practice

When your app has two levels of hierarchy (org → project, team → channel, company → repository), use workspaceId for the inner scope. This lets users mute one project without affecting their notifications elsewhere in the org.

// Project management app: user gets task notifications per-project
await notify.send({
  recipientId: user.id,
  tenantId: org.id,              // "Acme Inc"
  workspaceId: project.id,       // "Backend Refactor"
  notificationId: "task_assigned",
  payload: { taskTitle: "Fix auth bug", assignerName: "Rey" },
})

// Same user, same org, different project:
await notify.send({
  recipientId: user.id,
  tenantId: org.id,              // "Acme Inc"
  workspaceId: anotherProject.id, // "Mobile App"
  notificationId: "task_assigned",
  payload: { taskTitle: "Add dark mode", assignerName: "Sam" },
})

Per-workspace preferences

Users can mute notifications for a specific workspace without affecting their preferences in other workspaces within the same org:

// User mutes email for "Backend Refactor" but keeps it on elsewhere
await notify.preferences.update({
  recipientId: user.id,
  tenantId: org.id,
  workspaceId: "proj_backend_refactor",
  notificationId: "task_assigned",
  channels: { email: false },
})

// "Mobile App" still sends email — workspace preferences are independent
const explanation = await notify.explain({
  recipientId: user.id,
  tenantId: org.id,
  workspaceId: "proj_mobile_app",
  notificationId: "task_assigned",
  payload: { taskTitle: "Test", assignerName: "Test" },
})
// explanation.channels.email.outcome → "deliver"
Scope combinationInbox showsPreferences apply from
tenantId onlyAll items in that org (all workspaces)Org-level preferences
tenantId + workspaceIdOnly items in that specific workspaceWorkspace-level preferences (falls through to org-level if unset)

Building a "mute this project" UI

The most common workspace feature is a per-project mute toggle. Here's the pattern — a single button that suppresses all notifications from one workspace:

components/mute-project-button.tsx
import { usePreferences } from "@notifykitjs/react"

function MuteProjectButton({ workspaceId, workspaceName }: {
  workspaceId: string
  workspaceName: string
}) {
  const { isEnabled, update } = usePreferences()
  const isMuted = !isEnabled("*", "inbox", { workspaceId })

  return (
    <button onClick={() => update({
      notificationId: "*",
      workspaceId,
      channels: { inbox: !isMuted, email: !isMuted },
    })}>
      {isMuted ? `Unmute ${workspaceName}` : `Mute ${workspaceName}`}
    </button>
  )
}
Workspace mute uses the wildcard. Setting notificationId: "*" with a workspaceId disables all notifications for that workspace. The user can still override specific notifications back to "on" if they want — most-specific-wins resolution applies at the workspace level too.

Dynamic tenant settings

The tenantDefaults example above uses a static plan check. Real B2B apps need org admins to control notification settings at runtime — without a code deploy. Back the function with a database lookup:

ApproachWhen to useTrade-off
Static functionDefaults differ by plan tier onlySimple but requires a deploy to change
Database-backedOrg admins toggle channels from a settings panelFlexible but adds a DB query per send
Cached databaseHigh-volume sends where the extra query mattersBest of both — stale for up to cache TTL
lib/notifykit.ts
const notify = createNotifyKit({
  // ...
  tenantDefaults: async (tenantId) => {
    // Look up this org's admin-configured defaults
    const settings = await db.query.tenantNotificationSettings.findMany({
      where: eq(tenantNotificationSettings.tenantId, tenantId),
    })

    if (settings.length === 0) return null // fall through to app defaults

    // Merge global ("*") and notification-specific settings
    const global = settings.find(s => s.notificationId === "*")
    return global?.channels ?? null
  },
})

Admin endpoint for org settings

Expose an API route where org admins toggle channels for their organization. Individual users can still override via their own preferences — most-specific-wins applies.

app/api/admin/notification-settings/route.ts
import { auth } from "@/lib/auth"
import { db } from "@/lib/db"
import { tenantNotificationSettings } from "@/lib/schema"
import { eq, and } from "drizzle-orm"

export async function POST(request: Request) {
  const session = await auth(request)
  if (!session?.isOrgAdmin) return Response.json({ error: "Forbidden" }, { status: 403 })

  const { notificationId, channels } = await request.json()

  await db
    .insert(tenantNotificationSettings)
    .values({
      tenantId: session.organizationId,
      notificationId, // "*" for org-wide, or specific ID
      channels,
    })
    .onConflictDoUpdate({
      target: [tenantNotificationSettings.tenantId, tenantNotificationSettings.notificationId],
      set: { channels },
    })

  return Response.json({ ok: true })
}
components/org-notification-settings.tsx
function OrgNotificationSettings({ notifications }) {
  const orgId = useCurrentOrg().id

  async function toggleChannel(notificationId: string, channel: string, enabled: boolean) {
    await fetch("/api/admin/notification-settings", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        notificationId,
        channels: { [channel]: enabled },
      }),
    })
  }

  return (
    <div>
      <h2>Organization notification defaults</h2>
      <p>These apply to all members unless they override in their own settings.</p>
      <table>
        <thead>
          <tr><th>Notification</th><th>Inbox</th><th>Email</th></tr>
        </thead>
        <tbody>
          {notifications.map(n => (
            <tr key={n.id}>
              <td>{n.description}</td>
              <td><input type="checkbox" onChange={e => toggleChannel(n.id, "inbox", e.target.checked)} /></td>
              <td><input type="checkbox" onChange={e => toggleChannel(n.id, "email", e.target.checked)} /></td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  )
}
Cache for high-volume sends. The tenantDefaults function runs on every send(). For apps sending 100+ notifications/second, cache the result per tenant with a short TTL (30–60 seconds). Admin changes take effect after the cache expires — acceptable for default-level settings that change rarely.
What the admin controlsWhat users still control
Org-wide default: email on or off for each notificationIndividual override: a user can still enable email even if the org turned it off
Which channels are available to org membersPer-notification and per-workspace toggles within the available set
Global org mute (all channels off during an incident)Nothing — global off at the tenant layer suppresses everything except required
Tenant OFF + no user preference = no delivery. An absent user preference doesn't override a tenant setting — it means "I haven't chosen." For the user to get email after the org admin disables it, they must explicitly set email: true in their own preferences. Communicate this in your admin UI: "Members can still enable this individually."

Common pitfalls

SymptomRoot causeFix
User sees notifications from another orgidentify() doesn't return tenantIdAlways return the active org from the session — don't omit it even if nullable
Preferences reset when user switches orgPreferences were written without tenantId scopeEnsure tenantId is included in both send() and the handler's identify()
Unsubscribe link works for wrong orgToken signed without tenant scopeThe token is bound to the scope at send time — verify your send() includes tenantId
User gets notifications for orgs they leftRecipient record still exists for that tenantDelete or deactivate the scoped recipient when revoking org membership
SSE stream shows cross-org eventsRealtime subscription not scopedConfirm identify() returns the scope — the SSE handler uses it to filter events

Testing tenant isolation

Verify isolation in your test suite before shipping. This pattern catches the most common multi-tenant bugs — cross-org data leaks, missing scope in handlers, and broken preference scoping:

tests/tenant-isolation.test.ts
import { createNotifyKit, memoryAdapter, fakeEmailProvider } from "@notifykitjs/core"
import { createHandler } from "@notifykitjs/core"

const notify = createNotifyKit({
  notifications: [commentMentioned] as const,
  database: memoryAdapter(),
  providers: { email: fakeEmailProvider() },
})

// Send to same user in two different orgs
await notify.upsertRecipient({ id: "alice", tenantId: "org_acme", email: "a@test.com" })
await notify.upsertRecipient({ id: "alice", tenantId: "org_globex", email: "a@test.com" })

await notify.send({
  recipientId: "alice", tenantId: "org_acme",
  notificationId: "comment_mentioned",
  payload: { actorName: "Bob", postUrl: "/posts/1" },
})

// Handler scoped to org_globex should see nothing
const globexHandler = createHandler(notify, {
  identify: async () => ({ recipientId: "alice", tenantId: "org_globex" }),
})
const res = await globexHandler(new Request("http://localhost/api/notifykit/inbox"))
const { data: items } = await res.json()
expect(items).toHaveLength(0) // ✓ Acme notification not visible

// Handler scoped to org_acme sees the item
const acmeHandler = createHandler(notify, {
  identify: async () => ({ recipientId: "alice", tenantId: "org_acme" }),
})
const acmeRes = await acmeHandler(new Request("http://localhost/api/notifykit/inbox"))
const { data: acmeItems } = await acmeRes.json()
expect(acmeItems).toHaveLength(1) // ✓ correct isolation
What to assertWhy it mattersCatches
Inbox returns empty for wrong orgProves data isolation at the query layerMissing tenantId filter in adapter
Preferences don't bleed across orgsUser can have email off in Acme but on in GlobexScope missing from preference writes
SSE events only fire for the scoped orgRealtime must filter by tenant before broadcastingUnscoped pub/sub subscription
Unsubscribe token rejects wrong tenantHMAC is bound to scope at sign timeToken forged or replayed across orgs
Run this early. Add tenant isolation tests before you have real users. Use explain() to confirm the tenant layer appears in preference resolution trails.

Migrating from single-tenant to multi-tenant

If your app already has NotifyKit running without tenancy and you're adding organizations, you need to backfill existing records. The order below is critical — backfill data before deploying scoped handlers, or users will see empty inboxes until the migration catches up.

1
Drain in-flight sends

Call notify.drain() so queued sends land before you start migrating. Anything in-flight won't have a tenantId.

2
Backfill existing records

Assign a tenantId to all existing recipients, inbox items, and preferences. Query for tenantId IS NULL after — count must be zero.

3
Add tenantId to all send() calls

Every send must include the scope. Deploy this first — new data goes out scoped while reads still work unscoped.

4
Add tenantId to identify()

Deploy the scoped handler. Now reads filter by tenant — safe because all data already has a tenantId from steps 2–3.

5
Verify isolation

Log in as two different orgs and confirm inbox, preferences, and SSE are fully separated.

scripts/backfill-tenancy.ts
import { notifyKitSchema } from "@notifykitjs/drizzle"
import { isNull, eq } from "drizzle-orm"

const { recipients, inboxItems, preferences } = notifyKitSchema

// Map each user to their org (your app's logic)
const userOrgs = await db.select({ userId: users.id, orgId: users.organizationId }).from(users)

for (const { userId, orgId } of userOrgs) {
  // Backfill recipients
  await db.update(recipients)
    .set({ tenantId: orgId })
    .where(and(eq(recipients.id, userId), isNull(recipients.tenantId)))

  // Backfill inbox items
  await db.update(inboxItems)
    .set({ tenantId: orgId })
    .where(and(eq(inboxItems.recipientId, userId), isNull(inboxItems.tenantId)))

  // Backfill preferences
  await db.update(preferences)
    .set({ tenantId: orgId })
    .where(and(eq(preferences.recipientId, userId), isNull(preferences.tenantId)))
}
RiskWhat goes wrongPrevention
Deploy scoped reads before backfillUsers see empty inboxes — handler filters by tenantId but records have NULLComplete step 2 (backfill) before step 4 (scoped handler). Follow the order exactly.
Unscoped records remainOld inbox items visible to all orgs (or invisible to all)Query for tenantId IS NULL after step 2 — count must be zero before proceeding
Users in multiple orgsBackfill assigns only one org, orphaning the restCreate scoped recipient records per org membership, not per user
In-flight sends unscopedSends queued before migration land without tenantIdStep 1: drain the queue before starting the backfill
Don't skip the drain. Sends queued before step 2 land without a tenantId. If you backfill and then an old job writes an unscoped record, it becomes invisible to the scoped handler. notify.drain() ensures the queue is empty before you start.