12 min read

Server-Side Integration

Your server needs to know what each user has purchased — whether it's an active subscription, a one-time unlock, or a consumable item. This guide describes the recommended approach for keeping your server in sync with iaptic, using a combination of webhooks (for real-time updates) and client-side receipt verification (as a safety net).

The Push-Pull Pattern

The most resilient integration combines two mechanisms:

sequenceDiagram
    participant App as Mobile App
    participant Server as Your Server
    participant Iaptic as Iaptic

    Note over App,Iaptic: PUSH — Real-time updates via webhooks
    Iaptic->>Server: purchases.updated webhook
    Server->>Server: Upsert purchases in DB

    Note over App,Iaptic: PULL — Safety net at app startup
    App->>Iaptic: Verify receipt (transaction.verify)
    Iaptic->>Server: purchases.updated webhook
    Server->>Server: Upsert purchases in DB
    App->>Server: Check entitlements
    Server->>App: Current purchase status
  • Webhooks deliver updates in near real-time when purchases change (new purchase, renewal, cancellation, refund, etc.)
  • Client verification at startup ensures your server self-corrects even if a webhook was missed

Neither mechanism depends on perfect delivery. Together, they form a self-healing system.

Recommended Data Model

On your server, maintain a purchases table with purchaseId as the primary key:

Column Type Description
purchaseId PRIMARY KEY Unique identifier for each purchase (e.g. apple:12345, google:abc...)
userId string Your application username
productId string The product identifier (e.g. apple:premium_monthly, google:tilepack_25)
purchaseDate datetime When the purchase was made
expirationDate datetime When the purchase expires (subscriptions) or the purchase date (non-expiring products)
CREATE TABLE purchases (
    purchaseId TEXT PRIMARY KEY,
    userId TEXT NOT NULL,
    productId TEXT NOT NULL,
    purchaseDate TEXT NOT NULL,
    expirationDate TEXT
);
CREATE INDEX idx_purchases_user_product ON purchases(userId, productId);

Using purchaseId as the primary key is important:

  • Each purchase gets a unique purchaseId, even when the same user buys the same consumable product multiple times
  • Upserting by purchaseId is naturally idempotent — processing the same webhook twice is harmless
  • For consumables, you can count how many times a product was purchased

Handling Webhooks

On every purchases.updated webhook, upsert the purchases from the purchases collection into your database. Don't build logic around why the update happened — just sync the current state.

app.post('/webhooks/iaptic', (req, res) => {
    const body = req.body;

    // Verify the webhook password
    if (body.password !== process.env.IAPTIC_PASSWORD) {
        return res.status(401).json({ error: 'Unauthorized' });
    }

    if (body.type === 'purchases.updated') {
        const userId = body.applicationUsername;

        // Upsert each purchase — the purchases collection is the source of truth
        for (const purchase of Object.values(body.purchases)) {
            db.run(`INSERT OR REPLACE INTO purchases
                (purchaseId, userId, productId, purchaseDate, expirationDate)
                VALUES (?, ?, ?, ?, ?)`,
                purchase.purchaseId,
                userId,
                purchase.productId,
                purchase.purchaseDate,
                purchase.expirationDate
            );
        }
    }

    // Always return 200
    res.json({ ok: true });
});

Important: Always return HTTP 200, even for webhook types you don't handle. See Webhook Reference for retry and error handling details.

Checking Entitlements

Query your purchases table to determine what a user owns:

// Does the user have an active subscription or non-consumable?
async function hasAccess(userId, productId) {
    const row = await db.get(
        `SELECT * FROM purchases
         WHERE userId = ? AND productId = ?
         AND (expirationDate IS NULL OR expirationDate > ?)`,
        userId, productId, new Date().toISOString()
    );
    return !!row;
}

// How many of a consumable has the user purchased (and not been refunded)?
async function countPurchases(userId, productId) {
    const row = await db.get(
        `SELECT COUNT(*) as count FROM purchases
         WHERE userId = ? AND productId = ?
         AND expirationDate > purchaseDate`,
        userId, productId
    );
    return row.count;
}

Client Verification at Startup

Have your app verify receipts when it launches. This triggers a purchases.updated webhook with the latest state, acting as a periodic correction mechanism.

// In your app (using cordova-plugin-purchase)
store.when()
    .approved(transaction => transaction.verify())
    .verified(receipt => receipt.finish());

This serves two purposes:

  1. Immediate feedback — the app gets purchase status right away, no polling needed
  2. Self-healing — if a webhook was missed, the next app launch brings your server back in sync

Post-Purchase Flow

After a user completes a purchase, don't wait for the webhook to reach your server before granting access. Instead, take an optimistic approach:

sequenceDiagram
    participant User
    participant App
    participant Iaptic
    participant Server as Your Server

    User->>App: Taps "Buy"
    App->>App: Platform processes payment
    App->>Iaptic: Verify receipt
    Iaptic-->>App: Purchase verified ✓
    App->>User: Access granted immediately
    Iaptic->>Server: purchases.updated webhook
    Server->>Server: Upsert purchase in DB
  1. The user completes a purchase through the app store
  2. Your app verifies the receipt with iaptic — all client SDKs provide immediate feedback from this call
  3. Grant access in the app right away — don't poll your server waiting for the webhook
  4. The webhook updates your server in the background

This is the "optimistic" approach. The client-side unlock is cosmetic only — it updates the UI to reflect the expected state. The actual access control happens on the server when the user requests protected content.

For server-side protected content (e.g. files, maps, or API endpoints that your server delivers), always verify the purchase status server-side before granting access. You can either query the iaptic API directly or check your own database (populated via webhooks).

When the server denies access, your app should handle it gracefully:

  1. If a recent purchase was made (less than a minute ago) — show a spinner with a message like "Still processing your purchase..." and retry shortly. The webhook may not have reached your server yet.
  2. Otherwise — show an error like "Your purchase could not be verified. Please try again later." and prompt the user to restart the app or contact support.

About the reason Field

Each webhook includes a notification.reason field (e.g. PURCHASED, RENEWED, EXPIRED, REFUNDED). This field is useful for:

  • Analytics — counting purchases, renewals, churn events
  • Logging — tracking what happened and when
  • UI feedback — showing users "Purchase confirmed!" vs "Subscription renewed"

It should not be used to drive business logic (e.g. "if reason is PURCHASED, credit the account"). This event-driven approach is fragile: a single missed or failed webhook means your server state is permanently out of sync. Instead, always derive entitlements from the purchases collection.

See Webhook Reference — Notifications for the full list of notification reasons.

Summary

Aspect Recommendation
Data model Store purchases keyed by purchaseId (primary key)
On webhook Upsert from purchases collection — don't react to reason
On app startup Verify receipts — triggers webhook, self-heals missed updates
Subscriptions Check for non-expired entry by productId
Consumables Count entries by productId
After purchase Grant access on the client immediately — don't poll the server
Protected content Always verify server-side before delivering (via iaptic API or your DB)
reason field Use for analytics and logging only