Messages that don't get lost
A worker dies mid-send and another reclaims the message. Poison payloads land in a dead-letter queue you can replay when you're ready.
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.
Every provider has its own auth, rate limits, and failure modes. notifkit normalizes them behind a
single notify() — so you ship the feature, not six integrations. The call returns
right away, and delivery keeps going behind it: through a restart, a provider outage, or a bad deploy.
A single notify() call fans out to four channels at once — email, SMS, push, and webhook — with the webhook channel carrying Slack, Discord, Teams, and anything else that accepts a POST.
A notification is rarely one message. It's a wait, a question, and a nudge only if the answer never came — a day of logic, 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. No publish button. Just 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 — 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.
Need to inspect instead of ask your agent? A local dashboard is included — read-only by design. Take a look →
notify().Six background jobs, three webhook handlers, a queue, and a graveyard of edge cases — that's the notification plumbing one line replaces.
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 dies mid-send and another reclaims the message. Poison payloads land in a dead-letter queue you can replay when you're ready.
Quiet hours, channel opt-outs, topic preferences and the suppression list are all checked before delivery. Your call site never has to remember any of it.
Push failed? It tries email. Provider down? It fails over underneath the channel. You give the order; the ugly part is handled.
wait("3d") means three days — not three days if your worker happens to stay alive.
Completed steps never run twice on replay.
A timeout doesn't earn anyone a second copy. Sends are deduplicated for 24 hours and finished steps are recorded, so a retry never repeats work that already happened.
Tomorrow, next week, or whenever an event lands. No cron job, no delayed-message table, no background worker to babysit.
Start simple. One call delivers the order confirmation across email and push with itemized details and tracking — zero queues to configure, zero 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 disables app alerts or has no data. Try push first; fallback to SMS automatically so critical delivery updates always land.
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 never 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" })
A notification is rarely one isolated ping. Wait 45 minutes, suspend execution until
order.placed arrives, and send a dynamic 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 }); });
Full durable revenue defense. 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"] }); } });
Your notification system lives where your code lives. Nothing to leave your editor for — not a template, not a workflow, not checking what happened.
Stay in your editor. Stay in Git. Ship notifications like everything else you ship.
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 this on my own company which delivers lot of notifs daily. (100K+/day)