← website
Build

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:

note

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": "..." }
required

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

restart · deploy · crash — anywhere in here nothing is lost and nothing is re-sent trigger instance created status: running step.notify invite email enters the pipeline step.wait("3d") suspends status: pending waitForEvent suspends or times out step.notify reminder sms status: completed instance row step 0 output timer at +3d waiter + timeout step 3 output durable state · postgres + redis
Replay, not resume. On wake-up the handler runs again from the top. Steps that already have a stored output return it immediately without re-executing, so the email is not sent twice. Execution stops when it reaches the first step with no recorded outcome.
consequence

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

CallDoesReturns
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.

who a step sends to

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"] });
});
required

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.

note

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.

one payload, many users

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.

consequence

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.

consequence

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 wantUse
Same content, everyone, on a calendar slotExternal scheduler → notify({ segment })
Different content per recipientExternal scheduler → a loop of notify({ user })
One-off send at a known future momentnotify({ sendAt }) — see scheduling
A known number of repeats after a user actionA workflow with wait between notifies
Endless repetition, no external clock availableA self-chaining workflow, with the caveats above
A multi-step sequence each user walks at their own paceOne 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"
StatusMeaning
pendingWaiting to start, or suspended mid-flight on a wait or an event. This is the normal resting state.
runningA worker holds the lock and is executing the handler right now.
completedThe handler returned without suspending.
failedThe 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 running whose lock has expired — a hard-killed process — and returns them to pending so 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 running services: ["workflow"] must import your handler files.