Vendwa

An autonomous WhatsApp sales agent, built and operated by Bestdid Technology Ltd in Accra.

A vendor reading a customer chat on a phone.

Vendwa is an autonomous sales agent that runs a vendor's customer conversations on WhatsApp and Instagram. It is operated by BestDid Technology Ltd, our Ghana-registered entity. The product lives at vendwa.com.

This is a note about how it is built, not about what it sells.

The constraint

Two things shaped every decision.

The first is that we do not own the transport. Every message arrives through Meta's WhatsApp Cloud API, which redelivers on its own schedule, signs payloads with an app secret we have to verify, and refuses to send a free-form reply outside a 24-hour window opened by a real inbound message. During the pilot the platform's free test number would only talk to five pre-approved recipients, and outbound sends to anyone else bounced with error 131030. The agent received, reasoned, wrote a reply, stored it, and then the send failed silently at the last hop. That looked exactly like a broken agent for a week. It was an allowlist.

The second constraint is harder. A sales conversation is mostly arithmetic, and a language model is bad at arithmetic in a way that is invisible until money is involved.

The stack, and why

Node and TypeScript, Express, Prisma against Postgres, the Anthropic SDK running a tool-use loop, and Vitest. Deployed on Railway behind api.vendwa.com.

Prisma started on SQLite because a concierge pilot with three vendors did not need anything else. We moved to Postgres before the second vendor, which was the right time: early enough that the migration was one afternoon, late enough that we had not paid for a database nobody was using.

Tests run against PGlite, the Postgres engine compiled to WebAssembly, injected into the shared client through a test-only hook. That is not a preference. This machine cannot run Docker, and the shell runs elevated so a native Postgres server refuses to boot. PGlite was the only local Postgres that worked, and each test file gets its own fresh in-memory instance.

left column shows three inbound sources (WhatsApp Cloud API, Instagram, Messenger) converging on a single box labelled `POST /webhook`.

Three decisions

Money never comes from the model. Every price, discount and total the agent states has to be returned by a tool call in the same turn. counter_offer handles one item. quote_total handles a multi-item cart and clamps the result between the combined floor and the asking total. capture_order returns a payment line the agent must repeat verbatim. The trade-off is real: every new pricing shape needs a tool before it can exist, so bundles, delivery fees and per-unit discounts each cost a code change rather than a prompt edit. We think that is the correct price. A prompt rule the tools cannot satisfy is a rule the agent will break.

Three guarantees live in code, not in the prompt. Catalogue grounding, the floor price, and silence during a human takeover are enforced by the handler and the tools. The model can be talked into anything. capture_order cannot.

One webhook, many channels. When Instagram DMs and Facebook Messenger were added, we did not add endpoints. A channel-adapter registry dispatches on the top-level object field of the incoming payload, and conversations are keyed by (channel, channelUserId) rather than by phone number. The agent, the tools and the order path did not change. Instagram costs us a daily cron to refresh a 60-day token and its own app secret for signature checks, because a nested Instagram app is signed with a different secret than the WhatsApp one. That took a while to find.

What broke

A customer on the demo shop asked for GHS 50 off each of four smocks. The asking total was GHS 3,100, so the answer is GHS 2,900. The agent said GHS 3,000, captured the order at GHS 3,000, sent a receipt for GHS 3,000, then agreed to GHS 2,900 when the customer corrected it. Verbal deal and written receipt now disagreed.

The root cause was ours. quote_total returned only the asking total and the floor total. A per-unit discount had no code path, so the arithmetic had nowhere to go except the model's head. Swapping models does not fix that class of bug. Removing the arithmetic does.

Two rules came out of it. Extend the tool before you write the prompt rule. And once capture_order returns ok, the recorded number is the number: the agent holds it and escalates to the owner rather than agreeing a new total, even when the customer is right.

A related fix landed the same season. The escalate tool used to set the conversation status to escalated, which is the same flag the handler reads to stay silent during a human takeover. Looping the owner in therefore muted the agent from the customer's next message. Only a real operator reply sets that flag now.

Where it is now

Live in production on Railway, with WhatsApp serving customers and Instagram connected through an OAuth flow at api.vendwa.com. Payments, self-serve onboarding and an iOS companion app have shipped since. Meta App Review for the Instagram messaging permissions was the long pole, not the code.

Built by BestDid

We built and operate Vendwa. The product, its pricing and its terms live at vendwa.com, run by BestDid Technology Ltd in Ghana.

Two more from the same shelf: the Geotally case study covers multi-engine orchestration and cost control, and the FixAm case study covers phone-verified reviews. The full list is on our work page. If you want something like this built, start at services.

Visit Vendwa