All chapters Part V · Money
Chapter 27

Payments, Billing and Subscriptions

Payment code is the only code where a bug costs you money directly, and it's uniquely hard to test, the path that matters most is the one you can least easily exercise. The most dangerous version of this code is the kind that looks finished.


The concept

Three questions decide your entire payments architecture:

  1. Where does the money enter? App store, your website, or both.
  2. Who is the merchant of record? You, or a platform that handles tax and compliance.
  3. What is the source of truth for entitlement? Never the client.

Platform rules: non-negotiable

Digital goods consumed inside a mobile app must use the platform's billing. Apple and Google both require it, and it isn't negotiable, attempting to route around it is a rejection and, repeated, an account risk.

SoldiOS/Android appWeb
Digital content, subscriptions, featuresPlatform IAP requiredStripe/Paddle, your choice
Physical goods, real-world servicesExternal payment allowedAnything
Consumables (credits used in-app)Platform IAPYour choice

The platform fee is 30%, or 15% for the first $1M/year under the small-business programmes both stores run. Apply for these, it's a form, and it doubles your net revenue at small scale.

Some jurisdictions now permit external purchase links with conditions; the rules change frequently and vary by region. Check current guidance rather than what you remember.

Merchant of record: the decision that saves you from tax

You are MoR (Stripe)Provider is MoR (Paddle, Lemon Squeezy, app stores)
Fee~2.9% + 30¢~5%
Sales tax / VATYour problem, globallyTheir problem
Invoicing, complianceYoursTheirs
Control, dataFullSome abstraction
ChargebacksYoursTheirs

For a solo founder selling globally, MoR is usually worth the extra ~2%. Global VAT/GST registration and remittance is a genuine ongoing burden (Chapter 41), and the moment you cross a threshold in a country you didn't know about, it becomes an expensive one.

Use a subscription layer

Raw StoreKit and Play Billing are different APIs with different receipt semantics, different edge cases, and a lot of state to hold. A subscription layer (RevenueCat, Adapty) wraps both and handles receipt validation, cross-platform entitlement, restore, and webhooks. Free at small scale, ~1% above.

Building this yourself to save 1% means maintaining receipt validation for two platforms forever. Bad trade.

⭐ Entitlement architecture

The single most important structural decision:

 CLIENT                                    SERVER
 ──────                                    ──────
 purchase → unlock immediately    ─────▶   webhook from provider
 (snappy UX; client is NOT truth)          verify signature/secret
                                           write entitlement to your DB
                                           ← this is the source of truth
 on launch: ask the provider
 what this user is entitled to

Both halves matter. Client-only means anyone who can edit local storage is premium. Server-only means a real purchase spins for two seconds waiting on a webhook. Do both: unlock optimistically on the client, reconcile authoritatively on the server.

And in your database: the entitlement table must be read-only to clients. Only your server, using elevated credentials, writes it (Chapter 20).

The purchase flow

 1. Configure the SDK once, at startup
 2. Identify the user — tie the payment identity to YOUR user id, before any transaction
 3. Fetch available products from the store
 4. Find the exact product the user selected  ← never substitute
 5. Purchase → platform sheet
 6. On success: verify entitlement, unlock
 7. On cancel/failure: change nothing, stay on the paywall
 8. Provider webhook → your server → authoritative record

Step 2 is the one people miss. Without tying the payment identity to your user id before the transaction, your webhook receives an anonymous identifier and cannot map the payment to a user.

Step 4 deserves emphasis: never fall back to "whatever product is first." If the selected plan isn't available, fail, don't charge someone for a plan they didn't pick.

Required by the stores

RequirementWhy
Restore PurchasesA user on a new device must recover their subscription without paying twice
Full disclosure block on the paywallTitle, length, price, trial length, auto-renew terms, cancellation window, and working Terms + Privacy links
No fake urgencyCountdown timers that reset are a violation
Honest trial copyDon't advertise a trial to someone ineligible for it

A missing disclosure block is among the most common subscription rejections. It is pure copy and it is entirely avoidable.

Webhooks

The same rules as any inbound webhook (Chapter 22), and they matter more here:

Failure paths you must handle

Involuntary churn (failed payments) is typically 20-40% of all churn. Dunning is the highest-ROI retention work available and almost nobody does it early.


📐 Best practice

Use a subscription layer, not raw store APIs.

Prefer a merchant of record if you sell globally and you're small.

Identify the user to your payment provider before every transaction.

Never substitute a product the user didn't select.

Unlock optimistically; reconcile authoritatively.

Entitlement tables are read-only to clients.

Render all prices and trial copy from the store (Chapter 26).

Ship Restore Purchases and the full disclosure block.

Fail closed on webhook auth.

Make unimplemented payment code throw, never return success.

Apply for the small-business programmes.

Test a real purchase on a real device before launch.


💀 Common mistakes

⭐ A stub that returns success. The single most dangerous pattern in this chapter, see the case study.

Defensive fallbacks that grant access. if (!available) { letThemIn(); } is a paywall that doesn't exist.

Trusting the client for entitlement.

Client-writable entitlement tables.

Not identifying the user before purchase. Unattributable payments.

Substituting a product, charging for a plan they didn't choose.

Hard-coded prices and currency.

Advertising a trial without checking eligibility. Intro offers are per-account, not per-install.

Missing disclosure block. Very common rejection.

No Restore Purchases. Rejection, plus double-charged users.

Webhooks that accept anything when misconfigured.

No dunning. Losing 20-40% of churn you could have recovered.

Promising refunds you can't process. On mobile, the platform decides.

Testing only on a simulator. Real receipts behave differently.


The professional workflow

 1. DECIDE where money enters and who is merchant of record

 2. CHOOSE THE SUBSCRIPTION LAYER

 3. CREATE PRODUCTS in the store/billing dashboards
    IDs must match your code exactly · in a group · priced per territory ·
    marked ready

 4. IMPLEMENT
    configure once · identify user BEFORE transaction · fetch products ·
    exact match only · purchase · verify · unlock

 5. SERVER TRUTH
    webhook: verify + fail closed + idempotent + fast 2xx ·
    entitlement table read-only to clients

 6. COMPLIANCE
    Restore Purchases · full disclosure block · Terms + Privacy links ·
    no fake urgency · honest trial copy

 7. FAILURE PATHS
    dunning · refund · chargeback · grace · upgrade/downgrade · cancel

 8. TEST MATRIX
    success · decline · restore found · restore not found ·
    multiple currencies · no-trial config · ineligible-for-trial user

 9. ⭐ REAL PURCHASE ON A REAL DEVICE before launch

10. INSTRUMENT — paywall views, starts, conversions, churn, failures

Tools, websites & costs

NeedToolCost
Mobile subscriptionsRevenueCat, AdaptyFree < $2.5k/mo, then ~1%
Web payments (you as MoR)Stripe2.9% + 30¢
Web payments (provider as MoR)Paddle, Lemon Squeezy~5%
Store feesApple / Google30%, or 15% under $1M/yr
Dunning / recoveryChurn Buster, Stripe Smart Retries, RevenueCat$0-$$$
Usage meteringOrb, Metronome, Stripe Meters$$
Paywall A/BRevenueCat Experiments, SuperwallFree tiers
Invoicing (B2B)Stripe Invoicing, PaddleIncluded
Tax (if you're MoR)Stripe Tax, Anrok, Quaderno0.5%+

Alternatives & trade-offs

Stripe vs MoR. Stripe gives control, better data, lower fees, and hands you global tax compliance. MoR costs ~2% more and removes an entire operational burden. Solo and global: MoR.

Subscription layer vs direct. The layer costs ~1% above a threshold and saves receipt validation, cross-platform entitlement, and webhook plumbing forever.

Web vs in-app purchase. Web checkout avoids the 30% and adds friction and platform-policy risk if you promote it in-app. Many products offer both: in-app for convenience, web for margin, with careful compliance.

Trial with card vs without. With-card roughly doubles trial-to-paid and reduces trial starts and generates refund requests. Consumer mobile is card-required by platform default.

Lifetime purchase alongside subscription. Cash now, LTV capped on your best customers. Capped and launch-only, if at all.


Checklist


📓 Case Study: a paywall that gave every user a free subscription

Project: SOLIS. This is the most instructive payments failure in the book, because nothing errored and nobody noticed for weeks.

The setup. Payment code had existed since day 1. It looked complete, plausible function names, plausible bodies, plausible flow. When it was finally examined properly on day 22, the finding was blunt: it was a non-working stub. Three defects, stacked:

1. The SDK was never configured. The code called a method to check entitlement without first calling configure({ apiKey }). The SDK throws without it. Nothing had ever run.

2. The wrong global. The code read window.CapacitorPlugins, but every other plugin in the app correctly used window.Capacitor.Plugins. One plausible-looking word. So the payments object was always undefined.

3. The defensive fallback. And here is where it becomes dangerous:

const P = window.CapacitorPlugins?.Purchases;   // always undefined
if (!P) { beginPath(); return; }                // ← "don't block the user"

That fallback is good instinct. If payments aren't available, don't dead-end someone, let them into the app.

Combined with defect 2, it means: tap "Unlock," receive the full product, free, forever. No error. No log. From the user's side the paywall worked perfectly, because it did.

Three lessons, and the third generalises far beyond payments:

  1. A stub that returns success is worse than no stub. Unimplemented code should throw new Error("not implemented") until it's real.
  2. Copy-paste from docs breaks silently on globals. ?. and defensive defaults convert a typo into silence. Grep for how your own codebase accesses similar things rather than trusting a snippet.
  3. ⭐ Defensive fallbacks hide bugs. Every fallback should log, count, or surface itself. A fallback that never reports is a bug-hiding machine.

And a fourth, about documentation: the project's own build guide documented the wrong pattern, which is how the error survived three weeks. Wrong documentation is worse than none, it makes the bug look intentional.

⚠️ A second money bug, found while fixing the first:

const pkg = packages.find(p => p.id === wanted) || packages[0];   // ⚠️

Reads as helpful. Actually means: if the selected plan isn't in the offering, charge them for whichever plan is first. Choose annual, get billed monthly.

Removed, with the reasoning kept in place:

Deliberately NO fallback: substituting whatever package is first charges the user for a plan they did not pick. Failing over to the free path is the only honest option.

|| defaults are fine for display values and catastrophic around money, identity or permission.

What the rebuild got right:

Server-authoritative entitlement. The webhook function's own header states the principle:

The client unlocks premium immediately from customer info for snappy UX, but the client is not trustworthy. This function is the server's source of truth.

Fail-closed auth, with the important clause first:

const expected = env("WEBHOOK_SECRET");
if (!expected || got !== expected) return new Response("unauthorized", { status: 401 });

!expected, a missing secret rejects everything. The alternative, treating unset config as "no auth required," means a misconfigured deploy accepts entitlement grants from anyone on the internet. Verified: 401 until the secret was set.

Correct status codes. Events carrying an anonymous payment id that could never map to a user returned 200, acknowledged and skipped, rather than an error that triggers infinite retries.

Compliance fixes on the last day, both pure copy:

⚠️ Deviation: a real purchase was never executed.

Everything was verified against a mocked payments object, success, decline, restore-found, restore-not-found, and against fixture products across currencies and offer shapes. That's a good test matrix. But real receipts, real trial eligibility, and real store behaviour were never exercised on a device.

🚩 And one gap left open: the client still gated content on a local flag that a determined user could set. The webhook is the server-side truth, but the client never consulted it. For a content app that's an accepted risk; it should be a conscious decision rather than an oversight.


Lessons

  1. ⭐ A stub that returns success is worse than no stub. Make unimplemented code throw.
  2. ⭐ Defensive fallbacks hide bugs. Every fallback must log, count, or surface itself.
  3. Never substitute a product the user didn't select. || defaults are catastrophic around money.
  4. Wrong documentation is worse than none. It preserved this bug for three weeks.
  5. Grep your own codebase for how it accesses things, rather than trusting a doc snippet.
  6. Unlock optimistically; reconcile authoritatively. The client is never truth.
  7. Fail closed. A missing secret must reject, not allow.
  8. Return 200 for permanently unprocessable events, or you get infinite retries.
  9. Never promise what the platform controls. The trial is your guarantee.
  10. The disclosure block is pure copy and one of the most common rejections.
  11. Mocks prove your logic, not your integration. Complete one real purchase on real hardware before you claim you're ready.

Next: Chapter 28: Testing and Verification →

This completes Part V, Money.

Useful? Share this chapter