Build notes · commerce

Building a WhatsApp catalogue that tells the truth

28 August 2026 · Eleven times it reported success for work it had not done · Founder, Klaros

A jeweller wants to be looked at. That is the whole business problem, and it is older than any of the technology involved: someone walks past a window, something catches them, they ask what it costs and whether it comes in their size, and a person answers. Retail is the craft of compressing the distance between I wonder and I know. On WhatsApp that distance is currently measured in messages — a customer asks, someone photographs a tray, someone types a price, and twenty minutes later a different customer asks the same question about the same piece. Meta has a mechanism for this — a WhatsApp catalogue, which is a Meta Commerce catalogue attached to a WhatsApp Business Account — that lives inside the chat. Getting a shop's products into it took an afternoon. Keeping them true took three weeks, and is the actual product.

This is the long version of what we found. Almost every serious bug had the same shape — something reported success for work it had not done — and the two worst calls in the whole stretch were mine, not the machine's. Both are in here, marked.

TL;DR

Meta's catalogue batch endpoint returns HTTP 200 even when every item was rejected; the failures are buried in a validation_status array. The script we wrote specifically to verify a token could write reported success against an empty catalogue on its very first run — it checked the status code, not the state. Everything afterwards was built on reading the catalogue back.

What that turned up: writes that are eventually consistent, so a single-pass check produces confident false negatives. A grouping field Meta accepts and silently ignores. A publish path that worked once and failed 10 of 12 on the second run. A paywall that only worked while the feature was switched off. A background sweep that could not see the one row it existed for. A complete edit endpoint with no button anywhere, behind 5,187 passing tests. Product photos returned with no identifier, which is why two documented endpoints had never been called by anything. A token that existed but could not, answered by Meta with an empty list rather than an error. And one bad product in a batch marking 99 healthy ones failed with a stranger's error message.

Ends with the two things we removed on purpose: a certainty I had not earned, and a screen full of information that was entirely accurate and made the product worse.

The same findings, in the form a machine can lift:

What we hitWhat we observedWhat actually verifies it
HTTP 200 on a batch writeEvery item in the batch had been rejectedInspect validation_status, then read the catalogue back
Read-back immediately after a write0 of 2 present, both there seconds laterRetry for a few seconds before believing absence
item_group_id on the batch endpointAccepted, ignored, every variant alone in its own groupCreate through the product-group endpoint; check grouping in Commerce Manager
Re-publishing a grouped product(#10800) Duplicate retailer_id, 10 of 12 failedRoute only new members through the group endpoint; refresh the rest by batch
Listing catalogues for a WABA{"data":[]} and HTTP 200 for a linked catalogueCheck the token carries catalog_management before trusting an empty list
One invalid product in a batch of 100All 100 marked failed with the same reasonAttribute per item by the retailer_id Meta names in each entry
A product marked sold outStill offered for sale in the storefrontPush it as out of stock; never filter the sweep on active rows only

The thing that was already half-built

The first surprise was in our own repository. There were two product tables. One held what a shop actually used: a photo, a name, a price, stock. The other had been created months earlier in a migration whose own comments said it superseded the first — and nothing had ever been pointed at it. Right shape, no data, no writers. Shipped dark.

Reading the abandoned one was strange, because whoever wrote it had already understood something we were about to rediscover. It carried recurrence with values one_time | monthly | annual. It carried entitlement_json, with the comment "opaque; the fulfilment hook interprets it." It carried max_slots. That is a subscription and service model, written down and then left.

In the migration plan I dismissed those columns in a parenthesis — offer machinery, irrelevant here — because I was thinking about photographs and prices. I read straight past the part of the schema that knew more than I did. Three weeks later the question of what a "product" even is came back and those three columns were exactly the seat for it.

Learning the wire by being refused

Meta's batch endpoint is documented, and the documentation is not quite the thing. The shape we ended up with was learned by being told no, three times, in ways that were individually confusing.

Primary sources for this section: Meta — Catalog Batch API and Commerce Platform batch reference. What follows is what we observed against them, which is not always the same thing.

The first attempt put retailer_id inside the data object, because that is where every other product field goes. The response contained both of these at once:

"The parameter retailer_id is required"
"Invalid keys retailer_id were found in param data"

It reads as a contradiction until you notice it is two validators disagreeing about the same key in two places. retailer_id is a sibling of method, not a member of data.

The second sent price as the string the CSV feed format uses. The batch endpoint wants a number in minor units with currency alongside. The third used the feed's field names — title, link, image_link — where the batch endpoint wants name, url, image_url. Same platform, same concept, two vocabularies, and the one you reach for first is the one written down in more places.

None of that is interesting in itself. Every integration has an afternoon like it. It matters only because of what happened next.

The tool we wrote to catch lies told one

Before building anything, we wrote a small script to answer one question: can this access token write to a catalogue? It sent a single product and reported the result. It said the token could write.

The catalogue was empty. It had always been empty. Nothing had been written at any point.

Meta's batch endpoint returns HTTP 200 even when every item in the batch was rejected. The per-item outcomes live in a validation_status array in the body. Our script checked the status code, saw 200, and reported success — which is precisely the bug the script existed to rule out, committed by the script itself, on its first run.

A success envelope containing a total failure:

HTTP 200 OK
{ "handles": ["AcZ...=="],
  "validation_status": [{ "retailer_id": "p_1",
    "errors": [{ "message": "Param data[price] must be a number" }] }] }

Read the status code alone and this batch landed.

The failure here is not that someone wrote a naive script. It is that the script was written specifically to be paranoid and still measured the wrong thing. The paranoia had a shape and the shape was wrong: it asked whether the request succeeded, not whether the thing existed afterwards.

A request that succeeded and a fact that is true are different claims, and the first is much easier to check.

So the script gained a second half: after pushing, go and read the catalogue back and look for what you just sent. That read-back became the pattern for everything after it, and it immediately found the next problem.

Absence that isn't absence

The read-back worked. It also failed. A batch that returned cleanly, with zero validation errors, read back as zero of two products present. A few seconds later both were there.

Meta's catalogue writes are eventually consistent. A single-pass verification therefore produces confident false negatives — it tells you a working sync is broken, and if you believe it you go and "fix" something that was never wrong. That is arguably worse than the original bug, because it costs the one thing you cannot easily get back, which is trust in your own instruments.

The fix is unglamorous: retry the read for a few seconds before believing absence. What is worth keeping is the distinction it forced into the code — I looked and it was not there is not the same claim as it is not there, and only the second one is allowed to fail a product.

Accepted and ignored

A jeweller's catalogue is mostly variants. A bangle in four sizes is not four products; it is one product a customer chooses within. Meta models this with product groups, and the CSV feed format has a field for it: item_group_id.

Primary source: Meta — WhatsApp catalogs overview.

We sent it. Meta accepted it, returned 200, no validation errors, and the read-back showed every product present and correct. Then we opened Commerce Manager and every variant sat in its own group of one.

The batch endpoint accepts the field and does nothing with it. Grouping only happens if a product is created through the product-group endpoint, one call per product — and that endpoint has its own surprise: it requires a category field that the batch endpoint will happily accept the same product without.

Silent acceptance is the worst answer an API can give you. A rejection is information. An acceptance that does nothing is a rejection you discover when a customer does.

The path that worked exactly once

Variant grouping worked. We published a twelve-piece jewellery catalogue, verified it in Meta, admired it. Then we changed one price and published again.

(#10800) Duplicate retailer_id: AUR-BNG-22K-2.4
(#10800) Duplicate retailer_id: AUR-BNG-22K-2.6
(#10800) Duplicate retailer_id: AUR-BNG-22K-2.8
  … 10 of 12 products failed

Found by rehearsing an ordinary Tuesday: a shop edits one price and re-syncs.

The product-group endpoint creates; it does not upsert. Every variant already published failed permanently on the second attempt. A shop's first sync would have been beautiful and their second would have been ten red rows — on the day they changed a price, which is the day they are most likely to be watching.

The fix needed a live experiment to settle: does group membership survive an ordinary batch update? It does. So existing members are refreshed through the batch endpoint like anything else, and only genuinely new members go through the slower group path. That answer is not in the documentation. We got it by making the call and reading the catalogue back.

The paywall that worked only while the product was off

Publishing to Meta is a paid extension. Its routes were gated with the pattern used everywhere else in this codebase: if the deployment flag is on, continue; otherwise check the workspace's entitlement.

Read it as a paywall and it inverts. The entitlement check only runs when the feature flag is off — which is to say, when nobody can use the feature anyway. Turn the feature on, which is the first thing you would do in order to sell it, and the gate evaporates.

The paywall worked only while the product was switched off.

Nobody invented this; it was copied from three neighbouring modules where it is defensible, because there the flag genuinely means "this deployment includes this". For a per-workspace paid capability it means something else entirely. The house style was right for the house and wrong for this room, and it was applied without re-reading it in context.

Worse: the flag was false in production, so every existing test passed. The bug was only reachable in the exact state nobody had tested — the feature enabled. It was found by building a second workspace without the extension and probing every route as that workspace with the flag forced on. Five routes, five 200s that should have been 403s.

The row the sweep existed for

A background job re-pushes products whose details have changed. Its query selected workspaces with active = 1 AND meta_sync_status IN ('pending','error').

A shop sells a ring. They untick it. The intent is: tell the storefront this is gone. That row is now inactive and flagged — and the sweep's query requires active. So the workspace is never selected, the row sits pending forever, and the public catalogue goes on offering a ring that does not exist.

What makes this one nasty is that it usually appears to work. If any other still-active product happens to be pending at the same moment, that product drags the workspace into the sweep and the sold-out row rides along. The bug is invisible on a busy catalogue and lethal on a quiet one — which is to say, it waits for a small shop.

Found by isolating: one workspace, one change, nothing else pending. Three sweeps. Still pending. Meta still saying in stock.

A whole verb with no button

This one was found by the person the software is for, in about four seconds, by looking at the screen.

You could add a product. You could delete a product. There was no way to edit one. The server had a complete PATCH endpoint — name, price, stock, photo, active state, and the call that tells the catalogue sync a published product has changed — and nothing in the interface called it. A verb, fully built, with no button anywhere.

5,187 tests were green. Every one asked whether something rendered. Not one asked whether a person could finish the job.

And it was worse than an inconvenience, because of the catalogue sync we had just shipped. The only way to change a price was delete-and-re-add. Delete now withdraws the product from the customer's live storefront. Re-adding mints a new identifier. So the piece leaves the storefront as one product and comes back as another, orphaning its QR page and every analytics row keyed to the old id. We had built a feature that made the existing workaround destructive, and had not noticed because the workaround was invisible to us.

The same look found two more. Marking something sold out removed it from the dashboard entirely — the list only ever requested active items — so hiding a piece made it unreachable, with no way to see it, restore it, or know it was still there. And a bangle in four sizes rendered as four separate cards, all titled identically, all the same price, distinguishable only by an identical grey line reading Variant: grp-bangle. The variant group was used as an autocomplete source, a badge string and a form field. It was never used to group anything.

Pictures with no name

While fixing the gallery we noticed the product API exposed ten endpoints and the dashboard called six. Among the unused four were DELETE /:id/media/:mediaId and its reorder sibling — both complete, both documented in the public OpenAPI spec.

The reason nobody had called them was two lines away. The function that loads a product's gallery selected the image key, the content type, the sort order — and not the row's own id. It returned a list of pictures with nothing to name them by. The endpoints were addressable in principle and unaddressable in practice, and the spec cheerfully documented a mediaId parameter no consumer could ever obtain.

This bug has no villain. Someone wrote a loader returning exactly what the screen needed at the time, and the shape of that omission quietly decided what could be built for months afterwards.

A token that existed but could not

This one arrived as a message that said, roughly: the button doesn't work, it throws some error.

The dashboard was reporting "No catalog is linked to your WhatsApp Business Account yet — create one in Meta Commerce Manager first." A catalogue was linked. It had been verified an hour earlier.

The workspace's stored WhatsApp token carries business_management, whatsapp_business_management and whatsapp_business_messaging. It does not carry catalog_management. And Meta, asked to list catalogues by a token that lacks the scope, does not refuse:

Same account, same question, two credentials:

with catalog_management{"data":[{"id":"…","name":"Klaros Catalog"}]}
with the workspace's own token → {"data":[]}

Both HTTP 200. No error, no warning. Proven by decrypting the production credential, reading its scopes, and asking the same account the same question twice.

Every layer behaved correctly on false information. The service returned an empty array, the route returned 200, the interface said what it had been told. The only defect was the sentence's certainty: an empty list means either you have none, or this credential cannot see them, and we had picked one and asserted it.

Underneath was a real design fault. The credential resolver preferred the workspace's own token whenever one existed, falling back to a deployment credential only on absence — never on insufficiency. The same resolver feeds the write path, so had the operator gone off and dutifully created a duplicate catalogue, the next step would have failed on permissions, one further hop from the cause.

It now checks the scope before preferring the workspace token, and only when there is an alternative worth falling back to. It fails open: if the scope check itself errors, keep the workspace credential, because a network blip must never silently reroute a customer onto a shared token that can reach other people's catalogues.

One bad item and ninety-nine good ones

Batches carry up to a hundred products. When Meta reported a validation error, our code marked the whole chunk failed and wrote the same reason onto every product id in it.

Meta applies the valid items and reports only the invalid ones. So on a two-hundred-piece catalogue with three bad rows, a shop would have seen roughly a hundred failures carrying a stranger's error message — and because failed items were then excluded from the read-back, the ninety-nine healthy ones were never verified and never marked live either. The dashboard was loudly wrong in both directions at once, with the three real problems buried in the noise.

The attribution had been available the whole time. Meta names the offending item in every validation_status entry. Our parser flattened the array into message strings and discarded the identifiers. The entire fix is keeping the id Meta was already sending.

This is where we stopped writing happy-path tests. Every catalogue test until then had pushed a clean set and asserted it landed, which is the easy half. A real shop's first sync is ninety good products and a handful of broken ones, and the only thing that matters is whether the report says which. There is now a fault-injection suite — rejected at push, accepted then rejected afterwards, no price, no photo, transport down, all mixed into healthy products — asserting exactly which rows come back failed and that the rest are untouched.

Four of its tests go red against the previous behaviour. That is the number worth reporting: not how many tests exist, but how many would have caught the thing.

The day I said delete something that worked

After the first successful end-to-end publish, I read Meta's Commerce Policy, matched a prohibition on "digital subscriptions" to the product names on screen, and advised withdrawing ten live products immediately. I raised it twice.

I was wrong to be that certain. The certainty was the error, not the concern.

The prohibition is real, and quoted verbatim from Meta's Commerce Policy: "Commerce content may not promote the buying, selling or trading of downloadable digital content, digital subscriptions or digital accounts." The products were software plans. The match seemed obvious.

What I had not read carefully was the very next clause: "Commerce content may not promote the buying, selling or trading of services, except on the WhatsApp Business app…" — an explicit carve-out for exactly the surface this catalogue lives on. A hosted service is not downloadable content and not a digital account. Meta does not define where a service subscription ends and a digital subscription begins. The ambiguity is real and I flattened it into a verdict.

The tell I should have caught: I could not cite a single instance of such an item being actioned. I had inferred risk from wording and offered it as though it were evidence of enforcement. Asked for the verbatim text or examples of it being enforced, I had the first and nothing at all of the second.

And what had actually happened in the interval where I was recommending an emergency withdrawal? Ten products published, zero errors, review status never set, account approved and business verified, messaging health available across every entity, line quality green. Nothing had happened, because nothing was wrong enough to happen. That is the state I should have established before advising, not after.

A second, shorter retraction belongs here too. I reported that publishing was blocked because the catalogue was not attached to the messaging account, and wrote it into the project docs as an action for someone else. It was attached. My test harness was pointed at an account identifier lifted from a comment in a config file, which turned out to be Meta's auto-provisioned test account — one test number, and genuinely no catalogue. True, and about the wrong account entirely.

The small lesson is worth more than the embarrassment: a verification is only as true as the account it points at, and an identifier copied out of a comment is not evidence.

Saying less

By this point the interface had learned a great deal. Each product card could tell you its publishing state, the reason it had failed, how many extra photos it had, which variant group it belonged to, whether it was hidden. The panel below could tell you how many products were published, waiting, failed and ineligible. There were five filter chips.

Every one of those was added deliberately, to fix a real problem, and every one of them is true. The response, on being shown it, was one line: be very wary of what we do to enhance user information; friction or cognitive load is a no-go.

That was right, and it is uncomfortable because it is not a bug. On a healthy catalogue of two hundred products, a status dot on every card is two hundred green dots reporting that everything is exactly as expected. Four counts in a panel, three habitually zero, ask a reader to check three things in order to learn nothing.

A signal earns its place by changing what someone does next, not by being true.

So healthy is now silent. The Live chip is gone entirely — a shop already believes its products are live and only wants to be told when one is not. Waiting is gone too, for a second reason: it resolves itself within fifteen minutes, so announcing it only asks the reader to decide it does not matter. The panel shows one number, and an exception only when there is one. The failure count is itself the link to the failures, replacing a sentence that had been sitting directly beneath it repeating the same number in words. Three filter chips, not five.

Everything removed in that pass was true. That is the entire point of the pass.

What is actually being built here

Late in all this, the purpose got described better than any of our planning documents had managed: business owners are highly dependent on WhatsApp; customers expect a quick resolution of their anxieties; what needs to be done is psychological emancipation.

That re-describes every bug above. A customer's anxiety is small and immediate — is this still there, what does it cost, will it fit. A shop owner's is quieter and constant: is what my customers are seeing actually true?

Read the list again with that in mind. A sold-out ring still offered for sale. A price changed in the dashboard and unchanged in the storefront. Ninety-nine products marked failed that were fine. A product accepted by Meta and later rejected, still showing green. A screen saying "no catalogue is linked" to someone whose catalogue was linked. Every one of them is a machine telling a shop owner something reassuring that is not true.

The thing being built is not a catalogue sync. It is the conditions under which a person can stop checking.

You only get there by making the system's claims about itself expensive to fake — which is what a read-back is. It is the software equivalent of walking back into the shop to see whether the ring is still in the window.

Six things worth carrying forward

Check the state, not the call. A 200 is a statement about a request. Go and look at the thing afterwards. Almost every bug here lived in the gap between those two.

Distinguish "I looked and it wasn't there" from "it isn't there." Eventual consistency, a credential without a scope, and a paginated read that stopped early all produce the first while looking exactly like the second.

Test the state nobody tests. The paywall was correct in every state except the one where the feature is enabled — the state it exists for. Whatever the flag's default is, the bug is in the other setting.

Ask whether a person can finish the job. Five thousand passing tests did not notice that a product could not be edited. "Does it render" and "can someone complete the task" are unrelated questions, and only one of them is the product.

Suspect the fixture first. Written in this project's own notes, read, and then ignored — repeatedly, and correct every time.

A true signal is not automatically a useful one. The last pass on this work removed correct information from the screen, and the screen got better.

Where it stands

Working, and verified against the live catalogue

  • Products publish, and are read back to confirm they are really there
  • Images resolve and are successfully fetched by Meta
  • Variants group as one product rather than N duplicates
  • An edit reaches the storefront within fifteen minutes
  • A sold-out piece is pushed as out of stock, not silently left for sale
  • A deleted piece is withdrawn from Meta, not stranded
  • A failure lands on the product that caused it, with Meta's own reason
  • A nightly re-read catches anything Meta has rejected since publishing

Built, and not yet walked end to end

  • Browse → cart → order → payment request → paid → fulfilled
  • Three orders exist in production: two one-rupee API tests, and one real order through chat that expired unpaid
  • Every piece of that path exists. Nobody has followed a single customer through all of it

The second column is the honest half of this note. On the evidence of the last three weeks, walking that path will find things — it usually does, and when it does this note gets appended rather than quietly rewritten. If you want the shape of the platform underneath it, the Klaros REST API reference documents the product and catalogue endpoints described here, and the roadmap is where the sequencing lives.

Questions people ask about this

Why does Meta's catalogue API return HTTP 200 when items were rejected?

Because the batch endpoint reports transport success separately from per-item outcomes. The request genuinely was received and processed; whether each product inside it was accepted lives in a validation_status array in the response body, with one entry per rejected item and the reason inside it. This is defensible design for a batch API — a partial success is neither a 200 nor a 400 — but it means any integration that branches on the status code alone will report a completely failed publish as a success. The only reliable check is to read the catalogue back afterwards and look for what you sent.

Why did variants each end up in their own group?

Because item_group_id, the field the CSV feed format uses for grouping, is accepted by the batch endpoint and silently ignored by it. No error, no warning, and the products all read back present and correct. Grouping only happens when a product is created through POST /{product-group-id}/products, one call per product rather than a batch, and that endpoint additionally requires a category field the batch endpoint will accept the same product without. There is a second trap behind it: that endpoint creates rather than upserts, so re-publishing an already-grouped product fails with a duplicate error. Group membership does survive an ordinary batch update, so the working approach is to route only genuinely new members through the group endpoint and refresh everything else via batch.

How do you tell "no catalogues exist" from "this token cannot see them"?

From the response alone, you cannot — which is the problem. Meta answers a catalogue listing from a token without catalog_management with {"data":[]} and HTTP 200, identical to a business that genuinely owns none. A WhatsApp onboarding credential typically carries business_management and the whatsapp_business_* scopes and not catalog_management, so this is the common case rather than an edge one. The fix is to check the token's scopes before trusting an empty result, and to never write UI copy that asserts one interpretation of an ambiguous answer. Ours told an operator to go and create a catalogue they already had.

Why not just mark the whole batch failed when Meta reports an error?

Because Meta applies the valid items and rejects only the invalid ones, so the batch did not fail — some of it did. Blaming every product in a hundred-item chunk marks up to ninety-nine healthy products as failed with an error message that belongs to a different product, and if failed items are then excluded from the read-back verification, those ninety-nine are never confirmed live either. The identifier needed to attribute correctly is already in the response: each validation_status entry names its retailer_id. Transport failures are the genuine exception and should still blame the whole chunk, as should errors Meta declines to attribute — "we cannot tell which" is not the same as "none of them".

What does a sold-out product need to do in a WhatsApp catalogue?

It needs to be pushed to Meta as out of stock, not deleted and not skipped. Deleting withdraws the listing entirely and loses the identifier it was published under; skipping leaves Meta offering something the shop no longer has. That means any background sync must treat an inactive-but-previously-published row as work to do, which is easy to get wrong: a query filtering on active = 1 excludes exactly the row that most needs sending. Meta's availability field accepts five values — in stock, out of stock, available for order, discontinued, preorder — and stock quantity has to be part of the mapping, or a product tracked down to zero still publishes as in stock.

Should software subscriptions be listed in a WhatsApp catalogue?

Honestly: unclear, and we over-called it once. Meta's Commerce Policy prohibits "downloadable digital content, digital subscriptions or digital accounts", and separately prohibits selling services except on the WhatsApp Business app — an explicit carve-out for this exact surface. Where a hosted service subscription falls between those two clauses is not defined, and we could not find a single instance of such a catalogue item being actioned. The observable state of an account that had published exactly that was entirely healthy: approved, verified, zero item errors, green quality rating. Inferring risk from policy wording is not the same as evidence of enforcement, and treating the first as the second cost a working test an emergency withdrawal recommendation it did not need.

What actually finds bugs like these?

Reading state back, and rehearsing ordinary days. Every bug in this note was found one of three ways: pushing something and then going to look at whether it was really there; performing a mundane action a real shop performs — change a price, mark a piece sold out, publish twice — rather than the first-run happy path; or a person looking at the screen and asking why there was no edit button. None of them were found by reading the code, and the test suite was green for all of them. A green suite that only asks "does this render" will stay green through an entire missing feature.

Ask our WhatsApp number what it costs

Not a form. Message the line and type pricing: you will get our live catalog as a WhatsApp list, and a payment link on whatever you tap. No signup, no call, no PDF.

Written 28 August 2026, at the end of the catalogue work. We append when the facts change, including what the buy-journey walk turns up. Related: the enrichment failure nothing reported, the assistant that could explain booking but not book, what self-deployed actually means, all build notes.