Workflows
notify() is one shot. A workflow is a sequence that can sleep for three days,
block until a user does something, and pick up exactly where it left off after a deploy —
because every step's outcome is written down before the next one runs.
A worked example
Invite someone. Wait three days. If they have not accepted, nudge them by SMS. Written as ordinary TypeScript:
Code-defined workflows run inside the workflow worker, so this form is Node-only — the step bodies are closures the worker has to execute. Everything else on this page (triggering, events, watching, cancelling) is plain HTTP, and declarative workflows below let you define the sequence itself as JSON from any language.
import { workflow } from "notifkit";
workflow("invite-nudge", async ({ step, event }) => {
await step.notify({
template: "invite",
channels: ["email"],
data: { inviterName: event.inviterName },
});
await step.wait("3d");
const accepted = await step.waitForEvent("invite.accepted", { timeout: "24h" });
if (!accepted) {
await step.notify({
template: "invite-reminder",
channels: ["sms"],
data: { inviterName: event.inviterName },
});
}
});
Register it in the process that runs the workflow service, then trigger it:
import "./workflows/invite.js"; // registration happens on import
const { instanceId } = await notifkit.triggerWorkflow({
name: "invite-nudge",
input: {
user: { id: "usr_123" }, // required — step.notify() targets this user
inviterName: "Alice",
},
});
curl -X POST http://localhost:3000/v1/workflows/trigger \
-H "Authorization: Bearer $NK_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "invite-nudge",
"input": {
"user": { "id": "usr_123" },
"inviterName": "Alice"
}
}'
# → 202 { "messageId": "...", "instanceId": "..." }
input.user.id is what step.notify() sends to. Without it the
notify step has no target and goes nowhere. Everything else in input is yours
and arrives as the event argument.
Triggering for a user the project has not seen
A top-level user is shorthand for the same thing, and it also accepts the full
inline user shape notify() takes. Given an object, the trigger upserts that user
— contacts, segments, preferences and all — before starting the instance, so a signup flow
can register the person and start their onboarding sequence in one call:
await notifkit.triggerWorkflow({
name: "invite-nudge",
user: {
id: "usr_999",
email: "new@example.com",
timezone: "Europe/Berlin",
},
input: { inviterName: "Alice" },
});
// Or, when the user already exists, just the id:
await notifkit.triggerWorkflow({
name: "invite-nudge",
user: "usr_123",
input: { inviterName: "Alice" },
});
curl -X POST http://localhost:3000/v1/workflows/trigger \
-H "Authorization: Bearer $NK_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "invite-nudge",
"user": { "id": "usr_999", "email": "new@example.com" },
"input": { "inviterName": "Alice" }
}'
Either form lands as input.user for the handler, so event.user.id
reads the same whichever you use. The two are not exclusive: fields you set on
input.user yourself are kept and take precedence, so don't set
input.user.id to one person and user to another.
How it survives the gap
Steps are matched to their stored output by call order, not by name. Your
handler must issue the same sequence of step calls on every replay. Branching on
Math.random(), on the wall clock, or on a database read that may have changed
will desynchronise the indices and produce wrong results. Branch on
event or on the return value of an earlier step — both are durable.
The step API
| Call | Does | Returns |
|---|---|---|
step.notify(payload) |
Publishes a notification. Takes every field notify() takes; naming no target sends to the instance's own user. |
{ success, messageId, notificationId } |
step.wait(duration) |
Suspends. Accepts s, m, h, d — "30s", "15m", "2h", "3d". |
— |
step.waitForEvent(name, opts) |
Suspends until a matching event is ingested, or the timeout fires. Same units as step.wait; default timeout 24h. An unrecognised unit throws rather than expiring instantly. |
The event payload, or null on timeout |
step.run(name, fn) |
Runs arbitrary code once and memoises the result. For side effects that must not repeat on replay. | Whatever fn returned |
step.run for anything that is not a notification
workflow("trial-ending", async ({ step, event }) => {
// Charged once, even if the workflow replays a dozen times.
const invoice = await step.run("create-invoice", () =>
stripe.invoices.create({ customer: event.stripeCustomerId }),
);
await step.notify({
template: "invoice-ready",
channels: ["email"],
data: { invoiceUrl: invoice.hosted_invoice_url },
});
});
Waking a workflow with an event
waitForEvent parks the instance until your application reports that something
happened:
// In your invite-acceptance handler:
await notifkit.ingestEvent({
name: "invite.accepted",
properties: { userId: "usr_123", inviteId: "inv_88" },
});
# In your invite-acceptance handler:
curl -X POST http://localhost:3000/v1/events \
-H "Authorization: Bearer $NK_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "invite.accepted",
"properties": { "userId": "usr_123", "inviteId": "inv_88" }
}'
The events worker looks for instances waiting on that name whose match criteria
are satisfied, records the payload as the step's output, and republishes the instance for
resumption. Use match so an event for one user does not wake another's workflow:
const accepted = await step.waitForEvent("invite.accepted", {
timeout: "72h",
match: { userId: event.user.id },
});
if (accepted === null) {
// timed out — nobody accepted within 72 hours
}
The timeout is durable, like any other step outcome: once it fires, the step is recorded as
timed out and every later replay of the instance sees null again. An event that
arrives just after the timer does not retroactively fill the step in, so a branch on
accepted === null takes the same path on every replay.
Workflows without deploying code
The same three actions are available as JSON, stored per project. Useful when the sequence should be editable by someone who is not shipping a release.
await notifkit.createWorkflow({
name: "onboarding",
steps: [
{ action: "notify", payload: { template: "welcome", channels: ["email"] } },
{ action: "wait", duration: "1d" },
{ action: "notify", payload: { template: "getting-started", channels: ["email"] } },
{ action: "waitForEvent", event: "project.created", options: { timeout: "72h" } },
{ action: "notify", payload: { template: "need-a-hand", channels: ["email"] } },
],
});
curl -X POST http://localhost:3000/v1/workflows \
-H "Authorization: Bearer $NK_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "onboarding",
"steps": [
{ "action": "notify",
"payload": { "template": "welcome", "channels": ["email"] } },
{ "action": "wait", "duration": "1d" },
{ "action": "notify",
"payload": { "template": "getting-started", "channels": ["email"] } },
{ "action": "waitForEvent", "event": "project.created",
"options": { "timeout": "72h" } },
{ "action": "notify",
"payload": { "template": "need-a-hand", "channels": ["email"] } }
]
}'
Triggering is identical, and registered code workflows win over a JSON definition of the same name.
A step payload takes the same target fields as notify() —
user, segment or topic, at most one. Name none and
the step sends to input.user.id from the trigger, which is the ordinary case
and why most steps carry no target at all. Name one and it wins, so a single step can
notify somebody other than the instance's user, or fan out to a whole segment mid-sequence.
workflow("payment-failed", async ({ step, event }) => {
await step.notify({ template: "card-declined", channels: ["email"] }); // the customer
await step.notify({ template: "billing-alert", user: "usr_ops_oncall" }); // someone else
await step.wait("3d");
await step.notify({ template: "card-declined-final", channels: ["email", "sms"] });
});
A step that names no target in an instance triggered without
input.user.id throws — there is nobody to send to. That failure surfaces on
the instance rather than being swallowed, so check
the instance status if a workflow completes without
sending anything.
JSON workflows have no conditionals — the steps run in order, unconditionally. The
waitForEvent result is recorded but cannot be branched on. Anything with an
"if" belongs in a code workflow.
Recurring sends and digests
The one thing a workflow cannot do is start itself. There is no cron inside NotifKit — no step that loops, no schedule you can attach to a definition. Every instance begins with a trigger from outside. So the clock is yours to supply; everything after the tick — fan-out, preferences, quiet hours, retries — is NotifKit's.
A weekly digest is not a workflow
The common case needs no workflow at all. Point whatever already ticks in your infrastructure
— cron, a systemd timer, a GitHub Action, Cloud Scheduler — at /v1/notify with a
segment, and one request becomes one message per subscriber.
# Mondays, 09:00 UTC. Note the escaped %% — crontab treats a bare % as a newline.
0 9 * * 1 curl -X POST http://localhost:3000/v1/notify \
-H "Authorization: Bearer $NK_KEY" \
-H "x-idempotency-key: weekly-digest-$(date -u +\%G-W\%V)" \
-H "Content-Type: application/json" \
-d '{"segment":"digest-subscribers","template":"weekly-digest","channels":["email"]}'
// Whatever runs this on a schedule is outside NotifKit.
const week = isoWeek(new Date()); // e.g. "2026-W33"
// The idempotency key is a header, and headers are set per client, not per
// call — so build one scoped to this run.
const client = new NotifkitClient({
baseUrl: process.env.NK_URL!,
apiKey: process.env.NK_KEY!,
headers: { "x-idempotency-key": `weekly-digest-${week}` },
});
await client.notify({
segment: "digest-subscribers",
template: "weekly-digest",
channels: ["email"],
data: { week, topStories: await stories.forWeek(week) },
});
The key matters more here than anywhere else. A scheduler that fires twice — an overlapping run, a retried Action, two hosts with the same crontab — would otherwise send the digest twice to everyone at once. Derive it from the period, not from the clock, so both firings produce the same string. See not sending the same thing twice.
A segment send delivers the same data to every recipient — it is
attached to the notification, not resolved per user. That is fine for a newsletter or a
"here is what shipped this week" digest. If each subscriber's digest has different content,
you need one notify() per user.
A fan-out larger than SEGMENT_MAX_USERS — 10 000 by default, and any positive
integer you set it to — is rejected whole. The enricher marks the
notification failed and nobody receives it; it does not send the first 10 000 and drop the
rest. A digest list that quietly grows past the limit therefore stops sending entirely
rather than degrading, so raise the ceiling before you reach it, or page through the
segment yourself.
When every recipient's digest differs
Loop, and give each message its own key. listUsers() paginates but has no
segment filter, so drive the loop from the list your own application already has:
const week = isoWeek(new Date());
for (const userId of await db.digestSubscribers()) {
const items = await db.unreadFor(userId, week);
if (items.length === 0) continue; // no digest beats an empty digest
const client = new NotifkitClient({
baseUrl: process.env.NK_URL!,
apiKey: process.env.NK_KEY!,
headers: { "x-idempotency-key": `digest-${week}-${userId}` },
});
await client.notify({
user: userId,
template: "weekly-digest",
channels: ["email"],
data: { week, items },
});
}
Per-user keys mean a crash halfway through the loop is safe to re-run: the users already sent to collapse on their keys, the rest go out.
A fixed number of repeats
When the repetition is finite and known — a six-week onboarding drip, a three-day event
countdown — a workflow expresses it directly. Alternate notify and
wait:
workflow("onboarding-drip", async ({ step }) => {
const weeks = ["week-1", "week-2", "week-3"];
for (const [i, week] of weeks.entries()) {
if (i > 0) await step.wait("7d");
await step.notify({ template: `onboarding-${week}`, channels: ["email"] });
}
});
// Nothing above names a recipient. This does — and all three emails go to them:
await notifkit.triggerWorkflow({
name: "onboarding-drip",
input: { user: { id: "usr_123" } },
});
None of the steps above names a recipient, so all three emails go to the trigger user — one
instance, one person. Signing a hundred users up to the drip means a hundred
triggerWorkflow calls, one instance each, which is what you want here: every
subscriber is at their own point in the sequence. Give a step an explicit
user or segment only when that particular message is meant for
somebody else.
The loop itself is safe because it is deterministic — the same three iterations issue the
same five step calls on every replay, which is exactly what
replay matching requires. A loop over something that
can change between replays is not. Waiting before each notify rather than after also
avoids a trailing wait that would leave the instance sitting
pending for a week after the final email.
wait("7d") means seven times twenty-four hours from whenever the previous
step finished — not "next Monday". Timers are polled every five seconds, so each hop
lands slightly late and the error accumulates down a chain. Nor does it know about
calendars: a drip started at 09:00 in winter is still 09:00 UTC in summer, an hour off for
anyone whose clocks moved. If the send has to land on a calendar slot, use an external
scheduler and let it own the date arithmetic.
Endless recurrence without an external clock
If you genuinely have nowhere to put a cron entry, a workflow can re-trigger itself from
inside a step.run, creating a fresh instance as it finishes the current one:
workflow("weekly-summary", async ({ step, event }) => {
await step.notify({ template: "weekly-summary", channels: ["email"] });
await step.wait("7d");
// step.run so the re-trigger is recorded and cannot fire twice on replay.
await step.run("chain-next", () =>
notifkit.triggerWorkflow({ name: "weekly-summary", input: event }),
);
});
Each cycle is a separate instance, so the chain survives restarts the same way any single
instance does. Give the notify step a segment and one chain drives the whole
list, which keeps this to a single instance to track rather than one per subscriber.
The costs are still real: drift compounds forever rather than being reset by a calendar, and the only way to stop a chain is to cancel its currently pending instance before it reaches the last step, since there is no definition-level off switch. Prefer a real scheduler when you have one.
| You want | Use |
|---|---|
| Same content, everyone, on a calendar slot | External scheduler → notify({ segment }) |
| Different content per recipient | External scheduler → a loop of notify({ user }) |
| One-off send at a known future moment | notify({ sendAt }) — see scheduling |
| A known number of repeats after a user action | A workflow with wait between notifies |
| Endless repetition, no external clock available | A self-chaining workflow, with the caveats above |
| A multi-step sequence each user walks at their own pace | One triggerWorkflow per user |
Watching and cancelling
const instance = await notifkit.getWorkflow(instanceId);
// { id, name, status: "pending" | "running" | "completed" | "failed", input, createdAt, updatedAt }
await notifkit.listWorkflows(); // definitions registered for the project
await notifkit.cancelWorkflow(instanceId);
curl -s http://localhost:3000/v1/workflows/instances/$INSTANCE_ID \
-H "Authorization: Bearer $NK_KEY"
# { id, name, status: "pending" | "running" | "completed" | "failed",
# input, createdAt, updatedAt }
# definitions registered for the project
curl -s http://localhost:3000/v1/workflows -H "Authorization: Bearer $NK_KEY"
curl -X DELETE http://localhost:3000/v1/workflows/instances/$INSTANCE_ID \
-H "Authorization: Bearer $NK_KEY"
| Status | Meaning |
|---|---|
pending | Waiting to start, or suspended mid-flight on a wait or an event. This is the normal resting state. |
running | A worker holds the lock and is executing the handler right now. |
completed | The handler returned without suspending. |
failed | The handler threw something that was not a suspension. Steps already recorded stay recorded. |
Trace every message a workflow produced by filtering the delivery log on its instance id:
curl -s "http://localhost:3000/v1/notifications/logs?workflowInstanceId={instanceId}" \
-H "Authorization: Bearer $NK_KEY"
Operational notes
- One worker at a time. Each instance is guarded by a Redis lock with a 60-second TTL that renews while the handler runs, so two workers cannot execute the same instance concurrently.
- Stuck instances are reaped. A minute-interval sweep looks for instances marked
runningwhose lock has expired — a hard-killed process — and returns them topendingso they resume. - Timers are polled, not pushed. The wake-up loop runs every five seconds, so
wait("30s")is accurate to within about that. Do not use workflows as a precision scheduler. - Registration is per process.
workflow()writes to an in-memory registry. If you split services across deployments, the process runningservices: ["workflow"]must import your handler files.