Skip to main content

Novu vs Knock vs Courier: Notification Infrastructure APIs (2026)

·PkgPulse Team

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

FeatureNovuKnockCourier
Open-source
Self-hosted
Email
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 tier30K events/mo10K msgs/mo10K msgs/mo
PricingEvent-basedMessage-basedMessage-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.

Compare notification and developer tooling on PkgPulse →

Comments

Stay Updated

Get the latest package insights, npm trends, and tooling tips delivered to your inbox.