← back
Start here

Examples

Twenty notifications you have already received this week, and the code that produces each one. They run in order: send a message, react to an event, wait for something, branch on what happened, then coordinate several channels and providers at once.

You need a running server for any of it, which the quickstart gets you in a few minutes. Every example assumes a template already synced under the id it names. Both tabs on each example do the same thing: notifkit is a NotifkitClient, and the shell tab is the plain HTTP underneath it.

Send a message

Five notifications that are one call each. Read them in order and you will have the mental model: who gets it, on which channels, and whether they are allowed to refuse.

1. Order confirmation

What you are building

"Your order #99214 has been confirmed 🎉" — the email Amazon sends the second you check out. One user, one channel, one call.

Copy this

await notifkit.notify({
  user: "usr_9142",
  template: "order-confirmed",
  channels: ["email"],
  data: { orderId: "99214", total: "$349.00", eta: "Friday" },
});
curl -X POST https://notify.example.com/v1/notify \
  -H "Authorization: Bearer $NOTIFKIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "user": "usr_9142",
    "template": "order-confirmed",
    "channels": ["email"],
    "data": { "orderId": "99214", "total": "$349.00", "eta": "Friday" }
  }'

What happened

The call returned a 202 straight away, which means queued, not delivered. A background worker then looked up the address, checked the user has not opted out, rendered the template with your data, and handed it to your email provider. If the process restarts mid-flight, the message survives and another worker picks it up.

Try changing it

  • Swap "email" for "push". Nothing else changes; a device token is looked up instead of an address.
  • Pass the user inline: user: { id: "usr_9142", email: "[email protected]" } creates them as part of the send.

2. Login code

What you are building

"Your verification code is 480913. Expires in 5 minutes." It is useless in an hour, so it has to arrive even at 2am when the user asked not to be disturbed.

Copy this

await notifkit.notify({
  user: "usr_9142",
  template: "login-code",
  channels: ["sms", "email"],
  fallback: true,          // try SMS; only if it fails, email
  priority: "critical",    // ignore quiet hours and throttling
  data: { code: "480913", expiresMinutes: 5 },
});
curl -X POST https://notify.example.com/v1/notify \
  -H "Authorization: Bearer $NOTIFKIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "user": "usr_9142",
    "template": "login-code",
    "channels": ["sms", "email"],
    "fallback": true,
    "priority": "critical",
    "data": { "code": "480913", "expiresMinutes": 5 }
  }'

What happened

Two things at once. fallback: true turned the channel list into an ordered chain, so the email is only sent if the SMS fails. critical skipped the quiet-hours check and the per-user rate limit, and put the message on its own queue so a marketing blast queued a second earlier cannot delay it.

critical is not a master key

It walks past quiet hours and throttling. It does not walk past the suppression list, which records that someone unsubscribed, marked you as spam, or that the number is dead. An urgent message to a dead number is still a message to a dead number.

3. Package shipped

What you are building

"Your package is on the way. Arriving Friday." You want the email as the record and the push as the moment of reassurance, so you want both, every time.

Copy this

await notifkit.notify({
  user: "usr_9142",
  template: "package-shipped",
  channels: ["email", "push"],     // no fallback: send both
  data: { orderId: "99214", carrier: "UPS", tracking: "1Z999AA10123456784", eta: "Friday" },
});
curl -X POST https://notify.example.com/v1/notify \
  -H "Authorization: Bearer $NOTIFKIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "user": "usr_9142",
    "template": "package-shipped",
    "channels": ["email", "push"],
    "data": {
      "orderId": "99214",
      "carrier": "UPS",
      "tracking": "1Z999AA10123456784",
      "eta": "Friday"
    }
  }'
the one distinction to remember

channels: ["email", "push"] sends two messages. channels: ["email", "push"], fallback: true sends one. Same array, opposite behaviour. Almost every example below turns on whether that flag is there.

What happened

Each channel was evaluated on its own. If the user has push switched off, the email still goes; the push is dropped and logged with a reason instead of raising an error.

4. Payment received

What you are building

"$1,250.00 has been deposited into your account." Money arriving is not marketing, so nobody should be able to switch it off. That is a property of the template, not of the call.

Copy this

// a template with no `topic` is transactional: it cannot be opted out of,
// and it carries no unsubscribe header.
await notifkit.syncTemplates({
  templates: [
    {
      id: "deposit-received",
      channel: "push",
      content: { title: "Money in", text: "{{amount}} from {{sender}} just landed." },
    },
  ],
});

await notifkit.notify({
  user: "usr_9142",
  template: "deposit-received",
  channels: ["push", "email"],
  data: { amount: "$1,250.00", sender: "Stripe Payouts", balance: "$4,318.22" },
});
# a template with no "topic" is transactional: it cannot be opted out of,
# and it carries no unsubscribe header.
curl -X PUT https://notify.example.com/v1/templates \
  -H "Authorization: Bearer $NOTIFKIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "templates": [{
      "id": "deposit-received",
      "channel": "push",
      "content": { "title": "Money in", "text": "{{amount}} from {{sender}} just landed." }
    }]
  }'

curl -X POST https://notify.example.com/v1/notify \
  -H "Authorization: Bearer $NOTIFKIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "user": "usr_9142",
    "template": "deposit-received",
    "channels": ["push", "email"],
    "data": { "amount": "$1,250.00", "sender": "Stripe Payouts", "balance": "$4,318.22" }
  }'

What happened

Because the template declares no topic, the opt-out gates skip it. Give a template a topic and users can refuse that category; leave it out and they cannot. That same decision controls which mail carries a one-click unsubscribe header, which is the line mailbox providers actually care about.

5. New follower

What you are building

"Maya started following you." The opposite case: this one people should be able to switch off without losing their password reset mail.

Copy this

// the template declares what kind of message it is
await notifkit.syncTemplates({
  templates: [
    {
      id: "new-follower",
      channel: "push",
      topic: "social",                                  // refusable
      content: { title: "New follower", text: "{{name}} started following you." },
    },
  ],
});

await notifkit.notify({
  user: followedUserId,
  template: "new-follower",
  channels: ["push"],
  priority: "low",
  data: { name: "Maya", handle: "@maya", avatar: follower.avatarUrl },
});

// and when they switch it off in your settings screen
await notifkit.updateUserPreferences("usr_9142", { topics: { social: false } });
# the template declares what kind of message it is
curl -X PUT https://notify.example.com/v1/templates \
  -H "Authorization: Bearer $NOTIFKIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "templates": [{
      "id": "new-follower",
      "channel": "push",
      "topic": "social",
      "content": { "title": "New follower", "text": "{{name}} started following you." }
    }]
  }'

curl -X POST https://notify.example.com/v1/notify \
  -H "Authorization: Bearer $NOTIFKIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "user": "usr_8801",
    "template": "new-follower",
    "channels": ["push"],
    "priority": "low",
    "data": { "name": "Maya", "handle": "@maya" }
  }'

# and when they switch it off in your settings screen
curl -X PATCH https://notify.example.com/v1/users/usr_9142/preferences \
  -H "Authorization: Bearer $NOTIFKIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "topics": { "social": false } }'

What happened

After that preference change, every later new-follower send for this user is dropped before rendering and logged with a reason. Their receipts and password resets are untouched, because those templates carry no topic. Scoping opt-outs to a topic instead of a whole channel is the difference between someone muting one feature and muting you entirely.

Try changing it

  • Mute a whole channel instead: { channels: { push: false } }.
  • Give a template two topics: topic: ["social", "marketing"].

React to an event

Same single call, but now something in your product triggered it, and the details start to matter: which channel is worth paying for, when the message should land, and who exactly is listening.

6. Delivery arriving

What you are building

"Your DoorDash order is 2 minutes away." Push is free and instant, so try it first. The one moment you are staring at your phone is the one moment a dead push token costs somebody a cold dinner, so pay for the SMS if it fails.

Copy this

await notifkit.notify({
  user: "usr_9142",
  template: "driver-arriving",
  channels: ["push", "sms"],
  fallback: true,
  priority: "high",
  data: { driver: "Marcus", etaMinutes: 2, trackUrl: "https://app.co/t/99214" },
});
curl -X POST https://notify.example.com/v1/notify \
  -H "Authorization: Bearer $NOTIFKIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "user": "usr_9142",
    "template": "driver-arriving",
    "channels": ["push", "sms"],
    "fallback": true,
    "priority": "high",
    "data": { "driver": "Marcus", "etaMinutes": 2, "trackUrl": "https://app.co/t/99214" }
  }'

What happened

The push was attempted. If the device token had expired, the SMS went out instead; if the push landed, the SMS was never sent and never billed. high keeps it on a fast queue without overriding the user's quiet hours, which is right here: a delivery you ordered is not a reason to ignore someone's settings.

Try changing it

  • Go three deep: ["push", "sms", "email"].
  • Turn the user's SMS preference off and watch the chain stop instead of roll over. A person declining is not a delivery failure. The full chain has the whole table.

7. Comment reply

What you are building

"Alex replied to your comment: 'This is 🔥'" Triggered by one user's action, delivered to a different user, and refusable like any social notification.

Copy this

await notifkit.notify({
  user: parentComment.authorId,          // the person being replied to
  template: "comment-reply",             // template declares topic: "social"
  channels: ["push"],
  priority: "low",
  data: {
    replier: reply.author.handle,
    excerpt: reply.body.slice(0, 120),
    url: reply.url,
  },
});
curl -X POST https://notify.example.com/v1/notify \
  -H "Authorization: Bearer $NOTIFKIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "user": "usr_8801",
    "template": "comment-reply",
    "channels": ["push"],
    "priority": "low",
    "data": {
      "replier": "@alex",
      "excerpt": "This is fire",
      "url": "https://app.co/c/91"
    }
  }'
ten replies is ten pushes

notifkit sends what you ask it to send. Deciding that four likes in a minute should become one "Sarah and 3 others liked your photo" is your application's job, because only your app knows what counts as a burst. Aggregate first, then send once with data: { count: 4, first: "Sarah" }.

8. Price drop

What you are building

"The Sony headphones you saved dropped from $249 to $199." The audience is "everyone watching this one product", which is exactly what a topic is, so you never keep a recipient list yourself.

Copy this

// when someone taps "notify me when the price drops"
await notifkit.updateUserPreferences("usr_9142", {
  topics: { "price-drop:product-123": true },
});

// when the price actually moves, address the topic instead of a person
await notifkit.notify({
  topic: "price-drop:product-123",
  template: "price-drop",
  channels: ["push", "email"],
  data: { name: "Sony WH-1000XM5", was: "$249.00", now: "$199.00" },
});
# when someone taps "notify me when the price drops"
curl -X PATCH https://notify.example.com/v1/users/usr_9142/preferences \
  -H "Authorization: Bearer $NOTIFKIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "topics": { "price-drop:product-123": true } }'

# when the price actually moves, address the topic instead of a person
curl -X POST https://notify.example.com/v1/notify \
  -H "Authorization: Bearer $NOTIFKIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "topic": "price-drop:product-123",
    "template": "price-drop",
    "channels": ["push", "email"],
    "data": { "name": "Sony WH-1000XM5", "was": "$249.00", "now": "$199.00" }
  }'
topic does two jobs

On notify(), topic means who to send to: everyone subscribed. On a template, topic means what this message is about, so people can switch that category off. Same word, two different jobs, and this is the one people trip over.

9. Calendar reminder

What you are building

"Your meeting with John starts in 15 minutes." You know at booking time exactly when it should fire, so say so once and forget about it.

Copy this

await notifkit.notify({
  user: "usr_9142",
  template: "meeting-reminder",
  channels: ["push"],
  sendAt: "2026-09-12T16:45:00Z",        // absolute UTC, 15 min before a 17:00 meeting
  data: { with: "John", startsIn: 15, joinUrl: "https://meet.co/abc-defg" },
});

// they cancelled the meeting. find the parked task and drop it.
const { scheduled } = await notifkit.getScheduledMessages();
await notifkit.cancelNotification(scheduled[0].taskId);
curl -X POST https://notify.example.com/v1/notify \
  -H "Authorization: Bearer $NOTIFKIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "user": "usr_9142",
    "template": "meeting-reminder",
    "channels": ["push"],
    "sendAt": "2026-09-12T16:45:00Z",
    "data": { "with": "John", "startsIn": 15, "joinUrl": "https://meet.co/abc-defg" }
  }'

# they cancelled the meeting. find the parked task and drop it.
curl -s https://notify.example.com/v1/notifications/scheduled \
  -H "Authorization: Bearer $NOTIFKIT_API_KEY"

curl -X DELETE https://notify.example.com/v1/notifications/{taskId} \
  -H "Authorization: Bearer $NOTIFKIT_API_KEY"

What happened

The message was rendered and parked instead of queued, and a scheduler released it at the moment you named. Cancelling takes a task id, which is not the notificationId the original call returned: one notify() can park many tasks, so getScheduledMessages() is how you find the one you want. Once it has been released there is no recall.

sendAt is UTC, not their clock

sendAt is one absolute instant for everybody in the call. It is not shifted into each user's timezone. To reach a thousand people at 9am their time, either compute the instant per user and send separately, or lean on quiet hours, which are timezone-aware. The next example does the second.

10. Streak reminder

What you are building

"Your 47-day streak ends tonight. Keep it alive!" Duolingo's owl arrives in your evening, not at 19:00 UTC. You do not compute that per user, and you do not need a workflow.

The flow

every hour
    ↓
your database tags whoever's streak is at risk
    ↓
one call to the segment
    ↓
inside someone's quiet hours?  ──YES──▶ deferred to the end of their window
    │ NO
    ▼
push goes out now

Copy this

// the SDK takes no idempotency argument, so set the header yourself
await fetch("https://notify.example.com/v1/notify", {
  method: "POST",
  headers: {
    "Authorization": `Bearer ${process.env.NOTIFKIT_API_KEY}`,
    "Content-Type": "application/json",
    "X-Idempotency-Key": "streak-2026-09-07-18",   // the hour this run covers
  },
  body: JSON.stringify({
    segment: "streak-at-risk",
    template: "streak-reminder",
    channels: ["push"],
    priority: "low",
  }),
});
# your own cron, hourly
curl -X POST https://notify.example.com/v1/notify \
  -H "Authorization: Bearer $NOTIFKIT_API_KEY" \
  -H "X-Idempotency-Key: streak-2026-09-07-18" \
  -H "Content-Type: application/json" \
  -d '{
    "segment": "streak-at-risk",
    "template": "streak-reminder",
    "channels": ["push"],
    "priority": "low"
  }'

What happened

Quiet hours did the timezone work. Anyone inside their own window was not dropped: the send was deferred to the end of it, computed against their local wall clock. One call reaches a user in Boston in the evening and a user in Los Angeles in the evening.

derive the key from the period

X-Idempotency-Key is what stops a retried cron, an overlapping run, or two hosts with the same crontab from sending twice. Derive it from the hour the job covers, not from the clock at the moment it fires, so both firings produce the same string. streak-${Date.now()} gives you exactly the double-send you were avoiding.

11. Card payment alert

What you are building

"$2,450 spent at ZARA. If this wasn't you, tap here." Your bank pings you the instant the card is used. Here you want the push and the SMS, not whichever one works first.

Copy this

await notifkit.notify({
  user: "usr_9142",
  template: "card-charged",
  channels: ["push", "sms"],       // deliberately NO fallback: send both
  priority: "critical",
  data: {
    amount: "$2,450.00",
    merchant: "ZARA",
    city: "Chicago, IL",
    disputeUrl: "https://bank.co/dispute/tx_5512",
  },
});
curl -X POST https://notify.example.com/v1/notify \
  -H "Authorization: Bearer $NOTIFKIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "user": "usr_9142",
    "template": "card-charged",
    "channels": ["push", "sms"],
    "priority": "critical",
    "data": {
      "amount": "$2,450.00",
      "merchant": "ZARA",
      "city": "Chicago, IL",
      "disputeUrl": "https://bank.co/dispute/tx_5512"
    }
  }'

What happened

Compare this with the login code: same two channels, same critical priority, and the opposite outcome, because that one had fallback and this one does not. For a possible fraudulent charge the cost of a duplicate notification is nothing, and the cost of a missed one is real money.

12. Flight delay

What you are building

"Your flight to Denver is delayed 45 minutes." Same shape as the card alert, plus one thing: the person may well be somewhere their phone has no data, so email is in the mix too.

Copy this

await notifkit.notify({
  user: "usr_9142",
  template: "flight-delayed",
  channels: ["push", "sms", "email"],   // all three, no fallback
  priority: "critical",
  data: {
    flight: "UA 2043",
    destination: "Denver",
    delayMinutes: 45,
    newDeparture: "6:15 PM",
    gate: "C12",
  },
});
curl -X POST https://notify.example.com/v1/notify \
  -H "Authorization: Bearer $NOTIFKIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "user": "usr_9142",
    "template": "flight-delayed",
    "channels": ["push", "sms", "email"],
    "priority": "critical",
    "data": {
      "flight": "UA 2043",
      "destination": "Denver",
      "delayMinutes": 45,
      "newDeparture": "6:15 PM",
      "gate": "C12"
    }
  }'

Try changing it

  • Tell everyone on the flight at once by tagging them into a segment as they check in, then sending to segment: "flight-ua2043".
  • Add campaign: "irrops-2026-09-12" so you can report on how many passengers you actually reached during a disruption.

Wait for something, then branch

Everything so far happens in one call. What follows takes hours or days, so it needs somewhere to live between the steps. A workflow is a normal async function whose awaits can be three days long and survive a deploy, because every finished step is written to Postgres before the next one runs.

CallWhat it does
step.wait("45m")Sleeps. Nothing is held in memory; the instance is woken later.
step.waitForEvent(name, opts)Sleeps until your app reports something happened, or the timeout fires. Returns the event, or null on timeout.
step.notify(payload)Sends. Takes every field notify() takes.
step.run(name, fn)Runs your own code once and remembers the result, so a database read does not repeat on every replay.
handlers run in Node, everything else is HTTP

A workflow body is a closure, so it lives in the process running services: ["workflow"]. Starting one, and feeding it the events it waits on, is plain HTTP from any language. That is what the shell tab shows on each example below.

13. Abandoned cart

What you are building

"Still thinking about those sneakers?" The trick is not the nudge. It is the silence when they already bought.

The flow

cart abandoned
      ↓
 wait 45 minutes
      ↓
 did they order within 6 hours?
   ┌──────┴──────┐
  YES            NO
   ↓              ↓
 STOP        send the reminder
             (push, email if push fails)

Copy this

workflow("cart-reminder", async ({ step, event }) => {
  await step.wait("45m");

  const ordered = await step.waitForEvent("order.placed", {
    timeout: "6h",
    match: { cartId: event.cartId },
  });

  if (ordered) return;                 // they bought. say nothing.

  await step.notify({
    template: "cart-reminder",
    channels: ["push", "email"],
    fallback: true,
    data: { items: event.items, cartUrl: event.cartUrl },
  });
});
# when the cart goes quiet, start an instance
curl -X POST https://notify.example.com/v1/workflows/trigger \
  -H "Authorization: Bearer $NOTIFKIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "cart-reminder",
    "user": "usr_9142",
    "input": {
      "cartId": "cart_771",
      "items": ["Nike Pegasus 41"],
      "cartUrl": "https://shop.co/cart/771"
    }
  }'

# later, when they check out, this wakes the instance and cancels the nudge
curl -X POST https://notify.example.com/v1/events \
  -H "Authorization: Bearer $NOTIFKIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "order.placed",
    "properties": { "cartId": "cart_771", "userId": "usr_9142" }
  }'

What happened

The instance slept for 45 minutes, then suspended again waiting for an event that might never arrive. Nothing was running during either wait. Posting order.placed with a matching cartId woke it, ordered came back as the event payload, and the function returned before sending anything.

14. Abandoned cart, then a discount

What you are building

"10% off your cart — today only." The second stage, and only if the plain reminder did not work. Leading with the discount teaches people to abandon carts on purpose.

The flow

cart abandoned → wait 45m → bought within 6h? ──YES──▶ STOP
                                  │ NO
                                  ▼
                          send plain reminder
                                  ↓
                             wait 24 hours
                                  ↓
                          bought within 24h? ──YES──▶ STOP
                                  │ NO
                                  ▼
                          send 10% discount code

Copy this

workflow("cart-recovery", async ({ step, event }) => {
  await step.wait("45m");

  const boughtEarly = await step.waitForEvent("order.placed", {
    timeout: "6h",
    match: { cartId: event.cartId },
  });
  if (boughtEarly) return;

  await step.notify({
    template: "cart-reminder",
    channels: ["push"],
    data: { items: event.items },
  });

  await step.wait("24h");

  const boughtLate = await step.waitForEvent("order.placed", {
    timeout: "24h",
    match: { cartId: event.cartId },
  });
  if (boughtLate) return;

  await step.notify({
    template: "cart-discount",
    channels: ["email"],
    data: { items: event.items, code: "SAVE10", expiresHours: 24 },
  });
});
curl -X POST https://notify.example.com/v1/workflows/trigger \
  -H "Authorization: Bearer $NOTIFKIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "cart-recovery",
    "user": "usr_9142",
    "input": { "cartId": "cart_771", "items": ["Nike Pegasus 41"] }
  }'

# either checkout event ends the run, whichever stage it is in
curl -X POST https://notify.example.com/v1/events \
  -H "Authorization: Bearer $NOTIFKIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "name": "order.placed", "properties": { "cartId": "cart_771" } }'
steps are matched by call order

On wake-up the function runs again from the top, and steps that already finished return their stored result instead of re-executing. That only works if your code issues the same sequence of calls every time. Branch on event or on what an earlier step returned, never on Math.random() or the wall clock.

15. Trial onboarding

What you are building

The first week of Notion or Figma: a sequence that keeps going only while you are not getting on with it. Every stage can end the whole thing.

The flow

day 0  welcome                      always sent
   ↓
day 1  finished the first task?     yes ─▶ skip the nudge
       no  ─▶ "it takes 3 minutes"
   ↓
day 3  come back at all?            yes ─▶ celebrate, STOP
       no  ─▶ "try a smaller goal?"
   ↓
day 7  still nothing?               yes ─▶ STOP bothering them
                                    no  ─▶ tag them for the win-back segment

Copy this

workflow("trial-onboarding", async ({ step, event }) => {
  const uid = event.userId;

  await step.notify({ template: "welcome", channels: ["email"] });

  await step.wait("1d");

  const startedDay1 = await step.waitForEvent("project.created", {
    timeout: "1d",
    match: { userId: uid },
  });
  if (!startedDay1) {
    await step.notify({
      template: "first-project-nudge",
      channels: ["email", "push"],
      fallback: true,
      data: { minutes: 3 },
    });
  }

  await step.wait("2d");

  const startedDay3 = await step.waitForEvent("project.created", {
    timeout: "2d",
    match: { userId: uid },
  });
  if (startedDay3) {
    await step.notify({ template: "activation-congrats", channels: ["push"], priority: "low" });
    return;                                   // they are onboarded. stop here.
  }

  await step.notify({
    template: "smaller-goal",
    channels: ["email"],
    data: { suggestion: "Start with a single page" },
  });

  await step.wait("4d");

  const startedDay7 = await step.waitForEvent("project.created", {
    timeout: "4d",
    match: { userId: uid },
  });
  if (startedDay7) return;

  // hand them over to the win-back campaign and stop sending onboarding mail
  await step.run("mark-dormant", async () => {
    await db.users.tag(uid, "never-activated");
  });
});
# start it from your signup handler
curl -X POST https://notify.example.com/v1/workflows/trigger \
  -H "Authorization: Bearer $NOTIFKIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "trial-onboarding",
    "user": { "id": "usr_9142", "email": "[email protected]" },
    "input": { "userId": "usr_9142", "plan": "pro-trial" }
  }'

# every time they do the thing that matters, post it. any stage can exit on it.
curl -X POST https://notify.example.com/v1/events \
  -H "Authorization: Bearer $NOTIFKIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "name": "project.created", "properties": { "userId": "usr_9142" } }'

What happened

Note the shape: the sequence of step calls is identical on every run and only the if bodies differ, which is what makes it replay-safe. The trigger passed an inline user object, so the person is created as part of starting the workflow. The final step.run writes a tag your segment queries later, which is how a workflow hands a user to a campaign without either knowing about the other.

16. Failed payment recovery

What you are building

"Your payment failed. We'll retry automatically tomorrow." Then the ladder every subscription business runs, ending with your own billing team hearing about it before the account suspends. This is the example that pays for itself.

The flow

payment failed
      ↓
polite email ─────────────────────────── day 0
      ↓
wait 72h  (the card network retries in here)
      ↓
paid? ──YES──▶ "thanks, sorted" ──▶ STOP
  │ NO
  ▼
urgent SMS + email ───────────────────── day 3
      ↓
wait 48h
      ↓
paid? ──YES──▶ "thanks, sorted" ──▶ STOP
  │ NO
  ▼
alert #billing-ops in Slack ──────────── day 5
      ↓
final notice to the customer

Copy this

workflow("recover-payment", async ({ step, event }) => {
  await step.notify({
    template: "payment-failed-soft",
    channels: ["email"],
    data: { amount: event.amount, invoiceUrl: event.invoiceUrl },
  });

  await step.wait("72h");

  const paidAfterRetry = await step.waitForEvent("invoice.paid", {
    timeout: "48h",
    match: { customerId: event.customerId },
  });
  if (paidAfterRetry) {
    await step.notify({ template: "payment-recovered", channels: ["email"] });
    return;
  }

  await step.notify({
    template: "payment-urgent",
    channels: ["sms", "email"],
    fallback: true,
    priority: "high",
    data: { amount: event.amount, suspendsOn: event.periodEnd },
  });

  await step.wait("48h");

  const paidAfterSms = await step.waitForEvent("invoice.paid", {
    timeout: "24h",
    match: { customerId: event.customerId },
  });
  if (paidAfterSms) {
    await step.notify({ template: "payment-recovered", channels: ["email"] });
    return;
  }

  // your team, not the customer. different audience, different channel.
  await step.notify({
    topic: "billing-ops",
    template: "churn-risk",
    channels: ["slack"],
    priority: "high",
    data: { customerId: event.customerId, mrr: event.amount, daysOverdue: 5 },
  });

  await step.notify({ template: "final-notice", channels: ["email"], priority: "high" });
});
# from your Stripe webhook handler on invoice.payment_failed
curl -X POST https://notify.example.com/v1/workflows/trigger \
  -H "Authorization: Bearer $NOTIFKIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "recover-payment",
    "user": "usr_9142",
    "input": {
      "customerId": "cus_M4x",
      "amount": "$49.00",
      "invoiceUrl": "https://billing.co/i/8812",
      "periodEnd": "2026-09-12"
    }
  }'

# and on invoice.payment_succeeded, which ends the run at whichever rung it is on
curl -X POST https://notify.example.com/v1/events \
  -H "Authorization: Bearer $NOTIFKIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "name": "invoice.paid", "properties": { "customerId": "cus_M4x" } }'

What happened

Two audiences in one workflow. The customer messages name no target, so they go to the instance's own user. The Slack message names topic: "billing-ops", so it reaches whoever is subscribed to that topic instead, and your billing code never has to know who is on the rota this week.

17. Subscription cancellation rescue

What you are building

You hit cancel on Netflix and it confirms the cancellation, then a couple of days later it tells you what you are missing. Nothing commercial is sent if you change your mind first.

The flow

user cancels
      ↓
send cancellation confirmation
      ↓
   wait 1 hour
      ↓
did they resume?
 ┌────┴────┐
YES        NO
 ↓          ↓
STOP   send the win-back offer
            ↓
       wait 48 hours
            ↓
      resumed by now?
      ┌─────┴─────┐
     YES          NO
      ↓            ↓
"welcome back"  ask why they left

Copy this

workflow("cancellation-rescue", async ({ step, event }) => {
  // 1. always confirm. transactional, so the template carries no topic.
  await step.notify({
    template: "cancellation-confirmed",
    channels: ["email"],
    data: { accessUntil: event.periodEnd, plan: event.plan },
  });

  await step.wait("1h");

  // 2. a change of heart in the first hour is common. do not sell to them.
  const changedMind = await step.waitForEvent("subscription.resumed", {
    timeout: "1h",
    match: { customerId: event.customerId },
  });
  if (changedMind) return;

  // 3. pick the offer from what they actually used, once, not on every replay
  const offer = await step.run("choose-offer", async () => {
    const usage = await billing.usageFor(event.customerId);
    return usage.hoursLastMonth > 20
      ? { discount: "50%", months: 3 }
      : { discount: "30%", months: 2 };
  });

  await step.notify({
    template: "winback-offer",
    channels: ["email", "push"],
    fallback: true,
    data: offer,
  });

  await step.wait("48h");

  const cameBack = await step.waitForEvent("subscription.resumed", {
    timeout: "48h",
    match: { customerId: event.customerId },
  });
  if (cameBack) {
    await step.notify({ template: "welcome-back", channels: ["email"] });
    return;
  }

  await step.notify({ template: "exit-survey", channels: ["email"], priority: "low" });
});
curl -X POST https://notify.example.com/v1/workflows/trigger \
  -H "Authorization: Bearer $NOTIFKIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "cancellation-rescue",
    "user": "usr_9142",
    "input": {
      "customerId": "cus_M4x",
      "plan": "Standard",
      "periodEnd": "2026-10-01"
    }
  }'

# posting this at any point ends the run early
curl -X POST https://notify.example.com/v1/events \
  -H "Authorization: Bearer $NOTIFKIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "name": "subscription.resumed", "properties": { "customerId": "cus_M4x" } }'

What happened

Three exits, two of them before anything commercial goes out. step.run is what keeps the billing lookup honest: the function body re-runs from the top every time the instance wakes, but that block executes once and replays its stored result afterwards, so you do not hit your own billing service four times over three days.

Coordinate channels and providers

The last three combine everything: waiting on a human instead of a machine, addressing thousands of people without delaying anyone's login code, and keeping messages flowing when a provider goes down.

18. Escalate when they ignore it

What you are building

WhatsApp pings your phone, and if you still have not read it, you get an email. Delivery is not the question here: the push arrived fine. The question is whether the human did anything.

delivered is not read

fallback: true escalates when a channel fails. This escalates when a person does not respond, which is a different thing and needs your app to say so. Post an event when the user acts, and the workflow waits on that.

The flow

message arrives for an offline user
            ↓
        send push
            ↓
   opened the app within 20m? ──YES──▶ STOP
            │ NO
            ▼
        send email
            ↓
   opened within 2 hours? ──YES──▶ STOP
            │ NO
            ▼
   SMS, but only if the sender marked it urgent

Copy this

workflow("unread-escalation", async ({ step, event }) => {
  await step.notify({
    template: "new-message",
    channels: ["push"],
    data: { from: event.senderName, preview: event.preview },
  });

  const openedSoon = await step.waitForEvent("thread.opened", {
    timeout: "20m",
    match: { threadId: event.threadId, userId: event.userId },
  });
  if (openedSoon) return;

  await step.notify({
    template: "unread-message",
    channels: ["email"],
    data: { from: event.senderName, preview: event.preview, url: event.threadUrl },
  });

  const openedLater = await step.waitForEvent("thread.opened", {
    timeout: "2h",
    match: { threadId: event.threadId, userId: event.userId },
  });
  if (openedLater) return;

  if (event.urgent) {
    await step.notify({ template: "unread-urgent", channels: ["sms"], priority: "high" });
  }
});
# when a message arrives for someone who is offline
curl -X POST https://notify.example.com/v1/workflows/trigger \
  -H "Authorization: Bearer $NOTIFKIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "unread-escalation",
    "user": "usr_9142",
    "input": {
      "threadId": "thr_88",
      "userId": "usr_9142",
      "senderName": "Alex",
      "preview": "are we still on for 6?",
      "threadUrl": "https://app.co/t/88",
      "urgent": false
    }
  }'

# wherever a thread gets opened in your app
curl -X POST https://notify.example.com/v1/events \
  -H "Authorization: Bearer $NOTIFKIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "thread.opened",
    "properties": { "threadId": "thr_88", "userId": "usr_9142" }
  }'

19. Service outage broadcast

What you are building

"Checkout is currently unavailable. We'll update you when it's fixed." This goes to a lot of people at once, and the thing you care about is that it does not delay anybody's login code on the way out.

Copy this

await notifkit.notify({
  segment: "active-last-24h",
  template: "service-degraded",
  channels: ["email"],
  priority: "low",                       // its own lane, behind everything transactional
  campaign: "incident-2026-09-12",
  data: { service: "Checkout", statusUrl: "https://status.example.com" },
});

// when it is fixed, same segment, same campaign label
await notifkit.notify({
  segment: "active-last-24h",
  template: "service-restored",
  channels: ["email"],
  priority: "low",
  campaign: "incident-2026-09-12",
  data: { service: "Checkout", downtimeMinutes: 39 },
});
curl -X POST https://notify.example.com/v1/notify \
  -H "Authorization: Bearer $NOTIFKIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "segment": "active-last-24h",
    "template": "service-degraded",
    "channels": ["email"],
    "priority": "low",
    "campaign": "incident-2026-09-12",
    "data": { "service": "Checkout", "statusUrl": "https://status.example.com" }
  }'

# when it is fixed, same segment, same campaign label
curl -X POST https://notify.example.com/v1/notify \
  -H "Authorization: Bearer $NOTIFKIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "segment": "active-last-24h",
    "template": "service-restored",
    "channels": ["email"],
    "priority": "low",
    "campaign": "incident-2026-09-12",
    "data": { "service": "Checkout", "downtimeMinutes": 39 }
  }'

What happened

The server expanded the segment into one message per person and queued them all on the low lane. Priorities are separate physical streams, so a hundred thousand queued emails cannot sit in front of a critical send. Each message is still gated individually, so anyone who opted out of that topic gets nothing while their neighbour gets the mail.

everyone gets the same data

A segment send delivers identical data to every recipient. That is right for an outage notice and wrong for "here is your affected order". When the content differs per person, loop and send individually with a key like incident-2026-09-12-usr_9142 so a retry collapses instead of doubling up.

20. On-call incident, and provider failover

What you are building

"SEV-1: checkout-api error rate is 31%. Acknowledge now." The one message on this page that is meant to ignore everything, sent to a rota instead of a person, and escalating if nobody answers. Underneath it, the setup that keeps mail moving when your email provider is the thing having the outage.

The flow

SEV-1 detected
      ↓
page primary on-call  ──── acknowledged in 5m? ──YES──▶ STOP
      │ NO
      ▼
page secondary + #incidents ── acked in 5m? ──YES──▶ STOP
      │ NO
      ▼
page the engineering manager

every page goes to push AND sms AND slack.
NO FALLBACK: you want all three, not whichever lands first.

Copy this

workflow("incident-escalation", async ({ step, event }) => {
  const page = {
    severity: event.severity,
    summary: event.summary,
    runbook: event.runbook,
  };

  await step.notify({
    topic: "oncall-primary",
    template: "incident-page",
    channels: ["push", "sms", "slack"],   // no fallback: wake them on all three
    priority: "critical",
    data: page,
  });

  const ackedByPrimary = await step.waitForEvent("incident.acknowledged", {
    timeout: "5m",
    match: { incidentId: event.incidentId },
  });
  if (ackedByPrimary) return;

  await step.notify({
    topic: "oncall-secondary",
    template: "incident-page",
    channels: ["push", "sms", "slack"],
    priority: "critical",
    data: { ...page, escalation: 1 },
  });

  const ackedBySecondary = await step.waitForEvent("incident.acknowledged", {
    timeout: "5m",
    match: { incidentId: event.incidentId },
  });
  if (ackedBySecondary) return;

  await step.notify({
    topic: "engineering-managers",
    template: "incident-page",
    channels: ["push", "sms", "slack"],
    priority: "critical",
    data: { ...page, escalation: 2 },
  });
});
# from your alerting rule
curl -X POST https://notify.example.com/v1/workflows/trigger \
  -H "Authorization: Bearer $NOTIFKIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "incident-escalation",
    "input": {
      "incidentId": "inc_5512",
      "severity": "SEV-1",
      "summary": "checkout-api error rate 31%, p99 8.4s",
      "runbook": "https://wiki.internal/runbooks/checkout"
    }
  }'

# when someone hits acknowledge
curl -X POST https://notify.example.com/v1/events \
  -H "Authorization: Bearer $NOTIFKIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "name": "incident.acknowledged", "properties": { "incidentId": "inc_5512" } }'

What happened

Every page is a multicast, deliberately. At 3am you want the push and the SMS and the Slack message, because the cost of three notifications is nothing next to the cost of a missed page. Addressing a topic instead of a person means the rota owns who is subscribed this week and your alerting code never has to know.

Now the layer underneath

None of that helps if your email provider is down. Register a second transport on the same channel and the delivery worker uses it when the first fails. Nothing at any call site changes.

import { registerTransport } from "notifkit";
import { ResendTransport } from "@notifkit/provider-resend";
import { SesTransport } from "./transports/ses.js";   // one you wrote

// higher number is tried first
registerTransport(new ResendTransport({ apiKey: process.env.RESEND_KEY!, from: "[email protected]" }), 10);
registerTransport(new SesTransport({ region: "us-east-1", from: "[email protected]" }), 5);
# transports are server boot config, so there is no HTTP call to register one.
# what you can check over HTTP is what happened to a message afterwards:
curl -s https://notify.example.com/v1/notifications/{taskId} \
  -H "Authorization: Bearer $NOTIFKIT_API_KEY"

# every attempt, which provider took it, and the response it gave
curl -s "https://notify.example.com/v1/notifications/logs?limit=20" \
  -H "Authorization: Bearer $NOTIFKIT_API_KEY"
Resend saysWhat happens next
DeliveredDone. SES is never called and never billed.
503, timeout, or throwsSES is tried with the same rendered message.
Both failThe email channel is exhausted. If the call named a next channel with fallback, it rolls over; otherwise a failed row lands in the delivery log.
Repeated 429s past maxAttemptsCounts as exhausted, so the chain moves on instead of burning retries.
failover, not routing

Two transports on one channel give you provider failover. They do not let you pick between them per message: the registry keys on channel alone, so the second is only ever reached when the first fails. Anything that varies per message, a sender address included, belongs in the template and is read by one transport.

What happens when things fail

The full chain, and what stops it early

Stack provider failover under channel fallback and you get what most production setups end up with: two providers deep on email, three channels wide across the send. This is the trace you will be reading at 2am.

push   → FCM               token expired          ✗ channel exhausted
email  → Resend            503                    ✗
       → SES (priority 5)  250 OK                 ✓ delivered, chain stops
sms    → Twilio                                   never tried

Two different mechanisms in one trace. Inside email, one provider covering for another. Across the array, one channel covering for another. The chain stops at the first success, so the SMS is never sent.

Here is the distinction people get wrong. A channel that fails technically rolls over. A channel the user declined does not, because their refusal is not a delivery problem to route around:

Outcome on a channelRolls over to the next?
Every transport failed, threw, or timed outYes
No transport registered for the channelYes
Push token came back invalidYes, and the contact is deactivated
User opted out of that channel or topicNo. The attempt ends there
Inside the user's quiet hoursNo. Deferred to the end of the window, then sent
Per-user throttle exceededNo. Dropped and logged
Address on the suppression listNo. Nothing overrides this

Chasing somebody across three channels because they unsubscribed from the first is how a sending domain earns a complaint rate, and the complaint rate is what ends deliverability for your transactional mail too. Channels & fallback has the full rules, and Preferences & quiet hours covers what each gate checks.

Checking what actually happened

Every call on this page returns 202, which only means queued. These are how you find out whether anything arrived.

// one message, end to end: every attempt, provider response and timing
const { status, logs } = await notifkit.getNotificationStatus(taskId);

// a whole campaign
const { totals, warnings } = await notifkit.getCampaignStats("incident-2026-09-12");

// why did this person get nothing? usually the answer is here.
const { suppressions } = await notifkit.listSuppressions({ target: "[email protected]" });

// what is parked and waiting to go out later
const { scheduled } = await notifkit.getScheduledMessages();
# one message, end to end
curl -s https://notify.example.com/v1/notifications/{taskId} \
  -H "Authorization: Bearer $NOTIFKIT_API_KEY"

# a whole campaign
curl -s https://notify.example.com/v1/campaigns/incident-2026-09-12/stats \
  -H "Authorization: Bearer $NOTIFKIT_API_KEY"

# why did this person get nothing?
curl -s "https://notify.example.com/v1/[email protected]" \
  -H "Authorization: Bearer $NOTIFKIT_API_KEY"

# what is parked and waiting
curl -s https://notify.example.com/v1/notifications/scheduled \
  -H "Authorization: Bearer $NOTIFKIT_API_KEY"
a zero open rate may mean nobody measured

Opens and clicks arrive from provider webhooks. SMS and push report nothing at all, and on email nothing arrives unless the webhook is wired up. getCampaignStats returns a warnings array saying which applies, so read that before concluding the campaign flopped.

Five projects you can clone and run

Everything above is written out on this page. The five below are directories in the repository: working code with a package.json, meant to be run instead of read. They cover the plumbing the examples assume you already have, so start here if you want a server of your own before trying any of the patterns.

ProjectAnswers
basic-usageHow do I call this from a language with no SDK?
custom-serverHow do I boot the engine in my own process, with my own transport?
workflowsWhat does a workflow look like when it is defined as JSON instead of code?
scheduled-notificationsHow do sendAt and quiet hours interact?
load-testHow fast does it accept work, and how do I watch it drain?

basic-usage

Read this one first. It makes three HTTP calls, syncing a template, registering a user and sending, using nothing but fetch. The point it makes is that notifkit has no required SDK: anything that can POST JSON is a client, so the same three calls work from Go, Python, PHP, or a shell script.

The third call comes back 202 Accepted, which is notifkit saying queued, not delivered, and that is the distinction the rest of the docs keep coming back to. The task id it returns is what GET /v1/notifications/{taskId} reports against.

◆ examples/basic-usage on GitHub →
Shows youRead next
Template sync, user creation, a first sendQuickstart
Bearer auth on every /v1 routeReference

custom-server

Bootstraps NotifkitServer in your own process with services: ["all"], registers a hand-written email transport, sets worker concurrency, and installs graceful shutdown handlers. The transport is the interesting part: a class with a channel and a send() that returns { success, providerMessageId }. That is the entire contract: Resend and FCM implement the same one.

◆ examples/custom-server on GitHub →
Shows youRead next
Writing a TransportChannels & fallback
Running every service in one processArchitecture
Graceful shutdownOperations

workflows

Defines a two-step onboarding drip (welcome, wait an hour, follow up) as a JSON workflow, registers the recipient, then triggers one instance for them. Worth reading closely for one detail: no step names a recipient. Each inherits the user the instance was triggered with, which is why the same definition serves every subscriber.

The JSON form needs no deploy. The sequence is data, editable by someone who is not shipping a release. Code-defined workflows can do the same thing with conditionals and arbitrary side effects.

◆ examples/workflows on GitHub →
Shows youRead next
Declarative workflows, and who a step sends toWorkflows
Waiting between stepsThe step API

scheduled-notifications

Sends with sendAt set ten minutes out, for a user in America/New_York with a 22:00–08:00 quiet window. Two mechanisms overlap here, and the example is the shortest way to see the difference: sendAt is absolute UTC and is not shifted into the user's timezone, while quiet hours are timezone-aware, so a scheduled send that lands inside a quiet window gets deferred again to the far edge of it.

The message is parked in Postgres with a pointer in a Redis sorted set, where GET /v1/notifications/scheduled can see it and where it stays cancellable right up until the scheduler releases it.

◆ examples/scheduled-notifications on GitHub →
Shows youRead next
sendAt, listing and cancelling scheduled sendsScheduling
Quiet-hours deferralPreferences & quiet hours

load-test

Fires a configurable number of notifications through a pool of concurrent workers and reports throughput and elapsed time. Defaults to 500 notifications at concurrency 50, across 100 distinct users.

consequence

This measures ingestion, not delivery. Every call returns as soon as the request is queued, so a high number here says the API accepted the work quickly: it says nothing about how fast messages reached anyone. For that, watch stream depth in GET /v1/system/metrics, which shows whether the queue drains afterwards or grows without bound.

◆ examples/load-test on GitHub →
Shows youRead next
Ingestion throughput, batching behaviourArchitecture
What to watch while it runsOperations