Case Study · Independent Build
WhatsApp Commerce V2: Rebuilt for What the Demo Didn't Show
In August 2026 I built a WhatsApp Catalog + Cart ordering channel for a fictional restaurant. It took real orders, and then its own logs showed what the demo hid: every delivery-status webhook thrown away, errors answered with HTTP 200, and no record of what it had actually sent. Version two is a production-oriented reference implementation rebuilt around those problems, and verified against real Meta traffic.
At a glance
- What it is
- A WhatsApp ordering channel: a customer browses a restaurant's menu in WhatsApp's native Catalog, sends a Cart and gets a receipt; an operator accepts or rejects the order and the customer is told. The Local Table is a fictional restaurant, and its menu is seed data.
- Where it runs
- Deployed with Kamal over HTTPS at whatsapp.railsfanatics.com, whose home page explains the project. The public menu and catalog feed are open; the operator console is private.
- How it was checked
- 1,112 RSpec examples, including real-thread concurrency specs, every ordering of delivery statuses and sanitized real V1 payloads; CI with RuboCop, Brakeman and bundler-audit; a backup restore drill; and real Meta traffic: a verification session on 6 October 2026 and a second order recorded on camera on 7 October. Catalog API push, the 24-hour window block and real duplicate or mismatched orders were not verified live.
- Hardest problems
-
- Storing every webhook before acting on it, without losing Meta's retries.
- Processing each delivery idempotently when Meta sends duplicates and statuses out of order.
- Sending through an API with no idempotency key, without resending when the outcome is unknown.
- Turning a provider error into a diagnosis.
- Timeline
- V1's live run, 8 August 2026. V2 rebuilt and verified against Meta in October 2026; public release on 7 October 2026.
- Stack
- Ruby 3.4, Rails 8.1, PostgreSQL 17, Solid Queue, Faraday, RSpec; Kamal 2 with kamal-proxy and Let's Encrypt; the WhatsApp Cloud API.
- My role
- A solo, AI-directed build: I designed, directed and verified it, and Claude Code wrote the code under that direction. Every action in Meta's dashboards was mine, by hand.
- More
- The live project · the repository · the case study · the technical deep dive · every claim and its evidence
The problem
Version one worked in a demo: a CSV feed for Commerce Manager, a webhook that created orders, and automatic replies. Its live run on 8 August 2026 took three real orders. Then I read its own logs. Meta had sent 52 webhook requests, 36 of them delivery statuses, and every status had been thrown away. Seven errors had been answered with HTTP 200 and kept only in a log line. None of its ten outbound rows held a Meta message id, so it could not say what it had sent, and Meta had delivered one status twice with nothing in the code to notice. Five of the errors were the same code, 131009, with three different causes that a log line could not tell apart.
Four platform truths
- Webhooks are at-least-once and unordered: the same status can arrive twice, and
deliveredcan be skipped when a message is read at once. 200 OKfrom the send API is not delivery; the truth arrives later, in status webhooks, or not at all.- Failures are configuration-dependent: one error code can mean a bad request or a switch that is off in a settings page.
- The send API has no idempotency key: a timeout after the request was written may mean Meta already has the message.
Inbound: store first, then process
Each webhook is checked against its HMAC-SHA256 signature over the raw body, and an unsigned or forged request is refused before anything is stored. A valid delivery is stored together with its background job in one PostgreSQL transaction, and only then acknowledged; if the database fails, the app answers 500 so Meta retries. A background job applies each item once, keyed by its Meta message id with a unique constraint, so a duplicate changes nothing and one bad item does not roll back the others. Stored deliveries can be replayed safely, with the signature checked again.
Outbound: an outbox and a forward-only lifecycle
Every reply starts as a row in an outbox with its own idempotency key, written in the same transaction as the
decision to send it. The send job claims the row with a conditional update, and Meta is never called inside a
database transaction. If a send times out after the request was written, the message is marked
unknown and never resent automatically. Status webhooks then move each message forward only, sent to
delivered to read, each timestamp written once, matched by Meta's message id and by a correlation id we send with
the message.
Errors, orders and the operator
Meta's error code and details are stored on the message and classified as retryable, permanent, configuration or ambiguous, and only fixable categories offer a resend. Every order is recorded at the price the customer saw and compared with the catalog; a mismatch or an unknown item is flagged for review rather than rejected. A private operator console shows orders, conversations with delivery ticks, every stored delivery with a replay button, and a health page for failures, unknown outcomes, undelivered and blocked messages.
Verification against real Meta traffic
On 6 October 2026 the deployed app ran against Meta's real WhatsApp Cloud API, with me as the only customer. All ten real webhook deliveries were applied once. The first catalog card failed with error 131009; the details stored on the message said the catalog must be linked and enabled in the number's commerce settings, and the new number had it switched off. I fixed that by hand in Meta, and the next "Hi" got a working card; nothing was retried automatically. A real order, two items for $24.50, was checked against the catalog and accepted, every sent reply was tracked to read, and our correlation id came back on all seven status webhooks.
The next day, separately, I recorded a second real order on my phone: WhatsApp's own catalog, the cart, the app's receipt, and the confirmation after I accepted it in the console.
What was not verified
This is a production-oriented reference implementation, not a claim of production-scale operation: one test customer, no uptime or volume figures, and a planned multi-week operating period that I decided not to run. Catalog API push, the 24-hour window block, a real duplicate handled by V2, real price mismatches and real ambiguous sends were not verified against Meta; specs and a synthetic demo cover them.
Outcome
- Released on 7 October 2026 and deployed over HTTPS with Kamal, with a nightly backup and a passed restore drill; the live home page explains the project to a first-time visitor.
- 1,112 RSpec examples, 0 failures, and green CI with RuboCop, Brakeman and bundler-audit.
- A case study, a technical deep dive and a claims matrix that maps every claim to its evidence and says what is not claimed.
How it was built
A solo build directed through Claude Code: I designed the system, set the rules, reviewed the evidence and did every Meta step by hand. Four review rounds by a separate AI model found real defects before release, among them a late status for an old message that could be applied to its resend.
The lesson
A messaging integration is only as trustworthy as its record of what actually happened.
- Store first, interpret later: a stored delivery can be replayed, a dropped one cannot.
- Treat "sent" as a claim, and let the provider's callbacks confirm it.
- When a send's outcome is unknown, say so instead of guessing.
- Keep the provider's error details: they turned a failure into a one-step fix.
Links
- Live project: whatsapp.railsfanatics.com
- Source: github.com/amitkssolanki/whatsapp-integration
- Case study · Technical deep dive · Claims and evidence (each on GitHub)
- The V1 field guide on this site: Shipping WhatsApp's Native Catalog + Cart
- Related: Platestead (restaurant ordering, the POS side) and clicksend (the same rule: never repeat an ambiguous send)
The Local Table is a fictional restaurant. State as of 8 October 2026.
Facing a build like this?