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.

36 of 36 V1 delivery-status webhooks discarded, from its own logs
10 real webhook deliveries in the verification session, each applied once
2 real orders end to end, the author as the only customer
7 of 7 real status webhooks that echoed our correlation id
1,112 RSpec examples, 0 failures; CI green

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
  1. Storing every webhook before acting on it, without losing Meta's retries.
  2. Processing each delivery idempotently when Meta sends duplicates and statuses out of order.
  3. Sending through an API with no idempotency key, without resending when the outcome is unknown.
  4. 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 delivered can be skipped when a message is read at once.
  • 200 OK from 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.

Inbound: store first, then process. Meta sends webhooks for messages, carts and delivery statuses. Each request is checked against its HMAC-SHA256 signature over the raw body; an unsigned or forged request gets 401 and nothing is stored. A valid delivery is stored with its background job in one PostgreSQL transaction, and the app answers 200 only after the commit; if the database fails it answers 500, so Meta retries. A Solid Queue job applies each item once, keyed by its Meta message id under a unique constraint: a duplicate changes nothing, and one bad item does not roll back the others. An order is recorded at the price the customer saw and checked against the catalog; a mismatch is flagged for review. Stored deliveries can be replayed safely, with the signature checked again.
Inbound webhooks: stored with their job before the 200, then applied once per item. Full diagram.

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.

Outbound: an outbox and a forward-only lifecycle. Every reply starts as an outbox row with an idempotency key and a send job, 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. Meta's send API has no idempotency key, so there are three outcomes: accepted, where Meta's message id is stored; failed, where the error code and details are stored and classified; and unknown, a timeout after sending, which is never resent automatically. For an accepted message, status webhooks move it forward only: sent, then delivered, then read, each timestamp written once; delivered can be skipped when a message is read at once. Each status is matched by Meta's message id and by a correlation id sent with the message.
Outbound replies: an outbox row first, three possible outcomes, then status webhooks that only move forward. Full diagram.

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.

WhatsApp chat with The Local Table: a sent cart of 2 items at $20.50 (estimated total), then the reply "Thanks! We've received your order #11 (2 items). We'll confirm it shortly.", then "Good news! Your order #11 is confirmed: 2 items, total $20.50. Thank you for choosing The Local Table!"
The second real order, 7 October 2026, recorded on my phone: the sent cart, the app's receipt, and the confirmation after I accepted it.

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.

The Local Table is a fictional restaurant. State as of 8 October 2026.

Facing a build like this?

← All case studies