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.
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"
}
}'
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"
}
}'
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" }
}'
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 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.
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.
| Call | What 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. |
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" } }'
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.
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.
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 says | What happens next |
|---|---|
| Delivered | Done. SES is never called and never billed. |
| 503, timeout, or throws | SES is tried with the same rendered message. |
| Both fail | The 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 maxAttempts | Counts as exhausted, so the chain moves on instead of burning retries. |
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 channel | Rolls over to the next? |
|---|---|
| Every transport failed, threw, or timed out | Yes |
| No transport registered for the channel | Yes |
| Push token came back invalid | Yes, and the contact is deactivated |
| User opted out of that channel or topic | No. The attempt ends there |
| Inside the user's quiet hours | No. Deferred to the end of the window, then sent |
| Per-user throttle exceeded | No. Dropped and logged |
| Address on the suppression list | No. 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"
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.
| Project | Answers |
|---|---|
basic-usage | How do I call this from a language with no SDK? |
custom-server | How do I boot the engine in my own process, with my own transport? |
workflows | What does a workflow look like when it is defined as JSON instead of code? |
scheduled-notifications | How do sendAt and quiet hours interact? |
load-test | How 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.
| Shows you | Read next |
|---|---|
| Template sync, user creation, a first send | Quickstart |
Bearer auth on every /v1 route | Reference |
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.
| Shows you | Read next |
|---|---|
Writing a Transport | Channels & fallback |
| Running every service in one process | Architecture |
| Graceful shutdown | Operations |
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 you | Read next |
|---|---|
| Declarative workflows, and who a step sends to | Workflows |
| Waiting between steps | The 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.
| Shows you | Read next |
|---|---|
sendAt, listing and cancelling scheduled sends | Scheduling |
| Quiet-hours deferral | Preferences & 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.
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.
| Shows you | Read next |
|---|---|
| Ingestion throughput, batching behaviour | Architecture |
| What to watch while it runs | Operations |