Vibecode Wabery
track this build4 phases, 12 steps, beginner friendly0%The core loop (send, receive, verify webhooks, retry, inspect conversations) is one-shottable for a single business using Meta's WhatsApp Cloud API. What you cannot one-shot is the reason managed WhatsApp providers exist as products: production messaging infrastructure must stay reliable, secure, monitored, and compliant, while onboarding other businesses requires Meta business verification, app review, and approval for the relevant Tech Provider and Meta Business Partner capabilities. The honest DIY move is to build for your own verified WhatsApp Business Account and phone number; matching Wabery as a multi-tenant provider means operating that infrastructure and completing Meta's approval process.
You are building a lean indie version of Wabery. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== README.md ===== # Wabery · indie build 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-day**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node 22 with Fastify behind a reverse proxy that holds the certificate | the webhook endpoint has to be publicly reachable and very fast to acknowledge | | Storage | PostgreSQL, one schema, migrations checked into the repository | message history is what you will be asked to produce during an argument with a customer | | Queue | A job table in the same PostgreSQL, claimed with a conditional update | one fewer service to lose, and the jobs need the same transaction as the rows they create | | Inbound | Persist the raw body, acknowledge, then interpret from a worker | Meta retries a failed notification for well over a day and you cannot outrun that with fast parsing | | Outbound | One send path that requires a caller-supplied idempotency key | a retried send is a second message on somebody else's phone, and it cannot be recalled | | Tenancy | One business account, one phone number, one admin token, no signup | onboarding other businesses is Meta's verification and review process, not a flag you set | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the gateway and the worker. - Get it: nodejs.org or your version manager. - Verify: node --version prints v22 or higher - [ ] **PostgreSQL 16 or newer** · free 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 - [ ] **A Meta developer application with a WhatsApp Business Account and a verified phone number** · 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. - Verify: A test message from your own phone reaches the webhook endpoint - [ ] **A publicly reachable HTTPS endpoint with a valid certificate** · a 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 - [ ] **Written confirmation that this serves one business only** · free - 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 ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - 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 If one of those is essential to you, that is the reason to keep paying for Wabery, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Wabery The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a single-business WhatsApp gateway that receives, sends and reconciles messages through Meta's Cloud API to replace Wabery. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Runtime: Node 22 with Fastify behind a reverse proxy that holds the certificate · the webhook endpoint has to be publicly reachable and very fast to acknowledge - Storage: PostgreSQL, one schema, migrations checked into the repository · message history is what you will be asked to produce during an argument with a customer - Queue: A job table in the same PostgreSQL, claimed with a conditional update · one fewer service to lose, and the jobs need the same transaction as the rows they create - Inbound: Persist the raw body, acknowledge, then interpret from a worker · Meta retries a failed notification for well over a day and you cannot outrun that with fast parsing - Outbound: One send path that requires a caller-supplied idempotency key · a retried send is a second message on somebody else's phone, and it cannot be recalled - Tenancy: One business account, one phone number, one admin token, no signup · onboarding other businesses is Meta's verification and review process, not a flag you set ### Data model (create this before Phase 1) - 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. ### Phase 1 · Receive before you interpret Build: Implement the subscription verification handshake, then check the SHA-256 payload signature against the app secret using a constant-time comparison. Write the raw body into events_raw in one transaction, respond 200, and do everything else in a worker. A single notification can carry many updates batched together, so unpack and process each update on its own. Trap: 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. Done when: A notification with a wrong or absent signature is stored with signature_ok false and never processed, tested with a forged body. The endpoint acknowledges in a fixed small time budget with the worker stopped, measured. The same notification delivered twice produces exactly one messages row, tested by replaying a captured body. A notification carrying several updates in one batch produces one row per update, tested with a multi-update fixture. Do not build yet: No sending, no window logic, no outbound forwarding yet. ### Phase 2 · One send, however many times you ask Build: Require an Idempotency-Key header on every send. Store the key with a hash of the request payload. A repeat of the same key and the same payload returns the stored result. The same key with a different payload is rejected as unprocessable. A repeat while the first attempt is still in flight is a conflict, not a second send. Record the platform message identifier against the key when it comes back. Trap: 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. Done when: Two identical requests carrying the same key produce one outbound call and identical responses, tested against a stubbed API. The same key with a different payload is refused and no send occurs, tested. A request repeated while the first is in flight returns a conflict rather than sending, tested by holding the stub open. A send that times out mid-flight leaves the key in an unknown state that is resolved by the later status notification, tested. Do not build yet: No window rules, no template handling, no forwarding yet. ### Phase 3 · The window decides what you are allowed to say Build: When an inbound message arrives, set window_expires_at on the conversation from the platform's published customer service window. At send time, and not at queue time, check whether the window is still open. Inside it, send free-form. Outside it, send only a template whose approval state you have checked, and if no suitable template exists, fail the send with a message that says why rather than retrying. Trap: 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. Done when: A free-form send outside the window is refused locally with a named reason and never reaches the platform, tested by moving the clock. Window expiry is derived from the last inbound message time and the published window length held in one constant, tested at the boundary. Template sends check the stored approval state and refuse an unapproved name without an outbound call, tested. A platform rejection caused by the window is classified terminal and is not retried, tested with a stubbed error. Do not build yet: No status assembly, no outbound forwarding, no admin interface yet. ### Phase 4 · Statuses that arrive in the wrong order Build: Append every status notification as a message_states row with its own timestamp and raw payload. Derive the message's current state by ranking the observations rather than by writing to a column. Forward each normalised event to one configured endpoint, signed, with every attempt and response recorded, retried on a backoff, and parked in a replayable dead letter after a stated number of attempts. Trap: 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. Done when: A read notification followed by a delivered notification for the same message leaves the derived state at read, tested by replaying them in both orders. Forwarding signs the body and the receiver can verify it with a shared secret, tested. A receiver returning 500 causes retries on a backoff and the attempt history shows each one, tested. Events parked in the dead letter can be replayed by hand and the replay is recorded as a new attempt rather than by mutating the old one. Do not build yet: No operations work, no export, no rotation yet. ### Phase 5 · One tenant, one token, and a way out Build: An admin interface behind one token showing conversations, derived states, failed jobs and forwarding health, with a reply box. Rotate the access token, app secret and verify token without downtime and document the order. Export every table as newline-delimited JSON and reimport into an empty database. Back up nightly, restore into a scratch database on a schedule, and prove the message count matches. Trap: 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. Done when: Starting with more than one configured phone number identifier fails at boot with an explanatory message, tested. Rotating the app secret is possible with both old and new accepted for a stated overlap, tested by replaying a notification signed with each. Export and reimport round-trips every table and the derived states recompute identically, tested. A restore from last night's backup into an empty database reproduces the message count and the last inbound timestamp, exercised on a schedule rather than documented. Do not build yet: Nothing after this. ### Out of scope (and why) - Onboarding other businesses, including embedded signup flows, because that needs business verification and platform review rather than code, and shipping it half-done gets the number blocked. - Unofficial bridges that drive the consumer client, because they break the platform's published terms and the number they run on is the one that gets banned. - Marketing broadcasts to numbers that never messaged you, which is a consent problem before it is an engineering one. - Flows, hosted functions and a shared team inbox, each of which is a product in its own right rather than a phase of this one. ### README must contain - That this gateway is for exactly one business account, one phone number and one operator, and why the boot check enforces that - The exact platform setup order: application, business account, number, webhook subscription, verify token, template submission - That inbound notifications repeat, batch and arrive out of order, and that the schema deliberately appends rather than updates - That the customer service window is evaluated at send time and what happens when it has closed - How to rotate the access token, app secret and verify token, and how to restore last night's backup ===== AGENTS.md ===== # Agent instructions · Wabery indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Node 22 with Fastify behind a reverse proxy that holds the certificate, PostgreSQL, one schema, migrations checked into the repository, A job table in the same PostgreSQL, claimed with a conditional update, Persist the raw body, acknowledge, then interpret from a worker, One send path that requires a caller-supplied idempotency key, One business account, one phone number, one admin token, no signup. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - 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. - 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. - 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. - 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. ===== BUILD_PLAN.md ===== # Build plan · Wabery 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. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Receive before you interpret Every notification is signature-checked, stored raw and acknowledged before any of it is parsed. ### Steps 1. Verify the handshake and the payload signature Constant-time comparison, and a failed check is stored rather than discarded. Files: `src/routes/webhook.ts`, `src/lib/signature.ts`, `tests/signature.test.ts` 2. Persist the raw body, then acknowledge The body is the only evidence; parsing it is a separate concern. Files: `src/routes/webhook.ts`, `migrations/001_events_raw.sql` 3. Unpack batches and deduplicate in the worker One notification can carry many updates and the same update twice. Files: `src/worker/ingest.ts`, `tests/ingest-dedupe.test.ts` ### Done when - [ ] A forged signature is stored and never processed - [ ] The endpoint acknowledges within its time budget - [ ] A replayed notification creates no second row - [ ] A batched notification yields one row per update ### 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. ## Phase 2 · One send, however many times you ask A retried send request never puts a second message on somebody's phone. ### Steps 1. Make the idempotency key the primary key of the attempt Allocated before the outbound call, not after the response. Files: `src/send/idempotency.ts`, `migrations/002_send_requests.sql` 2. Distinguish failed from unknown A timeout is not a failure and retrying one is how people get double messages. Files: `src/send/client.ts`, `tests/timeout-unknown.test.ts` 3. Reconcile unknowns from inbound status notifications The platform will tell you what happened if you keep the key. Files: `src/worker/reconcile.ts` ### Done when - [ ] A repeated key and payload sends once - [ ] A reused key with a changed payload is refused - [ ] An in-flight repeat returns a conflict - [ ] A timeout resolves from the later status ### 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. ## Phase 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. ### Steps 1. Set the window from the inbound message, not from now The clock starts when they wrote to you. Files: `src/worker/ingest.ts`, `src/lib/window.ts` 2. Evaluate the window in the send path A queued job runs later than it was written, sometimes much later. Files: `src/send/guard.ts`, `tests/window-boundary.test.ts` 3. Classify window rejections as terminal A retry loop against a closed window is an outage you inflicted on yourself. Files: `src/send/errors.ts` ### Done when - [ ] A late free-form send is refused locally - [ ] Window expiry derives from the last inbound time - [ ] An unapproved template name never reaches the API - [ ] Window rejections are terminal, not retried ### 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. ## Phase 4 · Statuses that arrive in the wrong order Delivery state is assembled from observations rather than overwritten by whichever notification landed last. ### Steps 1. Append observations, never update a status Order is not guaranteed, so the last write is not the truth. Files: `src/worker/status.ts`, `migrations/003_message_states.sql` 2. Derive current state by ranking observations One pure function, tested with shuffled inputs. Files: `src/lib/state.ts`, `tests/state-order.test.ts` 3. Sign forwarded events and keep every attempt Including the failures, which are the ones you will be asked about. Files: `src/forward/deliver.ts`, `tests/forward-retry.test.ts` ### Done when - [ ] Out-of-order statuses give the same derived state - [ ] Forwarded bodies are signed and verifiable - [ ] Failed forwards retry on a backoff with history - [ ] Dead-lettered events replay as new attempts ### 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. ## Not in this build - 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. ## After v1, if you want it - 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 ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. The Cloud API access token. Rotate it on a schedule and never log it. WA_ACCESS_TOKEN=EAAG...redacted # Required · secret. Used to verify the payload signature on every inbound notification. WA_APP_SECRET=redacted-32-hex # Required · secret. Echoed during the subscription verification handshake only. WA_VERIFY_TOKEN=a-long-random-string # Required. The single number this deployment serves. The boot check refuses a list. WA_PHONE_NUMBER_ID=123456789012345 # Required · secret. Messages, raw events and jobs. This is the backup target. DATABASE_URL=postgres://gateway@localhost/wabery # Optional. Where normalised events are forwarded. Leave unset to keep everything local. FORWARD_URL=https://internal.example.com/hooks/whatsapp # Optional · secret. Signs forwarded bodies so the receiver can verify them. FORWARD_SECRET=redacted # Required · secret. The only credential for the admin interface. There are no user accounts. ADMIN_TOKEN=redacted
You are building a lean indie version of Wabery. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== README.md ===== # Wabery · indie build 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-day**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node 22 with Fastify behind a reverse proxy that holds the certificate | the webhook endpoint has to be publicly reachable and very fast to acknowledge | | Storage | PostgreSQL, one schema, migrations checked into the repository | message history is what you will be asked to produce during an argument with a customer | | Queue | A job table in the same PostgreSQL, claimed with a conditional update | one fewer service to lose, and the jobs need the same transaction as the rows they create | | Inbound | Persist the raw body, acknowledge, then interpret from a worker | Meta retries a failed notification for well over a day and you cannot outrun that with fast parsing | | Outbound | One send path that requires a caller-supplied idempotency key | a retried send is a second message on somebody else's phone, and it cannot be recalled | | Tenancy | One business account, one phone number, one admin token, no signup | onboarding other businesses is Meta's verification and review process, not a flag you set | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the gateway and the worker. - Get it: nodejs.org or your version manager. - Verify: node --version prints v22 or higher - [ ] **PostgreSQL 16 or newer** · free 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 - [ ] **A Meta developer application with a WhatsApp Business Account and a verified phone number** · 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. - Verify: A test message from your own phone reaches the webhook endpoint - [ ] **A publicly reachable HTTPS endpoint with a valid certificate** · a 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 - [ ] **Written confirmation that this serves one business only** · free - 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 ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - 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 If one of those is essential to you, that is the reason to keep paying for Wabery, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Wabery The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a single-business WhatsApp gateway that receives, sends and reconciles messages through Meta's Cloud API to replace Wabery. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Runtime: Node 22 with Fastify behind a reverse proxy that holds the certificate · the webhook endpoint has to be publicly reachable and very fast to acknowledge - Storage: PostgreSQL, one schema, migrations checked into the repository · message history is what you will be asked to produce during an argument with a customer - Queue: A job table in the same PostgreSQL, claimed with a conditional update · one fewer service to lose, and the jobs need the same transaction as the rows they create - Inbound: Persist the raw body, acknowledge, then interpret from a worker · Meta retries a failed notification for well over a day and you cannot outrun that with fast parsing - Outbound: One send path that requires a caller-supplied idempotency key · a retried send is a second message on somebody else's phone, and it cannot be recalled - Tenancy: One business account, one phone number, one admin token, no signup · onboarding other businesses is Meta's verification and review process, not a flag you set ### Data model (create this before Phase 1) - 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. ### Phase 1 · Receive before you interpret Build: Implement the subscription verification handshake, then check the SHA-256 payload signature against the app secret using a constant-time comparison. Write the raw body into events_raw in one transaction, respond 200, and do everything else in a worker. A single notification can carry many updates batched together, so unpack and process each update on its own. Trap: 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. Done when: A notification with a wrong or absent signature is stored with signature_ok false and never processed, tested with a forged body. The endpoint acknowledges in a fixed small time budget with the worker stopped, measured. The same notification delivered twice produces exactly one messages row, tested by replaying a captured body. A notification carrying several updates in one batch produces one row per update, tested with a multi-update fixture. Do not build yet: No sending, no window logic, no outbound forwarding yet. ### Phase 2 · One send, however many times you ask Build: Require an Idempotency-Key header on every send. Store the key with a hash of the request payload. A repeat of the same key and the same payload returns the stored result. The same key with a different payload is rejected as unprocessable. A repeat while the first attempt is still in flight is a conflict, not a second send. Record the platform message identifier against the key when it comes back. Trap: 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. Done when: Two identical requests carrying the same key produce one outbound call and identical responses, tested against a stubbed API. The same key with a different payload is refused and no send occurs, tested. A request repeated while the first is in flight returns a conflict rather than sending, tested by holding the stub open. A send that times out mid-flight leaves the key in an unknown state that is resolved by the later status notification, tested. Do not build yet: No window rules, no template handling, no forwarding yet. ### Phase 3 · The window decides what you are allowed to say Build: When an inbound message arrives, set window_expires_at on the conversation from the platform's published customer service window. At send time, and not at queue time, check whether the window is still open. Inside it, send free-form. Outside it, send only a template whose approval state you have checked, and if no suitable template exists, fail the send with a message that says why rather than retrying. Trap: 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. Done when: A free-form send outside the window is refused locally with a named reason and never reaches the platform, tested by moving the clock. Window expiry is derived from the last inbound message time and the published window length held in one constant, tested at the boundary. Template sends check the stored approval state and refuse an unapproved name without an outbound call, tested. A platform rejection caused by the window is classified terminal and is not retried, tested with a stubbed error. Do not build yet: No status assembly, no outbound forwarding, no admin interface yet. ### Phase 4 · Statuses that arrive in the wrong order Build: Append every status notification as a message_states row with its own timestamp and raw payload. Derive the message's current state by ranking the observations rather than by writing to a column. Forward each normalised event to one configured endpoint, signed, with every attempt and response recorded, retried on a backoff, and parked in a replayable dead letter after a stated number of attempts. Trap: 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. Done when: A read notification followed by a delivered notification for the same message leaves the derived state at read, tested by replaying them in both orders. Forwarding signs the body and the receiver can verify it with a shared secret, tested. A receiver returning 500 causes retries on a backoff and the attempt history shows each one, tested. Events parked in the dead letter can be replayed by hand and the replay is recorded as a new attempt rather than by mutating the old one. Do not build yet: No operations work, no export, no rotation yet. ### Phase 5 · One tenant, one token, and a way out Build: An admin interface behind one token showing conversations, derived states, failed jobs and forwarding health, with a reply box. Rotate the access token, app secret and verify token without downtime and document the order. Export every table as newline-delimited JSON and reimport into an empty database. Back up nightly, restore into a scratch database on a schedule, and prove the message count matches. Trap: 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. Done when: Starting with more than one configured phone number identifier fails at boot with an explanatory message, tested. Rotating the app secret is possible with both old and new accepted for a stated overlap, tested by replaying a notification signed with each. Export and reimport round-trips every table and the derived states recompute identically, tested. A restore from last night's backup into an empty database reproduces the message count and the last inbound timestamp, exercised on a schedule rather than documented. Do not build yet: Nothing after this. ### Out of scope (and why) - Onboarding other businesses, including embedded signup flows, because that needs business verification and platform review rather than code, and shipping it half-done gets the number blocked. - Unofficial bridges that drive the consumer client, because they break the platform's published terms and the number they run on is the one that gets banned. - Marketing broadcasts to numbers that never messaged you, which is a consent problem before it is an engineering one. - Flows, hosted functions and a shared team inbox, each of which is a product in its own right rather than a phase of this one. ### README must contain - That this gateway is for exactly one business account, one phone number and one operator, and why the boot check enforces that - The exact platform setup order: application, business account, number, webhook subscription, verify token, template submission - That inbound notifications repeat, batch and arrive out of order, and that the schema deliberately appends rather than updates - That the customer service window is evaluated at send time and what happens when it has closed - How to rotate the access token, app secret and verify token, and how to restore last night's backup ===== AGENTS.md ===== # Agent instructions · Wabery indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Node 22 with Fastify behind a reverse proxy that holds the certificate, PostgreSQL, one schema, migrations checked into the repository, A job table in the same PostgreSQL, claimed with a conditional update, Persist the raw body, acknowledge, then interpret from a worker, One send path that requires a caller-supplied idempotency key, One business account, one phone number, one admin token, no signup. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - 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. - 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. - 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. - 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. ===== BUILD_PLAN.md ===== # Build plan · Wabery 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. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Receive before you interpret Every notification is signature-checked, stored raw and acknowledged before any of it is parsed. ### Steps 1. Verify the handshake and the payload signature Constant-time comparison, and a failed check is stored rather than discarded. Files: `src/routes/webhook.ts`, `src/lib/signature.ts`, `tests/signature.test.ts` 2. Persist the raw body, then acknowledge The body is the only evidence; parsing it is a separate concern. Files: `src/routes/webhook.ts`, `migrations/001_events_raw.sql` 3. Unpack batches and deduplicate in the worker One notification can carry many updates and the same update twice. Files: `src/worker/ingest.ts`, `tests/ingest-dedupe.test.ts` ### Done when - [ ] A forged signature is stored and never processed - [ ] The endpoint acknowledges within its time budget - [ ] A replayed notification creates no second row - [ ] A batched notification yields one row per update ### 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. ## Phase 2 · One send, however many times you ask A retried send request never puts a second message on somebody's phone. ### Steps 1. Make the idempotency key the primary key of the attempt Allocated before the outbound call, not after the response. Files: `src/send/idempotency.ts`, `migrations/002_send_requests.sql` 2. Distinguish failed from unknown A timeout is not a failure and retrying one is how people get double messages. Files: `src/send/client.ts`, `tests/timeout-unknown.test.ts` 3. Reconcile unknowns from inbound status notifications The platform will tell you what happened if you keep the key. Files: `src/worker/reconcile.ts` ### Done when - [ ] A repeated key and payload sends once - [ ] A reused key with a changed payload is refused - [ ] An in-flight repeat returns a conflict - [ ] A timeout resolves from the later status ### 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. ## Phase 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. ### Steps 1. Set the window from the inbound message, not from now The clock starts when they wrote to you. Files: `src/worker/ingest.ts`, `src/lib/window.ts` 2. Evaluate the window in the send path A queued job runs later than it was written, sometimes much later. Files: `src/send/guard.ts`, `tests/window-boundary.test.ts` 3. Classify window rejections as terminal A retry loop against a closed window is an outage you inflicted on yourself. Files: `src/send/errors.ts` ### Done when - [ ] A late free-form send is refused locally - [ ] Window expiry derives from the last inbound time - [ ] An unapproved template name never reaches the API - [ ] Window rejections are terminal, not retried ### 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. ## Phase 4 · Statuses that arrive in the wrong order Delivery state is assembled from observations rather than overwritten by whichever notification landed last. ### Steps 1. Append observations, never update a status Order is not guaranteed, so the last write is not the truth. Files: `src/worker/status.ts`, `migrations/003_message_states.sql` 2. Derive current state by ranking observations One pure function, tested with shuffled inputs. Files: `src/lib/state.ts`, `tests/state-order.test.ts` 3. Sign forwarded events and keep every attempt Including the failures, which are the ones you will be asked about. Files: `src/forward/deliver.ts`, `tests/forward-retry.test.ts` ### Done when - [ ] Out-of-order statuses give the same derived state - [ ] Forwarded bodies are signed and verifiable - [ ] Failed forwards retry on a backoff with history - [ ] Dead-lettered events replay as new attempts ### 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. ## Not in this build - 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. ## After v1, if you want it - 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 ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. The Cloud API access token. Rotate it on a schedule and never log it. WA_ACCESS_TOKEN=EAAG...redacted # Required · secret. Used to verify the payload signature on every inbound notification. WA_APP_SECRET=redacted-32-hex # Required · secret. Echoed during the subscription verification handshake only. WA_VERIFY_TOKEN=a-long-random-string # Required. The single number this deployment serves. The boot check refuses a list. WA_PHONE_NUMBER_ID=123456789012345 # Required · secret. Messages, raw events and jobs. This is the backup target. DATABASE_URL=postgres://gateway@localhost/wabery # Optional. Where normalised events are forwarded. Leave unset to keep everything local. FORWARD_URL=https://internal.example.com/hooks/whatsapp # Optional · secret. Signs forwarded bodies so the receiver can verify them. FORWARD_SECRET=redacted # Required · secret. The only credential for the admin interface. There are no user accounts. ADMIN_TOKEN=redacted
You are building a production product version of Wabery. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== PRODUCT.md ===== # Wabery · product brief ## Problem The core loop (send, receive, verify webhooks, retry, inspect conversations) is one-shottable for a single business using Meta's WhatsApp Cloud API. What you cannot one-shot is the reason managed WhatsApp providers exist as products: production messaging infrastructure must stay reliable, secure, monitored, and compliant, while onboarding other businesses requires Meta business verification, app review, and approval for the relevant Tech Provider and Meta Business Partner capabilities. The honest DIY move is to build for your own verified WhatsApp Business Account and phone number; matching Wabery as a multi-tenant provider means operating that infrastructure and completing Meta's approval process. ## Product outcome A gateway one person can run for one business: inbound notifications are stored before they are trusted, sends are idempotent, the messaging window is checked at the moment of sending, delivery state survives out-of-order notifications, and the single-tenant boundary is a boot check rather than an intention. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - Meta Developer account and WhatsApp Cloud API access - verified business, WhatsApp Business Account, and phone number - always-on HTTPS server - PostgreSQL - monitoring and backups ## Explicit non-goals for v1 - 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 ## Success criteria - A forged signature is confirmed stored and never processed - A repeated idempotency key is confirmed to send once - A free-form send outside the window is confirmed refused locally - Out-of-order statuses are confirmed to give the same derived state - A second phone number identifier is confirmed to fail at boot ===== BRIEF.md ===== # Build brief · Wabery The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a single-business WhatsApp gateway that receives, sends and reconciles messages through Meta's Cloud API to replace Wabery. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Runtime: Node 22 with Fastify behind a reverse proxy that holds the certificate · the webhook endpoint has to be publicly reachable and very fast to acknowledge - Storage: PostgreSQL, one schema, migrations checked into the repository · message history is what you will be asked to produce during an argument with a customer - Queue: A job table in the same PostgreSQL, claimed with a conditional update · one fewer service to lose, and the jobs need the same transaction as the rows they create - Inbound: Persist the raw body, acknowledge, then interpret from a worker · Meta retries a failed notification for well over a day and you cannot outrun that with fast parsing - Outbound: One send path that requires a caller-supplied idempotency key · a retried send is a second message on somebody else's phone, and it cannot be recalled - Tenancy: One business account, one phone number, one admin token, no signup · onboarding other businesses is Meta's verification and review process, not a flag you set ### Data model (create this before Phase 1) - 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. ### Phase 1 · Receive before you interpret Build: Implement the subscription verification handshake, then check the SHA-256 payload signature against the app secret using a constant-time comparison. Write the raw body into events_raw in one transaction, respond 200, and do everything else in a worker. A single notification can carry many updates batched together, so unpack and process each update on its own. Trap: 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. Done when: A notification with a wrong or absent signature is stored with signature_ok false and never processed, tested with a forged body. The endpoint acknowledges in a fixed small time budget with the worker stopped, measured. The same notification delivered twice produces exactly one messages row, tested by replaying a captured body. A notification carrying several updates in one batch produces one row per update, tested with a multi-update fixture. Do not build yet: No sending, no window logic, no outbound forwarding yet. ### Phase 2 · One send, however many times you ask Build: Require an Idempotency-Key header on every send. Store the key with a hash of the request payload. A repeat of the same key and the same payload returns the stored result. The same key with a different payload is rejected as unprocessable. A repeat while the first attempt is still in flight is a conflict, not a second send. Record the platform message identifier against the key when it comes back. Trap: 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. Done when: Two identical requests carrying the same key produce one outbound call and identical responses, tested against a stubbed API. The same key with a different payload is refused and no send occurs, tested. A request repeated while the first is in flight returns a conflict rather than sending, tested by holding the stub open. A send that times out mid-flight leaves the key in an unknown state that is resolved by the later status notification, tested. Do not build yet: No window rules, no template handling, no forwarding yet. ### Phase 3 · The window decides what you are allowed to say Build: When an inbound message arrives, set window_expires_at on the conversation from the platform's published customer service window. At send time, and not at queue time, check whether the window is still open. Inside it, send free-form. Outside it, send only a template whose approval state you have checked, and if no suitable template exists, fail the send with a message that says why rather than retrying. Trap: 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. Done when: A free-form send outside the window is refused locally with a named reason and never reaches the platform, tested by moving the clock. Window expiry is derived from the last inbound message time and the published window length held in one constant, tested at the boundary. Template sends check the stored approval state and refuse an unapproved name without an outbound call, tested. A platform rejection caused by the window is classified terminal and is not retried, tested with a stubbed error. Do not build yet: No status assembly, no outbound forwarding, no admin interface yet. ### Phase 4 · Statuses that arrive in the wrong order Build: Append every status notification as a message_states row with its own timestamp and raw payload. Derive the message's current state by ranking the observations rather than by writing to a column. Forward each normalised event to one configured endpoint, signed, with every attempt and response recorded, retried on a backoff, and parked in a replayable dead letter after a stated number of attempts. Trap: 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. Done when: A read notification followed by a delivered notification for the same message leaves the derived state at read, tested by replaying them in both orders. Forwarding signs the body and the receiver can verify it with a shared secret, tested. A receiver returning 500 causes retries on a backoff and the attempt history shows each one, tested. Events parked in the dead letter can be replayed by hand and the replay is recorded as a new attempt rather than by mutating the old one. Do not build yet: No operations work, no export, no rotation yet. ### Phase 5 · One tenant, one token, and a way out Build: An admin interface behind one token showing conversations, derived states, failed jobs and forwarding health, with a reply box. Rotate the access token, app secret and verify token without downtime and document the order. Export every table as newline-delimited JSON and reimport into an empty database. Back up nightly, restore into a scratch database on a schedule, and prove the message count matches. Trap: 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. Done when: Starting with more than one configured phone number identifier fails at boot with an explanatory message, tested. Rotating the app secret is possible with both old and new accepted for a stated overlap, tested by replaying a notification signed with each. Export and reimport round-trips every table and the derived states recompute identically, tested. A restore from last night's backup into an empty database reproduces the message count and the last inbound timestamp, exercised on a schedule rather than documented. Do not build yet: Nothing after this. ### Out of scope (and why) - Onboarding other businesses, including embedded signup flows, because that needs business verification and platform review rather than code, and shipping it half-done gets the number blocked. - Unofficial bridges that drive the consumer client, because they break the platform's published terms and the number they run on is the one that gets banned. - Marketing broadcasts to numbers that never messaged you, which is a consent problem before it is an engineering one. - Flows, hosted functions and a shared team inbox, each of which is a product in its own right rather than a phase of this one. ### README must contain - That this gateway is for exactly one business account, one phone number and one operator, and why the boot check enforces that - The exact platform setup order: application, business account, number, webhook subscription, verify token, template submission - That inbound notifications repeat, batch and arrive out of order, and that the schema deliberately appends rather than updates - That the customer service window is evaluated at send time and what happens when it has closed - How to rotate the access token, app secret and verify token, and how to restore last night's backup ===== ARCHITECTURE.md ===== # Architecture · Wabery ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node 22 with Fastify behind a reverse proxy that holds the certificate | the webhook endpoint has to be publicly reachable and very fast to acknowledge | | Storage | PostgreSQL, one schema, migrations checked into the repository | message history is what you will be asked to produce during an argument with a customer | | Queue | A job table in the same PostgreSQL, claimed with a conditional update | one fewer service to lose, and the jobs need the same transaction as the rows they create | | Inbound | Persist the raw body, acknowledge, then interpret from a worker | Meta retries a failed notification for well over a day and you cannot outrun that with fast parsing | | Outbound | One send path that requires a caller-supplied idempotency key | a retried send is a second message on somebody else's phone, and it cannot be recalled | | Tenancy | One business account, one phone number, one admin token, no signup | onboarding other businesses is Meta's verification and review process, not a flag you set | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Ingest | Signature verification, raw storage, acknowledgement. | Any framework, provided it stores and acknowledges before it parses. | | Send | Idempotency keys, outbound calls, unknown-state handling. | Any HTTP client, provided a timeout is never treated as a failure. | | Window | Conversation window computation and terminal classification. | Not swappable for a check at queue time, which is the bug. | | State | Appended observations and the derived state function. | Any store, provided nothing overwrites a status in place. | | Forward | Signed outbound delivery, retries and the dead letter. | Any transport, provided every attempt is retained. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `WA_ACCESS_TOKEN` · required, secret · The Cloud API access token. Rotate it on a schedule and never log it. - `WA_APP_SECRET` · required, secret · Used to verify the payload signature on every inbound notification. - `WA_VERIFY_TOKEN` · required, secret · Echoed during the subscription verification handshake only. - `WA_PHONE_NUMBER_ID` · required · The single number this deployment serves. The boot check refuses a list. - `DATABASE_URL` · required, secret · Messages, raw events and jobs. This is the backup target. - `FORWARD_URL` · optional · Where normalised events are forwarded. Leave unset to keep everything local. - `FORWARD_SECRET` · optional, secret · Signs forwarded bodies so the receiver can verify them. - `ADMIN_TOKEN` · required, secret · The only credential for the admin interface. There are no user accounts. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it. ===== AGENTS.md ===== # Agent instructions · Wabery product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Node 22 with Fastify behind a reverse proxy that holds the certificate, PostgreSQL, one schema, migrations checked into the repository, A job table in the same PostgreSQL, claimed with a conditional update, Persist the raw body, acknowledge, then interpret from a worker, One send path that requires a caller-supplied idempotency key, One business account, one phone number, one admin token, no signup. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - 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. - 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. - 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. - 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. - 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. ===== MILESTONES.md ===== # Delivery milestones · Wabery Estimated effort: **multi-day** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Receive before you interpret Every notification is signature-checked, stored raw and acknowledged before any of it is parsed. ### Steps 1. Verify the handshake and the payload signature Constant-time comparison, and a failed check is stored rather than discarded. Files: `src/routes/webhook.ts`, `src/lib/signature.ts`, `tests/signature.test.ts` 2. Persist the raw body, then acknowledge The body is the only evidence; parsing it is a separate concern. Files: `src/routes/webhook.ts`, `migrations/001_events_raw.sql` 3. Unpack batches and deduplicate in the worker One notification can carry many updates and the same update twice. Files: `src/worker/ingest.ts`, `tests/ingest-dedupe.test.ts` ### Done when - [ ] A forged signature is stored and never processed - [ ] The endpoint acknowledges within its time budget - [ ] A replayed notification creates no second row - [ ] A batched notification yields one row per update ### 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. ## M2 · One send, however many times you ask A retried send request never puts a second message on somebody's phone. ### Steps 1. Make the idempotency key the primary key of the attempt Allocated before the outbound call, not after the response. Files: `src/send/idempotency.ts`, `migrations/002_send_requests.sql` 2. Distinguish failed from unknown A timeout is not a failure and retrying one is how people get double messages. Files: `src/send/client.ts`, `tests/timeout-unknown.test.ts` 3. Reconcile unknowns from inbound status notifications The platform will tell you what happened if you keep the key. Files: `src/worker/reconcile.ts` ### Done when - [ ] A repeated key and payload sends once - [ ] A reused key with a changed payload is refused - [ ] An in-flight repeat returns a conflict - [ ] A timeout resolves from the later status ### 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. ## M3 · 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. ### Steps 1. Set the window from the inbound message, not from now The clock starts when they wrote to you. Files: `src/worker/ingest.ts`, `src/lib/window.ts` 2. Evaluate the window in the send path A queued job runs later than it was written, sometimes much later. Files: `src/send/guard.ts`, `tests/window-boundary.test.ts` 3. Classify window rejections as terminal A retry loop against a closed window is an outage you inflicted on yourself. Files: `src/send/errors.ts` ### Done when - [ ] A late free-form send is refused locally - [ ] Window expiry derives from the last inbound time - [ ] An unapproved template name never reaches the API - [ ] Window rejections are terminal, not retried ### 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. ## M4 · Statuses that arrive in the wrong order Delivery state is assembled from observations rather than overwritten by whichever notification landed last. ### Steps 1. Append observations, never update a status Order is not guaranteed, so the last write is not the truth. Files: `src/worker/status.ts`, `migrations/003_message_states.sql` 2. Derive current state by ranking observations One pure function, tested with shuffled inputs. Files: `src/lib/state.ts`, `tests/state-order.test.ts` 3. Sign forwarded events and keep every attempt Including the failures, which are the ones you will be asked about. Files: `src/forward/deliver.ts`, `tests/forward-retry.test.ts` ### Done when - [ ] Out-of-order statuses give the same derived state - [ ] Forwarded bodies are signed and verifiable - [ ] Failed forwards retry on a backoff with history - [ ] Dead-lettered events replay as new attempts ### 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. ## M5 · One tenant, one token, and a way out (production only) The gateway is operable by one person: secrets rotate, history exports, and the single-tenant boundary is enforced rather than assumed. ### Steps 1. Enforce the one-number assumption at boot So the second tenant is a decision and not an accident. Files: `src/config.ts`, `tests/single-tenant.test.ts` 2. Rotate secrets with an accepted overlap Otherwise rotation means dropping notifications. Files: `src/lib/signature.ts`, `docs/rotation.md` 3. Restore into a scratch database on a schedule A backup nobody has restored is a rumour about a backup. Files: `scripts/restore-check.sh`, `docs/operations.md` ### Done when - [ ] A second phone number identifier fails at boot - [ ] Secret rotation accepts both values for an overlap - [ ] Export and reimport round-trips every table - [ ] A scheduled restore reproduces the message count ### Watch 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. ===== OPERATIONS.md ===== # Operations · Wabery ## Backup Nightly logical dumps of the whole database, kept offsite with a stated retention. The raw events table is the largest and the most valuable, because it is the only record of what the platform actually sent you. ## Restore Restore into an empty database, apply migrations, confirm the message count and the most recent inbound timestamp, then recompute derived states and compare. A restore that changes a derived state means the state function reads something outside the observations. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Webhook acknowledgement time, the rate of signature failures, the depth of the job table, the age of the oldest unreconciled send key, and dead letter size. A rising signature failure rate usually means a secret was rotated on one side only. ## Incident checklist The two incidents that matter are duplicate outbound messages and a webhook endpoint that stopped acknowledging. The first is diagnosed from send_requests, the second from the gap in events_raw, and both are why the raw bodies are kept rather than parsed and discarded. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] A forged signature is confirmed stored and never processed - [ ] A repeated idempotency key is confirmed to send once - [ ] A free-form send outside the window is confirmed refused locally - [ ] Out-of-order statuses are confirmed to give the same derived state - [ ] A second phone number identifier is confirmed to fail at boot ## Launch constraint Do not market omitted Wabery capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered. ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. The Cloud API access token. Rotate it on a schedule and never log it. WA_ACCESS_TOKEN=EAAG...redacted # Required · secret. Used to verify the payload signature on every inbound notification. WA_APP_SECRET=redacted-32-hex # Required · secret. Echoed during the subscription verification handshake only. WA_VERIFY_TOKEN=a-long-random-string # Required. The single number this deployment serves. The boot check refuses a list. WA_PHONE_NUMBER_ID=123456789012345 # Required · secret. Messages, raw events and jobs. This is the backup target. DATABASE_URL=postgres://gateway@localhost/wabery # Optional. Where normalised events are forwarded. Leave unset to keep everything local. FORWARD_URL=https://internal.example.com/hooks/whatsapp # Optional · secret. Signs forwarded bodies so the receiver can verify them. FORWARD_SECRET=redacted # Required · secret. The only credential for the admin interface. There are no user accounts. ADMIN_TOKEN=redacted
# Wabery · indie build 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-day**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node 22 with Fastify behind a reverse proxy that holds the certificate | the webhook endpoint has to be publicly reachable and very fast to acknowledge | | Storage | PostgreSQL, one schema, migrations checked into the repository | message history is what you will be asked to produce during an argument with a customer | | Queue | A job table in the same PostgreSQL, claimed with a conditional update | one fewer service to lose, and the jobs need the same transaction as the rows they create | | Inbound | Persist the raw body, acknowledge, then interpret from a worker | Meta retries a failed notification for well over a day and you cannot outrun that with fast parsing | | Outbound | One send path that requires a caller-supplied idempotency key | a retried send is a second message on somebody else's phone, and it cannot be recalled | | Tenancy | One business account, one phone number, one admin token, no signup | onboarding other businesses is Meta's verification and review process, not a flag you set | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the gateway and the worker. - Get it: nodejs.org or your version manager. - Verify: node --version prints v22 or higher - [ ] **PostgreSQL 16 or newer** · free 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 - [ ] **A Meta developer application with a WhatsApp Business Account and a verified phone number** · 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. - Verify: A test message from your own phone reaches the webhook endpoint - [ ] **A publicly reachable HTTPS endpoint with a valid certificate** · a 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 - [ ] **Written confirmation that this serves one business only** · free - 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 ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - 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 If one of those is essential to you, that is the reason to keep paying for Wabery, and the README should say so rather than pretend.
# Build brief · Wabery The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a single-business WhatsApp gateway that receives, sends and reconciles messages through Meta's Cloud API to replace Wabery. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Runtime: Node 22 with Fastify behind a reverse proxy that holds the certificate · the webhook endpoint has to be publicly reachable and very fast to acknowledge - Storage: PostgreSQL, one schema, migrations checked into the repository · message history is what you will be asked to produce during an argument with a customer - Queue: A job table in the same PostgreSQL, claimed with a conditional update · one fewer service to lose, and the jobs need the same transaction as the rows they create - Inbound: Persist the raw body, acknowledge, then interpret from a worker · Meta retries a failed notification for well over a day and you cannot outrun that with fast parsing - Outbound: One send path that requires a caller-supplied idempotency key · a retried send is a second message on somebody else's phone, and it cannot be recalled - Tenancy: One business account, one phone number, one admin token, no signup · onboarding other businesses is Meta's verification and review process, not a flag you set ### Data model (create this before Phase 1) - 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. ### Phase 1 · Receive before you interpret Build: Implement the subscription verification handshake, then check the SHA-256 payload signature against the app secret using a constant-time comparison. Write the raw body into events_raw in one transaction, respond 200, and do everything else in a worker. A single notification can carry many updates batched together, so unpack and process each update on its own. Trap: 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. Done when: A notification with a wrong or absent signature is stored with signature_ok false and never processed, tested with a forged body. The endpoint acknowledges in a fixed small time budget with the worker stopped, measured. The same notification delivered twice produces exactly one messages row, tested by replaying a captured body. A notification carrying several updates in one batch produces one row per update, tested with a multi-update fixture. Do not build yet: No sending, no window logic, no outbound forwarding yet. ### Phase 2 · One send, however many times you ask Build: Require an Idempotency-Key header on every send. Store the key with a hash of the request payload. A repeat of the same key and the same payload returns the stored result. The same key with a different payload is rejected as unprocessable. A repeat while the first attempt is still in flight is a conflict, not a second send. Record the platform message identifier against the key when it comes back. Trap: 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. Done when: Two identical requests carrying the same key produce one outbound call and identical responses, tested against a stubbed API. The same key with a different payload is refused and no send occurs, tested. A request repeated while the first is in flight returns a conflict rather than sending, tested by holding the stub open. A send that times out mid-flight leaves the key in an unknown state that is resolved by the later status notification, tested. Do not build yet: No window rules, no template handling, no forwarding yet. ### Phase 3 · The window decides what you are allowed to say Build: When an inbound message arrives, set window_expires_at on the conversation from the platform's published customer service window. At send time, and not at queue time, check whether the window is still open. Inside it, send free-form. Outside it, send only a template whose approval state you have checked, and if no suitable template exists, fail the send with a message that says why rather than retrying. Trap: 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. Done when: A free-form send outside the window is refused locally with a named reason and never reaches the platform, tested by moving the clock. Window expiry is derived from the last inbound message time and the published window length held in one constant, tested at the boundary. Template sends check the stored approval state and refuse an unapproved name without an outbound call, tested. A platform rejection caused by the window is classified terminal and is not retried, tested with a stubbed error. Do not build yet: No status assembly, no outbound forwarding, no admin interface yet. ### Phase 4 · Statuses that arrive in the wrong order Build: Append every status notification as a message_states row with its own timestamp and raw payload. Derive the message's current state by ranking the observations rather than by writing to a column. Forward each normalised event to one configured endpoint, signed, with every attempt and response recorded, retried on a backoff, and parked in a replayable dead letter after a stated number of attempts. Trap: 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. Done when: A read notification followed by a delivered notification for the same message leaves the derived state at read, tested by replaying them in both orders. Forwarding signs the body and the receiver can verify it with a shared secret, tested. A receiver returning 500 causes retries on a backoff and the attempt history shows each one, tested. Events parked in the dead letter can be replayed by hand and the replay is recorded as a new attempt rather than by mutating the old one. Do not build yet: No operations work, no export, no rotation yet. ### Phase 5 · One tenant, one token, and a way out Build: An admin interface behind one token showing conversations, derived states, failed jobs and forwarding health, with a reply box. Rotate the access token, app secret and verify token without downtime and document the order. Export every table as newline-delimited JSON and reimport into an empty database. Back up nightly, restore into a scratch database on a schedule, and prove the message count matches. Trap: 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. Done when: Starting with more than one configured phone number identifier fails at boot with an explanatory message, tested. Rotating the app secret is possible with both old and new accepted for a stated overlap, tested by replaying a notification signed with each. Export and reimport round-trips every table and the derived states recompute identically, tested. A restore from last night's backup into an empty database reproduces the message count and the last inbound timestamp, exercised on a schedule rather than documented. Do not build yet: Nothing after this. ### Out of scope (and why) - Onboarding other businesses, including embedded signup flows, because that needs business verification and platform review rather than code, and shipping it half-done gets the number blocked. - Unofficial bridges that drive the consumer client, because they break the platform's published terms and the number they run on is the one that gets banned. - Marketing broadcasts to numbers that never messaged you, which is a consent problem before it is an engineering one. - Flows, hosted functions and a shared team inbox, each of which is a product in its own right rather than a phase of this one. ### README must contain - That this gateway is for exactly one business account, one phone number and one operator, and why the boot check enforces that - The exact platform setup order: application, business account, number, webhook subscription, verify token, template submission - That inbound notifications repeat, batch and arrive out of order, and that the schema deliberately appends rather than updates - That the customer service window is evaluated at send time and what happens when it has closed - How to rotate the access token, app secret and verify token, and how to restore last night's backup
# Agent instructions · Wabery indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Node 22 with Fastify behind a reverse proxy that holds the certificate, PostgreSQL, one schema, migrations checked into the repository, A job table in the same PostgreSQL, claimed with a conditional update, Persist the raw body, acknowledge, then interpret from a worker, One send path that requires a caller-supplied idempotency key, One business account, one phone number, one admin token, no signup. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - 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. - 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. - 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. - 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.
# Build plan · Wabery 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. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Receive before you interpret Every notification is signature-checked, stored raw and acknowledged before any of it is parsed. ### Steps 1. Verify the handshake and the payload signature Constant-time comparison, and a failed check is stored rather than discarded. Files: `src/routes/webhook.ts`, `src/lib/signature.ts`, `tests/signature.test.ts` 2. Persist the raw body, then acknowledge The body is the only evidence; parsing it is a separate concern. Files: `src/routes/webhook.ts`, `migrations/001_events_raw.sql` 3. Unpack batches and deduplicate in the worker One notification can carry many updates and the same update twice. Files: `src/worker/ingest.ts`, `tests/ingest-dedupe.test.ts` ### Done when - [ ] A forged signature is stored and never processed - [ ] The endpoint acknowledges within its time budget - [ ] A replayed notification creates no second row - [ ] A batched notification yields one row per update ### 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. ## Phase 2 · One send, however many times you ask A retried send request never puts a second message on somebody's phone. ### Steps 1. Make the idempotency key the primary key of the attempt Allocated before the outbound call, not after the response. Files: `src/send/idempotency.ts`, `migrations/002_send_requests.sql` 2. Distinguish failed from unknown A timeout is not a failure and retrying one is how people get double messages. Files: `src/send/client.ts`, `tests/timeout-unknown.test.ts` 3. Reconcile unknowns from inbound status notifications The platform will tell you what happened if you keep the key. Files: `src/worker/reconcile.ts` ### Done when - [ ] A repeated key and payload sends once - [ ] A reused key with a changed payload is refused - [ ] An in-flight repeat returns a conflict - [ ] A timeout resolves from the later status ### 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. ## Phase 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. ### Steps 1. Set the window from the inbound message, not from now The clock starts when they wrote to you. Files: `src/worker/ingest.ts`, `src/lib/window.ts` 2. Evaluate the window in the send path A queued job runs later than it was written, sometimes much later. Files: `src/send/guard.ts`, `tests/window-boundary.test.ts` 3. Classify window rejections as terminal A retry loop against a closed window is an outage you inflicted on yourself. Files: `src/send/errors.ts` ### Done when - [ ] A late free-form send is refused locally - [ ] Window expiry derives from the last inbound time - [ ] An unapproved template name never reaches the API - [ ] Window rejections are terminal, not retried ### 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. ## Phase 4 · Statuses that arrive in the wrong order Delivery state is assembled from observations rather than overwritten by whichever notification landed last. ### Steps 1. Append observations, never update a status Order is not guaranteed, so the last write is not the truth. Files: `src/worker/status.ts`, `migrations/003_message_states.sql` 2. Derive current state by ranking observations One pure function, tested with shuffled inputs. Files: `src/lib/state.ts`, `tests/state-order.test.ts` 3. Sign forwarded events and keep every attempt Including the failures, which are the ones you will be asked about. Files: `src/forward/deliver.ts`, `tests/forward-retry.test.ts` ### Done when - [ ] Out-of-order statuses give the same derived state - [ ] Forwarded bodies are signed and verifiable - [ ] Failed forwards retry on a backoff with history - [ ] Dead-lettered events replay as new attempts ### 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. ## Not in this build - 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. ## After v1, if you want it - 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
# Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. The Cloud API access token. Rotate it on a schedule and never log it. WA_ACCESS_TOKEN=EAAG...redacted # Required · secret. Used to verify the payload signature on every inbound notification. WA_APP_SECRET=redacted-32-hex # Required · secret. Echoed during the subscription verification handshake only. WA_VERIFY_TOKEN=a-long-random-string # Required. The single number this deployment serves. The boot check refuses a list. WA_PHONE_NUMBER_ID=123456789012345 # Required · secret. Messages, raw events and jobs. This is the backup target. DATABASE_URL=postgres://gateway@localhost/wabery # Optional. Where normalised events are forwarded. Leave unset to keep everything local. FORWARD_URL=https://internal.example.com/hooks/whatsapp # Optional · secret. Signs forwarded bodies so the receiver can verify them. FORWARD_SECRET=redacted # Required · secret. The only credential for the admin interface. There are no user accounts. ADMIN_TOKEN=redacted
# Wabery · product brief ## Problem The core loop (send, receive, verify webhooks, retry, inspect conversations) is one-shottable for a single business using Meta's WhatsApp Cloud API. What you cannot one-shot is the reason managed WhatsApp providers exist as products: production messaging infrastructure must stay reliable, secure, monitored, and compliant, while onboarding other businesses requires Meta business verification, app review, and approval for the relevant Tech Provider and Meta Business Partner capabilities. The honest DIY move is to build for your own verified WhatsApp Business Account and phone number; matching Wabery as a multi-tenant provider means operating that infrastructure and completing Meta's approval process. ## Product outcome A gateway one person can run for one business: inbound notifications are stored before they are trusted, sends are idempotent, the messaging window is checked at the moment of sending, delivery state survives out-of-order notifications, and the single-tenant boundary is a boot check rather than an intention. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - Meta Developer account and WhatsApp Cloud API access - verified business, WhatsApp Business Account, and phone number - always-on HTTPS server - PostgreSQL - monitoring and backups ## Explicit non-goals for v1 - 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 ## Success criteria - A forged signature is confirmed stored and never processed - A repeated idempotency key is confirmed to send once - A free-form send outside the window is confirmed refused locally - Out-of-order statuses are confirmed to give the same derived state - A second phone number identifier is confirmed to fail at boot
# Build brief · Wabery The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a single-business WhatsApp gateway that receives, sends and reconciles messages through Meta's Cloud API to replace Wabery. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Runtime: Node 22 with Fastify behind a reverse proxy that holds the certificate · the webhook endpoint has to be publicly reachable and very fast to acknowledge - Storage: PostgreSQL, one schema, migrations checked into the repository · message history is what you will be asked to produce during an argument with a customer - Queue: A job table in the same PostgreSQL, claimed with a conditional update · one fewer service to lose, and the jobs need the same transaction as the rows they create - Inbound: Persist the raw body, acknowledge, then interpret from a worker · Meta retries a failed notification for well over a day and you cannot outrun that with fast parsing - Outbound: One send path that requires a caller-supplied idempotency key · a retried send is a second message on somebody else's phone, and it cannot be recalled - Tenancy: One business account, one phone number, one admin token, no signup · onboarding other businesses is Meta's verification and review process, not a flag you set ### Data model (create this before Phase 1) - 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. ### Phase 1 · Receive before you interpret Build: Implement the subscription verification handshake, then check the SHA-256 payload signature against the app secret using a constant-time comparison. Write the raw body into events_raw in one transaction, respond 200, and do everything else in a worker. A single notification can carry many updates batched together, so unpack and process each update on its own. Trap: 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. Done when: A notification with a wrong or absent signature is stored with signature_ok false and never processed, tested with a forged body. The endpoint acknowledges in a fixed small time budget with the worker stopped, measured. The same notification delivered twice produces exactly one messages row, tested by replaying a captured body. A notification carrying several updates in one batch produces one row per update, tested with a multi-update fixture. Do not build yet: No sending, no window logic, no outbound forwarding yet. ### Phase 2 · One send, however many times you ask Build: Require an Idempotency-Key header on every send. Store the key with a hash of the request payload. A repeat of the same key and the same payload returns the stored result. The same key with a different payload is rejected as unprocessable. A repeat while the first attempt is still in flight is a conflict, not a second send. Record the platform message identifier against the key when it comes back. Trap: 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. Done when: Two identical requests carrying the same key produce one outbound call and identical responses, tested against a stubbed API. The same key with a different payload is refused and no send occurs, tested. A request repeated while the first is in flight returns a conflict rather than sending, tested by holding the stub open. A send that times out mid-flight leaves the key in an unknown state that is resolved by the later status notification, tested. Do not build yet: No window rules, no template handling, no forwarding yet. ### Phase 3 · The window decides what you are allowed to say Build: When an inbound message arrives, set window_expires_at on the conversation from the platform's published customer service window. At send time, and not at queue time, check whether the window is still open. Inside it, send free-form. Outside it, send only a template whose approval state you have checked, and if no suitable template exists, fail the send with a message that says why rather than retrying. Trap: 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. Done when: A free-form send outside the window is refused locally with a named reason and never reaches the platform, tested by moving the clock. Window expiry is derived from the last inbound message time and the published window length held in one constant, tested at the boundary. Template sends check the stored approval state and refuse an unapproved name without an outbound call, tested. A platform rejection caused by the window is classified terminal and is not retried, tested with a stubbed error. Do not build yet: No status assembly, no outbound forwarding, no admin interface yet. ### Phase 4 · Statuses that arrive in the wrong order Build: Append every status notification as a message_states row with its own timestamp and raw payload. Derive the message's current state by ranking the observations rather than by writing to a column. Forward each normalised event to one configured endpoint, signed, with every attempt and response recorded, retried on a backoff, and parked in a replayable dead letter after a stated number of attempts. Trap: 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. Done when: A read notification followed by a delivered notification for the same message leaves the derived state at read, tested by replaying them in both orders. Forwarding signs the body and the receiver can verify it with a shared secret, tested. A receiver returning 500 causes retries on a backoff and the attempt history shows each one, tested. Events parked in the dead letter can be replayed by hand and the replay is recorded as a new attempt rather than by mutating the old one. Do not build yet: No operations work, no export, no rotation yet. ### Phase 5 · One tenant, one token, and a way out Build: An admin interface behind one token showing conversations, derived states, failed jobs and forwarding health, with a reply box. Rotate the access token, app secret and verify token without downtime and document the order. Export every table as newline-delimited JSON and reimport into an empty database. Back up nightly, restore into a scratch database on a schedule, and prove the message count matches. Trap: 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. Done when: Starting with more than one configured phone number identifier fails at boot with an explanatory message, tested. Rotating the app secret is possible with both old and new accepted for a stated overlap, tested by replaying a notification signed with each. Export and reimport round-trips every table and the derived states recompute identically, tested. A restore from last night's backup into an empty database reproduces the message count and the last inbound timestamp, exercised on a schedule rather than documented. Do not build yet: Nothing after this. ### Out of scope (and why) - Onboarding other businesses, including embedded signup flows, because that needs business verification and platform review rather than code, and shipping it half-done gets the number blocked. - Unofficial bridges that drive the consumer client, because they break the platform's published terms and the number they run on is the one that gets banned. - Marketing broadcasts to numbers that never messaged you, which is a consent problem before it is an engineering one. - Flows, hosted functions and a shared team inbox, each of which is a product in its own right rather than a phase of this one. ### README must contain - That this gateway is for exactly one business account, one phone number and one operator, and why the boot check enforces that - The exact platform setup order: application, business account, number, webhook subscription, verify token, template submission - That inbound notifications repeat, batch and arrive out of order, and that the schema deliberately appends rather than updates - That the customer service window is evaluated at send time and what happens when it has closed - How to rotate the access token, app secret and verify token, and how to restore last night's backup
# Architecture · Wabery ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node 22 with Fastify behind a reverse proxy that holds the certificate | the webhook endpoint has to be publicly reachable and very fast to acknowledge | | Storage | PostgreSQL, one schema, migrations checked into the repository | message history is what you will be asked to produce during an argument with a customer | | Queue | A job table in the same PostgreSQL, claimed with a conditional update | one fewer service to lose, and the jobs need the same transaction as the rows they create | | Inbound | Persist the raw body, acknowledge, then interpret from a worker | Meta retries a failed notification for well over a day and you cannot outrun that with fast parsing | | Outbound | One send path that requires a caller-supplied idempotency key | a retried send is a second message on somebody else's phone, and it cannot be recalled | | Tenancy | One business account, one phone number, one admin token, no signup | onboarding other businesses is Meta's verification and review process, not a flag you set | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Ingest | Signature verification, raw storage, acknowledgement. | Any framework, provided it stores and acknowledges before it parses. | | Send | Idempotency keys, outbound calls, unknown-state handling. | Any HTTP client, provided a timeout is never treated as a failure. | | Window | Conversation window computation and terminal classification. | Not swappable for a check at queue time, which is the bug. | | State | Appended observations and the derived state function. | Any store, provided nothing overwrites a status in place. | | Forward | Signed outbound delivery, retries and the dead letter. | Any transport, provided every attempt is retained. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `WA_ACCESS_TOKEN` · required, secret · The Cloud API access token. Rotate it on a schedule and never log it. - `WA_APP_SECRET` · required, secret · Used to verify the payload signature on every inbound notification. - `WA_VERIFY_TOKEN` · required, secret · Echoed during the subscription verification handshake only. - `WA_PHONE_NUMBER_ID` · required · The single number this deployment serves. The boot check refuses a list. - `DATABASE_URL` · required, secret · Messages, raw events and jobs. This is the backup target. - `FORWARD_URL` · optional · Where normalised events are forwarded. Leave unset to keep everything local. - `FORWARD_SECRET` · optional, secret · Signs forwarded bodies so the receiver can verify them. - `ADMIN_TOKEN` · required, secret · The only credential for the admin interface. There are no user accounts. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it.
# Agent instructions · Wabery product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Node 22 with Fastify behind a reverse proxy that holds the certificate, PostgreSQL, one schema, migrations checked into the repository, A job table in the same PostgreSQL, claimed with a conditional update, Persist the raw body, acknowledge, then interpret from a worker, One send path that requires a caller-supplied idempotency key, One business account, one phone number, one admin token, no signup. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - 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. - 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. - 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. - 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. - 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.
# Delivery milestones · Wabery Estimated effort: **multi-day** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Receive before you interpret Every notification is signature-checked, stored raw and acknowledged before any of it is parsed. ### Steps 1. Verify the handshake and the payload signature Constant-time comparison, and a failed check is stored rather than discarded. Files: `src/routes/webhook.ts`, `src/lib/signature.ts`, `tests/signature.test.ts` 2. Persist the raw body, then acknowledge The body is the only evidence; parsing it is a separate concern. Files: `src/routes/webhook.ts`, `migrations/001_events_raw.sql` 3. Unpack batches and deduplicate in the worker One notification can carry many updates and the same update twice. Files: `src/worker/ingest.ts`, `tests/ingest-dedupe.test.ts` ### Done when - [ ] A forged signature is stored and never processed - [ ] The endpoint acknowledges within its time budget - [ ] A replayed notification creates no second row - [ ] A batched notification yields one row per update ### 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. ## M2 · One send, however many times you ask A retried send request never puts a second message on somebody's phone. ### Steps 1. Make the idempotency key the primary key of the attempt Allocated before the outbound call, not after the response. Files: `src/send/idempotency.ts`, `migrations/002_send_requests.sql` 2. Distinguish failed from unknown A timeout is not a failure and retrying one is how people get double messages. Files: `src/send/client.ts`, `tests/timeout-unknown.test.ts` 3. Reconcile unknowns from inbound status notifications The platform will tell you what happened if you keep the key. Files: `src/worker/reconcile.ts` ### Done when - [ ] A repeated key and payload sends once - [ ] A reused key with a changed payload is refused - [ ] An in-flight repeat returns a conflict - [ ] A timeout resolves from the later status ### 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. ## M3 · 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. ### Steps 1. Set the window from the inbound message, not from now The clock starts when they wrote to you. Files: `src/worker/ingest.ts`, `src/lib/window.ts` 2. Evaluate the window in the send path A queued job runs later than it was written, sometimes much later. Files: `src/send/guard.ts`, `tests/window-boundary.test.ts` 3. Classify window rejections as terminal A retry loop against a closed window is an outage you inflicted on yourself. Files: `src/send/errors.ts` ### Done when - [ ] A late free-form send is refused locally - [ ] Window expiry derives from the last inbound time - [ ] An unapproved template name never reaches the API - [ ] Window rejections are terminal, not retried ### 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. ## M4 · Statuses that arrive in the wrong order Delivery state is assembled from observations rather than overwritten by whichever notification landed last. ### Steps 1. Append observations, never update a status Order is not guaranteed, so the last write is not the truth. Files: `src/worker/status.ts`, `migrations/003_message_states.sql` 2. Derive current state by ranking observations One pure function, tested with shuffled inputs. Files: `src/lib/state.ts`, `tests/state-order.test.ts` 3. Sign forwarded events and keep every attempt Including the failures, which are the ones you will be asked about. Files: `src/forward/deliver.ts`, `tests/forward-retry.test.ts` ### Done when - [ ] Out-of-order statuses give the same derived state - [ ] Forwarded bodies are signed and verifiable - [ ] Failed forwards retry on a backoff with history - [ ] Dead-lettered events replay as new attempts ### 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. ## M5 · One tenant, one token, and a way out (production only) The gateway is operable by one person: secrets rotate, history exports, and the single-tenant boundary is enforced rather than assumed. ### Steps 1. Enforce the one-number assumption at boot So the second tenant is a decision and not an accident. Files: `src/config.ts`, `tests/single-tenant.test.ts` 2. Rotate secrets with an accepted overlap Otherwise rotation means dropping notifications. Files: `src/lib/signature.ts`, `docs/rotation.md` 3. Restore into a scratch database on a schedule A backup nobody has restored is a rumour about a backup. Files: `scripts/restore-check.sh`, `docs/operations.md` ### Done when - [ ] A second phone number identifier fails at boot - [ ] Secret rotation accepts both values for an overlap - [ ] Export and reimport round-trips every table - [ ] A scheduled restore reproduces the message count ### Watch 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.
# Operations · Wabery ## Backup Nightly logical dumps of the whole database, kept offsite with a stated retention. The raw events table is the largest and the most valuable, because it is the only record of what the platform actually sent you. ## Restore Restore into an empty database, apply migrations, confirm the message count and the most recent inbound timestamp, then recompute derived states and compare. A restore that changes a derived state means the state function reads something outside the observations. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Webhook acknowledgement time, the rate of signature failures, the depth of the job table, the age of the oldest unreconciled send key, and dead letter size. A rising signature failure rate usually means a secret was rotated on one side only. ## Incident checklist The two incidents that matter are duplicate outbound messages and a webhook endpoint that stopped acknowledging. The first is diagnosed from send_requests, the second from the gap in events_raw, and both are why the raw bodies are kept rather than parsed and discarded. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] A forged signature is confirmed stored and never processed - [ ] A repeated idempotency key is confirmed to send once - [ ] A free-form send outside the window is confirmed refused locally - [ ] Out-of-order statuses are confirmed to give the same derived state - [ ] A second phone number identifier is confirmed to fail at boot ## Launch constraint Do not market omitted Wabery capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered.
# Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. The Cloud API access token. Rotate it on a schedule and never log it. WA_ACCESS_TOKEN=EAAG...redacted # Required · secret. Used to verify the payload signature on every inbound notification. WA_APP_SECRET=redacted-32-hex # Required · secret. Echoed during the subscription verification handshake only. WA_VERIFY_TOKEN=a-long-random-string # Required. The single number this deployment serves. The boot check refuses a list. WA_PHONE_NUMBER_ID=123456789012345 # Required · secret. Messages, raw events and jobs. This is the backup target. DATABASE_URL=postgres://gateway@localhost/wabery # Optional. Where normalised events are forwarded. Leave unset to keep everything local. FORWARD_URL=https://internal.example.com/hooks/whatsapp # Optional · secret. Signs forwarded bodies so the receiver can verify them. FORWARD_SECRET=redacted # Required · secret. The only credential for the admin interface. There are no user accounts. ADMIN_TOKEN=redacted
$ choose a build depth, inspect the files, then open the complete pack in your agent
The REST endpoints are the easy part. Wabery packages Meta onboarding, secure multi-tenant infrastructure, durable event delivery, Flows plumbing, monitoring, and ongoing API and policy changes into a service that is ready for production. A DIY gateway is reasonable for one business; becoming an approved and reliable provider for other businesses is a company, not a weekend project.
xMeta Business Partner status and approved Tech Provider onboarding path
xturnkey onboarding for multiple customer businesses and phone numbers
xmanaged queues, retries, monitoring, security, and production reliability
xWhatsApp Flows builder, encryption, and data-exchange hosting
xhosted functions, MCP tooling, and a production team inbox
Don't feel like building it? These folks already made it free.
no votes, no pay-to-list · just what's real
Wabery pricing
| plan | monthly | annual (per mo) | what you get |
|---|---|---|---|
| free | $0/workspace | $0/workspace | 500 messages/month; all product features. |
| 125k messages | $15/workspace | — | 125,000 messages/month; all product features. |
| 600k messages | custom | — | 600,000 messages/month; all product features. |
| 3.5m+ messages | custom | — | 3,500,000+ messages/month; all product features. |
free tier500 messages/month; all features
billingmonthly and cancel anytime; no annual plan published
hidden costsMeta's WhatsApp conversation/message charges are billed directly to the connected WhatsApp Business Account and are not included; AI-model tokens are also separate.
verified 2026-08-14 · source ↗
Vibecode Wabery
Kinda. The core of Wabery is buildable in a weekend with the prompt on this page, but there are real gaps: Meta Business Partner status and approved Tech Provider onboarding path, turnkey onboarding for multiple customer businesses and phone numbers. Read the honest list above before committing.
How much does Wabery cost?
Wabery costs about $15/month (125K messages, checked 2026-07-30), which is $180 per year.
What do I lose by replacing Wabery?
Honestly: 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. If any of those are load-bearing for you, keep paying.
Is there an open-source alternative to Wabery?
Yes: Whatomate (The missing inbox, flow builder and API for Meta's Cloud API; Meta still sends the bill.) The prompt is for when you want it exactly your way.