Novu vs Knock vs Courier: Notification Infrastructure APIs (2026)
TL;DR
Novu is the open-source notification infrastructure — multi-channel delivery (email, SMS, push, in-app, chat), workflow engine, subscriber preferences, digest/batch, self-hostable. Knock is the developer-friendly notification API — workflows, preferences, in-app feeds, batching, real-time, designed for fast integration. Courier is the notification orchestration platform — designer-friendly template builder, routing, preferences, multi-channel, strong enterprise features. In 2026: Novu for open-source notification infrastructure, Knock for the best developer experience, Courier for visual template design and enterprise.
Key Takeaways
- Novu: @novu/node ~80K weekly downloads — open-source, self-hostable, workflow engine
- Knock: @knocklabs/node ~30K weekly downloads — developer-first, real-time feeds, batching
- Courier: @trycourier/courier ~20K weekly downloads — visual designer, routing, enterprise
- Novu is open-source and can be self-hosted
- Knock has the best in-app notification feed component
- Courier has the most powerful visual template designer
Novu
Novu — open-source notification infrastructure:
Setup and trigger
import { Novu } from "@novu/node"
const novu = new Novu(process.env.NOVU_API_KEY!)
// Trigger a notification workflow:
await novu.trigger("package-update", {
to: {
subscriberId: "user-123",
email: "royce@example.com",
firstName: "Royce",
},
payload: {
packageName: "react",
oldVersion: "18.3.1",
newVersion: "19.0.0",
changelogUrl: "https://pkgpulse.com/react/changelog",
},
})
// Trigger to multiple subscribers:
await novu.trigger("weekly-digest", {
to: [
{ subscriberId: "user-123" },
{ subscriberId: "user-456" },
{ subscriberId: "user-789" },
],
payload: {
week: "2026-W10",
topPackages: ["react", "vue", "svelte"],
totalUpdates: 150,
},
})
Subscriber management
import { Novu } from "@novu/node"
const novu = new Novu(process.env.NOVU_API_KEY!)
// Create/update subscriber:
await novu.subscribers.identify("user-123", {
email: "royce@example.com",
firstName: "Royce",
lastName: "Claw",
phone: "+1234567890",
avatar: "https://example.com/avatar.png",
data: {
plan: "pro",
timezone: "America/New_York",
trackedPackages: ["react", "vue", "next"],
},
})
// Set subscriber preferences:
await novu.subscribers.setPreference("user-123", "package-update", {
channel: {
email: true,
in_app: true,
sms: false,
push: false,
chat: false,
},
})
// Get subscriber notifications:
const feed = await novu.subscribers.getNotificationsFeed("user-123", {
page: 0,
limit: 20,
})
feed.data.forEach((notification) => {
console.log(`${notification.templateIdentifier}: ${notification.content}`)
})
// Mark notification as read:
await novu.subscribers.markMessageAs("user-123", "message-id", {
seen: true,
read: true,
})
Workflow definition (code-first)
import { workflow } from "@novu/framework"
// Define notification workflow:
const packageUpdate = workflow("package-update", async ({ step, payload }) => {
// In-app notification (always):
await step.inApp("in-app-alert", async () => ({
body: `${payload.packageName} updated to ${payload.newVersion}`,
avatar: `https://pkgpulse.com/icons/${payload.packageName}.svg`,
redirect: { url: payload.changelogUrl },
}))
// Email (digest — batch over 1 hour):
await step.digest("batch-updates", async () => ({
amount: 1,
unit: "hours",
}))
await step.email("email-digest", async (inputs) => ({
subject: `${inputs.steps.length} package updates`,
body: `
<h2>Package Updates</h2>
${inputs.steps.map((s: any) => `
<p><strong>${s.payload.packageName}</strong>: ${s.payload.newVersion}</p>
`).join("")}
`,
}))
// SMS for critical updates only:
await step.sms("sms-critical", async () => ({
body: `🚨 ${payload.packageName} ${payload.newVersion} — breaking changes detected`,
}), {
skip: () => !payload.isBreaking,
})
})
React in-app notifications
import { NovuProvider, PopoverNotificationCenter, NotificationBell } from "@novu/notification-center"
function App() {
return (
<NovuProvider
subscriberId="user-123"
applicationIdentifier={process.env.NEXT_PUBLIC_NOVU_APP_ID!}
>
<Header />
</NovuProvider>
)
}
function Header() {
return (
<nav>
<PopoverNotificationCenter
colorScheme="dark"
onNotificationClick={(notification) => {
window.location.href = notification.cta?.data?.url || "/"
}}
>
{({ unseenCount }) => <NotificationBell unseenCount={unseenCount} />}
</PopoverNotificationCenter>
</nav>
)
}
Knock
Knock — developer-friendly notifications:
Trigger workflows
import { Knock } from "@knocklabs/node"
const knock = new Knock(process.env.KNOCK_API_KEY!)
// Trigger a workflow:
await knock.workflows.trigger("package-update", {
recipients: ["user-123"],
data: {
packageName: "react",
newVersion: "19.0.0",
changelogUrl: "https://pkgpulse.com/react/changelog",
isBreaking: true,
},
actor: "system", // Who triggered it
})
// Trigger for multiple recipients:
await knock.workflows.trigger("weekly-report", {
recipients: ["user-123", "user-456", "user-789"],
data: {
week: "2026-W10",
totalUpdates: 150,
topPackages: ["react", "vue", "svelte"],
},
})
// Trigger with tenant context:
await knock.workflows.trigger("team-alert", {
recipients: ["user-123"],
tenant: "org-456", // Multi-tenant support
data: {
alert: "New security advisory for lodash",
},
})
User and preference management
import { Knock } from "@knocklabs/node"
const knock = new Knock(process.env.KNOCK_API_KEY!)
// Identify user:
await knock.users.identify("user-123", {
name: "Royce",
email: "royce@example.com",
phone_number: "+1234567890",
avatar: "https://example.com/avatar.png",
properties: {
plan: "pro",
timezone: "America/New_York",
},
})
// Set channel data (push tokens, Slack channels):
await knock.users.setChannelData("user-123", "apns-channel-id", {
tokens: ["device-token-abc"],
})
await knock.users.setChannelData("user-123", "slack-channel-id", {
connections: [
{
incoming_webhook: { url: "https://hooks.slack.com/..." },
},
],
})
// Get user preferences:
const prefs = await knock.users.getPreferences("user-123")
// Set preferences:
await knock.users.setPreferences("user-123", {
workflows: {
"package-update": {
channel_types: {
email: true,
in_app_feed: true,
sms: false,
push: false,
},
},
"weekly-report": {
channel_types: {
email: true,
in_app_feed: false,
},
},
},
})
Batching and schedules
import { Knock } from "@knocklabs/node"
const knock = new Knock(process.env.KNOCK_API_KEY!)
// Batch notifications (configured in Knock dashboard workflow):
// Multiple triggers within the batch window get combined:
await knock.workflows.trigger("package-update", {
recipients: ["user-123"],
data: { packageName: "react", newVersion: "19.0.1" },
})
await knock.workflows.trigger("package-update", {
recipients: ["user-123"],
data: { packageName: "next", newVersion: "15.1.0" },
})
// → Combined into one notification: "2 packages updated"
// Schedule a workflow:
await knock.workflows.trigger("weekly-report", {
recipients: ["user-123"],
data: { week: "2026-W11" },
schedule: {
// Send next Monday at 9am ET:
timestamp: "2026-03-16T09:00:00-04:00",
},
})
// Cancel a workflow:
await knock.workflows.cancel("package-update", {
recipients: ["user-123"],
cancellation_key: "update-react-19.0.0",
})
// List messages for a user:
const messages = await knock.users.getMessages("user-123", {
channel_id: "in-app-feed-channel",
status: "unread",
})
React in-app feed
import { KnockProvider, KnockFeedProvider, NotificationIconButton, NotificationFeedPopover } from "@knocklabs/react"
import "@knocklabs/react/dist/index.css"
function App() {
return (
<KnockProvider
apiKey={process.env.NEXT_PUBLIC_KNOCK_PUBLIC_KEY!}
userId="user-123"
>
<KnockFeedProvider feedId={process.env.NEXT_PUBLIC_KNOCK_FEED_ID!}>
<Header />
</KnockFeedProvider>
</KnockProvider>
)
}
function Header() {
const [isOpen, setIsOpen] = useState(false)
const buttonRef = useRef<HTMLButtonElement>(null)
return (
<nav>
<NotificationIconButton
ref={buttonRef}
onClick={() => setIsOpen(!isOpen)}
/>
<NotificationFeedPopover
buttonRef={buttonRef}
isVisible={isOpen}
onClose={() => setIsOpen(false)}
onNotificationClick={(item) => {
window.location.href = item.data?.url || "/"
}}
/>
</nav>
)
}
// Real-time updates (WebSocket):
import { useKnockFeed } from "@knocklabs/react"
function NotificationCount() {
const { feedClient, useFeedStore } = useKnockFeed()
const { unseenCount } = useFeedStore()
// feedClient automatically connects via WebSocket
// New notifications appear in real-time
return <span className="badge">{unseenCount}</span>
}
Courier
Courier — notification orchestration:
Send notifications
import { CourierClient } from "@trycourier/courier"
const courier = CourierClient({ authorizationToken: process.env.COURIER_API_KEY! })
// Send a notification:
const { requestId } = await courier.send({
message: {
to: {
user_id: "user-123",
email: "royce@example.com",
},
template: "PACKAGE_UPDATE", // Template ID from Courier designer
data: {
packageName: "react",
newVersion: "19.0.0",
changelogUrl: "https://pkgpulse.com/react/changelog",
},
},
})
// Send to multiple channels:
const { requestId: multiId } = await courier.send({
message: {
to: {
user_id: "user-123",
},
content: {
title: "Package Update: {{packageName}}",
body: "{{packageName}} has been updated to {{newVersion}}. {{changelogUrl}}",
},
routing: {
method: "all", // Send to all channels
channels: ["email", "push", "inbox"],
},
data: {
packageName: "react",
newVersion: "19.0.0",
changelogUrl: "https://pkgpulse.com/react/changelog",
},
},
})
// Inline content (no template needed):
await courier.send({
message: {
to: { email: "royce@example.com" },
content: {
title: "🚨 Breaking Change Alert",
body: "react 19.0.0 contains breaking changes. Review before upgrading.",
},
channels: { email: { override: { subject: "Breaking Change: react 19.0.0" } } },
},
})
User profiles and preferences
import { CourierClient } from "@trycourier/courier"
const courier = CourierClient({ authorizationToken: process.env.COURIER_API_KEY! })
// Create/update user profile:
await courier.users.put("user-123", {
profile: {
email: "royce@example.com",
phone_number: "+1234567890",
name: "Royce",
custom: {
plan: "pro",
timezone: "America/New_York",
trackedPackages: ["react", "vue", "next"],
},
},
})
// Set push token:
await courier.users.tokens.put("user-123", "device-token-abc", {
provider_key: "apn", // apn | firebase-fcm
device: {
app_id: "com.pkgpulse.app",
platform: "ios",
},
})
// User preferences (subscription topics):
await courier.users.preferences.put("user-123", {
topics: {
"package-updates": { status: "OPTED_IN" },
"weekly-reports": { status: "OPTED_IN" },
"marketing": { status: "OPTED_OUT" },
},
})
// List user preferences:
const prefs = await courier.users.preferences.list("user-123")
prefs.items.forEach((topic) => {
console.log(`${topic.topic_id}: ${topic.status}`)
})
Automation and routing
import { CourierClient } from "@trycourier/courier"
const courier = CourierClient({ authorizationToken: process.env.COURIER_API_KEY! })
// Smart routing — try email, fall back to SMS:
await courier.send({
message: {
to: { user_id: "user-123" },
template: "CRITICAL_ALERT",
data: { alert: "Security vulnerability in lodash" },
routing: {
method: "single", // Try channels in order, stop on first success
channels: ["email", "sms", "push"],
},
timeout: {
// Escalate if not read within 30 minutes:
message: 1800000, // 30 min in ms
channel: {
email: 600000, // Try email for 10 min
},
escalation: "sms", // Then escalate to SMS
},
},
})
// Bulk send:
const { jobId } = await courier.bulk.createJob({
message: {
template: "WEEKLY_REPORT",
data: {
week: "2026-W10",
totalUpdates: 150,
},
},
})
// Add recipients to bulk job:
await courier.bulk.ingestUsers(jobId, {
users: [
{ user_id: "user-123", data: { personalizedContent: "..." } },
{ user_id: "user-456", data: { personalizedContent: "..." } },
// ... thousands of users
],
})
// Run the bulk job:
await courier.bulk.runJob(jobId)
React inbox component
import { CourierProvider, Inbox } from "@trycourier/react-provider"
function App() {
return (
<CourierProvider
clientKey={process.env.NEXT_PUBLIC_COURIER_CLIENT_KEY!}
userId="user-123"
>
<Header />
</CourierProvider>
)
}
function Header() {
return (
<nav>
<Inbox
title="Notifications"
theme={{
brand: { colors: { primary: "#3B82F6" } },
}}
onNotificationClick={(notification) => {
window.location.href = notification.data?.url || "/"
}}
renderIcon={(unreadCount) => (
<button className="relative">
🔔
{unreadCount > 0 && (
<span className="badge">{unreadCount}</span>
)}
</button>
)}
/>
</nav>
)
}
// Toast notifications:
import { Toast } from "@trycourier/react-toast"
function NotificationToasts() {
return <Toast position="top-right" autoClose={5000} />
}
Feature Comparison
| Feature | Novu | Knock | Courier |
|---|---|---|---|
| Open-source | ✅ | ❌ | ❌ |
| Self-hosted | ✅ | ❌ | ❌ |
| ✅ | ✅ | ✅ | |
| SMS | ✅ | ✅ | ✅ |
| Push notifications | ✅ | ✅ | ✅ |
| In-app notifications | ✅ | ✅ | ✅ (Inbox) |
| Chat (Slack/Discord) | ✅ | ✅ | ✅ |
| Workflow engine | ✅ (code-first) | ✅ (dashboard) | ✅ (designer) |
| Digest/batch | ✅ | ✅ | ✅ |
| User preferences | ✅ | ✅ | ✅ (topics) |
| Template designer | ✅ (basic) | ❌ (code) | ✅ (visual, drag-and-drop) |
| Multi-tenancy | ✅ | ✅ | ✅ |
| Real-time WebSocket | ✅ | ✅ | ✅ |
| React components | ✅ | ✅ | ✅ |
| Smart routing | ❌ | ✅ (conditions) | ✅ (escalation) |
| Bulk send | ✅ | ✅ | ✅ |
| Free tier | 30K events/mo | 10K msgs/mo | 10K msgs/mo |
| Pricing | Event-based | Message-based | Message-based |
When to Use Each
Use Novu if:
- Want open-source notification infrastructure you can self-host
- Need a code-first workflow engine with full control
- Prefer owning your notification stack
- Building with TypeScript and want framework-level flexibility
Use Knock if:
- Want the best developer experience for fast integration
- Need real-time in-app notification feeds with React components
- Want powerful batching and scheduling out of the box
- Building multi-tenant SaaS with per-tenant notification preferences
Use Courier if:
- Want a visual drag-and-drop template designer
- Need smart routing with escalation and fallback channels
- Building enterprise products with complex notification logic
- Want non-engineers to manage notification templates
Methodology
Download data from npm registry (weekly average, February 2026). Feature comparison based on @novu/node v2.x, @knocklabs/node v0.x, and @trycourier/courier v6.x.