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
purchaseIdis 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:
- Immediate feedback — the app gets purchase status right away, no polling needed
- 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
- The user completes a purchase through the app store
- Your app verifies the receipt with iaptic — all client SDKs provide immediate feedback from this call
- Grant access in the app right away — don't poll your server waiting for the webhook
- 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:
- 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.
- 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 |
