956 Members, Zero Redemptions: A Loyalty Program Bug
A loyalty program redemption bug hid for two months because a one-character type error crashed behind a generic 'something went wrong.' Here's how I found it.
By Mike Hodgen
956 Members, Zero Successful Redemptions
956 members. Zero successful redemptions.
That's the number that made my stomach drop when I finally looked closely at the loyalty program I built for a DTC fashion brand I run in San Diego. Two months live. Nearly a thousand people enrolled. And not a single one had ever redeemed their points successfully.
Here's what made it ugly: the feature wasn't broken in any way you could see.
The widget rendered in the cart. The points balance showed up, and it was correct. Someone with 400 points saw 400 points. The "redeem" button was right there, styled, clickable. People were clicking it. The analytics showed clicks.
And nothing happened.
This is the loyalty program redemption bug that taught me more than any clean launch ever could. Because a feature that crashes loudly gets fixed in an afternoon. Somebody sees the red banner, files a ticket, and you're off to the races.
This wasn't that. This was a feature that shipped, looked completely alive, and was 100% dead from the first second it went live.
The quiet horror of it is hard to describe to someone who hasn't lived it. You build something. It passes your checks. It goes out. Real customers interact with it every day. And the entire time, behind the friendly UI and the accurate-looking numbers, the thing has never once done the one job it exists to do.
A loyalty program with zero redemptions isn't underperforming. It's a corpse wearing a name tag.
I had 956 people who had each, at some point, decided they trusted this brand enough to come back and spend their points. Every one of them hit a wall they couldn't see and couldn't report. And the system told me everything was fine.
The One Character That Killed Every Redemption
The whole thing came down to a single character. Let me walk you through it the way I'd explain it to a CEO over coffee, because the technical part is simple once you see it.
The type mismatch that killed every redemption (number vs string)
What the cart actually sent
When a customer clicks redeem, the cart widget sends the customer's ID to my API so the system knows whose points to spend. In the storefront template, that customer ID comes through as a plain integer. A bare number. Not text wrapped in quotes, just 123456789 sitting there as a raw JSON number.
That's the input the real browser sent, every single time.
What the API actually did
My API received that ID and, as a first defensive step, tried to clean it up. It called .trim() on the value to strip any accidental whitespace. Reasonable instinct. You don't want a stray space breaking a lookup.
But .trim() only works on strings. You cannot call .trim() on a number. The moment the code tried, JavaScript threw a TypeError and stopped cold.
This is a textbook javascript type coercion bug. The code assumed text. The browser sent a number. They never agreed on the data type, and JavaScript doesn't forgive that quietly. It throws.
That error happened on the very first line of real work, before the redemption logic ever ran. Before the points-hold step. Before anything that mattered. The crash got caught by a generic handler and returned as a vague "internal error."
So every redemption died on line one. Every customer. Every time. For two months.
The fix was almost embarrassing. Coerce the value to a string at the API with String(id) before touching it. Quote the ID in the widget so it arrives as text in the first place. One character of difference, a pair of quotation marks and a wrapper function, between a working feature and 956 frustrated customers.
That's the part that stays with you. The gap between a healthy revenue feature and a dead one was so small you could miss it reading the code out loud.
Why QA Said It Worked (And Why That's the Real Lesson)
Here's the question that should bother you: how did this pass testing?
Why QA passed but production failed, test input vs real input
It passed because of how it was tested. The manual QA check sent the customer ID as a string in a curl command. Something like "123456789", in quotes, typed by a human into a terminal.
Strings have a .trim() method. So the test ran clean. The points came off, the redemption completed, the response came back green. Everything worked.
But the actual cart sent a number. The test and production disagreed on a single data type, and nobody noticed, because the test never sent the shape the browser sends.
This is the core trap of fast AI-built features, and it's worth sitting with. AI writes code that works for the input it imagines, not always the input it gets. When you ask an AI to build a redemption endpoint and verify it, it'll happily test with a clean, quoted string, because that's the obvious, tidy input a reasonable person types. It's the happy path it pictured.
The real path the storefront sends is subtly, invisibly different. A bare number instead of quoted text. And in that gap, the whole feature dies.
This isn't a knock on AI-generated code specifically. Human engineers make the same mistake. But AI makes it faster and more confidently, and it makes it while sounding certain. It generates the implementation and the test in one breath, and both reflect the same imagined input. So they agree with each other and disagree with reality.
I've watched this exact category of failure show up again and again. The same handful of mistakes show up in every AI-built app, and "tested with the wrong input shape" is near the top of the list. It's a vibe coding silent bug in its purest form: the code looks right, the test passes, and production has never once worked.
The Generic Error Message That Turned a One-Line Bug Into a Two-Month Outage
Now here's the part that actually cost me money. It wasn't the TypeError.
How a generic error message severed the diagnostic thread
The TypeError was a one-line fix. The expensive part was what the customer saw when it happened.
The cart's error map translated about four error codes into friendly messages. The system could throw roughly a dozen. Every code that wasn't in that map, including this TypeError-driven failure, fell through to a single catch-all message: "Something went wrong."
That message is a black hole.
"Something went wrong" carries exactly zero diagnostic information. A customer who hits it can't tell support anything useful, because there's nothing to tell. Support can't reproduce it, because they don't know what failed. The engineer can't trace it, because the message that reached the customer threw away the one detail that would have cracked the case in five minutes: the actual error code.
So the failure mode was perfect, in the worst way. The customer sees a vague apology and gives up. They don't file a ticket, because what would they even say? Support never hears about it. The metric quietly reads zero. And nobody connects "redemptions are flat" to "one character is throwing a TypeError," because the thread between those two facts got severed by a generic error message.
A vague error turned a one-line type bug into a two-month dead feature and, when I finally chased it, a multi-day support escalation to untangle.
I've come to believe this strongly: every "something went wrong" is a bug you've decided not to diagnose yet. You've taken a specific, traceable failure and deliberately blurred it into noise. It feels polite. It's actually expensive.
This connects to something I wrote about before, that silence is not success. A generic error message is a system whispering "I'm fine" while it's bleeding out. The absence of an alarm is not the presence of health. And a four-code error map on a twelve-code system is a guarantee that most of your failures will arrive disguised as nothing in particular.
How I Actually Found It
I didn't find this through a clever monitoring alert. I found it because the number was impossible.
Zero is different from low, the impossible metric
The redemption rate wasn't low. It was zero. And zero is a different animal than low.
A real feature has friction and some success. Some people redeem, some abandon, some fat-finger it. You expect a messy, human distribution. What you do not expect, across 956 enrolled members over two months, is a flat zero. Zero isn't low adoption. Zero is total failure. The shape of the number told me the feature was broken before I knew why.
So I went hunting. First thing I did was fix the error map, but not the way you'd guess. Instead of writing twelve friendly messages, I made the map append the raw error code for any failure a customer couldn't fix themselves. Now a customer screenshot carries the diagnosis. "Something went wrong (ERR_REDEEM_TYPE)" tells me in two seconds what "Something went wrong" never could.
Then I traced one real failed redemption from a real customer. Saw the raw code surface. Followed it straight to the TypeError on the .trim() call. The whole diagnosis took maybe twenty minutes once the system stopped hiding the evidence from me.
And in the same sweep, I caught a second bug I wasn't even looking for. A vesting issue where the available_balance calculation always resolved to roughly zero. So even if redemption had worked, the spendable balance would have been wrong, the system would have thought the customer had nothing to spend. Two independent failures, either one fatal, both invisible. That earn-and-vesting side has its own story, and I wrote up another loyalty bug I caught the hard way if you want the companion piece.
That's the thing about going to look. You rarely find just one.
The Pattern: AI-Built Features Fail Quietly, Not Loudly
Step back from loyalty programs for a second, because this pattern lives in your systems too.
AI-built features rarely crash with a red banner. They fail in the gap between types. They fail in the untranslated error code. They fail in the test that used a clean string instead of the messy number the real client actually sends. The failure is quiet by construction, and quiet failures are the expensive ones, because nobody's looking for what isn't screaming.
The three fixes that mattered
Here's the reusable checklist I pulled out of this:
The three reusable fixes checklist
- Coerce types at the boundary. Never trust the shape of incoming data. The moment a value enters your API, force it into the type you expect (
String(id)) before you do anything with it. Assume the browser will send the wrong shape, because eventually it will. - Test with the input the real client actually sends. Not the clean string you typed into curl. Capture what the browser, the webhook, the integration actually transmits, and test against that. The happy path you imagine and the real path you get are different paths.
- Map every error code. And for the failures a customer can't fix, surface the raw code so a screenshot becomes a diagnosis instead of a dead end. A friendly message that hides the cause is worse than an ugly one that reveals it.
What to check in your own systems
The broader rule is the one I'd tattoo on every dashboard: instrument for the absence of success, not just the presence of errors.
Most monitoring watches for things going wrong, exceptions, 500s, crashes. But my redemption bug never produced a visible error anyone watched. What it produced was a metric that read zero. A flat, impossible, deafening zero.
That zero was louder than any exception, if anyone had been watching for it. The lesson isn't "catch more errors." It's "watch the success number, and treat a flat zero as a five-alarm fire."
What This Costs You If Nobody's Looking
Let me put a price on this.
Two months of a dead feature on a real revenue lever. Loyalty drives repeat purchase, and repeat purchase is where DTC margins actually live. 956 customers tried to redeem, hit an invisible wall, saw a meaningless apology, and walked away trusting the brand a little less. None of them threw a visible error. None of it raised an alarm. The system reported health the entire time it was failing every single user.
That's the damage AI-built systems do when they're shipped fast and never audited. Not dramatic outages that everyone scrambles to fix. Quiet erosion. A feature that's alive on the surface and dead underneath, costing you a quarter of compounding repeat revenue while your dashboards stay green.
The value I bring isn't only building features. It's knowing where the silent failures hide and going to find them before they cost you a season. I run this same diagnostic posture across every system I build and every one I'm asked to inspect, looking first at the numbers that are suspiciously clean, the zeroes that should be messy, the error messages that explain nothing.
If you've shipped AI-built tooling fast and you're not certain what's quietly broken underneath it, have me audit what's quietly broken. That's usually where the recovered revenue is hiding.
Want to explore what AI could do for your business?
Book a free 30-minute strategy call. No pitch deck, no sales team, just a real conversation about your operations and where AI actually fits.
Get AI insights for business leaders
Practical AI strategy from someone who built the systems — not just studied them. No spam, no fluff.
Ready to automate your growth?
Book a free 30-minute strategy call with Hodgen.AI.
Book a Strategy Call