Quizzr Logo

In-App Purchase (IAP) Architecture

Designing a Centralized User Entitlement System for Cross-Platform Access

Build a scalable database schema to map verified store transactions to unique user identities, ensuring seamless access across web and mobile.

Mobile DevelopmentIntermediate12 min read

Bridging the Gap Between Store Identities and App Accounts

A common mistake when starting with in-app purchases is relying solely on the device to verify access. While the operating system provides a local receipt, this does not allow for cross-platform access or reliable background updates. We must build a server-side source of truth that maps store-specific identifiers to our internal user accounts.

The primary challenge in mobile commerce is the lack of a shared identifier between the App Store and your backend database. Apple and Google do not provide you with the user email or internal user ID when a transaction occurs. This anonymity forces developers to implement a bridging mechanism that links a store-specific transaction record to a unique profile in your own system.

When a user makes a purchase, the mobile client receives a receipt or a purchase token. This token must be sent to your backend, validated against the store provider, and then associated with the authenticated user record. By storing this link, you enable the user to access their premium features on any device, including a web browser or a secondary mobile platform.

Your backend should be the ultimate authority on entitlements. If the local device receipt and your database disagree, the database must always win to prevent bypass exploits and ensure consistency across platforms.
  • Original Transaction ID: The stable anchor that links all renewals and updates for a single subscription.
  • Platform Source: An indicator of whether the purchase originated from iOS, Android, or Stripe.
  • Validation Status: A flag to track whether the receipt has been successfully verified with the store API.

The Anchor: Understanding the Original Transaction ID

Every subscription sequence begins with an initial purchase that generates a unique identifier. Even as a user renews their subscription monthly, or switches from a monthly to a yearly plan, the underlying reference to that specific purchase chain remains constant. In the Apple ecosystem, this is known as the Original Transaction ID, while Google Play uses a consistent Purchase Token.

You must treat this identifier as the primary key for mapping store events to your users. When a renewal occurs, the store sends a notification containing this ID, allowing your server to find the correct user without needing the user to be active in the app. This is the foundation of a robust background synchronization strategy.

Designing a Normalized Schema for Entitlements

A scalable database design separates the concept of a transaction from the concept of an entitlement. A transaction is an immutable record of a financial event, while an entitlement represents the current state of what the user is allowed to access. Mixing these two concepts often leads to complex queries and bugs when handling upgrades or cancellations.

We recommend a three-table approach: users, transactions, and subscriptions. The users table holds identity, the transactions table logs every raw event from the stores, and the subscriptions table tracks the high-level status of the users access. This separation allows you to audit the payment history while providing a fast lookup for the application logic.

sqlDatabase Schema Design
1-- Core table to track the current state of a user access
2CREATE TABLE subscriptions (
3    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
4    user_id UUID NOT NULL REFERENCES users(id),
5    product_id TEXT NOT NULL, -- e.g., 'premium_monthly'
6    status TEXT NOT NULL, -- e.g., 'active', 'expired', 'grace_period'
7    platform TEXT NOT NULL, -- 'ios', 'android', or 'web'
8    original_transaction_id TEXT UNIQUE NOT NULL,
9    current_period_end TIMESTAMP WITH TIME ZONE NOT NULL,
10    auto_renew BOOLEAN DEFAULT true,
11    updated_at TIMESTAMP WITH TIME ZONE DEFAULT now()
12);
13
14-- Audit log for every transaction event received from stores
15CREATE TABLE transaction_history (
16    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
17    subscription_id UUID REFERENCES subscriptions(id),
18    raw_payload JSONB NOT NULL, -- Store the full response for debugging
19    event_type TEXT NOT NULL, -- 'INITIAL_PURCHASE', 'RENEWAL', 'REFUND'
20    created_at TIMESTAMP WITH TIME ZONE DEFAULT now()
21);

The use of a JSONB column for the raw payload is a strategic choice for future-proofing. App stores frequently update their data formats or add new fields for marketing and analytics. By capturing the full response during the validation phase, you can retroactively extract data if your business requirements change later.

Normalizing Product Identifiers

Your database should not rely on store-specific product IDs for business logic. Instead, map the App Store product ID and the Google Play ID to an internal, platform-agnostic tier name. This abstraction ensures that your frontend code only cares if a user has a gold tier membership, regardless of how or where they purchased it.

This mapping also simplifies price testing and localizations. If you decide to change the price for new customers, you can create a new store ID while keeping the same internal tier name. The backend logic remains untouched because it checks for the presence of the tier rather than the specific billing SKU.

Managing the Subscription Lifecycle and State Changes

The lifecycle of a subscription is rarely linear and involves various states such as active, past_due, and revoked. Your architecture must handle these transitions automatically using server-to-server notifications. These webhooks inform your backend of events that happen outside of your app, such as a credit card failure or a refund request.

When a webhook arrives, your system must first verify the authenticity of the message. Both Apple and Google provide cryptographic signatures or specific authentication headers for this purpose. Once verified, you extract the transaction ID, locate the corresponding record in your subscriptions table, and update the status accordingly.

javascriptServer-Side Receipt Validation Logic
1async function handleStoreNotification(payload) {
2    // 1. Verify notification signature and source
3    const verifiedData = await verifyStoreSignature(payload);
4
5    // 2. Extract the stable original_transaction_id
6    const transactionId = verifiedData.original_transaction_id;
7
8    // 3. Find the subscription record in your DB
9    const subscription = await db.subscriptions.findUnique({
10        where: { original_transaction_id: transactionId }
11    });
12
13    if (!subscription) {
14        // Handle cases where a purchase was made but the DB sync failed
15        return createNewSubscriptionFromReceipt(verifiedData);
16    }
17
18    // 4. Update the expiration date and status based on the store data
19    await db.subscriptions.update({
20        where: { id: subscription.id },
21        data: {
22            status: verifiedData.status,
23            current_period_end: new Date(verifiedData.expires_date_ms),
24            updated_at: new Date()
25        }
26    });
27}

Handling edge cases like the grace period is vital for user retention. If a payment fails, stores often provide a window of a few days where the user remains entitled to content while the store retries the payment. Your database should track this specific state so you can show a gentle warning in the UI rather than cutting off access immediately.

Ensuring Idempotency in Processing

Webhook delivery is not guaranteed to happen exactly once. Your backend must be idempotent, meaning that processing the same notification multiple times does not result in duplicate records or incorrect state changes. You can achieve this by checking if the specific transaction ID and event timestamp have already been logged in your history table.

If you receive a notification for an event you have already processed, you should acknowledge receipt to the store with a successful response but skip the database updates. This prevents race conditions and ensures that your data remains accurate even during store outages or network retries.

Handling Cross-Platform Synchronization and Race Conditions

A significant architectural hurdle appears when a user changes their subscription on one platform and then immediately checks their status on another. Because webhooks can sometimes be delayed, you cannot rely on them for near-instant updates. You must implement a hybrid approach where the mobile app triggers a refresh of the subscription state manually after a successful purchase.

When the app notifies the backend that a purchase is complete, the backend should perform a real-time validation check against the store API. This update ensures that the users profile is current before the user can navigate to the premium content. The webhook then acts as a secondary, fallback mechanism for background events like renewals.

Always treat client-initiated validation as a hint, and the server-to-server webhook as the final confirmation. This layered approach balances speed for the user and data integrity for your business.

Race conditions can occur if a user initiates multiple subscription changes in rapid succession. Using database transactions and row-level locking ensures that two concurrent processes do not overwrite each other. For example, when updating a subscription status, lock the specific user row to ensure the latest expiration date is correctly preserved.

Resolving Account Conflicts

What happens if User A logs into User B's device and makes a purchase? If your logic is not careful, you might link the same Apple ID to two different internal user accounts. You must decide on a policy for handling these collisions, such as restricting one store ID to exactly one app account at any given time.

Implementing a robust restoration flow is the best way to resolve these issues. If a user clicks Restore Purchases, your backend should look up the transaction ID and, if it is currently linked to a different user, prompt the user to migrate the subscription. This ensures that legitimate owners never lose access while preventing fraudulent account sharing.

We use cookies

Necessary cookies keep the site working. Analytics and ads help us improve and fund Quizzr. You can manage your preferences.