Novu, warstwa powiadomień dla aplikacji
Novu to warstwa pośrednia między Twoją aplikacją a dostawcami wiadomości. Jedno wywołanie API zamienia się w e-mail, SMS, powiadomienie push albo wpis w skrzynce wewnątrz produktu. Repozytorium novuhq/novu ma około 39,6 tysiąca gwiazdek, a licencja jest mieszana: MIT dla większości kodu i osobna umowa własnościowa dla katalogu enterprise/packages.
Po co osobna warstwa, skoro można wołać dostawców wprost
Pierwsza wersja powiadomień w każdym produkcie wygląda tak samo. Ktoś dodaje wywołanie Resend albo Postmark w miejscu, gdzie zamówienie zmienia status, i to działa. Przez pierwsze pół roku nie ma powodu, żeby robić cokolwiek więcej. Warstwa pośrednia zaczyna się opłacać dopiero wtedy, gdy pojawiają się trzy konkretne wymagania, i każde z nich jest kosztowniejsze do napisania samodzielnie, niż się wydaje na początku.
Pierwsze to preferencje użytkownika. Ktoś prosi o wyłączenie SMS-ów, ale chce zachować e-maile. Ktoś inny chce dostawać powiadomienia o komentarzach tylko w aplikacji, a o płatnościach wszędzie. To znaczy, że potrzebujesz macierzy: użytkownik razy typ zdarzenia razy kanał, z wartością domyślną, którą można nadpisać na dwóch poziomach. Do tego interfejs, w którym użytkownik to ustawia, i sprawdzenie tej macierzy przed każdą wysyłką. Napisanie tego zajmuje kilka tygodni, a utrzymanie trwa tak długo, jak produkt.
Drugie to sekwencje międzykanałowe. Reguła w rodzaju "pokaż powiadomienie w aplikacji, a jeśli użytkownik go nie przeczyta przez trzydzieści minut, wyślij e-mail" wymaga trzech rzeczy naraz: zapamiętania stanu odczytu, zaplanowanego zadania odpalanego z opóźnieniem i warunku, który sprawdzi stan w momencie odpalenia. Sama kolejka zadań tego nie załatwia, bo stan odczytu musi skądś przyjść. Podobnie wygląda grupowanie: dwadzieścia komentarzy pod postem w ciągu godziny powinno dać jeden e-mail, a nie dwadzieścia.
Trzecie to jedno miejsce na szablony. Rozrzucone po kodzie ciągi znaków z treścią wiadomości oznaczają, że każda poprawka literówki wymaga wdrożenia, a osoba odpowiedzialna za treść nie może jej dotknąć. Warstwa powiadomień przenosi szablony do panelu, gdzie da się je zmienić bez udziału programisty, i wersjonuje je na środowiska.
Novu daje wszystkie trzy rzeczy w jednym pakiecie. Kiedy żadna z nich nie jest Ci potrzebna, wywołanie dostawcy wprost pozostaje właściwym rozwiązaniem, a Novu będzie tylko dodatkowym elementem infrastruktury do utrzymania.
Licencja, wersje i stan projektu
To jest najważniejsza część tego tekstu, bo stan licencyjny Novu wygląda inaczej z każdego miejsca, z którego się na niego patrzy, a różnice są istotne przy audycie zależności.
Zacznijmy od repozytorium. Domyślną gałęzią novuhq/novu jest next i w jej katalogu głównym nie ma pliku o nazwie LICENSE. Są trzy inne: LICENSE-MIT, LICENSE-ENTERPRISE oraz EE-PACKAGES-LICENSE. Plik LICENSE-ENTERPRISE opisuje podział i mówi wprost, że wszystko, co leży pod enterprise/packages, podlega warunkom z EE-PACKAGES-LICENSE, a reszta kodu jest na MIT. W gałęzi main leży natomiast zwykły plik LICENSE z tekstem MIT i notą "Copyright (c) 2019 Dima Grossman", co przy pobieżnym sprawdzeniu potrafi wprowadzić w błąd, bo to nie jest gałąź domyślna.
Treść EE-PACKAGES-LICENSE jest twarda. To umowa własnościowa nazwana "Novu Proprietary Software License", która zabrania wynajmu, odsprzedaży, sublicencjonowania i świadczenia komercyjnych usług hostingowych, zakazuje modyfikacji oraz odtwarzania kodu źródłowego, a użycie warunkuje uprzednią pisemną zgodą Novu. Zdanie o tej zgodzie brzmi: kontakt należy nawiązać pod adresem [contact information]. Nawias kwadratowy z tekstem zastępczym nigdy nie został wypełniony, więc klauzula wymaga procedury, której nie da się wykonać zgodnie z jej własnym brzmieniem.
Co dokładnie leży pod tym reżimem, widać po nazwach podkatalogów: ai, api, auth, billing, shared-services i translation. Innymi słowy logowanie korporacyjne, rozliczenia i tłumaczenia nie są częścią kodu na MIT. Do tego plik .gitmodules deklaruje submoduł o ścieżce .source wskazujący na git@github.com:novuhq/packages-enterprise.git, czyli repozytorium, do którego publiczny adres SSH nie daje dostępu. Fragment kodu odpowiadający za funkcje płatne nie jest więc po prostu inaczej licencjonowany, on jest poza zasięgiem.
Interfejs programistyczny GitHuba raportuje dla tego repozytorium licencję NOASSERTION z etykietą "Other", bo jego wykrywacz nie potrafi opisać układu z trzema plikami i podziałem na katalogi. Każde narzędzie zbierające metadane z GitHuba pokaże Ci więc "inna", co jest technicznie poprawne i praktycznie bezużyteczne.
W rejestrze npm robi się jeszcze ciekawiej i tutaj rozjazdy są najbardziej wyraziste. Pakiet @novu/api w wersji 3.19.0 nie ma w package.json pola license w ogóle, ale opublikowana paczka zawiera plik LICENSE z pełnym tekstem MIT i notą "Copyright (c) 2024 Novu". Jego pole repository wskazuje przy tym na inne repozytorium, novuhq/novu-ts, bo to generowany klient. Pakiety @novu/js, @novu/react i @novu/nextjs w wersji 3.19.0 oraz @novu/framework w wersji 2.13.0 deklarują z kolei licencję ISC i nie zawierają żadnego pliku licencyjnego w archiwum. To samo dotyczy pakietu novu, czyli narzędzia wiersza poleceń w wersji 2.20.1. Licencja ISC nie występuje nigdzie w repozytorium i wygląda na wartość domyślną pozostawioną przez npm init, ale formalnie to właśnie ona jest deklaracją towarzyszącą tym paczkom.
Podsumowanie dla osoby prowadzącej listę zależności wygląda tak. Kod, który realnie instalujesz przez npm i wołasz z aplikacji, jest przeznaczony do swobodnego użycia, choć deklaracje są niespójne: raz brak pola i plik MIT, raz pole ISC bez pliku, a repozytorium mówi MIT. Rygor własnościowy dotyczy funkcji serwerowych, których w chmurze Novu i tak nie hostujesz sam. Jeżeli jednak planujesz własny hosting i chcesz mieć logowanie przez SSO albo tłumaczenia, dotykasz katalogu enterprise/packages, a tam obowiązuje umowa z wymogiem pisemnej zgody i pustym polem kontaktowym. Wpisz w rejestrze zależności obie informacje zamiast jednej.
Poza licencją warto znać stan projektu. Repozytorium nie jest zarchiwizowane, ma 4435 rozgałęzień i 109 otwartych zgłoszeń, a ostatnia zmiana w gałęzi next pochodzi z 21 sierpnia 2026 roku. Starszy pakiet serwerowy @novu/node w wersji 2.6.6 jest oznaczony jako wycofany, z komunikatem wskazującym 20 marca 2025 roku jako koniec wsparcia i @novu/api jako następcę. Jeśli natrafisz w sieci na przykłady z @novu/node, są nieaktualne.
Pierwsze wywołanie i wyzwalanie workflow
Instalacja i konfiguracja sprowadzają się do jednego pakietu i jednego klucza.
npm install @novu/api
export NOVU_SECRET_KEY=nv_...Klient przyjmuje klucz w polu secretKey, a przy własnym hostingu dochodzi serverURL wskazujący na Twój adres API.
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: 'pl-PL',
timezone: 'Europe/Warsaw'
},
payload: {
orderNumber: order.number,
trackingUrl: order.trackingUrl
}
})Trzy rzeczy w tym wywołaniu zasługują na komentarz. Pole workflowId wskazuje na definicję workflow, a nie na kanał, więc decyzja o tym, czy poleci e-mail, SMS, czy oba, zapada poza kodem wywołującym. Pole to przyjmuje albo sam identyfikator jako ciąg znaków, albo obiekt subskrybenta, który przy okazji tworzy go lub aktualizuje, dzięki czemu nie potrzebujesz osobnego kroku rejestracji użytkownika w Novu. Pole payload trafia do szablonu jako zmienne i to jedyne miejsce, w którym Twoja aplikacja przekazuje dane biznesowe.
Obiekt subskrybenta przyjmuje firstName, lastName, email, phone, avatar, locale, timezone, data na dowolne pola własne oraz channels na tokeny push. Wymagany jest wyłącznie subscriberId. Warto go ustawić na ten sam identyfikator, którego używasz w bazie, bo późniejsza zmiana oznacza migrację preferencji.
Dostępne są też triggerBroadcast do wysyłki do wszystkich subskrybentów oraz overrides, gdzie nadpisujesz konfigurację dostawcy dla całego workflow albo dla pojedynczego kroku. Sekcje overrides.email, overrides.sms, overrides.push i overrides.chat są oznaczone jako przestarzałe na rzecz overrides.channels i overrides.steps, więc w nowym kodzie używaj tych drugich.
Workflow jako kod z @novu/framework
Workflow można zdefiniować klikaniem w panelu albo w kodzie, przez pakiet @novu/framework. Druga droga trzyma definicję w repozytorium razem z resztą aplikacji i podlega tej samej weryfikacji zmian.
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: 'Nowy komentarz',
body: `${payload.authorName} skomentował Twój 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: 'Masz nieprzeczytany komentarz',
body: `${payload.authorName} skomentował Twój post.`
}),
{ skip: () => inApp.read }
)
},
{
payloadSchema: z.object({
postId: z.string(),
authorName: z.string()
})
}
)
export const { GET, POST, OPTIONS } = serve({ workflows: [commentWorkflow] })To jest właśnie odpowiedź na pytanie o powtarzanie przy awarii jednego kanału. Wynik kroku inApp ma pola seen, read, lastSeenDate i lastReadDate, więc warunek skip czyta stan odczytu w chwili, gdy opóźnienie już minęło. Bez warstwy powiadomień musiałbyś sam trzymać ten stan, planować zadanie i sprawdzać warunek przy jego wykonaniu.
Dostępne kroki kanałowe to email, sms, push, chat, inApp i custom, a kroki akcji to delay, digest oraz throttle. Krok delay w wariancie regular wymaga pól amount i unit, gdzie jednostką jest seconds, minutes, hours, days, weeks albo months. Wariant timed przyjmuje zamiast tego wyrażenie cron. Krok digest działa analogicznie i dodatkowo przyjmuje digestKey oraz lookBackWindow, dzięki czemu grupowanie idzie osobno dla każdego posta zamiast wspólnie dla całego użytkownika.
Ważne ograniczenie: trasa serve musi być publicznie dostępna, bo to serwer Novu odpytuje ją przy każdym kroku workflow. W Next.js oznacza to wdrożoną aplikację albo tunel podczas pracy lokalnej. Definicja workflow w kodzie i definicja klikana w panelu to dwa osobne tryby, których nie da się swobodnie mieszać dla tego samego workflow.
Preferencje użytkownika i komponent Inbox
Preferencje są dostępne przez podprzestrzeń subscribers.preferences z trzema metodami: list, update i bulkUpdate.
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 })Pole workflowId jest opcjonalne i to rozróżnienie decyduje o poziomie zapisu. Z nim aktualizujesz preferencję dla jednego workflow, bez niego globalną dla całego subskrybenta. Kanały to email, sms, inApp, push, chat i tool, przy czym w formacie przesyłanym po sieci inApp występuje jako in_app, więc przy ręcznym budowaniu żądań HTTP nazwy różnią się od tych w bibliotece.
Po stronie interfejsu dostajesz gotowe komponenty dla Reacta, w tym Inbox, Bell, Notifications i Preferences, oraz zestaw haków w rodzaju useNotifications, usePreferences i useCounts.
'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' } }}
/>
)
}Pole subscriberHash to podpis HMAC generowany po stronie serwera z klucza tajnego. Bez niego każdy, kto podmieni identyfikator w narzędziach deweloperskich, zobaczy cudzą skrzynkę, więc na produkcji jest obowiązkowe. Pole subscriberId w tym komponencie zostało oznaczone jako przestarzałe na rzecz subscriber, które przyjmuje ciąg znaków albo pełny obiekt subskrybenta.
Komponent utrzymuje połączenie WebSocket, żeby wstawiać nowe powiadomienia bez odświeżania. Można je wyłączyć przez realtime={false} i sterować odświeżaniem samodzielnie. Przy własnym hostingu opcja socketOptions.socketType przyjmuje wartość self-hosted, bo instancja lokalna używa socket.io, a chmura innego mechanizmu.
Cennik, limity i własny hosting
Cennik ma cztery poziomy i rozlicza się przede wszystkim liczbą uruchomień workflow, a nie liczbą użytkowników.
| Pozycja | Free | Pro | Team | Enterprise |
|---|---|---|---|---|
| Koszt miesięczny | 0 USD | od 30 USD | od 250 USD | wycena indywidualna |
| Uruchomienia workflow w cenie | 10 tys. | 30 tys. | 250 tys. | 10 mln i więcej |
| Dodatkowe uruchomienia | brak | 1,20 USD za 1 tys. | 1,20 USD za 1 tys. | wycena indywidualna |
| Przepustowość zdarzeń | 60 na sekundę | 240 na sekundę | 600 na sekundę | wycena indywidualna |
| Okno digest i delay | 24 godziny | 7 dni | 90 dni | wycena indywidualna |
| Historia aktywności | 24 godziny | 7 dni | 90 dni | wycena indywidualna |
| Członkowie zespołu | 3 | 3 | bez limitu | bez limitu |
| Liczba workflow | 20 | 20 | 100 | wycena indywidualna |
Kwoty przy planach Pro i Team podane są jako "od", a strona cennika nie pokazuje ceny rocznej, więc żadnej stawki rocznej tutaj nie podaję. Subskrybenci są nielimitowani we wszystkich planach, co jest istotne, bo to rozliczenie za zdarzenia, a nie za bazę użytkowników.
Dwa limity planu darmowego bolą najbardziej i warto je zobaczyć przed wdrożeniem. Okno digest wynosi 24 godziny, więc tygodniowe podsumowanie na tym planie jest nie do zbudowania. Historia aktywności też trwa 24 godziny, co oznacza, że zgłoszenie użytkownika złożone w poniedziałek rano nie da się już zweryfikować w logu z piątku. Umowa o poziomie usług deklaruje 99,9 procent dostępności w planach Free, Pro i Team.
Alternatywą jest własny hosting. Dokumentacja opisuje wariant z docker compose, w którym skrypt instalacyjny pobiera plik docker-compose.yml oraz .env.example, generuje losowe wartości dla JWT_SECRET, STORE_ENCRYPTION_KEY i NOVU_SECRET_KEY, po czym uruchamia komplet usług.
curl -fsSL https://raw.githubusercontent.com/novuhq/novu/next/docker/community/setup.sh | NOVU_DIR=~/novu bash
cd ~/novu && docker compose up -dPanel staje się dostępny pod adresem http://localhost:4000. Przy wdrożeniu na serwer trzeba jeszcze ustawić HOST_NAME w pliku .env. Uruchamiany zestaw obejmuje API, worker, serwer WebSocket i panel, a do tego bazę danych i kolejkę, więc to nie jest pojedynczy proces, tylko kilka usług do monitorowania. Pobieranie skryptu przez potok do powłoki oznacza wykonanie kodu z sieci bez wglądu w jego treść, więc rozsądniej jest ten plik najpierw ściągnąć i przeczytać.
Novu a alternatywy
| Cecha | Novu | Dostawca wprost, Resend albo Postmark | Trigger.dev plus własny kod |
|---|---|---|---|
| Kanały za jednym API | e-mail, SMS, push, chat, in-app | wyłącznie e-mail | tyle, ile sam podepniesz |
| Centrum preferencji | wbudowane, na kanał i na workflow | brak poza wypisem z listy | do napisania samodzielnie |
| Skrzynka w aplikacji | gotowy komponent dla React | brak | do napisania samodzielnie |
| Digest i okna opóźnień | kroki digest i delay | brak | możliwe, definiujesz sam |
| Kod otwarty | MIT poza katalogiem enterprise | nie | tak, Apache 2.0 w repozytorium |
| Własny hosting | tak, docker compose | nie | tak |
Wybór rozstrzyga się na pytaniu o preferencje i skrzynkę w aplikacji. Jeśli produkt wysyła wyłącznie e-maile transakcyjne i nikt nie prosi o wyłączanie ich typami, dostawca wprost jest prostszy, tańszy i ma mniej ruchomych części. Jeśli masz już kolejkę zadań i wolisz trzymać logikę u siebie, Trigger.dev obsłuży opóźnienia i ponowienia, ale preferencje oraz komponent skrzynki napiszesz sam, a to jest większość pracy. Osobną kategorią są narzędzia do e-maili cyklu życia i kampanii, jak Loops, które rozwiązują inny problem i sensownie stoją obok Novu, a nie zamiast niego.
Trzeba też nazwać ryzyko przywiązania do dostawcy. Szablony klikane w panelu, definicje workflow i preferencje subskrybentów siedzą po stronie Novu. Odejście oznacza wyeksportowanie tego wszystkiego i odtworzenie w nowym miejscu, a części, na przykład historii aktywności, nie odtworzysz w ogóle. Trzymanie workflow w kodzie przez @novu/framework ogranicza ten problem, bo definicje zostają w repozytorium, ale preferencji użytkowników to nie dotyczy.
Zamkniętą alternatywą w tej samej niszy jest Knock, który rozwiązuje ten sam problem, czyli jedno zdarzenie w kodzie zamienione na wysyłkę wieloma kanałami z uwzględnieniem preferencji odbiorcy, grupowania i tłumienia powtórzeń. Różnica jest zasadnicza i trzeba ją zważyć: nie ma tam wariantu do uruchomienia u siebie, a przepływy definiuje się w panelu dostawcy, więc logika powiadomień wychodzi poza Twoje repozytorium i poza zwykły przegląd zmian. Warto też odnotować, że biblioteka serwerowa jest tam na Apache 2.0, a pakiety klienckie na MIT.
Typowe błędy
Pierwszy to osadzenie komponentu Inbox bez subscriberHash. Identyfikator subskrybenta jest jawny po stronie przeglądarki, więc bez podpisu HMAC cudza skrzynka jest oddalona o jedną zmianę w narzędziach deweloperskich. Podpis generuj na serwerze i przekazuj do komponentu jako właściwość.
Drugi to użycie subscriberId zamiast identyfikatora z własnej bazy. Kiedy po pół roku zorientujesz się, że wygodniej byłoby użyć czegoś innego, wszystkie preferencje przypięte do starego identyfikatora zostaną w miejscu, z którego nikt ich nie przeniesie automatycznie.
Trzeci to plan darmowy pod wymagania, których nie obsługuje. Okno digest i historia aktywności trwają tam 24 godziny, więc podsumowanie tygodniowe oraz diagnostyka zgłoszeń sprzed kilku dni po prostu nie zadziałają, niezależnie od tego, jak poprawnie napiszesz workflow.
Czwarty to trasa serve niedostępna publicznie. Serwer Novu odpytuje ją przy każdym kroku, więc workflow zdefiniowany w kodzie i wdrożony za zaporą albo tylko lokalnie nie wykona się w ogóle, a komunikat o błędzie bywa mylący.
Piąty to używanie @novu/node. Pakiet jest wycofany, z końcem wsparcia oznaczonym na 20 marca 2025 roku, a większość poradników w sieci wciąż go pokazuje. Nowy kod pisz na @novu/api.
Szósty to przeoczenie różnicy nazw między biblioteką a formatem sieciowym. Kanał inApp w bibliotece przesyłany jest jako in_app, więc żądanie budowane ręcznie z nazwą z dokumentacji biblioteki po cichu niczego nie zmieni.
Siódmy to potraktowanie całego repozytorium jako MIT przy planowaniu własnego hostingu z logowaniem korporacyjnym albo tłumaczeniami. Te funkcje leżą w katalogu enterprise/packages objętym umową własnościową, która wymaga uprzedniej pisemnej zgody, a pole kontaktowe w jej treści pozostało niewypełnione.
FAQ
Czy Novu wysyła wiadomości samo, czy przez dostawców?
Przez dostawców. Novu jest warstwą sterującą, a właściwa wysyłka idzie przez skonfigurowaną integrację, na przykład Resend, Postmark albo Twilio dla SMS. Rachunek za samą wysyłkę płacisz więc dostawcy osobno, a Novu rozlicza uruchomienia workflow.
Jaka jest licencja Novu?
Mieszana. Domyślna gałąź repozytorium zawiera LICENSE-MIT, LICENSE-ENTERPRISE i EE-PACKAGES-LICENSE, gdzie katalog enterprise/packages objęty jest umową własnościową, a reszta kodu licencją MIT. Pakiety npm deklarują to niespójnie: @novu/api nie ma pola license, ale zawiera plik MIT, natomiast @novu/js, @novu/react, @novu/nextjs i @novu/framework deklarują ISC bez żadnego pliku licencyjnego w archiwum.
Czy własny hosting daje wszystkie funkcje?
Nie. Funkcje z katalogu enterprise/packages, czyli między innymi logowanie korporacyjne, rozliczenia i tłumaczenia, podlegają osobnym warunkom, a właściwy kod tych pakietów wskazywany jest przez submoduł prowadzący do repozytorium bez dostępu publicznego. Wariant społecznościowy uruchamiany przez docker compose obejmuje API, worker, serwer WebSocket i panel.
Czy da się trzymać definicje workflow w repozytorium?
Tak, przez pakiet @novu/framework. Definiujesz workflow w TypeScripcie ze schematem payloadSchema, wystawiasz trasę przez serve i synchronizujesz. Warunek jest jeden: ta trasa musi być publicznie osiągalna, bo serwer Novu odpytuje ją przy każdym kroku.
Ile kosztuje Novu przy tysiącu powiadomień dziennie?
Tysiąc uruchomień dziennie to około 30 tysięcy miesięcznie, czyli dokładnie limit planu Pro, którego cena zaczyna się od 30 USD miesięcznie. Powyżej limitu naliczane jest 1,20 USD za każdy tysiąc dodatkowych uruchomień. Do tego dochodzi koszt po stronie dostawcy wysyłki, którego Novu nie obejmuje.
Czy warstwa powiadomień ma sens przy jednym kanale?
Rzadko. Przy samych e-mailach transakcyjnych bez preferencji i bez skrzynki w aplikacji wywołanie dostawcy wprost jest prostsze i tańsze. Novu zaczyna się zwracać przy drugim kanale albo przy pierwszym żądaniu użytkownika o wyłączenie konkretnego typu powiadomień.
Dokumentację znajdziesz na stronie dokumentacji Novu, cennik na stronie cennika, a kod źródłowy w repozytorium na GitHubie.