Purchases & spend
Report real-money purchases from your game, get a per-player lifetime spend figure that's cheap to query, and show events or content only to players above a spend threshold.
Purchases captures what your players actually spend, normalizes it to one currency so the numbers are comparable, and turns it into a per-player figure you can filter on and gate content with.
You report a purchase with one call from the client SDK. Kraty handles deduplication, currency conversion, and the running totals.
The model
- One call, from your store callback.
trackPurchasetakes what your store SDK already hands you: a transaction id, a product id, an amount, and a currency. - The store's transaction id is the dedupe key. You don't invent an
idempotency key. Call
trackPurchaseas many times as you like for one transaction — on retry, on restore, on relaunch — and exactly one purchase is recorded. This is the intended usage: call it unconditionally, don't track what you already reported. - Amounts are in minor units.
499is $4.99 in USD, ¥499 in JPY. Every store SDK reports minor units already, so pass theirs through unchanged. Never send a float. - Everything is normalized to USD. Kraty stores what the player was charged and the USD equivalent at the rate in force on the purchase date. Spend queries and thresholds all use the USD value, so "spent more than $50" means the same thing in every region.
- Spend is read from your backend, not the client. The client can report a purchase but can never read a spend total — that's a monetization signal worth keeping out of a shipped binary. Content gating doesn't need it either: Kraty evaluates the threshold server-side and refuses entry, and tells the client what the gate is so it can render a lock — never the player's own total.
- Test and live never mix. A purchase made with a test key lands in test-side revenue. Sandbox purchases can't move your real numbers.
Reporting a purchase
const result = await kraty.trackPurchase({
transactionId: storeTx.id,
store: 'google_play',
productId: 'com.studio.game.gems_500',
productType: 'consumable',
amountMinor: 2990, // R$29.90 — minor units, as charged
currency: 'BRL',
receipt: storeTx.purchaseToken,
});
result.normalizedAmountMinor; // 520 → $5.20
result.lifetimeAmountMinor; // this player's USD-cent total, after this purchase
result.deduplicated; // true if we'd already seen this transactionvar result = await kraty.TrackPurchaseAsync(new TrackPurchaseInput
{
TransactionId = args.purchasedProduct.transactionID,
Store = "google_play",
ProductId = args.purchasedProduct.definition.id,
ProductType = "consumable",
AmountMinor = 2990, // R$29.90 — minor units, as charged
Currency = "BRL",
Receipt = args.purchasedProduct.receipt,
});
// result.NormalizedAmountMinor == 520 → $5.20Call it from your IStoreListener.ProcessPurchase handler.
final result = await kraty.trackPurchase(TrackPurchaseInput(
transactionId: purchase.purchaseID!,
store: 'google_play',
productId: purchase.productID,
productType: 'consumable',
amountMinor: 2990, // R$29.90 — minor units, as charged
currency: 'BRL',
receipt: purchase.verificationData.serverVerificationData,
));
// result.normalizedAmountMinor == 520 → $5.20Call it from your purchaseStream listener once the purchase is
PurchaseStatus.purchased or .restored.
Fields
| Field | Required | Notes |
|---|---|---|
transactionId | yes | From the store. The dedupe key. |
store | yes | app_store, google_play, steam, epic, amazon, microsoft, playstation, nintendo, web, other. |
productId | yes | Your store SKU. |
amountMinor | yes | Minor units, as charged. Integer. 0 is allowed for free promos. |
currency | yes | ISO 4217, e.g. USD, BRL, JPY. |
productType | no | consumable, non_consumable, subscription. Lets revenue views split subscription income out. |
receipt | no | Store receipt / purchase token. Stored for later validation. |
purchasedAt | no | ISO-8601. Defaults to now — set it when replaying an offline queue. |
metadata | no | Anything you want: campaign id, offer id, A/B bucket. |
amountMinor is minor units, not a decimal. 4.99 is not $4.99 —
it's four-point-nine-nine cents, which rounds to 4. Send 499.
Currencies
Kraty holds exchange rates for the 41 currencies the major stores settle
in. A purchase in a currency with no rate on file is rejected with
unknown_currency rather than being silently mis-converted — if you hit
that in QA, get in touch and we'll add the rate.
Zero-decimal currencies (JPY, KRW, VND, CLP, …) and three-decimal ones (KWD, BHD, …) are handled correctly: send the store's minor-unit value as-is and don't scale it yourself.
Reading spend
Spend reads live on the server API, so they run from your backend
with a server_integration key.
import { KratyServer } from '@kraty/server';
const kraty = new KratyServer({ apiKey: process.env.KRATY_SERVER_KEY! });
// One player's profile, with rolling windows
const spend = await kraty.purchases.spendFor('player_42', [7, 30]);
spend.lifetimeAmountMinor; // 12450 → $124.50 all time
spend.windows[30]; // 2990 → $29.90 in the last 30 days
spend.purchaseCount; // 8
// Everyone who has ever spent $50+
const whales = await kraty.purchases.queryPlayers({ minAmountMinor: 5000 });
whales.total; // how many match
whales.players[0]; // biggest spender first
// Everyone who spent $20+ in the last 30 days
const active = await kraty.purchases.queryPlayers({
minAmountMinor: 2000,
windowDays: 30,
});
// Revenue rollup for a range (defaults to the last 30 days)
const revenue = await kraty.purchases.revenueSummary();
revenue.grossAmountMinor; // USD cents
revenue.payingPlayerCount;
revenue.arppuMinor; // null when nobody paid
revenue.byProduct; // top earners
revenue.byDay; // daily seriesAll amounts are USD cents. minAmountMinor is inclusive:
minAmountMinor: 5000 means "is a $50 customer".
Showing content only to spenders
Any event's unlockCondition (or entryRequirement) accepts a
spend_at_least condition. Kraty evaluates it server-side against its
own records, so a modified client can't fake its way past it.
// A VIP event only $50+ players can enter
{ "type": "spend_at_least", "amountMinor": 5000 }
// Recently-active spenders only
{ "type": "spend_at_least", "amountMinor": 2000, "withinDays": 30 }
// A first-purchase offer: non-payers ONLY
{ "type": "not", "condition": { "type": "spend_at_least", "amountMinor": 1 } }
// Combine with anything else
{ "type": "and", "conditions": [
{ "type": "level_at_least", "level": 10 },
{ "type": "spend_at_least", "amountMinor": 5000 }
]}Players below the threshold are refused at events.start() with a clear
reason (spend $12.00 / $50.00).
Put the condition in entryRequirement instead of unlockCondition
if you want the client to render a locked state: entryRequirement is
surfaced on each row from events.listForPlayer(), so your UI can show
"Spend $50 to unlock" rather than the event silently not existing. Either
field is enforced server-side, so a modified client gains nothing by
ignoring the hint.
Pick your thresholds from the Revenue → Players by spend screen in the dashboard. It shows how many players clear each tier, so you can size the audience before you ship the gate.
Server-side reporting
If your backend validates receipts with Apple or Google itself, report the purchase from there instead of the client and mark it verified:
await kraty.purchases.track('player_42', {
transactionId: appleTx.transactionId,
store: 'app_store',
productId: appleTx.productId,
amountMinor: 499,
currency: 'USD',
receipt: appleTx.signedTransactionInfo,
validationStatus: 'verified', // you already checked
});Both routes dedupe against the same transaction id, so it's safe to move from client-side to server-side reporting without double-counting.
Kraty stores receipts but does not validate them yet. Purchases land
as unverified and still count toward spend totals — otherwise every
spend query would read zero. If you need guaranteed-genuine revenue,
validate on your backend and report with validationStatus: 'verified'.
Reacting to purchases
Subscribe to the purchase.tracked webhook to fulfil orders or react to
a player crossing a tier. It fires once per transaction — a replayed
receipt is deduped before the webhook, so you can safely grant goods
from it.
{
"eventName": "purchase.tracked",
"data": {
"playerId": "…",
"externalPlayerId": "player_42",
"transactionId": "1000000987654321",
"productId": "com.studio.game.gems_500",
"amountMinor": 2990,
"currency": "BRL",
"normalizedAmountMinor": 520,
"lifetimeAmountMinor": 4180,
"purchaseCount": 6
}
}lifetimeAmountMinor is the post-purchase total, so you can detect a
threshold crossing without a follow-up read.
In the dashboard
Revenue (under Players) has three tabs:
- Overview — gross, purchases, paying players, ARPPU, a daily chart, and your top-earning products.
- Players by spend — the threshold filter. Pick a tier and a window, see who matches.
- Purchases — the raw ledger, for support questions.
Each player's detail page also has a Spend tab with their lifetime total, 7/30/90-day windows, and full purchase history.
Keeping test accounts out of your numbers
Your own staff play the live build. Their purchases are real store transactions, so nothing about them looks different from a paying player's — and left alone they inflate revenue, skew ARPPU, and sit at the top of every "biggest spenders" list.
Mark those accounts as debug:
// From your backend, at account creation
if (isStaffEmail(email)) {
await kraty.players.markDebug(externalId, { reason: 'staff account' });
}Or flip the Mark as debug toggle on the player's page in the dashboard.
What changes:
| Debug player | |
|---|---|
| Revenue totals, top products, daily chart | excluded |
| "Players by spend" list | excluded |
| Their own spend total | still tracked |
| Spend-gated events | still work for them |
| Purchase history | still visible |
| Gameplay, scores, rewards | unaffected |
Those middle two rows are deliberate: a QA account whose spend read as zero couldn't test a spend-gated event, which is usually exactly why you made it.
Flagging is retroactive. The player's past purchases leave your reporting too, so a revenue figure you screenshotted last week can move. That's intended — the account was always a test account — but it's why the action is recorded in the audit log. Unflagging restores everything.
This is not the same as a test-environment player. environment: 'test'
already keeps sandbox purchases out of live revenue — but you can't hand
someone a test build of your live game, so staff on the real build need
this flag instead.
Exchange rates
Kraty refreshes its exchange rates daily from a public feed, so normalized USD figures track the market instead of drifting from a snapshot.
Rates are append-only: each day's rates are stored under that day's date, and a purchase permanently records the rate that applied when it was made. Re-reading a two-year-old purchase gives the same USD figure it always did — refreshing rates never rewrites history.
If the feed is unreachable, the previous day's rates stay in force and ingest continues normally.
Rates are a periodic market snapshot, good for spend tiering — "did this player cross $50" — not for accounting-grade reporting. Reconcile against your own provider if you need exact revenue figures.
Limits and gotchas
- Amounts are integers in minor units. Sending
4.99records 4 cents. - Refunds aren't modelled yet. A refunded purchase stays in the ledger and in the player's total.
- Subscriptions aren't tracked as lifecycles. Renewals arrive as separate purchases; there's no active/expired state.
- Client-reported revenue is unvalidated. Use server-side reporting if that matters to you.
- Exchange rates come from one public feed. Refreshed daily, but there's no second source cross-checking it.
- Debug exclusion is all-or-nothing — a flagged player leaves every revenue surface at once.