Build Wabery

KINDAreplaces $15/mosaves $180/yrback to the verdict

0%0 of 17 items done

Saved on this device only. Tick prerequisites first, then work the phases in order · do not start one until the checks above it pass.

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.

estimated effort multi-daythe files for this build are in the project pack

RuntimeNode 22 with Fastify behind a reverse proxy that holds the certificateStoragePostgreSQL, one schema, migrations checked into the repositoryQueueA job table in the same PostgreSQL, claimed with a conditional updateInboundPersist the raw body, acknowledge, then interpret from a workerOutboundOne send path that requires a caller-supplied idempotency keyTenancyOne business account, one phone number, one admin token, no signup

Before step 1

Everything below is assumed from the first step. Tick each one when you actually have it, not when you plan to.

  1. installfree

    Why Runs the gateway and the worker.

    Get it nodejs.org or your version manager. open ↗

    Verify node --version prints v22 or higher

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

  3. the 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

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

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

VariableNeededExampleWhere the value comes from
WA_ACCESS_TOKENsecretrequiredEAAG...redactedThe Cloud API access token. Rotate it on a schedule and never log it.
WA_APP_SECRETsecretrequiredredacted-32-hexUsed to verify the payload signature on every inbound notification.
WA_VERIFY_TOKENsecretrequireda-long-random-stringEchoed during the subscription verification handshake only.
WA_PHONE_NUMBER_IDrequired123456789012345The single number this deployment serves. The boot check refuses a list.
DATABASE_URLsecretrequiredpostgres://gateway@localhost/waberyMessages, raw events and jobs. This is the backup target.
FORWARD_URLoptionalhttps://internal.example.com/hooks/whatsappWhere normalised events are forwarded. Leave unset to keep everything local.
FORWARD_SECRETsecretoptionalredactedSigns forwarded bodies so the receiver can verify them.
ADMIN_TOKENsecretrequiredredactedThe only credential for the admin interface. There are no user accounts.

The build, in order

  1. Receive before you interpret

    Every notification is signature-checked, stored raw and acknowledged before any of it is parsed.

    1. Constant-time comparison, and a failed check is stored rather than discarded.

      Files src/routes/webhook.tssrc/lib/signature.tstests/signature.test.ts

    2. The body is the only evidence; parsing it is a separate concern.

      Files src/routes/webhook.tsmigrations/001_events_raw.sql

    3. One 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 passes
    watch 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.
  2. One send, however many times you ask

    A retried send request never puts a second message on somebody's phone.

    1. Allocated before the outbound call, not after the response.

      Files src/send/idempotency.tsmigrations/002_send_requests.sql

    2. A timeout is not a failure and retrying one is how people get double messages.

      Files src/send/client.tstests/timeout-unknown.test.ts

    3. The platform will tell you what happened if you keep the key.

      Files src/worker/reconcile.ts

    done when · tick each as it passes
    watch 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.
  3. 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.

    1. The clock starts when they wrote to you.

      Files src/worker/ingest.tssrc/lib/window.ts

    2. A queued job runs later than it was written, sometimes much later.

      Files src/send/guard.tstests/window-boundary.test.ts

    3. A retry loop against a closed window is an outage you inflicted on yourself.

      Files src/send/errors.ts

    done when · tick each as it passes
    watch 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.
  4. Statuses that arrive in the wrong order

    Delivery state is assembled from observations rather than overwritten by whichever notification landed last.

    1. Order is not guaranteed, so the last write is not the truth.

      Files src/worker/status.tsmigrations/003_message_states.sql

    2. One pure function, tested with shuffled inputs.

      Files src/lib/state.tstests/state-order.test.ts

    3. Including 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 passes
    watch 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.
what this build does not replace
after v1, if you want it

Need the files? The project pack on the verdict page hands your agent the whole brief · more dev tools.