Build notes

The day our business cards started asking for money

7 August 2026 · two features, one URL prefix, and a QR code that cannot be recalled · Founder, Klaros

Picture the handover. Someone meets a jeweller at an exhibition, takes the card, points a phone at the QR code in the corner. What should load is a name, a face, a line about what the business does, and a button that starts a WhatsApp conversation. What loaded instead, for five days, was a page that said: “This payment link is not valid. Please ask for a new one.”

In short

What happened: digital business cards (/p/:slug) and payment links (/p/:token) shared the same URL prefix. The payment route was registered first in the router, so it matched every /p/ request, looked the value up as a payment token, found nothing, and returned its own 404 before the card route ever saw the request.

Impact: every scan of every business card, for five days (2–7 August 2026), returned “This payment link is not valid.” The failure happened before the card page rendered, so scan telemetry, which fires client-side after load, never recorded a single affected scan. The real reach is permanently uncountable.

Fix: the payment handler now defers to the next route on a miss instead of answering with its own 404. A new test mounts both routes together in their real registration order, since the original eleven tests for the payment route all passed by testing it in isolation.

Not “page not found.” Something considerably worse. A stranger's first contact with a business was a broken demand for money. Every instinct a careful person has about scanning QR codes, and in India those instincts are well earned, fires at exactly that sentence. The most likely reaction is not “their website is down.” It is “this is a scam,” followed by putting the card in the bin.

We did this to ourselves, in a commit that was otherwise correct, and we want to write down exactly how, because the shape of this bug is one that every platform which grows into several products eventually meets.

Two things called /p/

Klaros serves free digital business cards at /p/:slug. That has been true for a long time. A slug looks like this:

https://www.tryklaros.com/p/mohammad-mustafa-ipr-56451w02t
                             └────────┬────────┘└─┬─┘└──┬──┘
                              name, slugified    last 4  short hash
                                                of phone

On 2 August we shipped payment links, and gave them a stable public address at /p/:token. The reasoning behind that address is written up in the note on software that asks your customers for money, and it is sound: a payment request has to reach people outside WhatsApp's 24 hour service window, and outside that window the only thing Meta will deliver is an approved template. A template can carry a URL button, but Meta fixes that button's URL at approval time apart from one trailing variable. So the prefix has to be something we control permanently, and short, because a human reads it in a chat.

Both features wanted a short, permanent, human-readable prefix. Both picked /p/. Neither author was wrong on their own terms. The problem is that a router does not know about terms, it knows about order.

The payment route was mounted at app.route('/', payRedirect) on line 196 of our worker entry. The card route sits at app.get('/p/:slug', ...) on line 274. Hono matches in registration order, so the payment handler saw every /p/ request first.

It took the value, looked it up as a payment token, found nothing, and, being a careful piece of code that refuses to leak whether a token ever existed, returned its own deliberate 404. It never occurred to that handler that the request might not have been about payment at all.

And a card slug is never a payment token. Not rarely. Never. The two identifiers come from different generators and cannot collide:

 Card slugPayment token
Shapemohammad-mustafa-ipr-56451w02tk3f9a1c7e2b8d4059f6a1c3e7b9d2f408
SourceName, slugified, plus last 4 digits of phone plus a short hash20 random bytes, base36, 32 chars
ReadableDeliberately. A human reads it off a card.Deliberately not. It is the entire credential.
HyphensAlwaysNever
LifetimePermanent. It is printed on paper.Scoped to one order.
100% of card scans hit the payment handler. 0% of them could ever succeed there. The failure rate was not high, it was total, and it was total from the first minute.

Why neither route could simply move

The obvious fix is to give one of them a different prefix. Both doors are locked, for different reasons, and the reasons are worth stating because they are both external constraints rather than engineering taste.

The payment link cannot move because its URL is baked into a WhatsApp message template that Meta has already approved. Changing the prefix means resubmitting the template and waiting on review, for every workspace using it. Template approval is measured in days, and a template that is mid-review is a template that cannot ask anyone for money.

The card link cannot move for a reason that is less technical and more final: it is printed on paper. It is on cards in wallets, on stickers on shop shutters, on exhibition banners, on the back of a rickshaw. A URL that has been physically printed is not a URL you get to change your mind about. We already knew this, which is why our landing page carries a quiet notice for anyone who scans a QR pointing at a retired card, rather than dead-ending them on a 404.

Software gets deployed. Paper gets handed to people. Only one of those can be rolled back.

The fix, which is three lines

If neither route can move, then the routes have to cooperate. The payment handler now declines politely instead of answering:

-payRedirect.get('/p/:token', async (c) => {
+payRedirect.get('/p/:token', async (c, next) => {
   const token = sanitizeString(c.req.param('token'), 64);
-  if (!token) return stopPage('Link not found', 'This payment link is not valid...', 404);
+  if (!token) return next();

   const order = await c.env.DB.prepare(
     `SELECT id, creator_id, status, checkout_url, expires_at, ...
        FROM commerce_engine_orders WHERE pay_token = ? LIMIT 1`,
   ).bind(token).first().catch(() => null);

-  if (!order) return stopPage('Link not found', 'This payment link is not valid...', 404);
+  // Not a real pay_token: most likely a card slug. Defer rather than claim it.
+  if (!order) return next();

A miss now falls through to the card route registered after it. Real payment tokens resolve and redirect exactly as before, and every guard around them is untouched: an already-paid order still says so rather than charging twice, an expired link still explains itself, and the checkout click is still recorded as the one signal between asking for money and being paid.

There is a bonus. The old code had a carefully written comment explaining that an unknown token and a malformed one must produce identical responses, so that probing cannot distinguish them. Falling through makes that property stronger rather than weaker: an unknown payment token and a genuinely missing business card are now the same response, from the same handler, because they are the same page.

The two shortcuts we did not take

We chose: fall through on a miss, not sniff the string.

Discriminate by token shape, or defer to the next route?

Look at that comparison table again. A payment token never contains a hyphen and is always 32 characters. We could have written if (!/^[a-z0-9]{32}$/.test(token)) return next(); and skipped the database lookup entirely on card traffic. It is faster and it reads as clever.

We rejected it, because it encodes today's token generator into the routing layer. The day someone shortens a token, or adds a prefix for a second payment provider, or allows a vanity slug that happens to be 32 characters of lowercase alphanumeric, the router starts silently sending real buyers to a business card. A regex that describes a format is a copy of a decision made somewhere else, and copies drift. Asking the database “is this a real order?” is slower and cannot be wrong.

This is the same instinct as the enrichment failure we wrote up yesterday: a mock that string-matched SQL instead of executing it looked equivalent, right up until a column name was wrong and it happily returned success.

We chose: prove the collision in a test that mounts both routes.

Test the handler in isolation, or reproduce the actual mounting?

Here is the uncomfortable part. There was already a test file for the payment redirect, eleven tests, all passing, written with real care. One of them asserted that an unknown token returns a 404 reading “not valid.” That test passed throughout the outage. It was, in a sense, testing the bug and calling it correct, because it exercised the payment router alone, and in isolation that behaviour is perfectly reasonable.

The collision only exists when both routes are mounted together in the order the real application mounts them. So the test now builds a small app that mirrors that mounting, and asserts a card slug reaches the card route:

const buildMountedApp = () => {
  const app = new Hono();
  app.route('/', payRedirect);                    // registered first, as in index.mjs
  app.get('/p/:slug', (c) => c.html(`card:${c.req.param('slug')}`));
  return app;
};

✔ falls through to the card route so a business-card slug is
  never mistaken for a payment link
✔ never leaks whether a token ever existed, and defers to the
  card route instead of guessing

$ npm test
✔ 3,116 tests passed

A unit test that mounts one route can never see a bug that lives in the relationship between two. That is not a criticism of the original test, it is the actual lesson: some defects do not exist inside any component, only in the wiring between them, and the only test that catches those is one that reproduces the wiring.

What we cannot tell you

The previous note in this series opened with a number: 68 dead-lettered messages. We would like to give you the equivalent here, and we cannot, permanently, for a reason that is itself the most interesting finding in this whole episode.

Card scan telemetry is written by GET /api/public/pages/:slug, the endpoint the card page calls after the HTML has loaded. That handler increments view_count and writes a scan row into a dedicated page_events table.

But the payment route answered before the card HTML was ever served. The page never loaded, so the JavaScript never ran, so the API was never called. Not one of those scans was recorded. They are absent from the view count, absent from page_events, and absent from every dashboard that reads either.

So the honest accounting is: the window was five days, 2 August to 7 August 2026, it affected every scan of every card in that window, and the number of real people who saw it is unknowable to us. We would rather publish that sentence than a comfortable estimate.

Our analytics only observe success. A failure early enough in the request never becomes a data point, which means the metric that looked healthiest was the one that had stopped being measured.

That is a design flaw worth naming plainly. Telemetry attached to a rendered page measures rendered pages, not attempts. A scan counter that lives in the browser cannot count the scans that never reached a browser.

What a free digital business card actually does

It is worth explaining what was broken, because “digital business card” sounds like a vanity page and this is not one. In Klaros it is the cheapest possible entry point into a WhatsApp relationship, and it is free to hand out and free to scan.

It is a real page. Name, headline, bio, avatar, cover, location, website, social links and arbitrary custom fields, plus a feed of recent updates the owner can post to.

It captures leads into the CRM, not into a spreadsheet. The connect form writes directly into the contact graph, tagged card-lead and origin:<slug>, so you can tell the exhibition card from the shop-counter card six months later. It is a public unauthenticated write into a customer's CRM, so it sits behind an IP rate limit that fails open, because a lead lost to an overzealous throttle is worse than a duplicate.

Its analytics do not drown the product. Scans go to page_events rather than the main activity feed, specifically so a card scanned four hundred times at an exhibition does not bury every genuine customer conversation in the owner's Overview. A card that is working should not make the inbox useless.

It can be claimed by the person it describes. This is the part we are most pleased with. A card carries a “this is me” button, and pressing it does not ask for proof, or an email, or a password. It sends a magic link over WhatsApp to the number the card already publishes, and shows you a masked version of that number first so you can recognise it is coming to you. Which means pressing it on a stranger's card accomplishes exactly nothing except notifying the rightful owner. The credential is possession of the phone, and the phone number was never a secret, it was on the card.

The safest ownership check is one where the attack and the courtesy are the same action.

Where the card meets the automation

A card on its own is a page. What makes it a business surface is what happens in the thirty seconds after a scan, and that is the part of the WhatsApp ecosystem Klaros actually exists to run.

A scan can become a captured lead. A captured lead is a contact in the graph, with a source you can still identify months later. A contact with a source can be enrolled in a drip sequence, or matched by a milestone rule that fires on something they do rather than on a date. When they reply, the reply is classified by an intent registry before any AI is consulted, so “how much is it” reaches your catalog and “stop” reaches your consent ledger, deterministically, every time. And if what they want is to buy, the same thread can carry a real payment request, which is where /p/ came in and where this whole note started.

That is the loop: a free piece of paper, a scan, a contact, a conversation, an ask, a payment. Every step after the first happens inside WhatsApp, where the person already is, rather than in an app you have to persuade them to install. The card is deliberately the cheapest step, because it is the one that has to happen before any of the others can.

Which is precisely why breaking it was expensive out of all proportion to the three lines that fixed it. A bug at the mouth of a funnel does not cost you one feature. It costs you everything downstream of it, and it costs it silently, because nobody who bounced at the first step was ever in your system to be counted.

The pattern, for anyone building something similar

Strip the specifics away and this is a bug about a namespace with two owners and no registry. It is not really about Hono, or WhatsApp, or QR codes.

Every platform that grows into several products eventually has a short path prefix that more than one feature wants, precisely because short and permanent are scarce. The first feature takes it without ceremony, because at the time there is nothing to collide with. The second feature has an excellent reason for wanting it too. Neither author is careless. The collision is a property of the pair, and it appears at the moment of mounting, in a file that neither of them was really editing.

The three things we would tell anyone in the same position:

RuleWhy
A handler that does not own a path must decline, not answer. Returning your own 404 for a path you share is a claim of exclusive ownership. If the claim is wrong, every other owner is invisible.
Externally fixed URLs are load-bearing infrastructure. An approved template button and a printed QR code are both immutable, for different reasons. Treat either one like a database migration you cannot roll back.
Test the mounting, not just the handler. Route-order defects are invisible to any test that mounts one route. Ours passed happily throughout.

What we have not fixed

This would be a dishonest note if it stopped at the tidy part.

Nothing would have told us. We found this because a person looked at a URL and said “why does my business card think it is a payment link.” There is no monitor on the card path. The previous note ended on almost exactly this gap for dead-lettered queue messages, and here it is again wearing different clothes.

Two notes running, the honest ending has been the same: the check existed, or the telemetry existed, and no alarm was attached to it.

The telemetry blind spot is still open. Scan counting still happens after render, so it still cannot see a request that failed before render. Moving that signal server-side, into the route that serves the HTML, would have turned this outage into a visible cliff on a chart rather than an anecdote. That is the obvious next piece of work and it is not done.

The prefix is still shared. The fallthrough makes the collision harmless, but two features still own /p/ by convention rather than by anything enforced. A third feature could take it tomorrow and reintroduce this in a different shape. The regression test now pins the pair, which is not the same as pinning the rule.

Appended 8 August 2026

Both gaps re-checked, both still open. Scan telemetry is still written by GET /api/public/pages/:slug, the JSON endpoint the card page calls once it has already rendered, so a request intercepted before render still writes no page_events row and still increments no view count. The blast radius of the original outage remains permanently uncountable, and would be again.

/p/ is likewise still shared by convention. Neither of these moved, and saying so on the date we looked is the point: the alternative is a page that quietly implies a gap closed because it stopped being mentioned.

The card is barely on our own website. Auditing this, we found the digital business card is a fully built capability, scan analytics, lead capture, WhatsApp claim flow, that is almost entirely absent from our features page. We broke a product we had not got around to describing. That is its own kind of failure and this note is, for the moment, the most complete public description of it.

What this is actually for

The failure text has been live and correct since we deployed the fix, on worker version a4c74dbc. Scanning a Klaros card returns a Klaros card. You can check that yourself against any card you hold, which is rather the point of publishing this.

We keep writing these because a specific number is checkable in a way that an adjective never is, and because the alternative, quietly fixing it and saying nothing, asks you to trust a claim rather than inspect a record. If you self-host Klaros, this is not a metaphor: the routes, the tests and the health endpoints are on infrastructure you run, and you can read the same three lines we just changed. That is a structurally different relationship than renting a seat on a platform where someone else decides what you are allowed to see.

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

Or just scan one and see

The most honest demo we have is the product itself. Message the line and type pricing to get our live catalog as a WhatsApp list, with a payment link on whatever you tap. Both halves of this note, the card and the payment link, in one thread.

Written 7 August 2026. We append when the facts change. Related: the payment request that started it, the last thing that failed silently, WhatsApp payments with Razorpay, the belief this comes from, all build notes.