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.
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.
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.
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.
How notifkit performs, what it costs, and how it compares.
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.Install it, start the server, create a project, send. The quickstart walks the whole path in about ten minutes.