Build Wabery
KINDAreplaces $15/mosaves $180/yrback to the verdict
A gateway for one verified business number whose hard problems are inbound notifications that repeat, batch and arrive out of order, a messaging window that decides what you are allowed to send at all, and staying a single tenant because onboarding other businesses is an approval process rather than a feature.
Before step 1
Everything below is assumed from the first step. Tick each one when you actually have it, not when you plan to.
- installfree
Why Runs the gateway and the worker.
Get it nodejs.org or your version manager. open ↗
Verify
node --version prints v22 or higher - installfree locally; a few pounds a month managed
Why Holds the raw events, the messages and the job table, which need to commit together.
Get it Your package manager, or a small managed instance.
Verify
psql connects and the migrations apply cleanly - accountthe platform charges per conversation; check the current published rates before you plan volume
Why There is no sandbox that behaves like production here, and business verification is the gate that decides whether this project is possible at all.
Get it Create the application, attach a business account, add and verify a number, and subscribe the webhook. open ↗
Verify
A test message from your own phone reaches the webhook endpoint - have readya few pounds a month
Why The webhook subscription will not verify without one, and a tunnel that dies overnight looks exactly like an outage.
Get it A small always-on server with a reverse proxy, or a tunnel you accept will need restarting.
Verify
The subscription verification handshake succeeds from the platform console - decidefree
Why Every design choice below assumes a single tenant, and discovering otherwise after launch means either a rewrite or holding other people's data without the approvals.
Get it Write down the single phone number identifier this deployment serves and put it in the README.
Verify
The boot check refuses to start with any other configuration
Data model
Create these before the first phase that stores anything. Changing a table later is the expensive kind of change.
- events_raw(id, received_at, signature_ok, headers_json, body_json, processed_at) · written before anything is parsed, because the body is the only evidence you will ever have - messages(id, wa_message_id, direction, conversation_id, peer_wa_id, type, body_json, occurred_at) · wa_message_id is unique, because the same message arrives more than once - message_states(message_id, state, at, raw_json) · one row per status notification, never an update, because statuses arrive out of order - conversations(id, peer_wa_id, last_inbound_at, window_expires_at, opt_in_at, opt_out_at) · window_expires_at decides whether a free-form reply is permitted - send_requests(idempotency_key, conversation_id, payload_hash, message_id, state, created_at) · the key is the primary key and a repeat returns the first result - deliveries(id, endpoint, event_id, attempt, status_code, response_excerpt, next_attempt_at) · your own outbound webhook, with every attempt kept - templates(name, language, status, body_hash, last_checked_at) · approval state belongs to Meta and drifts underneath you - Nothing here has a single mutable status column. Delivery state is a list of observations with times attached, and the current state is a function over that list.
Environment variables
These go in a .env file the app reads at startup. The pack's .env.example is this table as a file · copy it, never commit the filled-in version.
| Variable | Needed | Example | Where the value comes from |
|---|---|---|---|
WA_ACCESS_TOKENsecret | required | EAAG...redacted | The Cloud API access token. Rotate it on a schedule and never log it. |
WA_APP_SECRETsecret | required | redacted-32-hex | Used to verify the payload signature on every inbound notification. |
WA_VERIFY_TOKENsecret | required | a-long-random-string | Echoed during the subscription verification handshake only. |
WA_PHONE_NUMBER_ID | required | 123456789012345 | The single number this deployment serves. The boot check refuses a list. |
DATABASE_URLsecret | required | postgres://gateway@localhost/wabery | Messages, raw events and jobs. This is the backup target. |
FORWARD_URL | optional | https://internal.example.com/hooks/whatsapp | Where normalised events are forwarded. Leave unset to keep everything local. |
FORWARD_SECRETsecret | optional | redacted | Signs forwarded bodies so the receiver can verify them. |
ADMIN_TOKENsecret | required | redacted | The only credential for the admin interface. There are no user accounts. |
The build, in order
Receive before you interpret
Every notification is signature-checked, stored raw and acknowledged before any of it is parsed.
Constant-time comparison, and a failed check is stored rather than discarded.
Files
src/routes/webhook.tssrc/lib/signature.tstests/signature.test.tsThe body is the only evidence; parsing it is a separate concern.
Files
src/routes/webhook.tsmigrations/001_events_raw.sqlOne notification can carry many updates and the same update twice.
Files
src/worker/ingest.tstests/ingest-dedupe.test.ts
done when · tick each as it passeswatch out- Parsing the payload before acknowledging means one slow query or a bad migration turns into Meta retrying the same notification immediately and then repeatedly with decreasing frequency over the next thirty six hours, which is how one inbound message becomes forty rows and four outbound replies. Store the body, acknowledge, and deduplicate on the platform message identifier in the worker.
One send, however many times you ask
A retried send request never puts a second message on somebody's phone.
Allocated before the outbound call, not after the response.
Files
src/send/idempotency.tsmigrations/002_send_requests.sqlA timeout is not a failure and retrying one is how people get double messages.
Files
src/send/client.tstests/timeout-unknown.test.tsThe platform will tell you what happened if you keep the key.
Files
src/worker/reconcile.ts
done when · tick each as it passeswatch out- The naive gateway forwards the send, gets a socket timeout, and returns an error, so the caller retries and the customer receives the same message twice while your database records one failure and one success. Allocate the idempotency row before the outbound call, mark it in flight, and treat a timeout as unknown rather than as failed, reconciling it from the status notification instead of by guessing.
The window decides what you are allowed to say
Free-form replies are only attempted inside the customer service window, and outside it the gateway uses an approved template or refuses.
The clock starts when they wrote to you.
Files
src/worker/ingest.tssrc/lib/window.tsA queued job runs later than it was written, sometimes much later.
Files
src/send/guard.tstests/window-boundary.test.tsA retry loop against a closed window is an outage you inflicted on yourself.
Files
src/send/errors.ts
done when · tick each as it passeswatch out- Computing the window when the job is queued rather than when it runs means a reply scheduled at the edge of the window runs two minutes after it closes, gets rejected by the platform, and your retry loop then spends the next hour rejecting the same message while an operator watches a queue that never drains. Evaluate the window in the send path, and treat a window-closed rejection as terminal rather than retryable.
Statuses that arrive in the wrong order
Delivery state is assembled from observations rather than overwritten by whichever notification landed last.
Order is not guaranteed, so the last write is not the truth.
Files
src/worker/status.tsmigrations/003_message_states.sqlOne pure function, tested with shuffled inputs.
Files
src/lib/state.tstests/state-order.test.tsIncluding the failures, which are the ones you will be asked about.
Files
src/forward/deliver.tstests/forward-retry.test.ts
done when · tick each as it passeswatch out- A single status column updated in place will show a message as sent again after it was already read, because the platform does not guarantee that the order messages are delivered in matches the order you sent them and status notifications are no better behaved. Keep the observations and compute the state, so a late arrival changes nothing it should not.
One tenant, one token, and a way outproduct builder
The gateway is operable by one person: secrets rotate, history exports, and the single-tenant boundary is enforced rather than assumed.
So the second tenant is a decision and not an accident.
Files
src/config.tstests/single-tenant.test.tsOtherwise rotation means dropping notifications.
Files
src/lib/signature.tsdocs/rotation.mdA backup nobody has restored is a rumour about a backup.
Files
scripts/restore-check.shdocs/operations.md
done when · tick each as it passeswatch out- A gateway built for one business grows a customers table the week somebody asks, and now you are holding other people's conversations under an approval status you do not have and a security boundary you never designed. Keep the single-number assumption in the schema and in a startup check, so adding a second tenant is a deliberate rewrite rather than an afternoon.
That is the whole plan for Wabery. What it deliberately does not cover is below · check the gaps before you call it a replacement.
- Onboarding other businesses or embedded signup.
- Unofficial bridges to the consumer client.
- Marketing broadcasts to numbers that never wrote to you.
- Flows, hosted functions and a team inbox.
- Meta Business Partner status and approved Tech Provider onboarding path
- turnkey onboarding for multiple customer businesses and phone numbers
- managed queues, retries, monitoring, security, and production reliability
- WhatsApp Flows builder, encryption, and data-exchange hosting
- hosted functions, MCP tooling, and a production team inbox
- A conversation search over stored bodies, which is cheap once the raw events are already there
- A template drift check that compares stored approval state against the platform daily
- A replay tool that rebuilds the whole messages table from events_raw, which is the real test of the ingest code
Need the files? The project pack on the verdict page hands your agent the whole brief · more dev tools.