Build notes

La Facturación del CRM de WhatsApp que Corregimos Antes de Cobrar

31 July 2026 · what we found in the room where the money moves · Founder, Klaros

There is a moment in every software relationship when the customer stops evaluating and starts trusting. It is not the demo. It is not the trial. It is the moment they pay, and then wait to see whether what they bought is what arrives. We had built that entire moment and never once watched it happen. So before putting anyone's money through it, we opened it up.

In short

What happened: auditing our own billing before any customer had paid, we found five live defects: every paid plan resolving to the entry tier's capabilities regardless of what was bought, a Razorpay webhook signature check with its arguments in the wrong order that rejected every legitimate payment event, a subscription status mapper that treated any unrecognised value as active, a missing plan configuration that silently billed a customer on a different plan than the one they chose, and two Razorpay plans charging less than the price on our own pricing page.

Impact: none to a real customer, every defect predates any paid transaction. But each one describes exactly what a paying customer would have silently experienced if money had already been moving through the system.

Fix: plans now declare their capabilities explicitly, with a test that fails if a plan resolves to anything else, and a small reconciliation tool now compares what Razorpay actually charges against the published catalog on every run.

Billing flow: what the customer touches vs what broke underneath Pricing pagecustomer picks plan Razorpayprocesses payment Webhook POSTto Klaros Worker Plan lookupresolve capabilities Provision accountlines + contacts Five defects found (all before any customer paid) 1. Plan capabilitiesEvery plan resolvedto entry tier: 1 line 2. Webhook signatureHMAC args transposedAll events rejected 3. Status mappingUnknown statustreated as active 4. Missing plan IDSilent fallback todifferent subscription 5. Price mismatchRazorpay charged lessthan pricing page Fix: reconciliation tool + capability tests Plans declare capabilities explicitlyTest fails if plan resolves to wrong tier Reconciliation tool comparesRazorpay charges vs published catalog Impact: zero. Every defect predates any paid transaction.
Five live defects in the billing path, each annotated at the point in the flow where it would have silently affected a paying customer.

We found five things wrong. One of them is worth the whole article.

The customer who never complains

A jeweller signs up for Growth. She pays for three WhatsApp lines and fifteen thousand contacts, because that is what the page said and that is what her shop needs: one line for the store, one for the workshop, one for the owner.

She goes to add the second line. The software says no.

She does not open a support ticket. She assumes she has misunderstood the plan, or that the feature is coming, or that this is simply what these tools are like. She works around it. Six weeks later, when someone asks her about Klaros, she says it is fine, a bit limited.

That was a real defect in our code. Every paid plan key resolved to the entry tier's capabilities, so a customer paying ₹17,988 a year for three lines and fifteen thousand contacts would have been provisioned one line and two and a half thousand. Silently. No error, no warning, nothing to complain about.

We have a name for this, and we usually apply it to somebody else. Digital distance is the gap between what a person believes you know about them and what your software actually knows. A customer messages a shop that has served her for three years and gets treated like a stranger. She feels the distance. She does not mention it. She just comes back a little less often.

The jeweller above is that same gap, with us on the wrong side of it. She believed she had bought three lines. Our software silently gave her one. No error, no argument, nothing to escalate. Just a quiet difference between what she was promised and what arrived, sitting there permanently, doing its work.

Distance is not only between a business and its customers. It is between that business and every vendor it depends on.

The other four

The rest of the audit found four more, all live, none of which had ever been noticed for the same reason: no money had ever moved through the system that contained them.

What was wrongWhat it would have done to a customer
The payment webhook verified signatures with its arguments in the wrong order. Every payment confirmation rejected. You pay, and nothing happens. You email us, and we tell you it takes a moment.
Subscription status mapping treated anything unfamiliar as "active". A cancelled account keeps working. Comfortable, until the day it is not, and nobody can say when that day is.
A missing plan configuration quietly fell back to a different plan. You choose the ₹699 tier and get billed on a ₹17,988 one. Nothing in the flow tells you.
Two provider plans charged less than the price on our own pricing page. Nothing, to you. It was our money, not yours. Which is precisely why it could have gone on for years.

That last one deserves a moment. Two plans in our payment provider were charging ₹7,188 and ₹999 while the product advertised ₹17,988 and ₹2,499. An error in the customer's favour is the kind nobody reports, which makes it the kind that survives longest. It also told us something uncomfortable: we had no way to know what we charge. The page renders one number from our catalog. The provider charges another from its own records. Nothing had ever compared the two, because in most software stacks nothing ever does.

What we did about it

The fixes are two-line changes and not very interesting. Two things came out of this that are.

If you are implementing this yourself, the mechanics of the first two are written up separately in WhatsApp payments with Razorpay: the argument order that makes an HMAC-SHA256 signature check reject every legitimate event while returning a clean boolean, and why a Razorpay subscription entity status must never be read as though it were an event name.

The first is that every plan now declares the capabilities it grants, and a test fails if a plan resolves to anything else. What you were quoted and what you are given come from the same definition. There is one Growth, not three.

The second is a small tool that reconciles what the payment provider charges against what we publish. It runs in seconds and refuses to pass quietly:

$ node tools/verify-billing-plans.mjs

Razorpay account: LIVE

✓ klaros_founding (Founding Partner Launch Tier)
    charges ₹8,388 yearly. Matches the catalog.
✓ klaros_annual (Growth Cloud (Annual))
    charges ₹17,988 yearly. Matches the catalog.
✓ klaros_monthly (Growth Cloud (Monthly))
    charges ₹2,499 monthly. Matches the catalog.

All 3 plans charge what the catalog advertises.

It is fifteen lines of logic. It would have caught two of these five on the first day they existed. We built it because a business should not have to take our word for what we charge, and neither should we.

The same audit turned up a smaller one worth naming, because anyone building WhatsApp payments in India will meet it. A payment request inside a chat is Meta's order_details interactive message, and its payment_type: upi path is India-only and INR-only. We had a product priced in dollars that would have been assembled into that message quite happily and then rejected by Meta with something unhelpful. It now refuses at our end with a sentence a human can act on: WhatsApp payment requests support INR only, send a payment link instead. A good error message is also a way of closing distance. It is the difference between a customer who knows what to do next and one who is left guessing at somebody else's system.

The forks

Two of these decisions had a version that would have looked better. We took the other path, and it is worth saying why plainly.

We chose: fail the sale.

Refuse to sell, or sell something close enough?

When a plan has no payment configuration, the old code borrowed another plan's. That is the accommodating choice: the checkout always works. It is also how someone ends up on a subscription they did not choose. A missing configuration now stops the sale and names exactly what is absent. Losing a sale is recoverable. Taking money for the wrong thing is not.

We chose: publish the real number.

Show the flattering count, or the true one?

Our pricing page said "4 of 10 slots left" on the founding tier. Nothing counted anything. It was a number someone typed. We built the counter, and the honest answer today is that all ten are open, because nobody has bought one yet. A company whose entire argument is that its claims are checkable does not get to invent scarcity. The number on that page will move when someone actually buys.

What we have not fixed

This would be a dishonest article if it ended at the tidy part.

Contact and line limits are computed correctly on every request and enforced almost nowhere. The tier ladder is, at runtime, still closer to a description than a rule. We know exactly where that gap is and it is next. Add-on capacity has the same shape: correct, stored, and not yet consumed by the code that would make it matter.

And the honest state of the business: everything here is verified by tests and by reconciliation against the live payment provider, and none of it has yet been verified by a stranger's money. Both launch tiers show every slot remaining, because every slot remains. When that changes, this page will say so.

We would rather leave the record than pretend we always knew better.

What this is actually for

Klaros exists to close the distance between a business and the people it serves. Every structural decision follows from that. You connect your own WhatsApp Business Account. Meta bills you directly at Meta's rate, with us nowhere in that payment path. Your history exports whole, including on the day you leave. Buy the self-deployed licence and you hold a signed key for software that runs on your own infrastructure and keeps running whether or not we do.

We cannot credibly sell that while quietly running a gap of our own. A vendor's interests and its customer's come apart most easily in the room where the money moves, and almost everything that goes wrong in there is invisible from the outside. So we audited it before anyone's money was in it, and we built the check that fails loudly next time.

What we want a business to get from this is boring, and we think boring is the point. You should never need to audit us. You should not have to wonder whether the plan gives what the page said, whether the bill will look like the quote, whether leaving means a support ticket. The absence of surprise is what nearness with a vendor actually feels like, and it is built the same way nearness with a customer is built: by refusing to let small unspoken gaps accumulate.

Remembering your customer is architecture, not marketing. So is being predictable to you.

Get the next one on WhatsApp

Message the line and type NOTES. The latest one comes straight back, in the same thread, from the number that sends everything else. Reply STOP whenever you like and it stops.

Send NOTES on WhatsApp

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, with the real number of slots left, and a payment link on whatever you tap. Buy the self-deployed licence and your signed key arrives in the same thread, for software we cannot reach into. The whole path is the product, demonstrating itself before you own it.

Questions people ask about this

Why would a vendor publish defects found in its own billing?

Klaros sells a claim about money: you connect your own WhatsApp Business Account and pay Meta directly, so no markup sits between you and your message costs. A claim about money is worth only what a buyer can verify. Publishing what went wrong, alongside the check that catches it next time, costs less than asking to be trusted.

Was any customer overcharged or affected?

No. Every defect was found before a single transaction had been processed. Two of the misconfigured plans charged less than the advertised price rather than more, and no paying subscriber existed on either.

How does Klaros verify that what it charges matches what it advertises?

A reconciliation tool fetches every configured plan from the payment provider and compares its amount and billing period against the single-source catalog that also renders the public pricing page. It exits non-zero on any drift, so a charged price that disagrees with the published price fails loudly instead of silently.

Can I buy Klaros over WhatsApp?

Yes. Messaging the Klaros business line with a pricing question returns the live catalog as a WhatsApp list message, including real remaining slots on limited tiers. Tapping a plan returns a payment link. A self-deployed licence purchase delivers a signed licence key in the same thread, with no account required, because the self-deployed product runs entirely on the buyer's own infrastructure.

Can a WhatsApp payment request charge a currency other than INR?

No. Meta's order_details interactive message with payment_type upi is India-only and supports INR only, with total_amount expressed using an offset of 100. A non-INR order will be assembled successfully and then rejected by Meta, so it is better to reject it in your own code with an actionable message and send a payment link instead. Klaros validates currency before building the payload for this reason.

Written 31 July 2026. We append when the facts change. Related: the Razorpay implementation this billing sits on top of, the five guards on an automated payment request, the belief this comes from, proving consent, what it costs, what zero markup means against Twilio, WATI and Interakt, all build notes.