novu-framework-integration تایید شده

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.

88از ۱۰۰
۳۹.۳k
ستاره
۰
دانلود
۱۳
بازدید

// نصب مهارت

نصب مهارت

مهارت‌ها کدهای شخص ثالث از مخازن عمومی GitHub هستند. SkillHub الگوهای مخرب شناخته‌شده را اسکن می‌کند اما نمی‌تواند امنیت را تضمین کند. قبل از نصب، کد منبع را بررسی کنید.

نصب سراسری (سطح کاربر):

npx skillhub install novuhq/novu/novu-framework-integration

نصب در پروژه فعلی:

npx skillhub install novuhq/novu/novu-framework-integration --project

skill.install.customTargetHelp

npx skillhub install novuhq/novu/novu-framework-integration --target-dir /path/to/skills

مسیر پیشنهادی: ~/.claude/skills/novu-framework-integration/

بررسی هوش مصنوعی

88
از ۱۰۰
کیفیت دستورالعمل92
دقت توضیحات85
کاربردی بودن85
صحت فنی90

High score thanks to comprehensive coverage of the Novu Framework ecosystem, clear multi-step workflow instructions, 8 supplementary reference files for deep dives, and a detailed Common Pitfalls section. Minor deductions for lack of explicit negative triggers in description and slight over-reliance on external links in references.

productioncomplexbackend-devsfullstack-devsdevopsmulti-channel-notificationsemail-automationsms-alertspush-notificationsworkflow-orchestration
بررسی‌شده توسط review-skill-gateway(z-ai/glm-5.2) در تاریخ ۱۴۰۵/۴/۲۸

محتوای SKILL.md

---
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`](../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

1. You define workflows in code with `workflow(...)` from `@novu/framework`.
2. You expose a single `/api/novu` HTTP route in your app — the **Bridge Endpoint**.
3. You sync the bridge URL to Novu Cloud (via `npx novu sync` or GitHub Action).
4. 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

```bash
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

```bash
npm install @novu/framework zod @react-email/components react-email
```

```bash
NOVU_SECRET_KEY=<YOUR_NOVU_SECRET_KEY>
```

### 3. Define a workflow

```typescript
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](#bridge-endpoint-setup) below.

### 5. Run the Local Studio

```bash
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/`](../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**.

```typescript
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:

```typescript
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

```typescript
await step.email("welcome", async (controls) => ({
  subject: controls.subject,
  body: render(<WelcomeEmail name={subscriber.firstName} />),
  from: "[email protected]",
  replyTo: "[email protected]",
}));
```

### In-App Step (rich payload)

```typescript
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

```typescript
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.

```typescript
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.

```typescript
const { events } = await step.digest("daily", async () => ({
  unit: "days",
  amount: 1,
  digestKey: payload.projectId, // optional — group by custom key
}));

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.

```typescript
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:

```typescript
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.

```typescript
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

```typescript
await step.email(stepId, resolver, {
  controlSchema,         // Zod | JSON Schema | Class-Validator class
  skip,                  // (controls) => boolean | Promise<boolean>
  providers,             // per-provider override callbacks
  disableOutputSanitization, // boolean — for raw HTML in Inbox
});
```

### `skip`

Conditionally skip a step. Receives the resolved controls.

```typescript
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`.

```typescript
await step.email("alert", resolver, {
  providers: {
    sendgrid: ({ controls, outputs }) => ({
      from: "[email protected]",
      cc: ["[email protected]"],
      _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`):

```typescript
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

```typescript
import { z } from "zod";

workflow("comment", handler, {
  payloadSchema: z.object({
    postId: z.number(),
    authorName: z.string(),
    comment: z.string().max(200),
  }),
});
```

### With JSON Schema

```typescript
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

```typescript
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`](./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).

```typescript
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](https://liquidjs.com/filters/overview.html) 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`](../manage-preferences) for the full preference resolution model.

```typescript
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)

```typescript
import { serve } from "@novu/framework/next";
import { welcomeWorkflow } from "@/novu/workflows";

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

### Next.js (Pages Router)

```typescript
import { serve } from "@novu/framework/next";
import { welcomeWorkflow } from "../../novu/workflows";

export default serve({ workflows: [welcomeWorkflow] });
```

### Express

```typescript
import express from "express";
import { serve } from "@novu/framework/express";
import { welcomeWorkflow } from "./novu/workflows";

const app = express();
app.use(express.json()); // required
app.use("/api/novu", serve({ workflows: [welcomeWorkflow] }));
app.listen(4000);
```

### NestJS

```typescript
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`](./references/bridge-endpoint.md).

### Remix

```typescript
import { serve } from "@novu/framework/remix";
import { welcomeWorkflow } from "../novu/workflows";

const handler = serve({ workflows: [welcomeWorkflow] });
export { handler as action, handler as loader };
```

### SvelteKit

```typescript
import { serve } from "@novu/framework/sveltekit";
import { welcomeWorkflow } from "$lib/novu/workflows";

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

### Nuxt

```typescript
import { serve } from "@novu/framework/nuxt";
import { welcomeWorkflow } from "~/novu/workflows";

export default defineEventHandler(serve({ workflows: [welcomeWorkflow] }));
```

### H3

```typescript
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

```typescript
import { serve } from "@novu/framework/lambda";
import { welcomeWorkflow } from "./novu/workflows";

export const novu = serve({ workflows: [welcomeWorkflow] });
```

### Custom (any framework)

```typescript
import { NovuRequestHandler, ServeHandlerOptions } from "@novu/framework";

export const serve = (options: ServeHandlerOptions) =>
  new NovuRequestHandler({
    frameworkName: "my-framework",
    ...options,
    handler: (req, res) => ({ /* method, headers, body, url, transformResponse */ }),
  }).createHandler();
```

See [`references/bridge-endpoint.md`](./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.

```bash
npx novu@latest dev
# Defaults: --port 4000  --route /api/novu  --studio-port 2022
```

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 |

```bash
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):

```typescript
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: "[email protected]" },
  payload: { userName: "Jane", appName: "Acme" },
});
```

You can also trigger a workflow from inside a `step.custom` of another workflow:

```typescript
await step.custom("trigger-summary", async () => {
  return await summaryWorkflow.trigger({
    to: subscriber.subscriberId,
    payload: { events: events.map(e => e.payload) },
  });
});
```

See [`trigger-notification`](../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

```bash
npm install @react-email/components react-email
```

```tsx
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} />);
```

```typescript
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`](./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.

```typescript
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`](./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):

```typescript
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`](../inbox-integration)).

## Deployment

### Sync via CLI

Push your workflows to Novu Cloud:

```bash
npx novu@latest sync \
  --bridge-url https://api.acme.com/api/novu \
  --secret-key $NOVU_SECRET_KEY \
  --api-url https://api.novu.co  # use https://eu.api.novu.co for EU
```

### GitHub Actions

```yaml
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

1. Develop locally with the Studio against your own machine.
2. Open a PR — CI runs `npx novu sync` against the **Development** environment to test e2e.
3. 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](https://vercel.com/docs/security/deployment-protection/methods-to-bypass-deployment-protection) and pass the bypass token as `?x-vercel-protection-bypass=<token>` in your bridge URL.

### Custom Client

Override defaults globally:

```typescript
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, // disables HMAC — only for local dev
  }),
  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

1. **Bridge URL must be publicly reachable** — `localhost` won't work for Novu Cloud. Use the Studio tunnel locally; deploy publicly for production.
2. **`workflowId` is the trigger identifier** — same id you'll pass to `novu.trigger({ workflowId })`. Use kebab-case and keep it stable.
3. **Step `id`s must be unique within a workflow** — duplicates throw at registration.
4. **`as const` on JSON Schema** — without it, TS infers `string` instead of literal types and `payload` becomes `unknown`.
5. **Only one `step.digest` per workflow** — chain a second workflow via `step.custom` for two-stage digest patterns.
6. **Digest / delay results from one trigger don't influence other triggers** — they're per workflow run.
7. **Custom step results aren't usable in step controls** — only in subsequent step `resolver`, `providers`, or `skip` callbacks.
8. **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.
9. **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`.
10. **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.
11. **Provider override `_passthrough` is unvalidated** — typos won't error at compile time. Use known typed provider keys whenever possible.
12. **Changing a delay/digest step's content does not affect already-scheduled events** — content is captured at the time of the original trigger.
13. **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).
14. **`@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](./references/bridge-endpoint.md) — every framework wrapper, custom `serve`, NestJS DI
- [Workflow & Step API](./references/workflow-and-steps.md) — full options, all step types, conditional logic patterns
- [Schema Validation](./references/schema-validation.md) — Zod, JSON Schema, Class Validator deep dive
- [Email Templates](./references/email-templates.md) — React, Vue, Svelte Email integrations
- [Translations](./references/translations.md) — i18next-based localized workflows
- [Local Studio & CLI](./references/studio-and-cli.md) — every flag, tunnel modes, headless mode
- [Deployment](./references/deployment.md) — `npx novu sync`, GitHub Action, GitOps recipe, EU region
- [Production & Security](./references/security.md) — HMAC, public bridge requirements, Vercel preview bypass
- [Examples Cookbook](./references/examples.md) — multi-step onboarding, digest, delay-then-skip, LLM-powered digest

مجوز

در منبع این مهارت هیچ مجوزی اعلام نشده است.

متن کامل مجوز در مخزن منبع در دسترس است.

مشاهدهٔ مجوز در مخزن منبعنسخهٔ منتشرشده در آن‌جا مرجع است.