Examples Cookbook
Real-world workflow patterns built with@novu/framework.
Multi-Step Onboarding
Send a welcome email immediately, wait a week, then nudge with an in-app reminder if the user opted in.import { workflow } from "@novu/framework";
import { z } from "zod";
import { renderEmail } from "../emails/welcome";
export const onboardingWorkflow = workflow(
"new-signup",
async ({ step, payload }) => {
await step.email("welcome-email", async () => {
const user = await db.getUser(payload.userId);
return {
subject: `Welcome to Acme ${user.tier}, ${user.name}!`,
body: renderEmail({ name: user.name, tier: user.tier }),
};
});
await step.delay("wait-1-week", async () => ({
unit: "weeks",
amount: 1,
}));
await step.inApp("nudge", async (controls) => {
const user = await db.getUser(payload.userId);
return {
subject: "How is it going?",
body: `Hey ${user.name}, how do you like Acme so far?`,
primaryAction: {
label: "Give Feedback",
redirect: { url: controls.feedbackUrl, target: "_blank" },
},
};
}, {
controlSchema: z.object({
feedbackUrl: z.string().url().default("https://acme.com/feedback"),
}),
skip: () => !payload.shouldFollowUp,
});
},
{
payloadSchema: z.object({
userId: z.string(),
shouldFollowUp: z.boolean().default(true),
}),
name: "New Signup Onboarding",
tags: ["onboarding", "lifecycle"],
}
);
Skip Email if In-App Was Read
Send an in-app notification, wait 6 hours, then send an email — but skip the email if the in-app was read.import { workflow } from "@novu/framework";
import { z } from "zod";
export const reminderWorkflow = workflow(
"task-reminder",
async ({ step, payload }) => {
const inApp = await step.inApp("send-in-app", async () => ({
subject: "Task reminder!",
body: "Task is not yet complete. Please complete the task.",
data: { taskId: payload.taskId },
}));
await step.delay("wait-6h", async () => ({
unit: "hours",
amount: 6,
}));
await step.email("send-email", async () => ({
subject: "Task reminder!",
body: "Task is not yet complete. Please complete the task.",
}), {
skip: () => inApp.read === true,
});
},
{
payloadSchema: z.object({ taskId: z.string() }),
tags: ["reminder"],
}
);
Daily Digest with React Email
Aggregate all triggers within 24 hours into a single email.import { workflow } from "@novu/framework";
import { z } from "zod";
import { render } from "@react-email/components";
import { ActivityDigestEmail } from "../emails/activity-digest";
export const dailyDigestWorkflow = workflow(
"activity-digest",
async ({ step, payload }) => {
const { events } = await step.digest("digest-window", async () => ({
unit: "days",
amount: 1,
}));
await step.email("send-summary", async () => {
const activities = events.map((e) => ({
type: e.payload.type,
user: e.payload.userName,
action: e.payload.action,
time: e.time,
}));
return {
subject: `Activity Summary (${events.length} updates)`,
body: render(<ActivityDigestEmail activities={activities} />),
};
});
},
{
payloadSchema: z.object({
type: z.enum(["comment", "like", "follow"]),
userName: z.string(),
action: z.string(),
}),
tags: ["digest"],
}
);
Cron-Based Digest
Send a single morning digest at 9am UTC every day.export const morningDigest = workflow(
"morning-digest",
async ({ step }) => {
const { events } = await step.digest("digest", async () => ({
cron: "0 9 * * *", // every day at 09:00 UTC
}));
if (events.length === 0) return;
await step.email("digest", async () => ({
subject: `${events.length} updates from yesterday`,
body: render(<MorningDigest events={events} />),
}));
}
);
Custom Digest Key (Per-Project)
Aggregate events bysubscriberId + projectId instead of just subscriberId.
export const projectDigestWorkflow = workflow(
"project-digest",
async ({ step, payload }) => {
const { events } = await step.digest("digest-step", async () => ({
unit: "hours",
amount: 1,
digestKey: payload.projectId,
}));
await step.inApp("notify", async () => ({
subject: `${events.length} updates in ${payload.projectName}`,
body: events.map((e) => e.payload.title).join(", "),
}));
},
{
payloadSchema: z.object({
projectId: z.string(),
projectName: z.string(),
title: z.string(),
}),
}
);
Two-Stage Digest with LLM Categorization
A workflow can trigger another workflow viastep.custom. Use this to chain digests when you need two windows.
import { categorizeUsingLLM } from "../lib/llm";
const summaryWorkflow = workflow(
"llm-summary",
async ({ step }) => {
const { events } = await step.digest("digest-6h", async () => ({
unit: "hours",
amount: 6,
}));
await step.email("summary", async () => {
const allRequests = events.map((e) => e.payload.requests);
const { bugs, features, praise } = await categorizeUsingLLM(allRequests);
return {
subject: "LLM Feedback Digest — Last 6 Hours",
body: `
Bugs reported: ${bugs}\n
Feature requests: ${features}\n
Praise received: ${praise}\n
`,
};
});
}
);
export const requestsWorkflow = workflow(
"customer-requests",
async ({ step, subscriber, payload }) => {
const { events } = await step.digest("digest-15m", async () => ({
unit: "minutes",
amount: 15,
}));
await step.inApp("in-app-summary", async () => ({
subject: `${events.length} new requests`,
body: `You've received ${events.length} customer requests in the last 15 minutes.`,
}));
await step.custom("trigger-llm-summary", async () => {
return await summaryWorkflow.trigger({
to: subscriber.subscriberId,
payload: {
requests: events.map((e) => e.payload),
},
});
});
}
);
Skip Delay for Premium Users
Premium users get the email immediately; free users wait 24h.export const upsellWorkflow = workflow(
"upsell",
async ({ step, subscriber }) => {
await step.delay("wait", async () => ({ unit: "hours", amount: 24 }), {
skip: async () => subscriber.data?.tier === "premium",
});
await step.email("upsell", async () => ({
subject: "Upgrade to unlock more features",
body: "Try Premium free for 14 days.",
}));
}
);
Branch on Custom Step Result
Fetch a task from the database, then conditionally email a reminder.import { db } from "../lib/db";
export const taskReminderWorkflow = workflow(
"task-reminder",
async ({ step, payload }) => {
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: "This task is still open.",
}), {
skip: () => task.complete,
});
},
{
payloadSchema: z.object({ taskId: z.string() }),
}
);
Slack Provider Override (Block Kit)
Use thechat step but customize the Slack message with Block Kit.
export const deployAlertWorkflow = workflow(
"deploy-alert",
async ({ step, payload }) => {
await step.chat("slack", async () => ({
body: `Deploy ${payload.deployId} succeeded`,
}), {
providers: {
slack: ({ controls, outputs }) => ({
text: outputs.body,
blocks: [
{
type: "section",
text: { type: "mrkdwn", text: `:white_check_mark: *Deploy ${payload.deployId} succeeded*` },
},
{
type: "context",
elements: [
{ type: "mrkdwn", text: `Branch: \`${payload.branch}\` • Author: ${payload.author}` },
],
},
{
type: "actions",
elements: [
{
type: "button",
text: { type: "plain_text", text: "View Build" },
url: payload.buildUrl,
},
],
},
],
}),
},
});
},
{
payloadSchema: z.object({
deployId: z.string(),
branch: z.string(),
author: z.string(),
buildUrl: z.string().url(),
}),
tags: ["deploys"],
}
);
SendGrid CC + Provider Passthrough
export const alertWorkflow = workflow(
"alert",
async ({ step, payload }) => {
await step.email("alert", async () => ({
subject: `[ALERT] ${payload.title}`,
body: payload.message,
}), {
providers: {
sendgrid: () => ({
from: "[email protected]",
cc: ["[email protected]", "[email protected]"],
replyTo: "[email protected]",
_passthrough: {
body: { ip_pool_name: "transactional" },
headers: { "X-Priority": "1" },
},
}),
},
});
},
{
payloadSchema: z.object({
title: z.string(),
message: z.string(),
}),
tags: ["ops", "alerts"],
}
);
Critical Workflow (Subscribers Cannot Disable)
export const securityAlert = workflow(
"security-alert",
async ({ step }) => {
await step.email("notify", async () => ({
subject: "New login from unrecognized device",
body: "If this wasn't you, change your password immediately.",
}));
},
{
preferences: {
all: { enabled: true, readOnly: true }, // critical — subscribers cannot opt out
},
tags: ["security"],
}
);
In-App Only by Default
In-app is on; subscribers can opt into other channels via Preferences UI.export const newsletter = workflow(
"weekly-newsletter",
async ({ step }) => {
await step.inApp("in-app", async () => ({
subject: "Weekly digest",
body: "Read this week's highlights",
}));
await step.email("email", async () => ({
subject: "Weekly digest",
body: render(<WeeklyDigestEmail />),
}));
},
{
preferences: {
all: { enabled: false },
channels: { inApp: { enabled: true } },
},
tags: ["marketing"],
}
);
Locale-Aware Workflow with i18next
See translations.md for the full i18next setup.import i18n from "../translations";
export const localizedWelcome = workflow(
"welcome-localized",
async ({ step, subscriber }) => {
await step.email("send", async (controls) => {
const t = i18n.getFixedT([subscriber?.locale ?? controls.defaultLocale]);
return {
subject: t("welcomeSubject", { name: subscriber.firstName }),
body: render(<Welcome subject={t("welcomeSubject")} body={t("welcomeBody")} />),
};
}, {
controlSchema: z.object({ defaultLocale: z.string().default("en_US") }),
});
}
);