Obi Madu's Blog
Back to all articles
MobileSystem DesignInfrastructure

Architecting Reliable Mobile Billing: What I Learned the Hard Way

A real-world look at fixing mobile subscription billing when webhooks, sandbox purchases, and user identity break down.

Architecting Reliable Mobile Billing: What I Learned the Hard Way

Mobile subscription billing looks simple from the outside. You add a paywall SDK, configure products in the app stores, listen for webhooks, and update your database when a purchase succeeds.

That is the sales pitch. It is also only half true.

The happy path works beautifully until you hit the uncomfortable parts of the system: sandbox environments, missing identity fields, race conditions, delayed webhooks, restored purchases, renewals, cancellations, and the awkward fact that the app store owns the transaction while your app owns the user.

I ran into this while integrating Superwall with Google Play subscriptions. Production purchases looked fine. Sandbox testing was a mess. Test purchases completed inside the app, but my backend could not reliably figure out which user should receive the premium entitlement.

What started as a paywall integration became a distributed systems problem about identity, ownership, and reconciliation.

This is the architecture I ended up with, and why I had to get there.

The Architecture I Wanted to Believe In

The first version of my billing flow followed the standard webhook-driven model.

The user tapped an upgrade button. Superwall presented the paywall and handled the purchase. Superwall sent a webhook to my backend. The backend read the user ID from the webhook and upgraded that user.

In the ideal case, the webhook looked like this:

{
  "data": {
    "originalAppUserId": "user_123",
    "transactionId": "GPA.3373-4052-0812-08085",
    "productId": "pro:pro-monthly"
  }
}

That payload has everything a backend wants. It has the product, the transaction, and the app-level user identity. Given that data, granting access is straightforward.

For real production purchases, this worked well. Users bought subscriptions, webhooks arrived, 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 useful because subscription timelines are accelerated. A monthly subscription can renew every few minutes, which makes it possible to test renewal, cancellation, expiration, and resubscription flows without waiting a month.

But my first sandbox purchases failed in a confusing way. The purchase completed in the app, but the user did not get upgraded. The backend logs 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 missing. The webhook was effectively saying, "Someone bought this subscription, and here is the transaction ID, but I do not know who they are."

That broke the entire design. My backend was relying on the webhook to answer the most important question: which user owns this purchase?

The deeper problem is that Google Play does not know about my internal user IDs. The subscription belongs to a Google account. My app user belongs to my authentication system. Superwall can try to bridge those identity domains, but the bridge is not something you should treat as a source of truth in every environment.

Once I understood that, the old architecture stopped looking simple. It started looking fragile.

The Failed Fixes

Before finding the right model, I tried a few obvious fixes.

These failures forced me to separate two concepts I had accidentally merged.

Ownership and Lifecycle Are Different Problems

The breakthrough was realizing that subscription ownership and subscription lifecycle are not the same problem.

Ownership answers: who bought this subscription?

Lifecycle answers: what is the current state of this subscription?

I had been using webhooks for both. That was the mistake.

Ownership only needs to be established once, and it should be established by an authenticated user action. Lifecycle changes happen repeatedly through renewals, cancellations, expirations, refunds, and resubscriptions. Webhooks are good for lifecycle updates, but they are a poor primary source for ownership when identity fields can be missing.

The piece of data that made this work was transaction lineage.

{
  "transactionId": "GPA.3373-4052-0812-08085",
  "originalTransactionId": "GPA.3373-4052-0812-08085"
}

The originalTransactionId identifies the root of the subscription. It remains stable across renewals and lifecycle events. That makes it a good key for binding a store subscription to an app user.

The second important piece was the purchaseToken, which is available on the client after a Google Play purchase. The backend can use that token to ask Google Play directly whether the purchase is valid.

That led to a better architecture: bind ownership immediately using authenticated client context, verify the purchase directly with Google Play, and use webhooks later for lifecycle reconciliation.

The Architecture That Worked

In the final design, the webhook is no longer the thing that decides who gets premium access. It is a background messenger that keeps the subscription state current.

The immediate purchase flow starts on the client. When Superwall reports that a transaction completed, the app captures the transaction lineage and purchase token. It sends those values to my backend under the user's normal 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..."
}

The backend does not trust the user ID inside the body. It uses the Authorization header to identify the user. Then it creates a binding that says this authenticated user owns this original transaction ID.

That binding is the ownership record.

Next, the backend verifies the purchase directly with Google Play.

GET https://androidpublisher.googleapis.com/androidpublisher/v3/applications/{packageName}/purchases/subscriptionsv2/tokens/{purchaseToken}

If Google says the purchase token is valid and the subscription is active, the backend grants the entitlement immediately. The user gets premium access right after purchase completion. No webhook wait is required.

Later, when a Superwall webhook arrives, it can be missing identity fields and still be useful.

{
  "data": {
    "originalAppUserId": null,
    "originalTransactionId": "GPA.3373-4052-0812-08085",
    "name": "renewal",
    "expirationAt": 1775832567843
  }
}

The backend uses originalTransactionId to find the binding, then updates the existing subscription record. The webhook no longer needs to know who the user is. I already know.

Why This Handles Edge Cases Better

This model makes the ugly cases much easier to reason about.

If a webhook arrives before the client binding, the backend stores it as pending. When the authenticated client later submits the binding, the backend can replay the pending lifecycle event.

If a user resubscribes from the Play Store app instead of inside my app, no immediate client event may fire. The webhook can still arrive and sit in a pending state. When the user opens the app again and the SDK detects the restored purchase, the client sends the binding and the backend catches up.

If the Google Play sandbox omits identity fields, nothing breaks. Direct verification uses the purchase token. Webhook reconciliation uses the original transaction ID. Neither step depends on originalAppUserId being present.

This is the difference between a billing system that hopes all integrations preserve identity and one that has its own authoritative ownership model.

The Mental Model

The store owns the transaction. Your app owns the user. Your backend must own the binding between them.

Once you accept that, the architecture becomes much clearer.

ConcernSource of truth
User identityYour auth system
Purchase validityGoogle Play or App Store
Subscription ownershipYour binding table
Renewals and cancellationsStore events via webhooks, reconciled through bindings

The client is allowed to carry correlation data, such as purchaseToken and originalTransactionId. It is not allowed to grant itself access. The backend must verify with the store before creating durable entitlements.

The Lesson

Reliable mobile billing requires treating webhooks as asynchronous lifecycle messages, not as perfect identity records.

Webhooks can be delayed. They can arrive out of order. They can lack user fields. Sandbox environments can behave differently from production. None of that should decide whether a legitimate purchaser gets access.

The robust pattern is to establish ownership through an authenticated client request, verify the purchase directly with the store, and use webhooks to keep the lifecycle fresh over time.

That one shift changed the whole system. I stopped asking the webhook to tell me who owned the subscription. I made ownership explicit, verified it at the source, and let webhooks do the job they are actually good at.