docs/.mintlify/skills/inbox-integration/references/multi-tenancy.md
Use Contexts to scope notifications by tenant, workspace, organization, environment, or feature area — without duplicating workflows or subscribers. The Inbox displays only notifications whose trigger context exactly matches the Inbox context.
Contexts are a Novu primitive. If you're new to them, start with the Contexts overview.
The Inbox uses exact-match filtering. The full context object passed to the <Inbox> must equal (key for key, value for value) the context used at trigger time:
| Workflow Context | Inbox Context | Displayed? |
|---|---|---|
{ tenant: "acme" } | { tenant: "acme" } | ✅ |
{} | {} | ✅ |
{} | { tenant: "acme" } | ❌ |
{ tenant: "acme" } | { tenant: "globex" } | ❌ |
{ tenant: "acme" } | {} | ❌ |
{ tenant: "acme", app: "first" } | { tenant: "acme" } | ❌ |
This isolation makes context predictable and tamper-resistant (when combined with contextHash).
A tenant context is a JSON object that identifies a tenant. The id is the only required field; data is optional metadata.
const acmeContext = {
tenant: {
id: "acme-corp",
data: {
name: "Acme Corporation",
plan: "enterprise",
logo: "https://cdn.acme.com/logo.png",
},
},
};
You can also pass a bare string for simple cases:
const acmeContext = { tenant: "acme-corp" };
Contexts are auto-created when first seen by Novu (via trigger or Inbox). Existing contexts are not auto-updated to prevent overwriting tenant data.
To manage contexts manually, use the dashboard's Contexts section, the API, or @novu/api.
import { Novu } from "@novu/api";
const novu = new Novu({ secretKey: process.env.NOVU_SECRET_KEY! });
await novu.trigger({
workflowId: "invoice-paid",
to: { subscriberId: "user-123" },
payload: { amount: "$250" },
context: {
tenant: {
id: "acme-corp",
data: { name: "Acme Corporation", plan: "enterprise" },
},
},
});
All notifications emitted by this trigger are isolated to the acme-corp tenant. They will only surface in an Inbox initialized with the same tenant context.
import { Inbox } from "@novu/react";
<Inbox
applicationIdentifier="YOUR_NOVU_APP_ID"
subscriberId="user-123"
subscriberHash={subscriberHash}
context={{
tenant: {
id: "acme-corp",
data: { name: "Acme Corporation", plan: "enterprise" },
},
}}
/>;
If a subscriber switches tenants in your app, re-render the Inbox with the new context. The Inbox automatically refetches notifications and reconnects the WebSocket scope.
contextHashBecause context is set on the client, a hostile user could swap the tenant ID to peek at another tenant's notifications. To prevent that, generate an HMAC hash of a canonicalized context server-side.
Canonicalization is required because JSON objects with the same data but different key order would otherwise produce different hashes. Use a library that implements RFC-8259 canonicalization.
import { createHmac } from "crypto";
import { canonicalize } from "@tufjs/canonical-json";
const context = {
tenant: {
id: "acme-corp",
data: { name: "Acme Corporation", plan: "enterprise" },
},
};
export function getContextHash(context: object): string {
return createHmac("sha256", process.env.NOVU_SECRET_KEY!)
.update(canonicalize(context))
.digest("hex");
}
const contextHash = getContextHash(context);
Pass both context and contextHash to the Inbox:
<Inbox
applicationIdentifier="YOUR_NOVU_APP_ID"
subscriberId="user-123"
subscriberHash={subscriberHash}
context={context}
contextHash={contextHash}
/>
If you change the
contextyou must regenerate thecontextHash. The hash is bound to the exact, canonicalized JSON.
"use client";
import { useState } from "react";
import { Inbox } from "@novu/react";
export function MultiTenantInbox({ user, tenants }) {
const [activeTenant, setActiveTenant] = useState(tenants[0]);
return (
<>
<select
value={activeTenant.id}
onChange={(e) => setActiveTenant(tenants.find((t) => t.id === e.target.value))}
>
{tenants.map((t) => (
<option key={t.id} value={t.id}>{t.data.name}</option>
))}
</select>
<Inbox
applicationIdentifier={process.env.NEXT_PUBLIC_NOVU_APP_ID!}
subscriberId={user.id}
subscriberHash={user.subscriberHash}
context={{ tenant: activeTenant }}
contextHash={user.contextHashByTenant[activeTenant.id]}
/>
</>
);
}
Pre-compute one contextHash per tenant on the server:
const contextHashByTenant = Object.fromEntries(
user.tenants.map((t) => [t.id, getContextHash({ tenant: t })]),
);
Contexts aren't limited to tenants — use them to scope by feature area, environment, or anything else:
context: {
app: "billing",
workspace: "production",
}
The Inbox in your billing dashboard sees only billing notifications; the inbox in your monitoring dashboard sees only monitoring notifications.
context: {
tenant: { id: "acme-corp", data: { name: "Acme" } },
app: "billing",
}
The Inbox must declare the same combined context to see these notifications.
Once a context is created, its data is exposed in every template editor (email, in-app, SMS, push) via the {{context}} helper. This lets you personalize content per tenant from a single workflow definition.
Example In-App body:
Hello {{subscriber.firstName}}, your {{context.tenant.data.plan}} plan
on {{context.tenant.data.name}} just renewed.
You can also branch workflow logic on context. See Contexts in Workflows.
contextHash if context is set. Subscribers will see no notifications otherwise.{ a: 1, b: 2 } and { b: 2, a: 1 } produce the same hash.context triggers a re-fetch and a new WebSocket subscription scope.context={{}} is treated as "no context". Notifications triggered with a non-empty context will not appear, and vice versa.{ tenant: "acme" } is not equal to { tenant: { id: "acme" } }. Pick one shape and use it consistently in both trigger and Inbox.contextHash. A stale hash silently drops notifications.canonicalize step.data — context data is read from the client. Don't include API keys, tokens, or PII you wouldn't otherwise expose.