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:
- Where does the money enter? App store, your website, or both.
- Who is the merchant of record? You, or a platform that handles tax and compliance.
- 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.
| Sold | iOS/Android app | Web |
|---|---|---|
| Digital content, subscriptions, features | Platform IAP required | Stripe/Paddle, your choice |
| Physical goods, real-world services | External payment allowed | Anything |
| Consumables (credits used in-app) | Platform IAP | Your 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 / VAT | Your problem, globally | Their problem |
| Invoicing, compliance | Yours | Theirs |
| Control, data | Full | Some abstraction |
| Chargebacks | Yours | Theirs |
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
| Requirement | Why |
|---|---|
| Restore Purchases | A user on a new device must recover their subscription without paying twice |
| Full disclosure block on the paywall | Title, length, price, trial length, auto-renew terms, cancellation window, and working Terms + Privacy links |
| No fake urgency | Countdown timers that reset are a violation |
| Honest trial copy | Don'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:
- Verify the signature or secret, and fail closed when it's unset
- Idempotent, providers retry
- Return 2xx quickly; queue slow work
- 200 for permanently unprocessable events
- Log the raw payload
Failure paths you must handle
- Payment failed / card expired → dunning: retry schedule plus email. Recovers a meaningful share of involuntary churn.
- Refund → revoke entitlement.
- Chargeback → revoke, and respond with evidence.
- Grace period → platforms give a window after failure; honour it rather than cutting access instantly.
- Upgrade/downgrade → proration, and what happens mid-period.
- Cancellation → access until period end, not immediately.
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
| Need | Tool | Cost |
|---|---|---|
| Mobile subscriptions | RevenueCat, Adapty | Free < $2.5k/mo, then ~1% |
| Web payments (you as MoR) | Stripe | 2.9% + 30¢ |
| Web payments (provider as MoR) | Paddle, Lemon Squeezy | ~5% |
| Store fees | Apple / Google | 30%, or 15% under $1M/yr |
| Dunning / recovery | Churn Buster, Stripe Smart Retries, RevenueCat | $0-$$$ |
| Usage metering | Orb, Metronome, Stripe Meters | $$ |
| Paywall A/B | RevenueCat Experiments, Superwall | Free tiers |
| Invoicing (B2B) | Stripe Invoicing, Paddle | Included |
| Tax (if you're MoR) | Stripe Tax, Anrok, Quaderno | 0.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
- Using a subscription layer, not raw store APIs
- MoR decision made deliberately
- SDK configured once before any call
- User identified to the provider before every transaction
- No product substitution, exact match or fail
- Unlock optimistically; reconcile via webhook
- Entitlement table read-only to clients
- Prices, currency and trial copy render from the store
- Trial copy checks eligibility
- Restore Purchases implemented
- Full disclosure block + Terms + Privacy links
- Webhook verifies and fails closed; idempotent; correct status codes
- Dunning configured
- Declines/cancels change no state
- Tested across currencies, no-trial, ineligible-user configurations
- ⭐ Real purchase completed on a real device
- Applied for the small-business fee programme
📓 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:
- A stub that returns success is worse than no stub. Unimplemented code should
throw new Error("not implemented")until it's real. - 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. - ⭐ 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:
- A "7-day money-back guarantee" was removed. On mobile the platform owns refunds; the developer has no mechanism to honour it. Replaced with something true: "Nothing is charged until the trial ends."
- The full disclosure block was missing entirely, trial length, both prices, "charged to your Apple ID," auto-renew and cancellation terms, and working Terms and Privacy links. Its absence is one of the most common subscription rejections, and it's entirely avoidable 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
- ⭐ A stub that returns success is worse than no stub. Make unimplemented code throw.
- ⭐ Defensive fallbacks hide bugs. Every fallback must log, count, or surface itself.
- Never substitute a product the user didn't select.
||defaults are catastrophic around money. - Wrong documentation is worse than none. It preserved this bug for three weeks.
- Grep your own codebase for how it accesses things, rather than trusting a doc snippet.
- Unlock optimistically; reconcile authoritatively. The client is never truth.
- Fail closed. A missing secret must reject, not allow.
- Return 200 for permanently unprocessable events, or you get infinite retries.
- Never promise what the platform controls. The trial is your guarantee.
- The disclosure block is pure copy and one of the most common rejections.
- 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.