Two new users paid within 16 minutes of each other on one Android app. That app's lifetime numbers are 46 days, 99 users, 8 transactions. Three of those transactions happened in those 16 minutes.
I wanted to know where they came from, so I walked both users' entire paths through the server logs, line by line — funnel events, credit ledger, processed transactions, subscription state. I never did find the acquisition source. What I found instead was what we actually lost that day.
One question first. At the moment your payment SDK's purchase-success callback returns, does your server know that user is paid?
1. One of them paid twice for the same thing
| Time | Event |
|---|---|
| 02:05:37 | Monthly pass purchased (trial, expiry recorded) |
| 02:05:49 | Paywall shown again |
| 02:06:07 | Paywall |
| 02:06:40 | Paywall |
| 02:07:22 | Paywall — one of these was a second tap on the pass, returning "item already owned" |
| 02:07:36 | Bought credits on top |
| 02:07:40 | Server finally records the subscription as active |
| after | 0 readings, churned |
The feature they were trying to use was already included in the pass. For the two minutes the server didn't know, that feature ran as paid, the user paid a second time, used nothing, and left.
2. "Calling sync" and "the entitlement opening" are different events
We did call the server sync right after the purchase succeeded. On paper there is no gap.
But at that moment the store hadn't finished validating the receipt. Until validation completes, the server writes nothing. The response still returns 200. The balance still comes back correctly. There simply is no pass inside it.
The code treated the two as one event: call it once, it came back, done. The symptom looks like the time payment succeeded but Pro never turned on and the time payment was never the problem, but the cause is different. Those two were entitlement name mapping. This one is validation that hasn't finished yet. You can stare at the mapping forever and never find it.
3. And the paywall keeps selling what you already own
There was a second defect on the screen side. The paywall renders whatever packages the offering gives it. It never looks at the currently active subscription.
So even after the server belatedly recorded active, the same product was still purchasable. The two-minute gap didn't create that defect — it just exposed it four times in a row.
4. The other user could not buy at all
The second user tried the monthly pass three times and got an invalid-purchase error every time. They gave up and bought credits instead.
The hypothesis: both products carry exactly one offer, a 7-day trial, and that offer is targeted at "new, no prior subscription in this app". The app only ever purchases the package with the offer token attached, so an account that isn't trial-eligible has no purchasable path at all.
Here is the part that is easy to get backwards. The anonymous identifier is regenerated on every reinstall. So someone who doesn't exist in our database may still be ineligible at the store. Our "new" and the store's "new" count different things.
5. Where would you start?
That's where the logs end. Three candidate fixes:
- (a) Force-close the paywall right after a purchase
- (b) Optimistically flip the entitlement on the client when the purchase succeeds
- (c) Retry until the server actually records the entitlement, and hold paid usage during that window
(a) hides the symptom; open it again and it sells again. (b) grants entitlements to payments that later fail validation. The answer was (c) — and (c) has a trap in it.
6. The fix — zero server changes
The sync response had been returning three things all along: balance, credited amount, and the pass. The client read the balance and threw the rest away.
// before: balanceOf(response) → never reads the pass field
// after: parseSyncPayload(response) → { balance, credited, pass }On top of that, an exponential-backoff re-sync (1 · 2 · 4 · 8 · 8s, 5 attempts), and during that window the app holds back paywall entry and paid usage.
⚠️ Settling must terminate. If the entitlement still isn't open after 5 attempts, release the screen. Blocking forever removes the entire payment path and the app becomes unusable. This is the easiest place in the whole flow to turn a guard for the user into a cage around the user.
The paywall defect is a rendering defect, so I pinned it with a render test. I reverted the fix first to confirm RED, and exactly the two targeted cases failed (annual purchase and credit repurchase still passed) — meaning it isn't over-blocking.
The decision logic was extracted into a pure function with zero dependency on store or payment SDK types, pinned by unit tests: 62 tests including 26 new ones, 0 failures.
7. Two measurement defects that fell out of this
Both belong to the family that turns your numbers into lies.
- Cancellation was detected with
contains("cancel")on the error message. If the system language is Korean, cancellations get counted as failures. On an app with a large Korean user base, the "payment failure rate" was inflated. Switched to the error code. paywall_shownfired 15 times across 35 minutes for two people. Use that as a denominator and your conversion rate is fiction. This incident is itself part of what produced those 15.
What I still don't know
Honestly:
- Our data cannot answer where they came from. The request log has only an identifier and a timestamp — no country, locale, app version, install source, or first-run marker anywhere. All I know for sure is that both first appear that day, on the same version, and used exactly one of ten features (all 17 calls).
- The root cause of the invalid-purchase error is unconfirmed. I added a fallback (trial fails → base plan), but until I round-trip it on a real device with an ineligible real account, I can't say it's gone. So the failure event now carries the raw store response and the offer identifier. The next occurrence will decide it.
- I did not port this hypothesis to other apps. The other store filters ineligible offers out of the response, so the same defect probably can't form there.
Three things to check in your own code
- Have you measured the time between purchase success and entitlement open? It's the delta between two timestamps. Most code is written assuming it's zero.
- Does your paywall look at the active subscription? If it doesn't, there is a screen live right now selling people what they already bought.
- How do you classify payment failures? If it's by error message string, that classification inverts on any non-English system language.
The honest part
This isn't a clever piece of debugging. I lined up two users' logs in chronological order. That's it. But without those two users I would never have seen those two minutes — the dashboard showed "3 transactions", and that number looked good.
The real-device, real-account purchase round trip is still outstanding. So this post is less "I fixed it" and more "here is the shape of the hole".
Do one thing today. Pick one recent paying user and print the purchase-success timestamp next to the timestamp where your server recorded the entitlement. Then count the paywall impressions in between. Mine was four.