Open source · MIT License · Self-hosted

Notifications.
Made dead simple.

One notify() call handles push, email, SMS, fallback, preferences and retries. We send 260k+ notifications every day in our own company.

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, the AI nudge line being written into the template, 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.

# 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

No canvas and no publish button. Templates and workflows are files in your repo, so any agent can operate them.

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

Order confirmation on email and push

One call sends the receipt to both channels, with the items and a link to it. No queue to configure, no worker to babysit.

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 a stalled trial in their morning, not at 3 AM

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.

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

Marketing sends that never delay a receipt

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.

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

Login codes that arrive at 2 AM, on purpose

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.

login-code.ts
await notify({
  user: "usr_9142",
  template: "login-code",
  data: { code: "480913", expiresMinutes: 5 },
  channels: ["sms", "email"],
  fallback: true,
  priority: "critical"
})

A failed payment that escalates for five days

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.

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: ["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 → escalate

No per-message fees. Your SaaS bill doesn't grow with your users.

MIT 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.

Benchmarks and write-ups

How notifkit performs, what it costs, and how it compares.

All articles →

We Tried to Send 500M Notifications a Month on a $25 Server

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.

Read the article Detailed comparison
200/sheld for 15 minutes on 2 vCPUs, with failures and retries
400/spassed a 1-minute step, fell behind over 15 minutes
$133a month all in at 100M, from AWS list prices

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

The questions you'd ask before running it.

Is there a hosted version? +
No. You run it: Node 22 or newer, a PostgreSQL database and a Redis instance. To try it, Docker is the only prerequisite, since development mode starts throwaway Postgres and Redis containers for you. Production runs the same build. For what one small server handles, see the benchmark.
What happens when one server isn't enough? +
Split it up. The API, engine, delivery, workflow and scheduler services all ship in one package, and each process starts whichever of them you list. They are stateless and coordinate through Redis, so you scale delivery by running more delivery workers. The deployment guide covers the multi-replica setup.
What if a worker dies mid-send, or Redis restarts? +
A dead worker's messages are reclaimed by another worker in the consumer group, and anything that keeps failing lands in a dead-letter queue you can inspect and replay. Queued messages live in Redis, so turn on AOF persistence (without it, a Redis restart loses whatever hadn't been delivered yet) and size QUEUE_MAX_LEN above your worst backlog, since the oldest queued messages past it are trimmed.
Does it guarantee exactly-once delivery? +
No, and be wary of anything that claims to. notifkit deduplicates sends over a 24-hour window and records finished workflow steps, so a retry doesn't redo work that already happened. Past that, delivery is up to the provider you send through.
Can someone who doesn't write code edit a template? +
Not inside notifkit. Templates are files in your repo, and a change goes through a pull request like any other. That's the point for engineering teams, and a dealbreaker if marketing needs a visual editor. In that case Courier or Knock will suit you better.
Is there an in-app notification feed? +
No. There's no drop-in inbox widget or realtime feed. If you want notifications inside your app, register a small transport that writes them to your own database, and render them yourself.
Do I bring my own providers? +
Yes: your accounts and your keys, so billing and deliverability stay yours. Resend, Firebase Cloud Messaging, Twilio, Slack, WhatsApp, Telegram and Discord have first-party transports. Anything else implements the Transport interface, which is one send() method.
Why not Novu, Knock or Courier? +
Knock and Courier are hosted and charge per message. Novu can be self-hosted, but it needs MongoDB and several services alongside a web portal. notifkit runs on the Postgres and Redis you probably already have, with workflows in your code. Side-by-side comparisons: Novu, Knock, Courier.

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: send an email.

Or open an issue on GitHub.

→ channel email