CodeWorlds
Back to collections
Guide18 min readCodeWorlds Team

Novu, a notification layer for applications

Novu unifies email, SMS, push and an in-app inbox behind one API. MIT licence with a proprietary enterprise island, pricing, preferences, workflows as code.

Novu, a notification layer for applications

Novu is an intermediate layer between your application and message providers. One API call turns into an email, an SMS, a push notification, or an entry in the inbox inside your product. The novuhq/novu repository holds roughly 39.6 thousand stars, and the licence is mixed: MIT for most of the code and a separate proprietary agreement for the enterprise/packages directory.

Why a separate layer when you can call providers directly

The first version of notifications in every product looks the same. Somebody adds a Resend or Postmark call at the point where an order changes status, and it works. For the first six months there is no reason to do anything more. An intermediate layer starts paying off only once three specific requirements appear, and each of them costs more to write yourself than it seems at the outset.

The first is user preferences. Somebody asks to switch off SMS while keeping email. Somebody else wants comment notifications in the app only, and payment notifications everywhere. That means you need a matrix: user times event type times channel, with a default value that can be overridden at two levels. On top of that comes an interface where the user sets it, and a check against that matrix before every send. Writing this takes several weeks, and maintaining it lasts as long as the product does.

The second is cross-channel sequencing. A rule such as "show an in-app notification, and if the user has not read it within thirty minutes, send an email" needs three things at once: stored read state, a scheduled job fired with a delay, and a condition evaluated at firing time. A job queue alone does not cover it, because the read state has to come from somewhere. Grouping looks similar: twenty comments on a post within an hour should produce one email rather than twenty.

The third is one place for templates. Message strings scattered through the code mean every typo fix requires a deployment, and the person responsible for the copy cannot touch it. A notification layer moves templates into a dashboard where they can be changed without a developer, and versions them across environments.

Novu gives you all three in one package. When none of them is something you need, calling the provider directly remains the right answer, and Novu becomes one more piece of infrastructure to maintain.

Licence, versions and project health

This is the most important part of this text, because Novu's licence position looks different from every angle you inspect it from, and the differences matter during a dependency audit.

Start with the repository. The default branch of novuhq/novu is next, and its root holds no file named LICENSE. It holds three others: LICENSE-MIT, LICENSE-ENTERPRISE and EE-PACKAGES-LICENSE. The LICENSE-ENTERPRISE file describes the split and states plainly that everything under enterprise/packages falls under the terms in EE-PACKAGES-LICENSE, while the rest of the code is MIT. The main branch, by contrast, carries a plain LICENSE file with the MIT text and the note "Copyright (c) 2019 Dima Grossman", which can mislead a cursory check, because that is not the default branch.

The content of EE-PACKAGES-LICENSE is strict. It is a proprietary agreement titled "Novu Proprietary Software License" that forbids renting, reselling, sublicensing and providing commercial hosting services, bars modification and reverse engineering, and conditions use on prior written approval from Novu. The sentence about that approval reads: contact must be made at [contact information]. The square-bracket placeholder was never filled in, so the clause demands a procedure that cannot be carried out as written.

What exactly sits under that regime is visible from the subdirectory names: ai, api, auth, billing, shared-services and translation. In other words, enterprise sign-in, billing and translations are not part of the MIT code. On top of that, the .gitmodules file declares a submodule at path .source pointing at git@github.com:novuhq/packages-enterprise.git, a repository that a public SSH address gives no access to. The portion of the code behind the paid features is therefore not merely licensed differently, it is out of reach.

The GitHub programming interface reports NOASSERTION with the label "Other" for that repository, because its detector cannot express an arrangement of three files and a directory split. Any tool harvesting metadata from GitHub will show you "other", which is technically correct and practically useless.

In the npm registry it gets more interesting, and this is where the divergences are sharpest. The @novu/api package at version 3.19.0 carries no license field in package.json at all, yet the published archive contains a LICENSE file with the full MIT text and the note "Copyright (c) 2024 Novu". Its repository field meanwhile points at a different repository, novuhq/novu-ts, because it is a generated client. The @novu/js, @novu/react and @novu/nextjs packages at 3.19.0, along with @novu/framework at 2.13.0, declare the ISC licence and contain no licence file in the archive whatsoever. The same holds for the novu command line tool at version 2.20.1. The ISC licence appears nowhere in the repository and looks like a default left behind by npm init, but formally it is the declaration accompanying those packages.

The summary for whoever keeps the dependency list looks like this. The code you actually install through npm and call from your application is meant for free use, though the declarations are inconsistent: in one case a missing field with an MIT file, in another an ISC field with no file, while the repository says MIT. The proprietary regime covers server features that you do not host yourself on Novu Cloud anyway. If, however, you plan self-hosting and want SSO sign-in or translations, you are touching enterprise/packages, and there an agreement applies that requires written approval with an empty contact field. Record both facts in the dependency register rather than one.

Beyond the licence, the project's health is worth knowing. The repository is not archived, it carries 4435 forks and 109 open issues, and the last change on the next branch dates from 21 August 2026. The older server package @novu/node at version 2.6.6 is marked deprecated, with a message naming 20 March 2025 as the end of support and @novu/api as its successor. If you come across examples using @novu/node, they are out of date.

The first call and triggering a workflow

Installation and configuration come down to one package and one key.

Code
Bash
npm install @novu/api
export NOVU_SECRET_KEY=nv_...

The client takes the key in the secretKey field, and with self-hosting a serverURL pointing at your own API address is added.

Code
TypeScript
import { Novu } from '@novu/api'

const novu = new Novu({ secretKey: process.env.NOVU_SECRET_KEY })

await novu.trigger({
  workflowId: 'order-shipped',
  to: {
    subscriberId: user.id,
    email: user.email,
    phone: user.phone,
    locale: 'en-GB',
    timezone: 'Europe/London'
  },
  payload: {
    orderNumber: order.number,
    trackingUrl: order.trackingUrl
  }
})

Three things in that call deserve comment. The workflowId field points at a workflow definition rather than a channel, so the decision whether an email goes out, an SMS, or both, is taken outside the calling code. The to field accepts either a bare identifier as a string or a subscriber object, which incidentally creates or updates that subscriber, so you need no separate step to register a user in Novu. The payload field reaches the template as variables and is the only place where your application passes business data.

The subscriber object accepts firstName, lastName, email, phone, avatar, locale, timezone, data for arbitrary custom fields, and channels for push tokens. Only subscriberId is required. Set it to the same identifier you use in your database, because changing it later means migrating preferences.

There is also triggerBroadcast for sending to every subscriber, and overrides, where you override provider configuration for the whole workflow or for a single step. The overrides.email, overrides.sms, overrides.push and overrides.chat sections are marked deprecated in favour of overrides.channels and overrides.steps, so use the latter in new code.

Workflows as code with @novu/framework

A workflow can be defined by clicking through the dashboard, or in code through the @novu/framework package. The second route keeps the definition in the repository alongside the rest of the application and goes through the same change review.

Code
TypeScript
import { workflow } from '@novu/framework'
import { serve } from '@novu/framework/next'
import { z } from 'zod'

const commentWorkflow = workflow(
  'comment-on-post',
  async ({ payload, step }) => {
    const inApp = await step.inApp('new-comment', async () => ({
      subject: 'New comment',
      body: `${payload.authorName} commented on your post`,
      redirect: { url: `/posts/${payload.postId}`, target: '_self' }
    }))

    await step.delay('wait-before-email', async () => ({
      type: 'regular',
      amount: 30,
      unit: 'minutes'
    }))

    await step.email(
      'fallback-email',
      async () => ({
        subject: 'You have an unread comment',
        body: `${payload.authorName} commented on your post.`
      }),
      { skip: () => inApp.read }
    )
  },
  {
    payloadSchema: z.object({
      postId: z.string(),
      authorName: z.string()
    })
  }
)

export const { GET, POST, OPTIONS } = serve({ workflows: [commentWorkflow] })

That is the answer to the question about retrying when one channel does not land. The result of the inApp step carries the fields seen, read, lastSeenDate and lastReadDate, so the skip condition reads the read state at the moment the delay has elapsed. Without a notification layer you would have to hold that state yourself, schedule the job, and evaluate the condition when it runs.

The available channel steps are email, sms, push, chat, inApp and custom, while the action steps are delay, digest and throttle. The delay step in its regular variant requires the amount and unit fields, where the unit is seconds, minutes, hours, days, weeks or months. The timed variant takes a cron expression instead. The digest step works the same way and additionally accepts digestKey and lookBackWindow, so grouping happens per post rather than jointly across a whole user.

One important constraint: the serve route has to be publicly reachable, because it is the Novu server that calls it at every workflow step. In Next.js that means a deployed application or a tunnel during local work. A workflow defined in code and a workflow clicked together in the dashboard are two separate modes that cannot be mixed freely for the same workflow.

User preferences and the Inbox component

Preferences live under the subscribers.preferences namespace with three methods: list, update and bulkUpdate.

Code
TypeScript
await novu.subscribers.preferences.update(
  {
    workflowId: 'comment-on-post',
    channels: {
      email: false,
      sms: false,
      inApp: true,
      push: true,
      chat: false
    }
  },
  user.id
)

const { result } = await novu.subscribers.preferences.list({ subscriberId: user.id })

The workflowId field is optional, and that distinction decides the level of the write. With it you update the preference for one workflow, without it the global one for the whole subscriber. The channels are email, sms, inApp, push, chat and tool, with the caveat that in the wire format inApp appears as in_app, so hand-built HTTP requests use different names than the library does.

On the interface side you get ready-made components for React, among them Inbox, Bell, Notifications and Preferences, plus a set of hooks such as useNotifications, usePreferences and useCounts.

Code
TypeScript
'use client'

import { Inbox } from '@novu/react'

export function NotificationBell({ subscriberHash }: { subscriberHash: string }) {
  return (
    <Inbox
      applicationIdentifier={process.env.NEXT_PUBLIC_NOVU_APP_ID!}
      subscriber={currentUser.id}
      subscriberHash={subscriberHash}
      appearance={{ variables: { colorPrimary: '#2563eb' } }}
    />
  )
}

The subscriberHash field is an HMAC signature generated on the server from the secret key. Without it, anybody who swaps the identifier in developer tools sees somebody else's inbox, so in production it is mandatory. The subscriberId field on this component is marked deprecated in favour of subscriber, which takes a string or a full subscriber object.

The component keeps a WebSocket connection open so it can insert new notifications without a refresh. You can turn that off with realtime={false} and drive refreshing yourself. With self-hosting, the socketOptions.socketType option takes the value self-hosted, because a local instance uses socket.io while the cloud uses a different mechanism.

Pricing, limits and self-hosting

The price list has four tiers and bills primarily on the number of workflow runs rather than on the number of users.

ItemFreeProTeamEnterprise
Monthly cost0 USDfrom 30 USDfrom 250 USDcustom quote
Included workflow runs10k30k250k10M and above
Additional runsnone1.20 USD per 1k1.20 USD per 1kcustom quote
Event throughput60 per second240 per second600 per secondcustom quote
Digest and delay window24 hours7 days90 dayscustom quote
Activity retention24 hours7 days90 dayscustom quote
Team members33unlimitedunlimited
Workflow count2020100custom quote

The Pro and Team figures are quoted as "from", and the pricing page shows no annual price, so no annual rate is given here. Subscribers are unlimited on every plan, which matters, because this is billing per event rather than per user base.

Two free-plan limits hurt most and are worth seeing before you commit. The digest window is 24 hours, so a weekly summary cannot be built on that plan. Activity retention also runs 24 hours, meaning a user report filed on Monday morning can no longer be checked against Friday's log. The service level agreement declares 99.9 percent availability on the Free, Pro and Team plans.

The alternative is self-hosting. The documentation describes a docker compose variant in which a setup script downloads docker-compose.yml and .env.example, generates random values for JWT_SECRET, STORE_ENCRYPTION_KEY and NOVU_SECRET_KEY, and then starts the full set of services.

Code
Bash
curl -fsSL https://raw.githubusercontent.com/novuhq/novu/next/docker/community/setup.sh | NOVU_DIR=~/novu bash
cd ~/novu && docker compose up -d

The dashboard becomes available at http://localhost:4000. Deploying to a server additionally requires setting HOST_NAME in the .env file. The set that comes up covers the API, a worker, a WebSocket server and the dashboard, plus a database and a queue, so this is not a single process but several services to monitor. Piping a script from the network straight into a shell means executing code you have not read, so downloading that file and reading it first is the sensible order.

Novu against the alternatives

FeatureNovuProvider directly, Resend or PostmarkTrigger.dev plus your own code
Channels behind one APIemail, SMS, push, chat, in-appemail onlyas many as you wire up
Preference centrebuilt in, per channel and per workflownone beyond list unsubscribewrite it yourself
In-app inboxready-made React componentnonewrite it yourself
Digest and delay windowsdigest and delay stepsnonepossible, you define it
Open sourceMIT outside the enterprise directorynoyes, Apache 2.0 in the repository
Self-hostingyes, docker composenoyes

The choice comes down to the question of preferences and the in-app inbox. If the product sends transactional email only and nobody asks to switch categories off, a provider called directly is simpler, cheaper and has fewer moving parts. If you already run a job queue and prefer to keep the logic on your side, Trigger.dev will handle delays and retries, but preferences and the inbox component are yours to write, and that is most of the work. Lifecycle email and campaign tools such as Loops form a separate category that solves a different problem and sensibly sits beside Novu rather than in place of it.

The vendor lock-in risk deserves naming too. Templates clicked together in the dashboard, workflow definitions and subscriber preferences all live on Novu's side. Leaving means exporting all of that and recreating it elsewhere, and some of it, activity history for instance, cannot be recreated at all. Keeping workflows in code through @novu/framework limits the problem, since the definitions stay in your repository, but that does nothing for user preferences.

The closed alternative in the same niche is Knock, which solves the same problem: one event in the code turned into delivery across several channels, accounting for recipient preferences, batching and deduplication. The difference is fundamental and needs weighing: there is no self-hosted variant, and the flows are defined in the vendor dashboard, so notification logic leaves your repository and your ordinary code review. It is also worth noting that the server library there is Apache 2.0 while the client packages are MIT.

Common mistakes

The first is embedding the Inbox component without subscriberHash. The subscriber identifier is visible on the browser side, so without an HMAC signature somebody else's inbox is one edit in developer tools away. Generate the signature on the server and pass it to the component as a prop.

The second is using a subscriberId other than the identifier from your own database. When you realise six months later that something else would have been handier, every preference pinned to the old identifier stays where it is, and nothing moves it across automatically.

The third is putting the free plan under requirements it does not cover. The digest window and activity retention both run 24 hours there, so a weekly summary and diagnosing a report from a few days back simply will not work, however correctly you write the workflow.

The fourth is a serve route that is not publicly reachable. The Novu server calls it at every step, so a workflow defined in code and deployed behind a firewall or only locally will not execute at all, and the error message can be misleading.

The fifth is using @novu/node. The package is deprecated, with end of support dated 20 March 2025, and most tutorials on the web still show it. Write new code against @novu/api.

The sixth is missing the naming difference between the library and the wire format. The inApp channel in the library travels as in_app, so a hand-built request using the library's name will quietly change nothing.

The seventh is treating the whole repository as MIT when planning self-hosting with enterprise sign-in or translations. Those features sit in the enterprise/packages directory covered by a proprietary agreement that requires prior written approval, and the contact field in its text was left unfilled.

FAQ

Does Novu send messages itself, or through providers?

Through providers. Novu is the control layer, and the actual sending goes through a configured integration, Resend, Postmark, or Twilio for SMS for example. You pay the provider separately for the sending itself, while Novu bills workflow runs.

What licence is Novu under?

Mixed. The repository's default branch contains LICENSE-MIT, LICENSE-ENTERPRISE and EE-PACKAGES-LICENSE, where the enterprise/packages directory falls under a proprietary agreement and the rest of the code under MIT. The npm packages declare this inconsistently: @novu/api has no license field but ships an MIT file, while @novu/js, @novu/react, @novu/nextjs and @novu/framework declare ISC with no licence file in the archive at all.

Does self-hosting give you every feature?

No. The features in enterprise/packages, among them enterprise sign-in, billing and translations, fall under separate terms, and the actual code of those packages is referenced by a submodule pointing at a repository with no public access. The community variant started through docker compose covers the API, a worker, a WebSocket server and the dashboard.

Can workflow definitions live in the repository?

Yes, through the @novu/framework package. You define the workflow in TypeScript with a payloadSchema, expose a route through serve, and sync it. There is one condition: that route has to be publicly reachable, because the Novu server calls it at every step.

What does Novu cost at a thousand notifications a day?

A thousand runs a day is roughly 30 thousand a month, exactly the Pro plan allowance, whose price starts at 30 USD a month. Above the allowance, 1.20 USD is charged per additional thousand runs. On top of that comes the cost on the sending provider's side, which Novu does not cover.

Does a notification layer make sense with a single channel?

Rarely. With transactional email alone, no preferences and no in-app inbox, calling the provider directly is simpler and cheaper. Novu starts paying for itself at the second channel, or at the first user request to switch off one particular notification type.

Documentation lives on the Novu documentation site, the price list on the pricing page, and the source code in the GitHub repository.

Read next

We use cookies to enhance your experience on the site