Messages that don't get lost
A worker is killed mid-send; another worker reclaims the message 47 seconds later and delivers it.
One notify() call. Everything under it, handled.
Wait for an event. Try again tomorrow. Stop if they already converted. It's all built in.
Example: a single notify() call sends the "payment-failed" template to user usr_123
across push, email, and SMS, with fallback enabled and an AI-generated nudge line. The delivery log
beside it shows the route resolving, push failing on an expired token, the chain falling back to
email, email delivering in 142 milliseconds, and SMS never being tried.
A notification is rarely one message. It's usually a wait, then a question, then a nudge only if the answer never came. That's a day of logic in one ordinary function.
workflow("cart-nudge", async ({ step, event }) => { await step.wait("1h"); const ordered = await step.waitForEvent( "order.placed", { timeout: "24h", match: { cartId: event.cartId } }, ); if (ordered) return; await step.notify({ template: "cart-nudge", channels: ["push", "email"], fallback: true, data: { cartId: event.cartId }, }); });
The same workflow as a graph. A cart.abandoned event starts the run, which waits an hour and then suspends until either an order.placed event matching that cart arrives, or twenty-four hours pass. If the order arrives the run stops and nothing is sent. If it times out, the run sends the cart-nudge template to push, falling back to email.
No canvas and no publish button. It's code: reviewed in a PR, rolled back with
git revert, and operable by any agent.
Build workflows, send campaigns, inspect delivery, replay failures. The same API your app uses is available to your agent, so the whole system runs without a console to open.
workflows/activation.ts. It waits 2 days, then listens
for user.activated for 5 more. If it never arrives, the nudge goes to push,
falling back to email.spring-sale.
It is an MCP server over the same REST API, so whatever your key can do, your agent can do.
Need to inspect instead of ask? A local dashboard is included, read-only by design.
send → inspect → replay → report
Six background jobs, three webhook handlers, a queue, and a graveyard of edge cases.
One notify() returns immediately and keeps delivering behind you: through a
restart, an outage, or a bad deploy.
A single notify() call fans out to eight channels at once: email, SMS, push, Slack, WhatsApp, Telegram, Discord, and a webhook POST to any endpoint you choose. Any other provider is a transport you write.
What one notify call passes through, in order: deduplication, preference checks, quiet hours, template render, the suppression list, delivery, and retry with fallback to the next channel. It ends delivered and logged.
A worker is killed mid-send; another worker reclaims the message 47 seconds later and delivers it.
A push lands inside the user’s quiet hours, so it is deferred to 08:00 in their own timezone instead of dropped.
Push fails on an expired token, so the chain rolls over to email, which delivers in 142 milliseconds.
A three-day wait suspends the workflow; it resumes on the correct step after two deploys.
Three notify calls carrying the same idempotency key result in exactly one delivery.
A message scheduled for Tuesday at 09:00 is parked, then cancelled at 08:12 and never sent.
One call delivers the order confirmation across email and push, with itemized details and tracking. There are no queues to configure and no background workers to manage.
await notify({ user: "usr_9142", template: "order-placed", data: { orderId: "ord_99214", items: ["Mechanical Keyboard", "Desk Mat"], total: "$349.00", receiptUrl: "https://shop.co/receipt/99214" }, channels: ["email", "push"] })
Push notifications are free and instant until a user turns off app alerts or loses data. Try push first and fall back to SMS, so the delivery update still arrives.
await notify({ user: "usr_9142", template: "order-out-for-delivery", data: { driver: "Marcus", etaMinutes: 12, liveTrackUrl: "https://shop.co/track/99214" }, channels: ["push", "sms"], fallback: true })
Trial users who don't connect an integration in 48 hours churn. Schedule a milestone nudge that respects their local timezone, so you don't wake a prospective client at 3 AM.
await notify({ user: "usr_enterprise_82", template: "onboarding-connect-source", data: { workspace: "Acme Corp", missingStep: "Stripe API Key" }, channels: ["email"], priority: "normal", sendAt: "2026-09-02T10:00:00Z" })
Target entire customer cohorts with server-side queue isolation and campaign tracking. Thousands of marketing emails fan out on low-priority background lanes while transactional receipts stay instant.
await notify({ segment: "subscribers-tier-pro", template: "vip-early-access", data: { promoCode: "PRO20", expiresHours: 48 }, channels: ["email", "push"], priority: "low", campaign: "q3-pro-launch" })
Wait 45 minutes, suspend execution until order.placed arrives, and send a discount
only if the cart is still abandoned.
workflow("cart-recovery", async ({ step, event }) => { await step.wait("45m"); const purchased = await step.waitForEvent("order.placed", { timeout: "24h", match: { cartId: event.cartId } }); if (purchased) return; await step.notify({ template: "cart-nudge-incentive", data: { items: event.items, discountCode: "SAVE10" }, channels: ["push", "email"], fallback: true }); });
Send an immediate polite email, wait for Stripe's automatic retry, check for
invoice.paid, escalate to urgent SMS, and alert the finance team in Slack before account
suspension.
workflow("recover-mrr", async ({ step, event }) => { await step.notify({ template: "payment-failed-soft", data: { amount: event.amount, invoiceUrl: event.invoiceUrl }, channels: ["email"] }); await step.wait("72h"); const paid = await step.waitForEvent("invoice.paid", { timeout: "48h", match: { customerId: event.customerId } }); if (!paid) { await step.notify({ template: "subscription-suspension-warning", channels: ["sms"], priority: "high" }); await step.notify({ topic: "billing-ops", template: "churn-risk-alert", data: { customerId: event.customerId, mrr: event.amount, daysOverdue: 5 }, channels: ["webhook"] }); } });
Twenty of these in the docs, each in TypeScript and curl: the login code that ignores quiet hours, the cart that goes silent the moment they buy, the on-call page that escalates until somebody acknowledges.
send → react → wait → escalateYour notification system lives where your code lives. Templates, workflows, and checking what happened all stay in your editor.
Your IDE is the console, and your agent is the operator.
It runs on your servers. No plans, no seats, no per-message fee.
Are you stuck or want my help? Get in touch.
It's just HTTP. Go, Python, Rust, PHP, a shell script: if it can POST JSON, it's a client. No SDK to wait for, nothing to keep in sync. TypeScript gets typed helpers if you want them.
curl -X POST https://notifkit.yourdomain.com/v1/notify \ -H "Authorization: Bearer $NOTIFKIT_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "user": "usr_123", "template": "welcome-message", "channels": ["email", "push"] }'
Transport interface, which is one send() method.Install it, start the server, create a project, send. The quickstart walks the whole path in about ten minutes.
I run notifkit at my own company, which delivers over 100,000 notifications a day.