In-App Purchase (IAP)
Integrate Google Play and App Store in-app purchases and subscriptions via JavaScript.
Overview
Control Google Play Billing and App Store StoreKit 2 from your WebView using JavaScript. It provides product listing, purchase, restore, and acknowledgement, while server-side receipt verification is performed through your own web server (see Server verification below).
| Method | Description |
|---|---|
queryProducts | Fetch product list and pricing from the store |
purchase | Launch the purchase screen |
acknowledgePurchase | Confirm purchase (Android only, required within 3 days) |
restorePurchases | Restore previous purchases (after reinstall, etc.) |
Supported plan: Pro
| Platform | Implementation |
|---|---|
| Android | Google Play Billing |
| iOS | StoreKit 2 |
Purchase Flow Order
Correct order: queryProducts → purchase → server verification (your web server → Unveily) → acknowledgePurchase (Android only)
Server verification is mandatory to prevent receipt fraud, and your own web server — not the app — calls the Unveily API (/api/iap/verify). There is no separate serverVerify method in the SDK — you forward the purchase result to your web server, which performs verification. See the Server verification section below for details.
On iOS (StoreKit 2), the transaction is automatically finished when purchase() completes — acknowledgePurchase is not needed.
queryProducts
Fetches product details and current pricing from the store. Android uses Google Play product IDs; iOS uses App Store Connect product IDs.
window.unveilyBridge.iap.queryProducts(
["monthly_pro", "yearly_pro"], // product ID array
"subs", // "inapp" or "subs"
"onProductsLoaded" // callback function name
);
function onProductsLoaded(result) {
const { products, error } = result;
if (error) { console.error("Query failed:", error); return; }
products.forEach(p => console.log(`${p.title}: ${p.price}`));
}Parameters
| Parameter | Type | Description |
|---|---|---|
productIds | string[] | Product IDs to query (max 50) |
type | "inapp" | "subs" | One-time or subscription |
callback | string | Global callback function name |
Response
{
"products": [
{
"productId": "monthly_pro",
"title": "Pro Monthly",
"description": "Full access to Pro features",
"type": "subs",
"price": "$9.99",
"priceAmountMicros": 9990000,
"priceCurrencyCode": "USD"
}
]
}On error: { "error": "error message" }
purchase
Launches the store purchase screen. The callback fires when the user completes or cancels.
window.unveilyBridge.iap.purchase(
"monthly_pro", // product ID
"subs", // product type
"onPurchaseResult" // callback function name
);
function onPurchaseResult(result) {
if (!result.success) {
if (result.cancelled) return; // user cancelled
console.error("Purchase failed:", result.error);
return;
}
// Purchase succeeded → forward to your web server for server verification (see "Server verification" below)
// Android: result.purchaseToken / iOS: result.signedTransaction
verifyOnYourServer(result);
}Response
Android success:
{
"success": true,
"productId": "monthly_pro",
"purchaseToken": "purchase_token...",
"orderId": "GPA.1234-5678",
"purchaseTime": 1713456789000,
"purchaseState": 1
}iOS success:
{
"success": true,
"productId": "monthly_pro",
"transactionId": "2000000123456789",
"purchaseTime": 1713456789000,
"signedTransaction": "<Apple-signed transaction (JWS string)>"
}Cancelled: { "success": false, "cancelled": true }
Error: { "success": false, "error": "error message" }
Values used for server verification
For server verification, Android sends purchaseToken and iOS sends signedTransaction (a JWS signed by Apple) to your web server. The iOS transactionId is for identification only; tamper verification is performed using the signed signedTransaction.
Subscription upgrade / downgrade
To change an existing subscription to a different one (upgrade/downgrade), use the purchase overload that takes an options object. The options object comes before the callback name.
window.unveilyBridge.iap.purchase(
"yearly_pro", // new product ID
"subs", // subscription
{
oldPurchaseToken: "existing purchase token", // purchaseToken of the current subscription
replacementMode: 1 // proration / replacement mode
},
"onPurchaseResult" // callback function name
);Platform difference
This options object is used on Android (Google Play Billing) only. iOS silently ignores the options object — App Store subscription groups handle upgrades and downgrades automatically.
replacementMode values (Android):
| Value | Mode | Description |
|---|---|---|
1 | WITH_TIME_PRORATION | Default. Remaining time is recalculated against the new price |
2 | CHARGE_PRORATED_PRICE | Charge the prorated difference immediately |
3 | WITHOUT_PRORATION | New price applies at the next renewal |
5 | CHARGE_FULL_PRICE | Charge the full price immediately |
6 | DEFERRED | Change takes effect after the current subscription expires |
Feature gating — coming_soon status
In-app purchase requires both the license feature "iap" (Pro) and the server-side iapEnabled flag. Until they are enabled, queryProducts and purchase return { "status": "coming_soon" }, so handle this status in your web app and show an appropriate message.
function onPurchaseResult(result) {
if (result.status === "coming_soon") {
alert("In-app purchase is coming soon.");
return;
}
// ... normal handling
}Server verification (via your web server)
To prevent receipt tampering and duplicate billing, server verification is mandatory. The SDK does not call Unveily directly — you send the purchase result to your own web server, and your web server calls the Unveily API (/api/iap/verify). (Most web apps already have a web server, so there's no need to build a separate IAP backend.)
After purchase succeeds, verify before calling acknowledgePurchase (Android).
Unlocking features without server verification leaves you vulnerable to receipt fraud.
Flow
App (SDK purchase) → purchase result → web content sends it to your web server
→ your web server → Unveily POST /api/iap/verify → verification result
Values forwarded per platform:
- Android: purchaseToken + short-lived googleAccessToken (web server generates it with the service account)
- iOS: signedTransaction (Apple-signed JWS) — no credential needed1) Web app — send the purchase result to your server
// Called from the purchase callback (onPurchaseResult)
async function verifyOnYourServer(result) {
const payload = result.purchaseToken
? { platform: "android", productId: result.productId, productType: "subs",
purchaseToken: result.purchaseToken }
: { platform: "ios", productId: result.productId, productType: "subs",
signedTransaction: result.signedTransaction };
const res = await fetch("/api/verify-iap", { // your web server endpoint
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const verify = await res.json();
if (!verify.success) { alert("Payment verification failed. Please contact support."); return; }
unlockProFeatures();
// Acknowledge on Android only (iOS is finished automatically by StoreKit 2)
if (result.purchaseToken) {
window.unveilyBridge.iap.acknowledgePurchase(result.purchaseToken, "onAckResult");
}
}2) Your web server — call Unveily (relay)
Keep the credential (Google service account) on your web server only and never store it with Unveily.
// Example: Node.js / Express
import { GoogleAuth } from "google-auth-library";
const UNVEILY_LICENSE_KEY = process.env.UNVEILY_LICENSE_KEY;
app.post("/api/verify-iap", async (req, res) => {
const { platform, productId, productType, purchaseToken, signedTransaction } = req.body;
const body = { licenseKey: UNVEILY_LICENSE_KEY, platform, productId, productType };
if (platform === "android") {
body.packageName = process.env.ANDROID_PACKAGE_NAME;
body.purchaseToken = purchaseToken;
body.googleAccessToken = await getGoogleAccessToken(); // ↓ generate a short-lived token
} else {
body.bundleId = process.env.IOS_BUNDLE_ID;
body.signedTransaction = signedTransaction; // forward the Apple-signed JWS as-is
}
const r = await fetch("https://api.actuallyworks.net/api/iap/verify", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
res.status(r.status).json(await r.json());
});
// Generate a short-lived access token from the service account JSON (the long-lived key never leaves the server)
async function getGoogleAccessToken() {
const auth = new GoogleAuth({
keyFile: process.env.GOOGLE_SERVICE_ACCOUNT_JSON_PATH,
scopes: ["https://www.googleapis.com/auth/androidpublisher"],
});
const client = await auth.getClient();
const { token } = await client.getAccessToken();
return token;
}Security
- Send the short-lived token — never let the service account JSON (the long-lived key) leave your web server. As shown above, generate only a ~1-hour access token and pass that.
- Do not log tokens or signed transactions.
- Use HTTPS for all communication. Keep
licenseKeyin a server environment variable (never expose it in the app).
Unveily response (/api/iap/verify)
{
"success": true,
"data": {
"productId": "monthly_pro",
"orderId": "GPA.1234-5678",
"purchaseTime": 1713456789000,
"isAcknowledged": false,
"storeApiVerified": true
}
}On error: { "success": false, "message": "error message" }
storeApiVerified field
storeApiVerified: true — Verified in real time via the Google Play API (Android) or Apple signature verification (iOS)
storeApiVerified: false — On Android, when googleAccessToken is not provided (DB record only)
See the IAP Setup Guide for Google Play service account configuration. On iOS, the server verifies the Apple signature (JWS) without any credential.
acknowledgePurchase
Confirms the purchase. Google automatically refunds and cancels unacknowledged purchases after 3 days.
Android only — required within 3 days
This method is for Android (Google Play) only.
On iOS (StoreKit 2), the transaction is automatically finished when purchase() completes — no separate call is needed.
On Android, always call acknowledgePurchase after server verification succeeds.
Missing this will result in an automatic Google refund and subscription cancellation.
window.unveilyBridge.iap.acknowledgePurchase(
purchaseToken, // purchase token (Android)
"onAckResult" // callback function name
);
function onAckResult(result) {
if (result.success) console.log("Purchase confirmed");
else console.error("Acknowledge failed:", result.error);
}Parameters
| Parameter | Type | Description |
|---|---|---|
purchaseToken | string | Purchase token received from purchase (Android) |
callback | string | Global callback function name |
Success: { "success": true }
Error: { "success": false, "error": "error message" }
restorePurchases
Restores prior purchases after reinstall or device change. Call on app start or when the user taps a "Restore Purchases" button.
- Android: Returns the active subscription list from Google Play.
- iOS: Restored from StoreKit 2's
Transaction.currentEntitlements.
window.unveilyBridge.iap.restorePurchases("subs", "onRestoreResult");
function onRestoreResult(result) {
const { purchases, error } = result;
if (error) { console.error("Restore failed:", error); return; }
const active = (purchases || []).filter(p => p.isAcknowledged || p.transactionId);
if (active.length > 0) unlockProFeatures();
}Response
Android:
{
"purchases": [
{
"productId": "monthly_pro",
"purchaseToken": "...",
"orderId": "GPA.1234-5678",
"purchaseTime": 1713456789000,
"purchaseState": 1,
"isAcknowledged": true
}
]
}iOS:
{
"purchases": [
{
"productId": "monthly_pro",
"transactionId": "2000000123456789",
"purchaseTime": 1713456789000,
"isAcknowledged": true,
"signedTransaction": "<Apple-signed transaction (JWS)>"
}
]
}Full Example
// ── Global state ───────────────────────────────────────
let currentProductId = null;
// ── 1. Restore existing subscriptions on app start ─────
window.addEventListener("load", () => {
if (window.unveilyBridge?.iap) {
window.unveilyBridge.iap.restorePurchases("subs", "onRestoreResult");
}
});
// ── 2. Purchase button ─────────────────────────────────
function startSubscription(productId) {
currentProductId = productId;
window.unveilyBridge.iap.purchase(productId, "subs", "onPurchaseResult");
}
// ── 3. Purchase result → verify via your web server (relay) ──
window.onPurchaseResult = async function(result) {
if (!result.success) return;
// Forward Android: purchaseToken / iOS: signedTransaction(JWS) to your server
const payload = result.purchaseToken
? { platform: "android", productId: currentProductId, productType: "subs",
purchaseToken: result.purchaseToken }
: { platform: "ios", productId: currentProductId, productType: "subs",
signedTransaction: result.signedTransaction };
const res = await fetch("/api/verify-iap", { // your web server calls Unveily
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const verify = await res.json();
if (!verify.success) {
alert("Payment verification failed. Please contact support.");
return;
}
unlockProFeatures();
// Acknowledge on Android only (iOS is handled automatically by StoreKit 2)
if (result.purchaseToken) {
window.unveilyBridge.iap.acknowledgePurchase(result.purchaseToken, "onAckResult");
}
};
// ── 5. Acknowledge complete ────────────────────────────
window.onAckResult = function(result) {
if (result.success) console.log("Subscription active");
};
// ── Restore handling ───────────────────────────────────
window.onRestoreResult = function(result) {
const active = (result.purchases || []).filter(
p => p.isAcknowledged || p.transactionId
);
if (active.length > 0) unlockProFeatures();
};
function unlockProFeatures() {
// Enable Pro feature UI
document.body.classList.add("pro-user");
}Related
- IAP Setup Guide — Google Play Console and App Store Connect setup
- App Info Bridge — Check the current license plan via
getInfo()