docs/platform/subscription/manage-topic-subscriptions.mdx
Topic subscriptions let a subscriber opt in to a topic with fine-grained rules. Each subscription can target specific workflows and include a JSON Logic condition that is evaluated when a workflow is triggered to the topic.
<Note> Conditions are evaluated at **trigger time**, not when the subscription is created. A subscription is always stored; Novu decides at delivery whether the condition matches the incoming event data. </Note>Topic subscriptions can be scoped to a Context, the same metadata you pass when triggering a workflow. This lets one subscriber maintain separate subscriptions to the same topicKey for different tenants, apps, or regions without duplicating topics or workflows.
Novu uses exact-match Context filtering for subscriptions, the same rules as the Inbox:
| Context on subscription | Context on trigger | Subscription included? |
|---|---|---|
{ tenant: "acme-corp" } | { tenant: "acme-corp" } | Yes |
{ tenant: "acme-corp" } | { tenant: "globex" } | No |
| None (default) | None (default) | Yes |
| None (default) | { tenant: "acme-corp" } | No |
{ tenant: "acme-corp" } | None (default) | No |
Both sides must match: key for key and value for value. Passing Context on only the subscription or only the trigger is not enough.
<Note> When you omit Context on a subscription or trigger, Novu treats it as the **default** (no Context) scope, not as a wildcard that matches all Contexts. </Note>At subscription create time, Context is stored on the subscription record:
context object in the create request body (same shape as trigger Context).@novu/js / @novu/react: pass context when initializing the Novu client or <NovuProvider>. The session inherits that Context for all subscription API calls.At trigger time, pass the same Context on the workflow trigger. Novu resolves topic members whose stored Context matches the trigger Context, then evaluates each matching subscription's JSON Logic condition.
If Novu auto-generates a subscription identifier and Context is present, the identifier includes a :ctx_ suffix so subscriptions in different Contexts remain unique.
See Contexts for structure, limits (up to five keys per trigger), and how Context differs from payload.
| Path | Auth | Best for |
|---|---|---|
| Topics v2 API | API key (Authorization: ApiKey …) | Backend provisioning: subscribe users from your server, set conditions programmatically |
| Inbox API | Subscriber JWT (via @novu/js / @novu/react) | Subscriber self-service: follow buttons, preference centers, custom UIs |
@novu/js | Subscriber JWT | Headless JavaScript/TypeScript apps |
@novu/react | Subscriber JWT | React/Next.js apps with hooks or <Subscription /> components |
A subscriber can have up to 10 subscriptions per topic, each with its own identifier and conditions. See the introduction for how subscriptions fit into the notification flow.
Use the server API when your backend creates subscriptions on behalf of subscribers, for example during onboarding or when syncing preferences from your database.
Endpoint: POST /v2/topics/{topicKey}/subscriptions
The topic is created automatically if it does not exist. You can pass subscriber IDs directly or use custom subscription identifiers when a subscriber needs multiple subscriptions on the same topic.
const novu = new Novu({ secretKey: process.env.NOVU_SECRET_KEY! });
await novu.topics.subscriptions.create( { subscriptions: [{ identifier: 'user-123-alerts', subscriberId: 'user-123' }], preferences: ['product-update-workflow'], }, 'product-updates', );
</Tab>
<Tab title="Python">
```python
import os
from novu_py import Novu
with Novu(secret_key=os.getenv("NOVU_SECRET_KEY", "")) as novu:
novu.topics.subscriptions.create(
topic_key="product-updates",
create_topic_subscriptions_request_dto={
"subscriptions": [
{"identifier": "user-123-alerts", "subscriberId": "user-123"}
],
"preferences": ["product-update-workflow"],
},
)
Scope a subscription to a tenant (or any Context type) by passing context in the request body:
When you trigger the workflow to the topic, pass the same Context so this subscription is included:
await novu.trigger({
workflowId: 'product-update-workflow',
to: { type: 'Topic', topicKey: 'product-updates' },
payload: { status: 'completed' },
context: {
tenant: { id: 'acme-corp' },
},
});
Attach a condition to a workflow preference. When you trigger the workflow to the topic, only subscriptions whose condition evaluates to true receive the notification.
| Method | Endpoint | Description |
|---|---|---|
GET | /v2/topics/{topicKey}/subscriptions | List subscriptions on a topic |
GET | /v2/topics/{topicKey}/subscriptions/{identifier} | Get one subscription |
PATCH | /v2/topics/{topicKey}/subscriptions/{identifier} | Update name or preferences |
DELETE | /v2/topics/{topicKey}/subscriptions | Remove subscriptions |
GET | /v2/subscribers/{subscriberId}/subscriptions | List all topic subscriptions for a subscriber |
The Inbox API is the subscriber-facing surface that powers @novu/js and @novu/react. It uses a subscriber session token (created from your applicationIdentifier and subscriberId) instead of an API key.
| Method | Endpoint | Description |
|---|---|---|
POST | /v1/inbox/topics/{topicKey}/subscriptions | Create a subscription for the authenticated subscriber |
GET | /v1/inbox/topics/{topicKey}/subscriptions | List the subscriber's subscriptions on a topic |
GET | /v1/inbox/topics/{topicKey}/subscriptions/{identifier} | Get one subscription |
PATCH | /v1/inbox/topics/{topicKey}/subscriptions/{identifier} | Update subscription |
DELETE | /v1/inbox/topics/{topicKey}/subscriptions/{identifier} | Delete subscription |
PATCH | /v1/inbox/subscriptions/{identifier}/preferences/{workflowId} | Update a single workflow preference |
You typically do not call these endpoints directly. Use the SDKs below instead.
@novu/jsInitialize the client with the subscriber's credentials. Pass context when the subscription belongs to a specific tenant or app scope:
import { Novu } from '@novu/js';
const novu = new Novu({
subscriberId: 'user-123',
applicationIdentifier: 'YOUR_APPLICATION_IDENTIFIER',
context: {
tenant: { id: 'acme-corp', data: { name: 'Acme Corporation', plan: 'enterprise' } },
},
});
Then use novu.subscriptions:
const { data, error } = await novu.subscriptions.create({
topicKey: 'product-updates',
identifier: 'user-123-premium-alerts',
preferences: [
{
workflowId: 'product-update-workflow',
condition: {
'===': [{ var: 'payload.tier' }, 'premium'],
},
},
],
});
const { data: subscription } = await novu.subscriptions.get({
topicKey: 'product-updates',
identifier: 'user-123-premium-alerts',
});
await subscription?.updatePreference({
workflowId: 'product-update-workflow',
value: {
'===': [{ var: 'payload.tier' }, 'enterprise'],
},
});
// List all subscriptions on a topic
const { data: subscriptions } = await novu.subscriptions.list({ topicKey: 'product-updates' });
// Update subscription metadata and preferences
await novu.subscriptions.update({
topicKey: 'product-updates',
identifier: 'user-123-premium-alerts',
preferences: [{ workflowId: 'product-update-workflow', enabled: false }],
});
// Delete
await novu.subscriptions.delete({
topicKey: 'product-updates',
identifier: 'user-123-premium-alerts',
});
@novu/reactThe React SDK exposes the same operations as hooks and pre-built components.
import {
useSubscription,
useCreateSubscription,
useUpdateSubscription,
useRemoveSubscription,
} from '@novu/react';
function SubscriptionSettings() {
const topicKey = 'product-updates';
const identifier = 'user-123-premium-alerts';
const { subscription, isLoading } = useSubscription({ topicKey, identifier });
const { create, isCreating } = useCreateSubscription();
const handleSubscribe = async () => {
await create({
topicKey,
identifier,
preferences: [
{
workflowId: 'product-update-workflow',
condition: {
'===': [{ var: 'payload.tier' }, 'premium'],
},
},
],
});
};
if (isLoading) return <div>Loading…</div>;
return (
<button onClick={handleSubscribe} disabled={isCreating || !!subscription}>
{subscription ? 'Subscribed' : 'Subscribe to updates'}
</button>
);
}
For the full hook reference, see Headless hooks.
If you want a ready-made subscribe/preferences UI, use <Subscription />, <SubscriptionButton />, and <SubscriptionPreferences /> inside <NovuProvider>:
import { NovuProvider, Subscription, SubscriptionButton, SubscriptionPreferences } from '@novu/react';
export function SubscriptionSettings() {
return (
<NovuProvider
subscriber="user-123"
applicationIdentifier="YOUR_APPLICATION_IDENTIFIER"
context={{
tenant: { id: 'acme-corp', data: { name: 'Acme Corporation', plan: 'enterprise' } },
}}
>
<Subscription topicKey="product-updates" identifier="user-123-premium-alerts">
<SubscriptionButton />
<SubscriptionPreferences />
</Subscription>
</NovuProvider>
);
}
See the quickstart for setup steps. For Context on <NovuProvider>, see Inbox with Context.
Subscription conditions use JSON Logic, the same rule format as step conditions. Reference trigger data with { "var": "<namespace>.<path>" }.
Topic subscription conditions are evaluated at trigger time when fanning out a workflow to a topic. The following namespaces are in scope:
| Namespace | Source | Examples |
|---|---|---|
payload.* | Trigger payload | payload.tier, payload.status, payload.category |
subscriber.* | Recipient subscriber profile | subscriber.email, subscriber.locale, subscriber.data.plan |
actor.* | Optional trigger actor (who performed the action) | actor.subscriberId, actor.email, actor.data.role |
context.* | Context passed on the trigger | context.tenant.id, context.tenant.data.plan |
{ "var": "..." }. For example, { "var": "payload.tier" }, { "var": "subscriber.data.plan" }, or { "var": "context.tenant.id" }.{{payload.tier}}) in conditions. That syntax is for workflow step content, not JSON Logic rules.payload., subscriber., actor., or context.). For example, use payload.tier rather than tier.Tier is premium:
{
"===": [{ "var": "payload.tier" }, "premium"]
}
Subscriber custom data:
{
"===": [{ "var": "subscriber.data.plan" }, "enterprise"]
}
Trigger actor:
{
"===": [{ "var": "actor.data.role" }, "admin"]
}
Only subscriptions whose condition passes are delivered when the trigger includes a matching actor. If the trigger omits actor, conditions that reference actor.* evaluate against missing data and typically do not match.
Context tenant:
{
"===": [{ "var": "context.tenant.id" }, "acme-corp"]
}
Status and price combined:
{
"and": [
{ "==": [{ "var": "payload.status" }, "completed"] },
{ ">": [{ "var": "payload.price" }, 100] }
]
}
Multiple matching rules (OR):
{
"or": [
{
"===": [{ "var": "payload.tier" }, "premium"]
},
{
"===": [{ "var": "payload.tier" }, "enterprise"]
}
]
}
condition vs enabled| Field | Behavior |
|---|---|
condition | JSON Logic rule evaluated against trigger payload, subscriber profile, actor, and context. When present, enabled is ignored during evaluation. |
enabled | Simple on/off toggle. Used only when no condition is set. Defaults to true if omitted. |
When creating preferences, use any of these shapes:
// Workflow ID shorthand: enables the workflow with no condition
"workflow-identifier"
// Single workflow with condition or enabled flag
{
"workflowId": "product-update-workflow",
"condition": { "===": [{ "var": "payload.tier" }, "premium"] }
}
// Group filter: applies to workflows matching IDs or tags
{
"filter": { "workflowIds": ["workflow-mongo-id"], "tags": ["alerts"] },
"condition": { "==": [{ "var": "payload.status" }, "active"] }
}
filter.workflowIds accepts either the workflow MongoDB _id or the workflow trigger identifier.
await novu.trigger({
workflowId: 'product-update-workflow',
to: { type: 'Topic', topicKey: 'product-updates' },
payload: {
tier: 'premium',
status: 'completed',
category: 'billing',
},
context: {
tenant: { id: 'acme-corp' },
},
});
Novu Cloud bills by workflow runs: one execution of a workflow for one subscriber. Usage is counted when Novu creates a workflow run for that subscriber, not when you call the trigger API.
Topic subscription filtering happens before that point, during topic fan-out:
If a subscriber is filtered out because their Context does not match the trigger, or because their subscription condition evaluates to false, no workflow run is created for that subscriber and it does not count toward usage.
| Outcome | Workflow run created? | Counts toward usage? |
|---|---|---|
| Subscription Context does not match trigger Context | No | No |
JSON Logic condition evaluates to false | No | No |
| Subscription passes, workflow executes (even if steps are skipped) | Yes | Yes |
| Subscription passes, channel preference blocks a channel | Yes | Yes (run still created) |
identifier