Social LiveDocumentationPLATFORM DOCS

In-App Purchases (Apple & Google)

How coin purchases are verified, credited, and made idempotent on both stores.

Last updated Thu Jul 16 2026 00:00:00 GMT+0000 (Coordinated Universal Time)

TL;DR -Viewers buy coins through the Apple and Google stores. Coins are only credited after the backend confirms the payment directly with the store -the app's word for it is never trusted.

Who this is for -Business & Product: the coin packages and why the counts look the way they do (the first two sections). Mobile & Backend engineers: the purchase flow, the server-side verification, and idempotency below.

Coins are sold as consumable in-app purchases (IAPs) through both the Apple App Store and Google Play -"consumable" meaning each pack is used up and can be bought again, like refilling arcade tokens. One design rule sits above every other detail on this page:

Coins are credited only after the backend verifies the purchase with the store, server-to-server. Client-side purchase state is never trusted.

Why so strict? A phone app can be tampered with. The only party that truly knows whether a payment went through is the store itself, so the backend always asks the store directly before adding a single coin.

In plain terms: the coin packages

There are six coin packs, from a 99-cent starter to a $99.99 "whale" pack. You buy a pack, you get coins. The packs are defined once in code (economy.constants.ts) and served to the app via GET /wallet/coin-packages.

PackageCoinsPriceApple product idGoogle product id
Starter70$0.99coins.70coins_70
Social360$4.99coins.360coins_360
Popular750$9.99coins.750coins_750
Supporter1,950$24.99coins.1950coins_1950
VIP4,000$49.99coins.4000coins_4000
Whale8,200$99.99coins.8200coins_8200

You might notice you get slightly fewer than 100 coins per dollar. That's on purpose: the store takes a 30% fee, so the coin counts are trimmed to keep the platform's margin intact. Each purchase records both the gross and the net — grossUsdCents / netUsdCents.

How it works: the purchase flow (client)

The app-side controller CoinPurchaseController (lib/modules/wallet/application/) drives both platforms through the same shape of flow: buy at the store, send proof to the backend, wait for the credit to succeed, and only then finalize the purchase with the store. The diagram shows the full round trip.

Rendering diagram…

The ordering that makes it safe

The order of those steps is what prevents lost or duplicated coins. Each point below is a safeguard against a specific crash-at-the-wrong-moment scenario.

  1. Android buys with autoConsume: false. Consuming is what makes the SKU purchasable again and acknowledges it to Google -so it must happen only after the backend credit succeeds.
  2. A failed credit leaves the purchase unfinished. The store redelivers it on next launch; the backend replay is idempotent. A crash between payment and credit can never lose or double-credit coins.
  3. Resync on wallet open (restorePurchases, Android only) redelivers anything paid-but-uncredited: app killed mid-flow, reinstall, or a pending cash payment that cleared. iOS unfinished transactions arrive on the purchase stream by themselves.
  4. Pending payments (e.g. konbini/cash codes) show a waiting state and credit later through the same stream.

How it works: server-side verification

This is the part the whole page hinges on -the backend confirming the payment with the store before crediting. Each store works a little differently.

Apple (apple-iap.service.ts)

The client sends the StoreKit 2 signed transaction (JWS). The backend decodes it unverified only to extract the transaction id, then fetches the authoritative record from Apple's App Store Server API over TLS (ES256-signed API token). Validates bundle id, consumable type, and revocation. Sandbox/production fallthrough handles App Review's sandbox receipts reaching production config; it triggers on 404 and on 401, which Apple's production endpoint returns instead of 404 while the app has never been published to the App Store.

Google (google-iap.service.ts)

The client sends the purchase token. The backend calls purchases.products.get (service-account JWT → OAuth token, cached) with the package's own product id in the URL -a token bought for a small package can never be redeemed against a bigger one. Validates purchaseState = purchased; rejects pending and canceled.

Idempotency & replay defense (both stores)

The store's transaction/order id becomes externalRef on the COIN_PURCHASE ledger row with key coin_purchase:<channel>:<ref>:

  • Same account replays → original result returned, no double credit.
  • Different account presents the same receipt → rejected: a purchase is spendable exactly once platform-wide.

How it works: dev grants

For testing on simulators (which can't make real store payments) there's a back door -but it's off by default and must stay off wherever real money flows.

Without a store payload, POST /wallet/coins/purchase performs a dev grant -only when ALLOW_DEV_COIN_GRANTS permits (defaults off in production). This keeps simulators and emulators testable. It must be false before real money flows.

Store console setup

The operational checklist (product creation, service accounts, keys, agreements) lives in the repo: docs/coin-iap-setup-handoff.md -kept current as steps complete.

  • Economy -where purchased coins fit in the money model.
  • Gifting -what viewers spend those coins on.
  • Refunds -how a store refund reverses a coin purchase.
  • Environments -where ALLOW_DEV_COIN_GRANTS and store config live.