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.
| Package | Coins | Price | Apple product id | Google product id |
|---|---|---|---|---|
| Starter | 70 | $0.99 | coins.70 | coins_70 |
| Social | 360 | $4.99 | coins.360 | coins_360 |
| Popular | 750 | $9.99 | coins.750 | coins_750 |
| Supporter | 1,950 | $24.99 | coins.1950 | coins_1950 |
| VIP | 4,000 | $49.99 | coins.4000 | coins_4000 |
| Whale | 8,200 | $99.99 | coins.8200 | coins_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.
- 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. - 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.
- 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. - 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.
Related pages
- 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_GRANTSand store config live.