I have never thought mobile apps were just screens.
The more apps I build, the more obvious it gets that the real product boundary sits outside the binary: web funnels, attribution, billing state, analytics, redirects, and the API layer that keeps all of it connected.
The app itself is only one layer.
That became obvious once I started treating distribution as part of the engineering problem. It is easy to build a screen. It is harder to answer a basic business question with confidence: where did this user come from, what did they do before install, did they subscribe, and can the app respond to that state without leaking private identifiers?
The product boundary
The shift for me was to stop thinking about "the app" as one thing.
There is the mobile client. There is a marketing website. There are onboarding pages that run before install. There is an API layer that accepts web events, app events, attribution callbacks, subscription webhooks, and redirect requests. There are third-party systems that each see a different slice of the user journey.
The click and landing page do not disappear into a store URL. They become scoped source context at the API boundary.
That diagram is closer to the real product than a screenshot of the app.
The native client matters a lot. It has to be fast, stable, useful, and well designed. But distribution creates a separate set of requirements. A user may first touch the product on the web. Their billing state may arrive through a server-side webhook. Their acquisition source may be known to an ad platform before it is known to the app. Their identity may be split across an anonymous browser id, a device install id, an app user id, and a subscriber id.
If those boundaries are not designed deliberately, the team ends up with anecdotes instead of evidence.
Separating website, mobile, and API
The most useful architectural decision was separating the website, the mobile app, and the API as different surfaces with different responsibilities.
The website is for explanation, pre-install onboarding, landing pages, and redirect flows. It should move quickly because distribution experiments change often. The mobile app is for the core product experience, so it should be more conservative. App releases move through review, users update at different times, and mistakes are slower to unwind.
The API is the contract between them. It receives events, normalizes source context, creates handoff records, accepts webhooks, and gives the app a stable way to ask for the state it needs. Instead of asking the app to know everything, the app reports what it knows. Instead of asking the website to invent long-lived identity, the website passes scoped source context to the API.
That boundary matters more than the exact infrastructure. The website should not become a pile of invisible attribution hacks, and the app should not carry every distribution experiment inside a released binary.
Attribution is state, not a parameter
The biggest trap in attribution is treating it like a query string problem.
A campaign URL can contain source, medium, campaign, ad group, creative id, click id, placement, and platform-specific parameters. It is tempting to append those to the store link and call it done. That is not enough.
The problem is time. A user may click an ad, read a landing page, close the tab, return later, install, start onboarding, subscribe tomorrow, and only then become valuable. Some platforms preserve click identifiers through their own handoff. Some do not. Some data arrives late through callbacks. Some data should never be copied into client-visible places.
So I started treating attribution as state.
When the user arrives on the web, the API can create a short-lived journey record and send the product event into analytics. Pre-install actions attach to that anonymous journey. The store redirect creates a handoff attempt with destination, timestamp, campaign context, and any safe platform token. When the app opens, it reports a first session with appropriate identifiers. The backend tries to connect that app session to the earlier web journey without pretending the connection is always perfect.
App Store product pages and custom product pages help here too. They can preserve campaign-level context inside Apple's surfaces, which is useful for store-side measurement. I still do not want the entire attribution story to depend on a single store parameter. The product needs its own record of what happened before the store jump, what destination it sent the user to, and what the app later reported back.
The user journey becomes a set of records that can survive the store jump and later subscription events.
That phrase matters: best-effort match.
A mature attribution system should be honest about uncertainty. It can preserve source context, reduce loss, stitch events when identifiers line up, and send better server-side events to analytics and ad platforms. It cannot magically know everything after privacy boundaries remove the join keys.
The engineering goal is not perfection. The goal is to make the system explicit enough that the remaining uncertainty is visible.
Analytics that can survive the funnel
Analytics for a mobile app often starts with client events: app opened, account created, onboarding completed, paywall viewed, purchase started, subscription activated.
Those are necessary, but they are not enough for a distribution-ready product. The funnel may start before the app exists on the device. I prefer to model the journey across surfaces: web landing, pre-install onboarding, store redirect, first app open, app onboarding, paywall view, purchase attempt, subscription activation, renewal, cancellation, refund, grace period, and expiration.
Some events come from the browser. Some come from the native client. Some come from the billing provider. Some come from ad platforms. They should not all be trusted equally, but they should all be represented.
The difference between an event log and a useful analytics system is identity stitching. For me, the API owns the operational joins and handoff records. PostHog-style analytics owns the product event stream and the reporting layer. Those two jobs overlap, but they are not the same thing.
I do not mean invasive identity stitching. I mean careful, scoped joins: an anonymous browser id that expires, a redirect id created for a single handoff, an app user id generated by the app, a subscriber id from the billing layer, and platform click ids only where the platform rules allow them.
The event schema needs to make those ids visible without turning them into one careless global identifier.
This is where naming matters. If every id is called userId, the system gets sloppy quickly. A browser visitor is not an app account. A subscriber id is not a device id. A campaign click id is not a person. The schema should force the distinction.
Subscription state is backend state
Subscription state is one of the places where client-only thinking breaks down.
The app can start a purchase and receive an immediate result. That is useful for responsiveness. But the durable state of a subscription should come from the billing system and server-side webhooks. Renewals, cancellations, refunds, grace periods, billing retry, transfers, expirations, offers, and restores do not all happen while the app is open.
If the product only knows what the device last saw, entitlement becomes stale.
The better model is to let the billing provider send lifecycle events to the API, then let the API normalize those events into a subscription profile. The app can still ask the billing SDK for local purchase state, but the backend owns the durable record and the analytics event stream.
Purchase feedback can be instant, but durable subscription state should settle through the backend.
Subscriber attributes are useful here, but they need discipline. It is tempting to push every campaign parameter into the billing system. I think of subscriber attributes as a bridge, not a warehouse. They should carry durable context that connects billing events back to acquisition and product state: app user id, first seen source where appropriate, install context, billing origin, and the minimum campaign metadata needed for analysis.
Billing origin is especially important in apps with more than one purchase path. A subscription started from a native paywall, web checkout, win-back flow, or promotional link may need different analysis and different operational handling. If that origin is not captured when the purchase is created, it is hard to reconstruct later.
Ad platform handoff
Ad platforms want feedback loops.
They do not just want to know that someone clicked. They want to know whether the click produced an install, signup, trial, subscription, or other downstream event. The platform wants optimization signals. The product wants truthful measurement.
That difference changes the implementation.
For a platform handoff, I want the redirect route to capture the click context before sending the user onward. The route should record the platform, campaign, click id, destination, timestamp, user agent hints where appropriate, and any internal redirect id. Then, when downstream events happen, the backend can decide which events are safe and valid to send back to the platform.
The backend should own that decision. A browser page should not blindly forward every query parameter to every destination. The mobile app should not interpret every ad platform's rules. A server-side integration can enforce allowlists, dedupe events, respect consent state, and avoid sending private data where it does not belong.
The backend turns click context and downstream events into validated feedback signals.
This is also where deduplication matters. The same subscription can be observed by the app, the billing provider, analytics, and an ad platform. If the system does not have stable event ids and clear source precedence, it can overcount its own success.
My preference is to treat server-side billing events as the source of truth for paid lifecycle events, then use app-side events for product behavior and immediate UX. The two should reconcile, not compete.
Privacy-sensitive identifiers
Distribution work forces you to handle identifiers that feel useful and dangerous at the same time.
Click ids, anonymous browser ids, app user ids, subscriber ids, device ids, and advertising identifiers are not interchangeable. Some are available in one context and restricted in another. Some should be short-lived. Some should never leave the server.
I try to follow a few rules: collect the narrowest identifier that can do the job, separate operational ids from analytics ids, make expiration part of the model, and avoid putting sensitive identifiers into URLs when a server-side lookup would work. URLs leak into browser history, logs, referrers, screenshots, support tickets, and analytics tools. They are a convenient transport, but a bad place to store durable private state.
The hard part is that every shortcut works at first. Then the product grows, more channels appear, and the shortcut becomes an unreviewable identity system.
Deployment boundaries
A distribution-ready app has more deployable pieces than the app binary.
There is the mobile release. There is the website release. There is the worker or API release. There are database migrations, analytics schema changes, webhook endpoint updates, ad platform settings, billing product changes, and store destination changes.
Each one can break the funnel in a different way.
That is why I like explicit deployment boundaries and boring verification checklists. After an API change, can the website still record landing events? Can the redirect route still create handoff records? Can the app still report a first session? Can the subscription webhook still authenticate and normalize events? Can analytics still receive the expected event names and properties?
The checks do not need to be theatrical. They need to be real. A local test can prove the route shape. A staging event can prove the schema. A live smoke test can prove the deployed boundary. A webhook replay can prove the lifecycle handler.
What made it hard
The hard part was not integrating any single tool.
PostHog-style product analytics is understandable. Subscription webhooks are understandable. Ad platform event APIs are understandable. Worker APIs are understandable. Store redirects are understandable.
The difficulty is that none of them own the whole journey.
The website sees intent before install. The app sees product behavior after install. The billing system sees paid lifecycle state. The ad platform sees campaign delivery and click context. Analytics sees whatever the implementation sends. The API has to turn those fragments into a coherent model without exaggerating what it knows.
That is the senior engineering work in this layer. Not just sending events, but deciding which event is authoritative. Not just adding parameters, but deciding where they should live. Not just building a paywall, but making sure subscription state survives restores, webhooks, and delayed renewals.
I do not think this kind of system is ever finished. Channels change. Platform rules change. Privacy expectations change. Billing products change. What you want is not a perfect machine. You want boundaries clean enough to change without losing memory.
The part I like most
The part I like most is that distribution engineering makes the product more honest.
It is easy to judge a mobile app only by the interface. The interface matters, but a consumer app lives or dies by the loop around it. Can people find it? Can the web explain it? Can a campaign send someone to the right place? Can billing state flow back into the product? Can analytics describe reality closely enough to make better decisions?
Those are product questions, but they are also engineering questions.
A distribution-ready app is not just a native client with analytics sprinkled on top. It is a set of boundaries that preserve context as a user moves from the outside world into the product and, if the product earns it, into a paid relationship.
The app is the experience.
The distribution system is how the experience becomes legible.