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.

One call.
Every channel.

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.

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

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.

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. No publish button. Just 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 — 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.

Need to inspect instead of ask your agent? A local dashboard is included — read-only by design. Take a look →

You write notify().
Everything after it is handled.

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.

retries

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.

redis streams · consumer groups · dlq · replay
preferences

Preferences nobody can accidentally bypass

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.

preferences · quiet hours · suppression
fallback

Provider failure, no failover code

Push failed? It tries email. Provider down? It fails over underneath the channel. You give the order; the ugly part is handled.

ordered fallback · circuit breakers
workflows

Waits that survive reality

wait("3d") means three days — not three days if your worker happens to stay alive. Completed steps never run twice on replay.

durable waits · event suspension · replay-safe
idempotency

Retries without duplicate sends

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.

24h dedupe · replay safety
scheduling

Send now or send later. Same call.

Tomorrow, next week, or whenever an event lands. No cron job, no delayed-message table, no background worker to babysit.

sendAt · quiet-hours deferral · cancel before dispatch

Hooray! Order placed & receipt dispatched

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.

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 disables app alerts or has no data. Try push first; fallback to SMS automatically so critical delivery updates always land.

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 never 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

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.

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

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.

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"]
    });
  }
});

No console to learn.
No tab to switch to.

Your notification system lives where your code lives. Nothing to leave your editor for — not a template, not a workflow, not checking what happened.

# 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

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.

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.

Is this another notification SaaS? +
No. NotifKit runs on your infrastructure — your Postgres, your Redis, your provider accounts, your deployment. There is no hosted console to manage and no per-message tax.
Do I need to learn another dashboard? +
No. Workflows, templates and configuration live in code: reviewed in a PR, deployed with your app, rolled back with Git. If you want a visual view of what is happening, an optional local dashboard ships with the project — but it is not the control plane.
Can my agent operate it? +
Yes. The MCP server exposes the same system through agent tools. Your agent can create and trigger workflows, send notifications and campaigns, inspect runs and delivery, and replay failures. It is a thin client over the REST API, so whatever your key can do, it can do.
What does NotifKit replace? +
The notification plumbing you would otherwise build yourself: queues, retries, preference checks, quiet hours, deduplication, provider fallback, scheduling, durable waits, delivery history and recovery. It does not replace your product database, your analytics or CDP, or a marketing automation platform.
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 and Firebase Cloud Messaging 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 this on my own company which delivers lot of notifs daily. (100K+/day)