| name | novu-framework-integration |
| description | Build code-first notification workflows with @novu/framework. Use when defining workflows in TypeScript (Zod / JSON Schema / Class Validator), composing channel steps (email, SMS, push, chat, in-app) with action steps (delay, digest, custom), exposing Step Controls for non-technical teammates, rendering React/Vue/Svelte Email templates, hosting the Bridge Endpoint inside Next.js, Express, NestJS, Remix, Nuxt, SvelteKit, H3, or AWS Lambda, syncing to Novu Cloud via CLI / GitHub Actions, securing production with HMAC, or implementing translations, hydration, multi-channel orchestration, and LLM-powered notification logic in code. |
| inputs | [{"name":"NOVU_SECRET_KEY","description":"Server-side API key from https://dashboard.novu.co/api-keys. Used by @novu/framework and the Bridge Endpoint.","required":true,"type":"secret"}] |
Framework Integration
Use @novu/framework to build notification workflows in code, alongside your application source. Workflows live in your repo, content is rendered using libraries you already use (React Email, Vue Email, Svelte Email), and a single HTTP endpoint (the Bridge) lets Novu Cloud execute them with just-in-time data from your services.
Use this skill when building workflows in code. For workflows authored in the Novu Dashboard, just trigger them via trigger-notification — no Framework needed.
When to Use the Framework
| Use Framework | Use Dashboard Workflows |
|---|
| Workflows must live in source control / GitOps | Non-technical peers own all the content |
| Need just-in-time data from your DB / APIs | All data fits in the trigger payload |
| Render emails with React/Vue/Svelte Email | Block editor is enough |
| Execute custom code (LLMs, third-party APIs) | Pure send-only flows |
| Need typed payload + step controls | Quick prototype |
The two approaches coexist — a single environment can have both code-defined and dashboard-defined workflows.
How It Works
- You define workflows in code with
workflow(...) from @novu/framework.
- You expose a single
/api/novu HTTP route in your app — the Bridge Endpoint.
- You sync the bridge URL to Novu Cloud (via
npx novu sync or GitHub Action).
- Novu Cloud calls your bridge over an authenticated tunnel during workflow execution to fetch step content with the latest data.
Trigger ──► Novu Cloud Worker ──► Your Bridge (/api/novu) ──► Provider (SendGrid, FCM, …)
Quick Start
1. Bootstrap a project
npx novu init --secret-key=<YOUR_NOVU_SECRET_KEY>
This creates a sample bridge app with a workflow, env file, and a working /api/novu route.
2. Or add to an existing app
npm install @novu/framework zod @react-email/components react-email
NOVU_SECRET_KEY=<YOUR_NOVU_SECRET_KEY>
3. Define a workflow
import { workflow } from "@novu/framework";
import { z } from "zod";
export const welcomeWorkflow = workflow(
"welcome-email",
async ({ step, payload, subscriber }) => {
await step.email("send-email", async (controls) => {
return {
subject: controls.subject,
body: `Welcome ${subscriber.firstName ?? payload.userName}!`,
};
}, {
controlSchema: z.object({
subject: z.string().default("Welcome to {{payload.appName}}"),
}),
});
},
{
payloadSchema: z.object({
userName: z.string(),
appName: z.string().default("Acme"),
}),
name: "Welcome Email",
description: "Sent when a new user signs up",
tags: ["onboarding"],
}
);
4. Mount the Bridge Endpoint
Pick the wrapper that matches your framework — see Bridge Endpoint Setup below.
5. Run the Local Studio
npx novu@latest dev --port <YOUR_APP_PORT>
Open http://localhost:2022 to preview workflows, edit controls, and trigger test events. The Studio creates a public tunnel automatically so Novu Cloud can reach your local bridge.
Workflow Anatomy
Designing the workflow itself? See design-workflow/ for channel selection, severity, critical, digest defaults, step conditions, and the 9 reference templates (order confirmation, payment failed, account suspended, comment, trial expiring, password reset, webhook fan-out, fetch-then-notify). The Framework SKILL covers how to express those decisions in code; design-workflow/ covers what to decide.
workflow(workflowId, handler, options);
| Param | Type | Description |
|---|
workflowId | string | Unique identifier in your environment |
handler | ({ step, payload, subscriber }) => Promise<void> | Workflow body — calls steps in order |
options | WorkflowOptions | Schema, name, description, tags, preferences |
Workflow Options
| Option | Type | Purpose |
|---|
payloadSchema | ZodSchema | JsonSchema | ClassValidatorClass | Validates the trigger payload, infers TS type for payload |
name | string | Human-readable name shown in Dashboard / <Inbox /> |
description | string | Description shown in Dashboard |
tags | string[] | Categorize for filtering / Inbox tabs |
severity | 'low' | 'medium' | 'high' | Visual prioritization in the Inbox. Leave unset for most workflows. |
critical | boolean | Bypasses subscriber preferences, skips digest, runs without delays. Reserve for must-deliver events. |
preferences | WorkflowPreferences | Default channel preferences and readOnly flag |
Workflow Context
The handler receives { step, payload, subscriber }:
step — channel and action step builders (step.email, step.delay, step.digest, …)
payload — strongly-typed data passed at trigger time, validated against payloadSchema
subscriber — { subscriberId, firstName?, lastName?, locale?, data?, ... } of the recipient
Channel Steps
All channel steps share the same shape:
await step.<channel>(stepId, resolver, options?);
| Step | Output Required | Notable Outputs | Returns Result |
|---|
step.email | subject, body | attachments, from, replyTo | No |
step.sms | body | — | No |
step.push | title (or subject), body | data, image, icon | No |
step.chat | body | — (override per-provider) | No |
step.inApp | body | subject, avatar, redirect, primaryAction, secondaryAction, data | { seen, read, lastSeenDate, lastReadDate } |
Email Step
await step.email("welcome", async (controls) => ({
subject: controls.subject,
body: render(<WelcomeEmail name={subscriber.firstName} />),
from: "hello@acme.com",
replyTo: "support@acme.com",
}));
In-App Step (rich payload)
await step.inApp("inbox", async () => ({
subject: "Welcome to Acme!",
body: "We are excited to have you on board.",
avatar: "https://acme.com/avatar.png",
redirect: { url: "/welcome", target: "_self" },
primaryAction: {
label: "Get Started",
redirect: { url: "/get-started", target: "_self" },
},
data: { entityType: "user", entityId: payload.userId },
}));
The In-App step returns { seen, read, lastSeenDate, lastReadDate } — use it to drive the skip of subsequent steps.
SMS / Push / Chat
await step.sms("verification", async () => ({
body: `Your code is ${payload.code}`,
}));
await step.push("new-message", async () => ({
title: "New message",
body: payload.preview,
data: { messageId: payload.id },
}));
await step.chat("notify", async () => ({
body: `:rocket: Deploy ${payload.id} succeeded`,
}));
Action Steps
step.delay
Pause workflow execution before the next step.
await step.delay("wait-a-day", async () => ({
unit: "days",
amount: 1,
}));
Supported unit values: seconds, minutes, hours, days, weeks, months.
step.digest
Aggregate multiple triggers into a single notification over a window.
const { events } = await step.digest("daily", async () => ({
unit: "days",
amount: 1,
digestKey: payload.projectId,
}));
await step.email("summary", async () => ({
subject: `${events.length} updates today`,
body: render(<DigestEmail events={events} />),
}));
Use cron: "0 0 * * *" instead of unit/amount for cron-based digests. Each digest event has { id, time, payload }. Only one digest per workflow — chain a second workflow via step.custom if you need a two-stage digest.
step.http
Call an external HTTP endpoint as part of the workflow — webhook fan-out or just-in-time data fetch.
const plan = await step.http("fetch-plan", async () => ({
method: "GET",
url: `https://api.example.com/users/${payload.userId}/plan`,
responseBodySchema: {
type: "object",
properties: { planName: { type: "string" }, renewalDate: { type: "string" } },
required: ["planName", "renewalDate"],
} as const,
}));
await step.email("notify", async () => ({
subject: `Your ${plan.planName} plan`,
body: `Renews on ${plan.renewalDate}.`,
}));
Webhook-style:
await step.http("webhook", async () => ({
method: "POST",
url: payload.webhookUrl,
headers: [{ key: "Content-Type", value: "application/json" }],
body: [
{ key: "event", value: "payment_failed" },
{ key: "subscriberId", value: subscriber.subscriberId },
],
continueOnFailure: true,
}));
When a subsequent step references HTTP response data, the HTTP step must declare a responseBodySchema. Only properties declared in the schema are addressable as {{ steps.<http-step-id>.<property> }}.
step.custom
Run arbitrary code and persist its result for later steps.
const task = await step.custom("fetch-task", async () => {
const t = await db.fetchTask(payload.taskId);
return { id: t.id, title: t.title, complete: t.complete };
}, {
outputSchema: {
type: "object",
properties: {
id: { type: "string" },
title: { type: "string" },
complete: { type: "boolean" },
},
required: ["id", "complete"],
} as const,
});
await step.email("reminder", async () => ({
subject: `Reminder: ${task.title}`,
body: "Please complete your task.",
}), {
skip: () => task.complete,
});
The custom step result is only usable inside subsequent step resolver, providers, and skip functions — not in step controls.
Step Options
await step.email(stepId, resolver, {
controlSchema,
skip,
providers,
disableOutputSanitization,
});
skip
Conditionally skip a step. Receives the resolved controls.
await step.email("follow-up", resolver, {
skip: () => inAppNotification.read === true,
});
providers (Per-Step Provider Overrides)
Customize the request sent to the underlying provider — e.g. Slack blocks or SendGrid cc.
await step.email("alert", resolver, {
providers: {
sendgrid: ({ controls, outputs }) => ({
from: "alerts@acme.com",
cc: ["ops@acme.com"],
_passthrough: {
body: { ip_pool_name: "transactional" },
headers: { "X-Custom": "value" },
},
}),
},
});
_passthrough deep-merges into the final provider request — typed provider keys take precedence over _passthrough.
disableOutputSanitization
Allow raw HTML / unescaped characters in the output (e.g. & in In-App data.link):
await step.inApp("link", async () => ({
body: "Check it out",
data: { link: "/p/123?active=true&env=prod" },
}), { disableOutputSanitization: true });
Payload Schema
The payload is the data passed at trigger time. Define a schema to get typed payload and runtime validation.
With Zod
import { z } from "zod";
workflow("comment", handler, {
payloadSchema: z.object({
postId: z.number(),
authorName: z.string(),
comment: z.string().max(200),
}),
});
With JSON Schema
workflow("comment", handler, {
payloadSchema: {
type: "object",
properties: {
postId: { type: "number" },
authorName: { type: "string" },
comment: { type: "string", maxLength: 200 },
},
required: ["postId", "comment"],
additionalProperties: false,
} as const,
});
The as const is required for TS to infer the payload type from JSON Schema.
With Class Validator
import { IsString, IsNumber } from "class-validator";
class CommentPayload {
@IsNumber() postId!: number;
@IsString() authorName!: string;
@IsString() comment!: string;
}
workflow("comment", handler, { payloadSchema: CommentPayload });
Requires class-validator, class-validator-jsonschema, reflect-metadata. See references/schema-validation.md.
Step Controls — No-Code for Your Team
Controls are step-level inputs your non-technical peers can edit in the Novu Dashboard UI without touching code. They're validated by a schema you define (Zod / JSON Schema / Class-Validator).
await step.email("welcome", async (controls) => ({
subject: controls.subject,
body: render(<EmailTemplate hideBanner={controls.hideBanner} />),
}), {
controlSchema: z.object({
hideBanner: z.boolean().default(false),
subject: z.string().default("Hi {{subscriber.firstName | capitalize}}"),
}),
});
Variables in Controls
Control values support LiquidJS templating:
{{subscriber.firstName}} — any subscriber attribute
{{payload.userId}} — any payload field defined in payloadSchema
{{payload.invoiceDate | date: '%a, %b %d, %y'}} — Liquid filters
{{subscriber.firstName | append: ': ' | append: payload.status | capitalize}} — chained filters
Type {{ in the Dashboard UI to autocomplete available variables.
Controls vs Payload
| Controls | Payload |
|---|
| Edited by | Non-technical peers in Dashboard | Developers in code |
| Schema | controlSchema per step | payloadSchema per workflow |
| Persistence | Stored in Novu Cloud per environment | Sent at trigger time |
| Use case | Subject, copy, styling, behaviour toggles | Dynamic per-trigger data |
Workflow Preferences
Define default channel preferences in code. See manage-preferences for the full preference resolution model.
workflow("system-alert", handler, {
preferences: {
all: { enabled: true, readOnly: false },
channels: {
email: { enabled: true },
sms: { enabled: false },
inApp: { enabled: true },
},
},
});
all.readOnly: true makes the workflow critical — subscribers cannot disable it.
all.enabled is the fallback for any channel not in channels.
- Default if omitted:
enabled: true, readOnly: false for all channels.
Bridge Endpoint Setup
The Bridge is a single HTTP route (/api/novu by default) where Novu Cloud calls your app to:
- Discover registered workflows (
GET)
- Resolve step content for a given subscriber + payload (
POST)
- Verify HMAC signatures on requests
Each framework ships a serve wrapper that handles parsing, HMAC verification, and response shaping.
Next.js (App Router)
import { serve } from "@novu/framework/next";
import { welcomeWorkflow } from "@/novu/workflows";
export const { GET, POST, OPTIONS } = serve({
workflows: [welcomeWorkflow],
});
Next.js (Pages Router)
import { serve } from "@novu/framework/next";
import { welcomeWorkflow } from "../../novu/workflows";
export default serve({ workflows: [welcomeWorkflow] });
Express
import express from "express";
import { serve } from "@novu/framework/express";
import { welcomeWorkflow } from "./novu/workflows";
const app = express();
app.use(express.json());
app.use("/api/novu", serve({ workflows: [welcomeWorkflow] }));
app.listen(4000);
NestJS
import { Module } from "@nestjs/common";
import { NovuModule } from "@novu/framework/nest";
import { welcomeWorkflow } from "./novu/workflows";
@Module({
imports: [
NovuModule.register({
apiPath: "/api/novu",
workflows: [welcomeWorkflow],
}),
],
})
export class AppModule {}
For dependency injection, use NovuModule.registerAsync — see references/bridge-endpoint.md.
Remix
import { serve } from "@novu/framework/remix";
import { welcomeWorkflow } from "../novu/workflows";
const handler = serve({ workflows: [welcomeWorkflow] });
export { handler as action, handler as loader };
SvelteKit
import { serve } from "@novu/framework/sveltekit";
import { welcomeWorkflow } from "$lib/novu/workflows";
export const { GET, POST, OPTIONS } = serve({ workflows: [welcomeWorkflow] });
Nuxt
import { serve } from "@novu/framework/nuxt";
import { welcomeWorkflow } from "~/novu/workflows";
export default defineEventHandler(serve({ workflows: [welcomeWorkflow] }));
H3
import { createApp, eventHandler, toNodeListener } from "h3";
import { createServer } from "node:http";
import { serve } from "@novu/framework/h3";
import { welcomeWorkflow } from "./novu/workflows";
const app = createApp();
app.use("/api/novu", eventHandler(serve({ workflows: [welcomeWorkflow] })));
createServer(toNodeListener(app)).listen(4000);
AWS Lambda
import { serve } from "@novu/framework/lambda";
import { welcomeWorkflow } from "./novu/workflows";
export const novu = serve({ workflows: [welcomeWorkflow] });
Custom (any framework)
import { NovuRequestHandler, ServeHandlerOptions } from "@novu/framework";
export const serve = (options: ServeHandlerOptions) =>
new NovuRequestHandler({
frameworkName: "my-framework",
...options,
handler: (req, res) => ({ }),
}).createHandler();
See references/bridge-endpoint.md for the full custom handler signature.
Local Studio
Live preview of your workflows with a public tunnel for Novu Cloud to reach your machine.
npx novu@latest dev
Then open http://localhost:2022 (Chrome only).
CLI Flags
| Flag | Default | Purpose |
|---|
-p, --port | 4000 | Your app's port |
-r, --route | /api/novu | Bridge route path |
-o, --origin | http://localhost | Bridge origin |
-d, --dashboard-url | https://dashboard.novu.co | Dashboard URL — use https://eu.dashboard.novu.co for EU |
-sp, --studio-port | 2022 | Studio UI port |
-t, --tunnel | auto | Self-hosted tunnel URL (e.g. ngrok) |
-H, --headless | false | Skip the Studio UI |
npx novu@latest dev --port 3002 --dashboard-url https://eu.dashboard.novu.co
The Studio:
- Auto-creates a stable tunnel URL like
https://<id>.novu.sh/api/novu
- Lets you edit Step Controls and Payload to preview different states
- Runs against
process.env.NODE_ENV=development — HMAC verification is off to allow Studio access
- Has a "Sync" button to push state to Cloud (use CI/CD for real deployments)
Triggering Workflows
Code-defined workflows are triggered the same way as Dashboard workflows — using @novu/api from your trigger surface (server, queue worker, webhook handler):
import { Novu } from "@novu/api";
const novu = new Novu({ secretKey: process.env.NOVU_SECRET_KEY });
await novu.trigger({
workflowId: "welcome-email",
to: { subscriberId: "user-123", email: "jane@acme.com" },
payload: { userName: "Jane", appName: "Acme" },
});
You can also trigger a workflow from inside a step.custom of another workflow:
await step.custom("trigger-summary", async () => {
return await summaryWorkflow.trigger({
to: subscriber.subscriberId,
payload: { events: events.map(e => e.payload) },
});
});
See trigger-notification for full trigger options (bulk, broadcast, topics, overrides, transactionId, cancel).
React Email (and friends)
Render emails using your existing component library.
React Email
npm install @react-email/components react-email
import { Body, Container, Head, Html, render } from "@react-email/components";
export const WelcomeEmail = ({ name }: { name: string }) => (
<Html>
<Head />
<Body>
<Container>Hello {name}, welcome!</Container>
</Body>
</Html>
);
export const renderWelcome = (name: string) => render(<WelcomeEmail name={name} />);
await step.email("welcome", async () => ({
subject: "Welcome",
body: renderWelcome(payload.userName),
}));
Vue Email, Svelte Email, and Remix + React Email are also supported. See references/email-templates.md.
Translations (i18n)
For Framework-based workflows, translation lives in your code (not in the Novu Translation system, which targets Dashboard workflows). Use any i18n library (e.g. i18next) and resolve content from subscriber.locale inside the resolver.
import { workflow } from "@novu/framework";
import i18n from "./i18n";
export const localizedWorkflow = workflow(
"welcome-localized",
async ({ step, subscriber }) => {
await step.email("email", async (controls) => {
const t = i18n.getFixedT([subscriber.locale ?? controls.defaultLocale]);
return {
subject: t("welcomeEmailSubject", { username: subscriber.firstName }),
body: render(<Welcome subject={t("subject")} body={t("body")} />),
};
}, {
controlSchema: z.object({
defaultLocale: z.string().default("en_US"),
}),
});
},
);
See references/translations.md for a complete i18next + React Email example.
Tags
Tag a workflow to group it with related notifications (used by Inbox tabs and Dashboard filtering):
workflow("login-alert", handler, { tags: ["security"] });
workflow("password-change", handler, { tags: ["security"] });
In the Inbox, render a "Security" tab with tabs={[{ label: "Security", filter: { tags: ["security"] } }]} (see inbox-integration).
Deployment
Sync via CLI
Push your workflows to Novu Cloud:
npx novu@latest sync \
--bridge-url https://api.acme.com/api/novu \
--secret-key $NOVU_SECRET_KEY \
--api-url https://api.novu.co
GitHub Actions
name: Sync Novu Workflows
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: novuhq/actions-novu-sync@v2
with:
secret-key: ${{ secrets.NOVU_SECRET_KEY }}
bridge-url: ${{ secrets.NOVU_BRIDGE_URL }}
api-url: https://api.novu.co
GitOps Workflow
- Develop locally with the Studio against your own machine.
- Open a PR — CI runs
npx novu sync against the Development environment to test e2e.
- Merge to
main — CI runs npx novu sync against Production.
GitLab CI, Jenkins, CircleCI, Bitbucket, Azure DevOps, and Travis CI all work via the CLI.
Production & Security
- Bridge URL must be publicly reachable over HTTPS. Novu Cloud auto-scales — no IP allowlist is published.
- HMAC verification is on by default when
NODE_ENV !== "development". The serve wrapper handles this — you don't need to write any code. Each request includes a Novu-Signature header (t=timestamp,v1=signature) that's verified against NOVU_SECRET_KEY.
- Disable HMAC for local dev automatically via
NODE_ENV=development. Don't disable it in production.
- Vercel Preview URLs are protected by default — enable Protection Bypass for Automation and pass the bypass token as
?x-vercel-protection-bypass=<token> in your bridge URL.
Custom Client
Override defaults globally:
import { Client as NovuFrameworkClient } from "@novu/framework";
import { serve } from "@novu/framework/next";
export const { GET, POST, OPTIONS } = serve({
client: new NovuFrameworkClient({
secretKey: process.env.NOVU_SECRET_KEY,
strictAuthentication: false,
}),
workflows: [],
});
Environment variables read by the Client:
NOVU_SECRET_KEY — your secret key
NOVU_API_URL — defaults to https://api.novu.co (use https://eu.api.novu.co for EU)
Common Pitfalls
- Bridge URL must be publicly reachable —
localhost won't work for Novu Cloud. Use the Studio tunnel locally; deploy publicly for production.
workflowId is the trigger identifier — same id you'll pass to novu.trigger({ workflowId }). Use kebab-case and keep it stable.
- Step
ids must be unique within a workflow — duplicates throw at registration.
as const on JSON Schema — without it, TS infers string instead of literal types and payload becomes unknown.
- Only one
step.digest per workflow — chain a second workflow via step.custom for two-stage digest patterns.
- Digest / delay results from one trigger don't influence other triggers — they're per workflow run.
- Custom step results aren't usable in step controls — only in subsequent step
resolver, providers, or skip callbacks.
- Sync after every workflow change — Novu Cloud needs to know about new/renamed workflows and updated control schemas. Add
npx novu sync to your CI/CD.
- HMAC fails locally if
NODE_ENV !== "development" — set it to development for the Studio to reach your bridge, or disable strict auth in your Client.
- Don't store the
secretKey in the client bundle — it's server-only. Keep workflows + bridge route inside server code, not in any "use client" module.
- Provider override
_passthrough is unvalidated — typos won't error at compile time. Use known typed provider keys whenever possible.
- Changing a delay/digest step's content does not affect already-scheduled events — content is captured at the time of the original trigger.
- Workflow handlers must be deterministic across retries — Novu re-invokes the bridge to resolve step content. Avoid side-effects outside
step.custom (custom is the only step whose result is durably persisted).
@novu/framework requires Node.js ≥ 20.
Code Style Tips
- One file per workflow under
src/novu/workflows/<workflow-id>.ts, re-exported from a barrel src/novu/workflows/index.ts.
- Prefer Zod schemas — best autocomplete and inference. Use JSON Schema only when you need features Zod doesn't expose (
oneOf, if/then/else, $ref).
- Co-locate React Email templates next to the workflow that uses them (
src/novu/workflows/welcome/template.tsx).
- Wrap shared
step.custom logic into helpers (fetchUser(payload.userId)) for reuse.
- For NestJS, use
NovuModule.registerAsync with a NotificationService so workflow definitions can inject services.
References
- Bridge Endpoint Setup — every framework wrapper, custom
serve, NestJS DI
- Workflow & Step API — full options, all step types, conditional logic patterns
- Schema Validation — Zod, JSON Schema, Class Validator deep dive
- Email Templates — React, Vue, Svelte Email integrations
- Translations — i18next-based localized workflows
- Local Studio & CLI — every flag, tunnel modes, headless mode
- Deployment —
npx novu sync, GitHub Action, GitOps recipe, EU region
- Production & Security — HMAC, public bridge requirements, Vercel preview bypass
- Examples Cookbook — multi-step onboarding, digest, delay-then-skip, LLM-powered digest