Workflow & Step API Reference
Full reference for theworkflow() and step.* APIs in @novu/framework.
workflow(id, handler, options?)
workflowId: string
Unique identifier within your environment. Used as the trigger key in novu.trigger({ workflowId }). Convention: kebab-case (weekly-digest, password-reset).
handler: ({ step, payload, subscriber }) => Promise<void>
The body of the workflow. Receives:
| Field | Type | Description |
|---|---|---|
step | StepBuilder | All step methods (step.email, step.delay, etc.) |
payload | InferredFromSchema | Validated trigger payload |
subscriber | Subscriber | Recipient — { subscriberId, firstName?, lastName?, email?, phone?, locale?, timezone?, data?, ... } |
options: WorkflowOptions
| Option | Type | Description |
|---|---|---|
payloadSchema | ZodSchema | JsonSchema | ClassValidatorClass | Validates trigger payload, infers payload type |
name | string | Display name in Dashboard / Inbox (defaults to workflowId) |
description | string | Description shown in Dashboard |
tags | string[] | Filter / Inbox tab grouping |
severity | 'low' | 'medium' | 'high' | Visual prioritization in the Inbox. Default unset. See inbox-integration/SKILL.md. |
critical | boolean | If true, the workflow bypasses subscriber preferences, skips digest, and runs without delays. Reserve for must-deliver events (account suspended, security alert, password reset). |
preferences | WorkflowPreferences | Default channel preferences and readOnly flag |
severity vs critical vs readOnly
Three distinct dials — pick deliberately. See design-workflow/references/severity-and-critical.md for the full matrix.
| Dial | What it does |
|---|---|
severity | Pure visual prioritization in the Inbox. Does NOT change preferences, digest, or delivery. |
critical: true | Runtime override: bypasses preferences, skips digest, no delays. Forces delivery. |
preferences.all.readOnly: true | Hides the workflow from the Preferences UI. Subscribers can’t toggle channels for it. |
critical: true is a stronger guarantee than readOnly: true. Use critical when you need to force delivery; use readOnly only when you want to hide the toggle.
Workflow Preferences
| Field | Default | Notes |
|---|---|---|
all.enabled | true | Fallback for any channel not specified in channels |
all.readOnly | false | If true, subscribers cannot disable channels in Preferences UI; this does not make the workflow critical |
channels.<channel>.enabled | true | Per-channel default |
Channel Steps
All channel steps share the same shape:step.email
void.
| Output | Type | Required |
|---|---|---|
subject | string | Yes |
body | string | Yes |
attachments | Attachment[] | No |
from | string | No |
replyTo | string | No |
step.sms
| Output | Type | Required |
|---|---|---|
body | string | Yes |
step.push
| Output | Type | Required |
|---|---|---|
title | string | Yes |
body | string | Yes |
data | Record<string, unknown> | No |
image | string | No |
icon | string | No |
step.chat
| Output | Type | Required |
|---|---|---|
body | string | Yes |
blocks, Discord embeds, etc., use providers overrides — see below.
step.inApp
| Output | Type | Required | Description |
|---|---|---|---|
body | string | Yes | Main content (HTML allowed if disableOutputSanitization: true) |
subject | string | No | Notification title |
avatar | string | No | URL — overrides actor avatar |
redirect | { url, target? } | No | Click destination (target is _self/_blank/_parent/_top/_unfencedTop, default _blank) |
primaryAction | { label, redirect? } | No | Accent-colored CTA button |
secondaryAction | { label, redirect? } | No | Muted CTA button |
data | Record<string, scalar> | No | Custom metadata (≤ 10 keys; strings ≤ 256 chars) |
| Result | Type | Description |
|---|---|---|
seen | boolean | True after the user views the notification in the Inbox |
read | boolean | True after the user marks it read |
lastSeenDate | Date | null | When seen flipped to true |
lastReadDate | Date | null | When read flipped to true |
skip on subsequent steps (e.g. don’t email if already read).
Action Steps
step.delay
Pause workflow execution.
| Output | Type | Required | Notes |
|---|---|---|---|
amount | number | Yes | Number of units |
unit | 'seconds' | 'minutes' | 'hours' | 'days' | 'weeks' | 'months' | Yes | Time unit |
{ duration: number } (in milliseconds).
If a delay step fails, the workflow stops — it does not proceed to the next step.
step.digest
Aggregate triggers over a time window or cron schedule.
| Output | Type | Required | Notes |
|---|---|---|---|
amount | number | One of (amount+unit) or (cron) | |
unit | 'seconds' | 'minutes' | 'hours' | 'days' | 'weeks' | 'months' | ||
cron | string | Cron expression (e.g. "0 0 * * *") | |
digestKey | string | No | Group key in addition to subscriberId |
| Result | Type | Description |
|---|---|---|
events | DigestEvent[] | Array of digested triggers |
DigestEvent is { id: string, time: Date, payload: object }.
Constraints:
- One digest step per workflow. For two-stage digests, trigger a second workflow from
step.custom. - Digest content captured at trigger time — editing the workflow doesn’t affect events already in flight.
- Digest results are not available in step controls — only inside subsequent step
resolver/providers/skipcallbacks.
step.custom
Run arbitrary code and persist its output.
| Option | Type | Description |
|---|---|---|
outputSchema | JsonSchema | ZodSchema | Validates and types the return value (defaults to unknown if omitted) |
step.http
Call an external HTTP endpoint as part of the workflow — for fetching just-in-time data, posting to a webhook, or fanning out to a downstream service.
| Output | Type | Required | Notes |
|---|---|---|---|
method | 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | Yes | HTTP verb |
url | string | Yes | Fully qualified URL. Liquid templating is allowed ({{payload.webhookUrl}}). |
headers | Array<{ key: string; value: string }> | No | Outgoing headers |
body | Array<{ key: string; value: string }> | No | Form-style body. Use the SDK’s typed body for JSON payloads. |
responseBodySchema | JsonSchema | ZodSchema | Required if downstream steps reference response data | Declares which response properties are addressable |
continueOnFailure | boolean | No | If true, a non-2xx response does not stop the workflow. Default false. |
responseBodySchemais required when subsequent steps reference response data. Only properties declared in the schema are available as{{ steps.<http-step-id>.<property> }}(Dashboard) or as typed fields on the returned object (Framework).- The HTTP step participates in retries. Treat it as a side effect — if you need exactly-once external calls, prefer
step.customwith your own idempotency key. - The Liquid
{{subscriber.*}}and{{payload.*}}variables are usable insideurl,headers, andbodyvalues.
Step Options
controlSchema
Defines no-code controls editable in the Dashboard. Pass a Zod schema, JSON Schema (as const), or Class-Validator class.
{{subscriber.firstName}}{{payload.userId}}{{payload.invoiceDate | date: '%b %d, %y'}}{{subscriber.firstName | capitalize | append: '!'}}
skip
Skip a step based on dynamic logic.
boolean | Promise<boolean>.
providers (Per-Step Overrides)
Override the request sent to the underlying provider SDK.
_passthrough block deep-merges into the final provider request — typed provider keys take precedence over _passthrough.
disableOutputSanitization
Allow raw HTML / unescaped characters in step output.
dangerouslySetInnerHTML in renderBody / renderSubject (see inbox-integration).
Conditional Patterns
Send email only if in-app wasn’t seen
Skip delay for premium users
Branch on a fetched value
Failure & Retries
- If a delay or digest step fails, the workflow stops — subsequent steps do not run.
- If a channel step fails delivery, retries depend on provider config and Novu’s retry policy.
- Workflow handlers may be re-invoked on retry. Keep them deterministic — push side effects into
step.customso the result is persisted in durable context.
Type Inference
WhenpayloadSchema and controlSchema are provided as Zod or JSON Schema (with as const), payload and controls are fully typed:
payload and controls are unknown.
Appendix: Step Conditions (Dashboard JSON-Logic ↔ Framework skip)
Dashboard authors gate a step with JSON-Logic on step.condition. Framework authors pass a skip: () => boolean callback. The semantics are inverse — Dashboard runs when the condition is true, Framework skip skips when the callback returns true.
| Intent | Dashboard JSON-Logic | Framework skip |
|---|---|---|
| Run only when subscriber is offline | { "==": [{ "var": "subscriber.isOnline" }, "false"] } | skip: ({ subscriber }) => subscriber.isOnline === true |
| Run only when In-App not read | { "==": [{ "var": "steps.<inAppId>.read" }, "false"] } | skip: () => inAppResult.read === true |
| Run only when In-App not seen | { "==": [{ "var": "steps.<inAppId>.seen" }, "false"] } | skip: () => inAppResult.seen === true |
Run only for workflows tagged billing | { "in": ["billing", { "var": "workflow.tags" }] } | (filter at trigger time) |
Run only when HTTP status == "active" | { "==": [{ "var": "steps.<httpId>.status" }, "active"] } | skip: () => httpResult.status !== "active" |
design-workflow/references/step-conditions.md):
workflow.*—workflowId,name,description,tags,severitysubscriber.*—subscriberId,firstName,lastName,email,phone,avatar,locale,timezone,isOnline,lastOnlineAt,data.*payload.*— any field declared inpayloadSchemasteps.<stepId>.*— In-Appseen/read, digestevents/eventCount, HTTP properties declared inresponseBodySchemacontext.*— multi-tenant metadata passed at trigger time (tenant, region, app)
See design-workflow/references/step-conditions.md for the full list of canonical conditions and the design reasoning.