Plenty of my projects need to send a WhatsApp message at some point: an internal alert, a transactional notice. The easy route is to pay a third-party provider and forget about it. The catch is that your messages, your data, and your reliability then live in someone else's system, and you pay per message forever.
So I built my own. This is a post about how it is put together and why I made the choices I did. A word of honesty up front: it rides on Baileys, an open-source, community-maintained WhatsApp client. It is not an official Meta product and I would never describe it as one. Baileys is an unofficial, reverse-engineered client rather than an approved integration, and using it sits outside WhatsApp's Business Terms, which is precisely why it sits behind personal projects here and not behind anything customer-facing. It is a tool I built myself on open components, and the interesting part is not the idea, it is the engineering around it.
Two halves that do one job
The system is deliberately split in two.
The gateway is the front door. It runs as a container on fly.io in London, it is always reachable, and it is what the rest of my software talks to. It validates requests, persists messages, runs the admin dashboard, and enforces every security rule. It does not talk to WhatsApp directly.
The worker is the engine room. It is the only part that holds the live WhatsApp connection, and it runs as a second fly.io machine in the same region, with a persistent volume for its session state. It receives commands, executes them, and reports back.
It did not start there. For the first few months the worker was a signed binary on a MacBook Air at a fixed address, running under launchd, and the original version of this post made a virtue of that: the sensitive half lived on my own hardware rather than the public internet. The argument was sound and the uptime was not. A machine at a desk inherits every power cut, every router reboot and every time somebody needs the desk. None of those are interesting failures and all of them are outages, and once other things depended on the gateway the honest answer was that my flat is not a datacentre. So the worker moved to a machine that does nothing else.
Why split it at all? Because the two jobs have opposite needs. The front door is ordinary web work: it scales with requests, it can be restarted at will, and any instance can serve any caller. The connection is the opposite. Baileys holds one long-lived WebSocket that WhatsApp treats as a device, and it must be exactly one, from a machine that keeps its session files across restarts. Those are not variations on a theme, they are different kinds of program, and forcing them onto one box compromises both. It is also why neither half sits on a Workers-style serverless runtime: a socket that must stay open for weeks is precisely what that model is built not to do. Both machines have auto-stop disabled for the same reason.
The two halves never hold an open connection to each other, and that is deliberate. Commands travel from the gateway to the worker through a managed Redis instance: the gateway drops a signed command onto a queue, and the worker, which is listening, picks it up. Replies and incoming events travel back the other way, over ordinary outbound HTTPS requests that the worker makes to the gateway. The asymmetry is the point. Every connection the worker makes is outbound; it dials Redis and the gateway, and nothing on the public internet can dial it. The only port it opens at all is bound to the local machine, for a small admin screen, and is unreachable from the network. There is no public door to knock on.
The worker looks after itself
A connection that needs babysitting is not a system, it is a chore. The worker is built so that nobody has to remember it exists.
It ships as a container and Fly restarts it if it exits. Session credentials are encrypted at rest on a persistent volume that survives redeploys, and the key, like every other secret, is set through fly secrets rather than living in the repository, so a leaked image is not a leaked account. When the socket drops, it reconnects on its own; when configuration changes on the gateway, it picks that up on a short polling loop without needing a redeploy. The design goal was simple: it should survive a reboot, a crash, and a bad network without a human in the loop.
Making a send actually mean something
This is the part I am most pleased with.
Most messaging code is fire-and-forget. You hand a message to the provider, you get back "accepted", and you move on. But "accepted into a queue" and "delivered" are two different claims, and the gap between them is where the confusing bugs live. I wanted a caller to be able to ask for a real answer: did this message actually go out, yes or no?
That turns out to be harder than it sounds, because the answer has to travel back across every layer: from Baileys on the worker machine, up through the worker process, onto the queue, back to the gateway, and out to the original caller, all while an HTTP request is still open and waiting. The flow now works like this:
- A caller posts a message and asks for confirmation.
- The gateway validates everything, writes the message to the database as queued, and subscribes to the reply channel before it publishes the command. This ordering matters more than it looks: the worker can answer in milliseconds, and if the gateway published first and subscribed second, it could miss its own reply.
- The worker receives the command, sends it through Baileys, and publishes back a small acknowledgement: sent, with the provider's message id, or failed, with a coded reason.
- The gateway's open subscription resolves, and it returns a real status to the caller.
The ordering in step two is the whole game, and it is a bug you tend to meet exactly once. Redis pub/sub has no memory: a message published to a channel with no live subscriber is not queued for later, it is simply gone. The worker often acknowledges within a few milliseconds of receiving the command, which is faster than the round trip it takes the gateway's own subscribe call to register on the server. So the naive order, publish the command and then start listening for the reply, loses the race more often than you would believe. The acknowledgement fires into a channel nobody is listening on yet, vanishes, and the caller waits out the entire timeout for a message that in fact sent perfectly. The fix is to subscribe first, and to wait for the subscription to be confirmed ready, before publishing a single byte of the command. Only once the ear is definitely open does the gateway speak.
If the acknowledgement does not arrive in time, the request does not hang or lie. It returns queued and lets the caller decide whether to poll or fall back. The honest answer, including "I do not know yet", is always better than a hopeful guess.
There is a subtle case worth mentioning. If an identical confirmed request arrives while the first is still in flight, a unique key in the database catches the duplicate before it can be published a second time; the second request finds the original and waits on the same acknowledgement rather than sending again. Idempotency has to be designed in from the first line; it is not something you bolt on once the bugs show up.
One contract, obeyed by both sides
Split systems drift. The two halves quietly start disagreeing about what a valid message looks like, and you get bugs that only appear in the seam between them.
The defence is a single shared package that defines every contract: the database shapes, the wire protocol between gateway and worker, the public API, and the full list of error codes. Both halves import the same definitions and validate against the same schemas, so a message that is valid on one side is valid on the other by construction. When the protocol changes, the schema and both consumers change in the same commit. There is one source of truth, and it is enforced in code, not in a document nobody reads.
Treating everything as hostile
Security was not bolted on afterwards; it shaped the structure.
Every input is validated at every trust boundary before it is trusted, whether it arrives over HTTP or off the queue. Messages between the gateway and the worker are signed, and each carries a one-time nonce and a timestamp, so a captured message cannot be replayed later. API access is granted through scoped keys (a key that may send notifications cannot necessarily send anything else), and traffic is rate-limited at several levels so no single caller can swamp the system. Sensitive actions are written to an append-only, hash-chained audit log, so the record of what happened cannot be quietly edited. Every webhook destination the gateway delivers to is checked before it connects, so it cannot be pointed at an internal address: private, loopback, and link-local ranges are rejected, and the connection is pinned to the address it already resolved. Logs are structured, and the gateway redacts sensitive fields before anything is written, so credentials and message contents are kept out of them. The working assumption throughout is that anything from outside is hostile until proven otherwise.
There is one deliberate exception to the signing, and the reasoning behind it matters more than the rule itself. The acknowledgements the worker fires back to confirm a send are not signed. At first glance that looks like a hole. It is not. The channel each one travels on is named after a freshly minted, server-side identifier that an attacker cannot guess, the gateway only ever listens on channels it created moments earlier, and the worst a forged acknowledgement could achieve is to flip the reported status of a single message already in flight, never to cause a send. Signing it would add a cryptographic round trip to the hottest path in the system, to defend against an attack the channel design already rules out. Knowing where not to spend a security primitive is as much the job as knowing where to.
The split earns its keep here too. The half exposed to the internet, the gateway, holds no WhatsApp session and cannot send anything by itself; it can only ask the worker to. The half that holds the crown jewels, the live session that is effectively the account itself, is the half nothing outside can reach. Force the front door and you find a machine that can queue requests but holds no credentials and no connection. The valuable thing and the reachable thing are, by design, never the same thing.
What you gain, and what it costs
Running your own gateway gives you control, privacy, and the ability to make it as reliable as you are willing to engineer it. None of that is free. You own the connection, the uptime, the security, and the maintenance, and there is no support line to call when something breaks at midnight. For most teams, paying a provider is the right call.
For me it was worth it, not because the idea is rare, but because the execution is where the real work lives: the split that lets each half do its job, the worker that heals itself, the send that waits for a real answer, and the single contract that keeps both sides honest. It took roughly 125 commits between April and June 2026 to get there, and almost none of that work shows in the description above. That is rather the point. Build the boring parts properly and the clever parts take care of themselves.