Shipping & Infra9 min read

"I Paid and the App Is Empty" — The Payment Was Never the Problem

A subscriber reported an empty app. Charge, receipt and entitlement were all fine. The real cause was a signup trigger creating a complete-looking empty row plus one `!= nil` check in the app. All 200 recent signups had skipped onboarding.

#supabase#postgres#onboarding#reality-check#gotchas#revenuecat
Left panel shows the payment path the ticket blamed - charge, receipt, entitlement and webhook all OK. Right panel shows the signup trigger inserting an (id, stage) row, the app reading row != nil as onboarded, and 200 of 200 recent signups with an empty selected_symptoms array
Left: where the customer looked. Right: where it was actually broken.

A subscriber wrote in. One sentence, essentially: "I paid, and there's nothing in the app."

When that ticket lands, your hands go straight to billing. Receipt validation, entitlement sync, a dropped webhook. I searched there first, and everything was fine. The charge went through, the receipt was valid, the subscription was active. The payment was never the problem.

The problem was that the app they paid for had never asked them anything. Onboarding had been skipped entirely. So of course the screens were empty — the paid features had no input to compute from.

One question before we go further. What does your app use to decide "this user finished onboarding"? The existence of a row, or the presence of actual values inside that row? This post is about how that distinction quietly burned my only paying subscriber.

1. The payment path was innocent

What I checked, in order:

  • Charge succeeded — processor records normal
  • Receipt — valid, not expired
  • Entitlement — active
  • Webhook — delivered and processed

Stop here and you close the ticket as "user confusion." Instead I read that account's data row directly. That is the only methodology in this post: look at the user row, not the billing dashboard.

The row existed. And it was empty.

2. The real observation — it wasn't one user

This is where the incident changed shape. To check whether that account was special, I scanned recent signups' profile rows.

All 200 recent signups had an empty selected_symptoms array.

This was not a payment accident for one person. Everyone who signed up had never seen onboarding. The person who wrote the ticket wasn't the only victim — they were the only one who paid money and therefore noticed.

Worth dwelling on: a dashboard raises no alarm in this situation. Signups go up, rows get created, payments clear. I hit the same class of illusion in the post where a metric reading zero was itself the bug. A green dashboard is not evidence.

3. The cause — two pieces, each correct, wrong together

Piece A: the shared signup trigger

These apps share auth.users inside a single Supabase project, so a shared trigger materialises a profile row in every app schema on each signup.

create or replace function public.handle_new_user_multiapp()
returns trigger language plpgsql security definer as $$
begin
  -- ...
  begin
    insert into sage.users (id, stage)
      values (new.id, 'perimenopause')
      on conflict (id) do nothing;
  exception when undefined_table or undefined_column or not_null_violation then null;
  end;
  -- ...
  return new;
end;
$$;

Look at why stage is in there. The table is defined like this:

CREATE TABLE IF NOT EXISTS sage.users (
  id                uuid PRIMARY KEY REFERENCES auth.users ON DELETE CASCADE,
  stage             text NOT NULL CHECK (stage IN ('perimenopause','menopause','postmenopause')),
  selected_symptoms text[] NOT NULL DEFAULT '{}',
  created_at        timestamptz NOT NULL DEFAULT now()
);

stage is NOT NULL with a CHECK constraint, so the insert fails unless the trigger supplies a value. Past me picked a default and hardcoded it. That's half the trap. The row is now perfectly valid by the schema — stage is set, and selected_symptoms is NOT NULL DEFAULT '{}' so it pretends not to be missing. A syntactically flawless row made of values nobody ever entered.

Piece B: the app's one-line decision

hasProfile = (try? await SupabaseService.shared.fetchUserProfile(id: uid)) != nil
} else if !hasProfile {
    OnboardingView(onComplete: { hasProfile = true })
} else {
    MainTabView()
}

!= nil. If the row exists, onboarding is considered done.

Piece A creates the row at signup; piece B reads that row as "complete." So the onboarding screen never appeared once. The app dropped users straight into the main tabs without asking anything, leaving stage at whatever I hardcoded in the trigger and selected_symptoms an empty array.

Both pieces would pass review. The trigger does something reasonable ("guarantee a profile at signup"). The app does something reasonable ("skip onboarding if a profile exists"). What's wrong is the contract between them. Nobody wrote down that "a row exists" and "the user told us something" are different facts.

4. Where would you fix it?

Before the answer, pick one. There are three options:

  1. Stop creating rows in the trigger — let each app create its own on first login. Risk: every other app that assumed "the row is there" breaks.
  2. Change the app's check — decide on content, not existence. Empty selected_symptoms means onboarding is incomplete.
  3. Add an explicit flagonboarding_completed_at timestamptz, filled only when the user actually finishes.

I shipped option 2 immediately. It ships in one app, and it also pulls back every existing user who was already stuck in the empty state on their next launch. Option 3 is the more honest model, but it only rescues new signups — the 200 already trapped stay trapped, because you then have to decide whether pre-existing rows without the flag count as complete or not. A content check applies retroactively for free.

Compressed to one line: existence is not completion. I made the same mistake at another layer in a build finishing is not a release.

5. What else fell out of the same dig

Follow one account all the way down and the neighbours show up.

Missing RevenueCat logIn. The call that ties billing identity to login identity is absent in many apps. I counted the whole repo just now:

files calling Purchases.configure: 27
files calling Purchases.logIn:      7

configure without logIn attaches the subscription to an anonymous app user ID. Change device or reinstall, log in with the same account, and the purchase does not follow. That's roughly twenty unexploded tickets sitting there.

Restore fallback. It's the user's last resort when an entitlement doesn't show up, and that path could fail silently with nothing said on screen.

The local Edge Function source was older than the deployed one. Read the local file, conclude "the code does X," and you're wrong. What's deployed is the truth. This local-vs-remote divergence has the same root as the test suite writing to productionnot knowing which environment you're looking at.

6. And then submission itself was blocked

Once you have a fix, you have to ship it. App Store Connect blocked me in sequence:

  • The EULA has to be supplied for all 63 locales. Miss one, get rejected.
  • A link containing .html in the terms body tripped up handling.
  • Querying appInfos with limit=1 returns one arbitrary record, producing a false positive. You have to fetch the list and select.
  • A 409 conflict on the iPad-related update.

I also re-confirmed here that an API returning 200 does not mean the change landed. I now always read back and compare after a write — a habit from the time the store lied about "not editable".

7. The honest part

I fixed it, verified it, and replied. At that point I thought the case was closed.

It wasn't. A few hours later a reply came, asking for a refund. Onboarding was fixed, but the way she actually wanted to use the app — logging things as they happened through the day — still didn't work. And to that second message I answered "that's one entry per day by design, I can't change it." That was also a bug. That reply was a worse mistake than the one this post is about, so it gets its own post → I told her it was by design. It was data loss.

So this is not a story about saving a customer. It's a story about my only paying subscriber asking for a refund. She was the one person who opened her wallet for my apps, and the first thing I handed her was an empty screen. The fixed code is for the next person; for this one it's late.

Correction (night of 2026-08-14). When this post first went up, the closing section said "no response came and the account was deleted." That is not true. She did reply, and that reply is what surfaced the second, worse bug. I had filed the most valuable feedback I've ever received as "no response." The passage above has been replaced.

One thing did survive: that single ticket surfaced a defect affecting 200 users. I don't consider this a trade I won. The dashboard failed to show this defect for over three months, and it took one angry human to reveal it.

Self-check — just three

Things you can apply to your own project right now.

  1. Find your onboarding decision and read it with your eyes. Is it != nil, != null, if row:? Then check whether something other than you (a trigger, a backfill, a sibling app) can create that row.
  2. Query the "user should have entered this" columns for your last 20 signups. All defaults means onboarding isn't running. It's a one-line query.
  3. Grep your billing SDK's identity call. If the count of configure differs from the count of logIn, that difference is your future pile of "my subscription vanished" tickets.

Conclusion

Looking at billing when a billing ticket arrives is natural, but this cause sat five steps away from billing. The user bought a feature, and the feature had no input to compute from. The screen that was supposed to collect that input never appeared, because a database trigger had already created the row.

Do exactly one thing now: query the onboarding columns for your last 20 signups. If they're all {} or all defaults, you may have been in the dark for months, like I was. I'd like to hear what you find — especially if the answer is "ours were empty too" rather than "we were fine."

Related