Our own infrastructure, in production

The wallet is charged before the message leaves

The hub is the layer that sits between our products and the official WhatsApp API: it sends, receives, charges and keeps the evidence of every conversation. The constraint that shaped everything downstream is commercial before it is technical: Meta charges per conversation and not per attempt, so a queue that retries without control does not produce an ugly log, it produces an invoice.

  • 2, each with its own number and brand
    Products in production on the hub
  • 29
    Templates approved by Meta
  • 5, plus a dead-letter queue in Postgres
    Work queues
  • 25 documents
    Recorded architecture decisions
  • 523 unit and 46 integration
    Automated tests
  • about 5 minutes
    From push to code live
  • R$ 50 by default, adjustable
    Daily spend ceiling per product

The channel everyone wants and the invoice nobody sees coming

Every company wants to talk to customers where they already are, and they are already on WhatsApp. Meta's official path, however, charges per conversation opened, and the price changes with the category: marketing costs more than utility and authentication, while a service conversation, opened inside the twenty-four-hour window after a customer reply, costs nothing at all. Whoever integrates without understanding that table does not make a mistake in a report, they make one on an invoice.

The second problem is not price, it is repetition. The Cloud API accepts no idempotency key on send, so a POST that hits the timeout may or may not have delivered the message, and the honest answer to that ambiguity is to try again. Without a uniqueness contract of our own sitting above the transport, every retry becomes a new billable conversation and, worse, a duplicate message on the phone of someone who never asked for two.

The third is legal and forgives no improvisation: a marketing message requires recorded consent, an opt-out request has to take effect immediately, and the history has to exist so it can be exported or erased later. Solving all three once, in a single place, is cheaper than solving each of them halfway inside every product: that arithmetic is what created the hub.

One layer, two products, and a brand that never shows up

The hub is multi-tenant by design and not by configuration: each product enters through its own API key, holds its own wallet and sees only its own data, while routing takes each tenant to its own phone number. Two products run this way in production today, under MVAS's Meta portfolio and with a separate WhatsApp Business account for each. The cap is two numbers per portfolio and rises to twenty once the portfolio is verified, so growing from here is an account decision, not new construction in the code.

What makes this work as white label is a detail of WhatsApp itself: the display name is approved per number, individually, by Meta. Whoever receives the message sees the brand of that number and nothing beyond it: the intermediary signs no footer, appears in no profile and never enters the conversation. Changing the name later requires a new approval, so the flexibility lives in the tenant-to-number routing, not in the message.

One of our own assumptions fell to measurement along the way. We assumed Meta only hands out test numbers and that production would demand a line of our own, with a SIM card and verification by SMS or voice. In the display-name flow, Meta provisions one of its own numbers that is born already live, verified and in the tier of two hundred and fifty unique conversations per day: we checked both through the Graph API before believing it. Bringing our own line is still possible, but for ownership and scale, not as a requirement.

The debit happens before Meta is called

The rule that organises everything else is a simple inversion: the product's wallet is charged before Meta is called, and reconciliation comes afterwards, when the callback confirms what actually happened. It is the opposite of the intuitive choice, because charging on confirmed delivery feels more natural: except that charging afterwards means spending with no balance during the whole window between the call and the reply, and that window is exactly where a defective queue multiplies the damage.

Prices come out in Brazilian reais, and the exchange rate must never bring a send down. The calculation uses the daily central-bank rate with a configurable spread, and the degradation is chained: if the day's quote fails, the cache stands; if the cache fails, the last rate stored in the database stands; if none of that exists, the fixed environment rate stands. The exchange service raises no exception on principle, because money cannot stop over a third party's API. Every number enters production with forty-eight pricing rules seeded.

On top of that sit the spending locks. There is a daily ceiling per product, fifty reais by default and adjustable, so that a badly written loop on the other side does not turn into a four-figure invoice. Scheduled sends accept up to thirty days ahead and are charged on acceptance; cancelling up to sixty seconds before dispatch refunds the amount and emits the cancellation event. A campaign is all or nothing: the full amount leaves the wallet when it is accepted and, if something fails midway, the batch is cancelled and refunded in full instead of going out half done.

A double refund is the mistake nobody catches by reading logs. We closed that door in the database, with a partial unique index over the refund reference, and taught the wallet service to treat a violation of that index as work already done rather than as a failure: the second attempt becomes a silent no-op instead of taking a worker down. The lock lives in the database, so it holds even when the mistake is in the code.

Deliver once, and only once

Idempotency runs on a key chosen by the product: the same message identifier arriving twice produces a single effect. That is what makes retrying safe, and retrying is mandatory, because the Cloud API offers no idempotency key of its own and a timeout on send is ambiguous by nature. We assume at-least-once delivery in the transport and solve uniqueness above it, where we have a database and control.

The retry policy is a taxonomy, not a number. A 400-range error from Meta is deterministic and is never re-POSTed by the HTTP client; a 500-range error and rate limiting surface flagged as recoverable and let the queue try again with growing backoff; and Meta's send limit, which is per number and grows with quality, becomes a throttle keyed by phone number identifier inside the send worker. The errors that change what the product should do: expired twenty-four-hour window, undeliverable recipient, mismatched template parameter, expired token: are mapped one by one, by name, instead of landing in a generic bucket.

In the opposite direction, Meta's webhook comes with a timing contract: answer 200 within twenty seconds, always, even on internal error, or Meta resends the event. So the route verifies the signature before processing anything, publishes the event and returns; the real work happens in five separate queues, one for sending, one for the inbound webhook, one for the callback to the product, one for data erasure on request and one for auto-reply. Whatever fails after every attempt does not vanish: it lands in a dead-letter queue in Postgres, with the reason recorded and a path back to reprocessing.

That same send path carries the verification code as a service. The product asks for issuance, the hub generates six digits, stores only the BCrypt hash (never the code in the clear, never in a log), applies a double limit in Redis by phone number and by product, invalidates the previous code on every new issuance and settles verification with an atomic state swap, so that two simultaneous clicks cannot consume the same code twice. Sending reuses the flow that already exists, with charge, queue and idempotency already solved, instead of opening a second door for money.

Consent as a lock in code, not a clause in a contract

A marketing message only goes out to someone who recorded consent, and the check is closed by default: with no opt-in record, the send is refused with a dedicated error before the charge, not after it. A campaign does not discover this midway through dispatch: it pre-filters the list and reports how many recipients were left out for lack of consent. A template of unknown category resolves as marketing, because when in doubt the conservative decision is the one that does not generate a complaint.

Opting out is equally literal. An inbound message carrying the exact unsubscribe word marks the contact as off the list, with no auto-reply and no ceremony; the match is exact on purpose, so that the same word in the middle of a sentence does not remove someone who never asked. And the marking runs on a best-effort basis: if it fails, it does not bring down webhook processing, because losing the whole event would be worse than repeating the attempt.

The rest is data hygiene treated as code. Retention, export and erasure on request run in a worker of their own, with the request becoming traceable work instead of a manual chore. Meta tokens sit encrypted at rest with AES-256-GCM, the products' API keys exist only as hashes, and a phone number is treated as personal data across the whole logging mesh: at most the last four digits ever appear.

What holds this up when nobody is watching

The operation runs on an eight-gigabyte VPS with Docker Compose and Caddy handling the certificate, with no orchestration platform in between: an explicit decision not to spend memory on a dashboard. In production only the proxy publishes a port; database, queue, API and console stay on the internal network, and the API container runs without root privilege. Every change on the main branch goes through the tests, becomes an image published to the registry and reaches the VPS through the updater's cycle: from push to code live, around five minutes, with no SSH and no production credential stored in CI.

The verification mesh is what allows changing things without fear: five hundred and twenty-three unit tests and forty-six integration tests running against real Postgres and Redis in containers, plus the console's end-to-end tests, eighteen versioned migrations and twenty-five written architecture decisions: each one stating what was decided, what was discarded and why. That is why integrating with the hub does not depend on anyone remembering anything.

Two recent findings say more than any declaration of rigour. The console's real-time feed had not worked in the browser for weeks without producing a single error in any log: hijacking the response to write the event stream discards the headers set by hooks, including the CORS one, and EventSource's own automatic reconnection masked the failure every five seconds. The other was about observability: the metrics route label fell back to the raw path whenever the route did not exist, and a scanner with ten thousand paths would create ten thousand series and take the machine's monitoring down. That label is now a constant, and the set of possible values for each one is closed before the metric exists.

None of this is a mockup: the hub answers on its own address, the console on another, and two products in production are connected by API key, each with its own number and its own templates approved by Meta. Part of the path is finished and switched off on purpose: Pix top-up is waiting on credentials, the AI auto-reply exists behind a per-product flag and is currently off for both, and Meta's embedded signup is waiting on app review. We prefer it that way: switching something on through an environment variable is a business decision, writing it again is construction work.