Mobile subscription billing often looks deceivingly simple from the outside. The standard sales pitch suggests you just add a paywall SDK, configure your products in the respective app stores, listen for webhooks, and then update your database whenever a purchase succeeds. Unfortunately, that narrative is only half true. The happy path works beautifully until you inevitably hit the more uncomfortable edges of the system: sandbox environments, missing identity fields, race conditions, delayed webhooks, restored purchases, renewals, cancellations, and the awkward reality that the app store owns the transaction while your application owns the user.
I ran headfirst into this reality while integrating Superwall with Google Play subscriptions. While production purchases looked fine, sandbox testing quickly devolved into a mess. Test purchases would complete successfully inside the app, but my backend could not reliably figure out which user should actually receive the premium entitlement. What started as a straightforward paywall integration suddenly became a distributed systems problem centered around identity, ownership, and reconciliation. This article details the architecture I ultimately ended up with, and the painful lessons that forced me to get there.
The Architecture I Wanted to Believe In
The first version of my billing flow naturally followed the standard webhook-driven model. The user would tap an upgrade button, Superwall would present the paywall, handle the purchase, and then send a webhook to my backend. The backend would simply read the user ID from the incoming webhook and upgrade that specific user.
In the ideal scenario, the webhook payload looked like this:
{
"data": {
"originalAppUserId": "user_123",
"transactionId": "GPA.3373-4052-0812-08085",
"productId": "pro:pro-monthly"
}
}That payload contains everything a backend developer wants. It includes the product, the transaction, and most importantly, the app-level user identity. Given that clean data, granting access is straightforward. For real production purchases, this flow worked exceptionally well. Users bought subscriptions, webhooks arrived promptly, and the database moved them to the premium plan. Then, I started properly testing with the Google Play sandbox.
The Sandbox Broke the Assumption
Google Play sandbox testing is incredibly useful because subscription timelines are dramatically accelerated. A monthly subscription can renew every few minutes, making it possible to thoroughly test renewal, cancellation, expiration, and resubscription flows without waiting an actual month. But my first sandbox purchases failed in a highly confusing way. The purchase completed in the app, yet the user did not get upgraded.
The backend logs quickly explained why:
{
"data": {
"originalAppUserId": null,
"userAttributes": null,
"transactionId": "GPA.3373-4052-0812-08085",
"originalTransactionId": "GPA.3373-4052-0812-08085",
"productId": "pro:pro-monthly",
"environment": "SANDBOX"
}
}The identity fields were completely missing. The webhook was effectively stating, "Someone just bought this subscription, and here is the transaction ID, but I have absolutely no idea who they are." That single omission broke the entire design because my backend heavily relied on the webhook to answer the most critical question: which user owns this purchase?
The deeper architectural problem is that Google Play fundamentally does not know about my internal user IDs. The subscription belongs to a Google account, while my app user belongs to my proprietary authentication system. Superwall can certainly try to bridge those two distinct identity domains, but treating that bridge as an infallible source of truth across every environment is a mistake. Once I understood that disconnect, the old architecture stopped looking simple and started looking incredibly fragile.
The Failed Fixes
Before landing on the right model, I naturally tried a few obvious fixes that ultimately fell short.
These repeated failures forced me to carefully separate two distinct concepts that I had accidentally merged together in my initial design.
Ownership and Lifecycle Are Different Problems
The major breakthrough was realizing that subscription ownership and subscription lifecycle are not the same problem at all. Ownership answers: who bought this subscription? Lifecycle answers: what is the current state of this subscription? I had been relying on webhooks for both, which was my fundamental mistake.
Ownership only needs to be established once, and it should strictly be established by an authenticated user action. Lifecycle changes, on the other hand, happen repeatedly over time through renewals, cancellations, expirations, refunds, and resubscriptions. Webhooks are fantastic for asynchronous lifecycle updates, but they are a very poor primary source for ownership when critical identity fields can be missing.
The crucial piece of data that finally made this work was transaction lineage:
{
"transactionId": "GPA.3373-4052-0812-08085",
"originalTransactionId": "GPA.3373-4052-0812-08085"
}The originalTransactionId firmly identifies the root of the subscription. It remains completely stable across renewals and lifecycle events, making it the perfect key for binding a store subscription to a specific app user. The second important piece of the puzzle was the purchaseToken, which is readily available on the client after a Google Play purchase. The backend can use that token to ask Google Play directly whether the purchase is actually valid.
This realization led to a much better architecture: bind ownership immediately using authenticated client context, verify the purchase directly with Google Play, and then rely on webhooks strictly for lifecycle reconciliation moving forward.
The Architecture That Worked
In the final design, the webhook is no longer the authority that decides who gets premium access. Instead, it serves as a background messenger that keeps the overall subscription state current.
The immediate purchase flow now starts on the client. When Superwall reports that a transaction has completed, the app captures the transaction lineage and purchase token. It then sends those specific values to my backend under the user's normal, secure authentication session.
POST /v1/billing/superwall-binding
Authorization: Bearer <user_token>
Content-Type: application/json
{
"store": "PLAY_STORE",
"originalTransactionId": "GPA.3373-4052-0812-08085",
"transactionId": "GPA.3373-4052-0812-08085",
"purchaseToken": "AOP..."
}Crucially, the backend does not trust any user ID hidden inside the body. It relies entirely on the Authorization header to identify the user. Then, it creates a binding that definitively states: this authenticated user owns this original transaction ID. That binding becomes the permanent ownership record.
Next, the backend immediately verifies the purchase directly with Google Play:
GET https://androidpublisher.googleapis.com/androidpublisher/v3/applications/{packageName}/purchases/subscriptionsv2/tokens/{purchaseToken}If Google responds that the purchase token is valid and the subscription is active, the backend grants the entitlement right then and there. The user gets premium access instantly after purchase completion, completely eliminating any reliance on a webhook wait.
Later, when a Superwall webhook eventually arrives, it can safely miss identity fields and still remain useful:
{
"data": {
"originalAppUserId": null,
"originalTransactionId": "GPA.3373-4052-0812-08085",
"name": "renewal",
"expirationAt": 1775832567843
}
}The backend simply uses the originalTransactionId to look up the binding, then safely updates the existing subscription record. The webhook no longer needs to know who the user is, because the system already knows.
Why This Handles Edge Cases Better
This decoupled model makes all the ugly edge cases significantly easier to reason about.
If a webhook happens to arrive before the client binding, the backend simply stores it as pending. When the authenticated client later submits the binding, the backend can safely replay that pending lifecycle event. If a user resubscribes directly from the Play Store app instead of inside my app, no immediate client event will fire. However, the webhook can still arrive and sit in a pending state until the user opens the app again. Once the SDK detects the restored purchase, the client sends the binding and the backend seamlessly catches up.
Most importantly, if the Google Play sandbox omits identity fields, nothing breaks. Direct verification relies on the purchase token, and webhook reconciliation relies on the original transaction ID. Neither critical step depends on originalAppUserId actually being present. This is the profound difference between a billing system that merely hopes all integrations preserve identity, and one that enforces its own authoritative ownership model.
The Mental Model
The store owns the transaction. Your app owns the user. Your backend must firmly own the binding between them. Once you truly accept that dynamic, the entire architecture becomes much clearer.
| Concern | Source of truth |
|---|---|
| User identity | Your auth system |
| Purchase validity | Google Play or App Store |
| Subscription ownership | Your binding table |
| Renewals and cancellations | Store events via webhooks, reconciled through bindings |
The client is perfectly allowed to carry correlation data, such as the purchaseToken and originalTransactionId. However, it is fundamentally not allowed to grant itself access. The backend must always verify the transaction with the store before creating durable entitlements.
The Lesson
Reliable mobile billing requires fundamentally treating webhooks as asynchronous lifecycle messages, rather than treating them as perfect identity records. Webhooks can be delayed, they can arrive completely out of order, and they can easily lack critical user fields. Sandbox environments can behave wildly differently from production. None of those factors should ever decide whether a legitimate purchaser actually gets access to the features they paid for.
The robust, resilient pattern is to firmly establish ownership through an authenticated client request, verify the purchase directly with the store, and use webhooks solely to keep the lifecycle fresh over time. That one architectural shift completely stabilized my system. I stopped asking the webhook to tell me who owned the subscription, made ownership explicit, verified it at the source, and let webhooks do the background synchronization job they are actually built for.
Read more
How Mobile Apps Work
The three ways to build a mobile app, what each one actually runs under the hood, and the shared architecture pattern behind React Native, Flutter, and Tauri mobile.
Pooling API Keys with Bifrost
A practical guide to configuring API key pooling in Bifrost, and how to safely scale it across multiple nodes by externalizing your rate limits.
OAuth vs OIDC: Access Tokens vs ID Tokens
A practical explanation of OAuth, OIDC, access tokens, and ID tokens without the usual authentication confusion.
