Skip to content

In‑app purchases (IAP)

FRVR.iap abstracts over Google Play, Facebook Instant, Apple iAP, and other channel billing backends. You describe a catalog once; each channel maps your product IDs onto its own store.

FRVR.config.iap = {
  catalog: {
    COINS_100:  { label: '100 coins',  storeId: 'com.mygame.coins_100' },
    COINS_500:  { label: '500 coins',  storeId: 'com.mygame.coins_500' },
    REMOVE_ADS: { label: 'Remove ads', storeId: 'com.mygame.remove_ads' },
  },
};
  • The key (COINS_100) is what your code uses.
  • storeId is what the current channel’s store calls the product.
  • label is a human name you can show in UI if you don’t already have one.

After FRVR.init() and the first bootstrap tick, the catalog is ready:

const catalog = FRVR.iap.getCatalog();
// { COINS_100: { label, storeId, price, currency, … }, … }

const item = FRVR.iap.getProductById('COINS_100');
// { label, storeId, price, currency, description, … }

Price/currency come from the underlying store. Use them — don’t hard‑code prices.

FRVR.iap.purchase('COINS_100', { meta: 'optional_game_context' })
  .then(purchase => grantCoins(100, purchase))
  .catch(err => {
    if (err instanceof FRVR.iap.errors.IAPPurchaseErrorCancelledByUser) return;
    if (err instanceof FRVR.iap.errors.IAPPurchaseErrorAlreadyOwned)    return ownedFlow();
    console.error('purchase failed', err);
  });

Error types:

ErrorMeaning
IAPPurchaseErrorCancelledByUserUser closed the sheet. Not an error you show.
IAPPurchaseErrorAlreadyOwnedDurable already owned by this account.
IAPPurchaseErrorUnknownProductWrong product ID.
IAPPurchaseErrorInProgressAnother purchase is still running.
IAPPurchaseErrorPopupBlockedThe browser blocked the checkout window. Nothing was charged.
IAPPurchaseErrorPendingThe player paid, but the payment isn’t confirmed yet. Don’t grant the item.
IAPPurchaseErrorHeldByEconomyThrown by consumePurchase: FRVR’s economy is still settling this purchase. Don’t grant the item.
IAPErrorGeneric fallback. Inspect .code.

Every one of these extends IAPError, so a generic catch keeps working. Each also sets err.name to its class name, which is the safer check when the error may come from another bundle.

The web channel sells through FRVR’s web payment providers: Google Pay, Apple Pay, and a card checkout. When more than one is available, the SDK shows the player a chooser. A few things differ from a store purchase:

  • Call purchase() straight from a click or tap. When the game runs inside another site’s iframe, the checkout opens in a new window, and a browser that blocks it rejects with IAPPurchaseErrorPopupBlocked. Ask the player to try again.
  • A payment can still be pending when the checkout closes. purchase() then rejects with IAPPurchaseErrorPending (err.pending === true). Tell the player it’s on its way. Once confirmed, the purchase comes back from getUnconsumedPurchases(): grant and consume it there.
  • The player may need to sign in first. The SDK opens the login screen itself, then continues the purchase.
  • Card checkout prices include tax. getProductById() returns those products with taxIncluded: true, and the player pays exactly the price shown.

To draw your own payment buttons, read the options and pass the one the player picked:

const options = FRVR.iap.getPurchaseOptions();
// [{ id, displayName, ready }, …]

await FRVR.iap.purchase('COINS_100', {}, { provider: options[0].id });

Consumables (coins, gems) are bought over and over. After you grant the content, consume the purchase so the store frees the user to buy again:

FRVR.iap.purchase('COINS_100')
  .then(purchase => {
    grantCoins(100);
    return FRVR.iap.consumePurchase(purchase);   // returns the purchaseId
  });

Durables (ad removal, character skins) are never consumed. To re‑grant them on a new device:

FRVR.iap.restorePurchases().then(purchases => {
  for (const p of purchases) applyDurable(p.productId);
});

If your app crashed between purchase and content delivery, the store keeps the receipt in a pending state. On every startup:

FRVR.iap.getUnconsumedPurchases().then(purchases => {
  purchases.forEach(p => {
    grantCoins(lookupAmount(p.productId));
    FRVR.iap.consumePurchase(p);
  });
});

This closes the loop even if the user force‑killed the app mid‑purchase.

If consumePurchase() rejects with IAPPurchaseErrorHeldByEconomy, skip that purchase and don’t grant it. FRVR’s economy service is settling it and will finish on a later launch.

onReady can fire more than once. When the player signs in to a different account mid‑session, the SDK calls it again so the game can restore that account’s purchases:

FRVR.iap.onReady(async (ready) => {
  if (!ready) return;
  for (const p of await FRVR.iap.restorePurchases()) applyDurable(p.productId);
});

Make the callback safe to run twice.

dev channel ships a mock catalog and a fake purchase flow. Try buy → consume → restore.

IAP: catalog, purchase, consume, restore

loading SDK…
 
MethodPurpose
isReady() / onReady(cb)Provider readiness. onReady fires again after an account change.
getCatalog()All products keyed by your catalog IDs.
getProductById(id)One product, including store price.
purchase(id, meta?, options?)Start checkout. Resolves with a purchase receipt. options.provider skips the payment chooser.
getPurchaseOptions()Payment options the player can choose from.
consumePurchase(purchase)Mark a consumable as delivered.
restorePurchases()Re‑emit durables owned by the user.
getUnconsumedPurchases()Unfinished consumables — call at startup to recover.
errors.*Error classes to instanceof‑check.