Self-hosted notification infrastructure

Notifications.
Made dead simple.

One notify() call. Everything under it, handled.

Wait for an event. Try again tomorrow. Stop if they already converted. It's all built in.

$ npm install notifkit

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.

Simple when you need a notification.
Powerful when you need a system.

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.

workflows/cart-nudge.ts
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.

edit review merge ship

Your terminal
is the dashboard.

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.

Claude ChatGPT Gemini Cursor OpenCode
you
Create an activation workflow: wait 2 days, check activation, then push with email fallback if they're still inactive.
Write workflows/activation.ts
claude
Wrote 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.
later that day
you
Send the spring-sale template to these 200 customers. alice@…, bern@…, cara@… +197 more
send_campaign campaign: "spring-sale" · 200 recipients
claude
Queued 197 of the 200; three were duplicates. Tagged spring-sale.
the next morning
you
How did it do?
get_campaign_stats "spring-sale"
claude
194 of 197 delivered, 98%. 71 opened 36% 12 clicked 6% 4 bounced 3 unsubscribed
The 4 bounces and 3 unsubscribes are suppressed.

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

The plumbing
you don't write.

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.

retries

Messages that don't get lost

A worker is killed mid-send; another worker reclaims the message 47 seconds later and delivers it.

redis streams · consumer groups · dlq · replay
preferences

Preferences nobody can bypass

A push lands inside the user’s quiet hours, so it is deferred to 08:00 in their own timezone instead of dropped.

preferences · quiet hours · suppression
fallback

Provider failure, no failover code

Push fails on an expired token, so the chain rolls over to email, which delivers in 142 milliseconds.

ordered fallback · circuit breakers
workflows

Waits that survive reality

A three-day wait suspends the workflow; it resumes on the correct step after two deploys.

durable waits · event suspension · replay-safe
idempotency

Retries without duplicate sends

Three notify calls carrying the same idempotency key result in exactly one delivery.

24h dedupe · replay safety
scheduling

Now, or Tuesday at 09:00

A message scheduled for Tuesday at 09:00 is parked, then cancelled at 08:12 and never sent.

sendAt · quiet-hours deferral · cancel before dispatch

Hooray! Order placed & receipt dispatched

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.

order-confirmation.ts
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"]
})

Fallback to SMS when push fails to deliver

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.

delivery-dispatch.ts
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
})

Nudge onboarding activation in their working hours

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.

onboarding-nudge.ts
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"
})

Blast promotional drops without slowing transactional pipes

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.

vip-campaign.ts
await notify({
  segment: "subscribers-tier-pro",
  template: "vip-early-access",
  data: { promoCode: "PRO20", expiresHours: 48 },
  channels: ["email", "push"],
  priority: "low",
  campaign: "q3-pro-launch"
})

Recover abandoned carts, cancelled the second they buy

Wait 45 minutes, suspend execution until order.placed arrives, and send a discount only if the cart is still abandoned.

workflows/cart-recovery.ts
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
  });
});

Recover failed subscription revenue across a 5-day escalation

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.

workflows/recover-mrr.ts
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 escalate

No console to learn.
No tab to switch to.

Your notification system lives where your code lives. Templates, workflows, and checking what happened all stay in your editor.

# change a subject line - open dashboard → find project → templates → edit → publish + edit the file → open a PR # know who changed it - ask whoever still has access + git log # undo it - work out what changed, and where + git revert # keep staging and production aligned - "pretty sure they're in sync" + same code → same deploy

Your IDE is the console, and your agent is the operator.

NotifKit
$1,990/mo
vs
Novu
$2,225/mo
250,000 / mo
10k 50k 250k 1M 3M 5M
Delivery provider
Twilio SMS: ~$1,975/mo (direct carrier pass-through)

That's what you pay me.

It runs on your servers. No plans, no seats, no per-message fee.

Are you stuck or want my help? Get in touch.

Works with whatever you already use

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.

request.sh
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"]
}'

Questions, answered.

What happens if a worker dies halfway through a send? +
The message is not lost. Messages are persisted through Redis Streams and reclaimed by another worker in the consumer group. Anything that cannot be processed lands in a dead-letter queue you can inspect and replay.
Does it guarantee exactly-once delivery? +
No, and be wary of anything that claims to. notifkit deduplicates notifications over a 24-hour window and persists completed workflow steps, so a retry or a replay does not blindly resend work that already finished. Delivery itself is still subject to the semantics of the provider you send through. No system can honestly promise exactly-once delivery across an arbitrary provider boundary.
Can I target a cohort, or send to thousands of users? +
Yes. Address a segment or a topic and the fan-out happens server-side, on its own priority lane. Your application and data layer own the logic that decides who belongs in a segment; notifkit owns getting the message to them.
Do I bring my own providers? +
Yes. Your own accounts and your own keys, so billing and deliverability stay yours. Resend, Firebase Cloud Messaging, Twilio, Slack, WhatsApp (Meta Cloud API), Telegram, and Discord have first-party transports; any other provider implements the same small Transport interface, which is one send() method.
What am I signing up to run? +
Node 22 or newer, a PostgreSQL database and a Redis instance. In development notifkit can start throwaway Postgres and Redis containers for you, so Docker is the only prerequisite to try it. In production you run the same build you develop with.
What if my app isn't JavaScript? +
Every endpoint is plain HTTP, so any stack that can POST JSON works with no SDK at all. TypeScript gets a typed client over the same routes if you want one.

Send your first notification.

Install it, start the server, create a project, send. The quickstart walks the whole path in about ten minutes.

Questions, bugs, or ideas: mail me.

I run notifkit at my own company, which delivers over 100,000 notifications a day.