
Why we built our own notification engine
On October 1st Meta starts charging for service messages. How we modelled the cost, and why we ended up writing the channel ourselves.
Some architecture decisions get made while reading a pricing changelog.
On October 1st, 2026, Meta starts charging for WhatsApp service messages: every free-form reply the bot sends inside the 24-hour window, which costs nothing today. In Argentina the expected rate is USD 0.0260 per delivered message and — this is the part that matters — with no volume tiers. The millionth message costs exactly what the first one costs.
This post is how we modelled that change and what we built in response. It isn't a post about leaving WhatsApp: nobody in events left WhatsApp. It's about no longer depending on a single channel for something the product does thousands of times a day.
What it costs to send a notice
Circli's highest-volume send is the talk reminder: it goes to the event roster, for every talk that's been ticked, fifteen minutes ahead. It's also the one with the most impact, because it's the one that fills the rooms.
At the new rate, a small event — the kind we run often, around 190 registrants with a dozen messages each — costs roughly USD 49 in notices. A large one, ten thousand people, lands between USD 2,500 and 9,900 depending on how many people switch notices on.
For scale: the same notices over SMS would cost about USD 195; over email, under USD 2; over Web Push, zero.
But the number that decided the architecture is none of those. It's this one: the cost isn't existential, the absence of a cap is. Forty-nine dollars for an event doesn't change the business. What does change it is having no way to know, when an organizer ticks twelve talks at a ten-thousand-person event, that they have just signed a four-figure invoice — and finding out at the end of the month.
Why write it instead of buying it
The obvious answer was to hire a push provider. There are several, and several are free.
We chose to write it, and the reason fits in one sentence: the per-message cost of Web Push is zero with any provider; what changes is who holds the subscriptions and who can cut us off. We had arrived here precisely by depending on a platform that changed its price one day. Replacing that dependency with another one, in the very layer built to avoid it, would have fixed nothing.
Web Push is an open standard, which means you can implement all of it. We sign every send with our own keys (VAPID, RFC 8292) and encrypt the notice end to end (RFC 8291), so the browser's push service delivers a blob it cannot read. There is no intermediary billing us, and no intermediary who knows what the notice says.
It isn't a parallel channel: it's a ladder
This is the part that took the longest and taught us the most.
A notice is announced once, on the best available channel. If the person switched browser notices on, it goes there. If they have no subscribed device, it falls back to WhatsApp.
The tempting way to write that is with a boolean: did the push land? If not, send WhatsApp. And that's the bug, because "it didn't land" covers two situations that have nothing in common:
- They were already told. The reminder went out a while ago and the lock says this person, for this talk, is already announced.
- The send failed. The browser's push service had an outage, or the device stopped existing.
With a boolean those two read identically, and the result is the same talk being announced twice on two channels — the exact bug we had already hit with direct-message notices.
So the send returns three states, not two: sent, suppressed, and no subscription. And only "no subscription" falls back to WhatsApp. The line between the last two isn't "was I supposed to tell them?" but "could anything have arrived?". A five-minute outage of a push service cannot cost somebody the reminder for a talk starting in fifteen minutes: that falls down the ladder and goes out anyway.

Turning the channel on, then, never leaves anyone without a notice — it only moves the channel for the people who asked. Turning it off doesn't either: everyone goes back the way they came.
The lock lives in the database, not in memory
A detail that looks like plumbing and isn't.
The first version kept track of who had already been told in memory. It worked perfectly in the lab and never worked in production: every cron run is a fresh, cold container, so that record always started empty. Deduplication simply did not exist — and nothing failed, which is the worst thing a bug like that can do.
Today the lock is a row in Postgres, with a uniqueness constraint on event, notice kind, dedupe key and person. It's claimed before the send, not after. If two runs overlap, exactly one wins the row and the other sees that it's already announced.
Two gates, one per channel
The reminder now goes out two ways and only one of them passes through Meta. That forces the kill switch to be double.
With a single gate, a reputation problem on the WhatsApp number — which shuts itself off, and rightly so — would also silence the channel that never touches Meta. Which is to say: the failure mode Web Push exists to avoid would be the one that switches it off. Each channel has its own gate, and they close independently.
What the browser already knew how to do
We tested it on a real iPhone, on iOS 26. The site added to the home screen receives notices with the app closed and the screen locked — added from Safari and from Chrome alike, and with no manifest at all. On Android you don't even need that: it works in an ordinary tab.
It's worth being precise about what this is and isn't. There is no Circli app, and we are not building one. Nothing to download, no app store, no pending update. All there is is a web address and a permission the person grants or doesn't. The worker running in the background caches nothing and intercepts not a single navigation: it draws notices and opens links. That is all it does, deliberately, because everything else it could do is surface that breaks.
What isn't there yet
The first thing we still owe isn't a new channel: it's a spend cap for the path that stops being free on October 1st, per event and per person, with the same claim-before-send the templates already use. Until that exists, the only real limit is a time window, and a window is not a budget.
Which is, again, the same idea: the problem was never the price. It was not having the number in front of you before you spent it.
Do you run events and care that notices arrive without anyone installing anything? Discover Circli Events or reach us at events.circli.app.