Messages that don't get lost
A worker is killed mid-send; another worker reclaims the message 47 seconds later and delivers it.
notifkit is an open-source notification service you host yourself. One
notify() call sends push, email, SMS and Slack, falls back when a channel fails, and
respects every user's preferences.
await notify({ user: "ana", template: "payment-failed", channels: ["push", "email", "slack"], fallback: })
Press send, or flip fallback to see the difference.
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. Templates and workflows are files in your repo, so any agent can operate them.
edit → review → merge → shipBuild 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 sends the receipt to both channels, with the items and a link to it. No queue to configure, no worker to babysit.
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. Send the nudge now. If it's night where they are, their quiet hours hold it until the window ends, on their own clock.
await notify({ user: "usr_enterprise_82", template: "onboarding-connect-source", data: { workspace: "Acme Corp", missingStep: "Stripe API Key" }, channels: ["email"], priority: "normal" })
Address a whole segment and the fan-out happens server-side on the low-priority lane, tagged with a campaign for stats. Receipts and login codes sent in the meantime don't wait behind it.
await notify({ segment: "subscribers-tier-pro", template: "vip-early-access", data: { promoCode: "PRO20", expiresHours: 48 }, channels: ["email", "push"], priority: "low", campaign: "q3-pro-launch" })
A verification code is useless in an hour, so critical skips quiet hours and
throttling and gets its own queue, ahead of any marketing send. SMS first, email only if SMS fails.
await notify({ user: "usr_9142", template: "login-code", data: { code: "480913", expiresMinutes: 5 }, channels: ["sms", "email"], fallback: true, priority: "critical" })
A polite email right away, then three days for Stripe's automatic retry and two more for
invoice.paid. Still unpaid: an urgent SMS to the customer and a Slack alert to
finance before the account is suspended.
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: ["slack"] }); } });
Twenty of these in the docs, each in TypeScript and curl: the price-drop alert sent to everyone watching a product, the streak reminder that lands in each user's evening, the on-call page that escalates until somebody acknowledges.
send → react → wait → escalateMIT licensed, and it runs on your servers. No plans, no seats, no per-message fee. A $20/mo VPS can easily send 5M notifications a day.
Are you stuck or want our help? Get in touch.
Five messages a shop sends after you order, and the real incidents behind each one: duplicate receipts, midnight alerts, phantom orders and test emails sent to everyone.
We load-tested notifkit, Postgres and Redis on 2 vCPUs, ran it until it broke, and wrote down what the benchmark does and doesn’t prove.
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"] }'
QUEUE_MAX_LEN above your worst backlog, since the
oldest queued messages past it are trimmed.Transport interface, which is one send()
method.npm create notifkit@latest scaffolds a server and a test send, and needs no provider
account. The quickstart walks the whole path, by hand or in one command.
Need help running notifkit in production? We can deploy it on your infrastructure, migrate your templates from Knock, Courier, Novu or your custom engine, and give you priority support.