Analytics & App Integration
Consume loyalty state two ways: process pushed loyalty events through webhooks for analytics and data pipelines, and pull loyalty on demand with the read-only API to show loyalty in-app and enrich offers.
There are two goals your backend uses loyalty state for, and each has a natural path:
- Analytics and data pipelines consume the loyalty events Stash pushes on webhooks: earn, tier changes, milestone claims, and refunds, as they happen.
- In-app integration pulls loyalty on demand with the read-only API: show a player's tier and progress in your app, and enrich offers based on tier.
Stash owns all program state (XP balances, tiers, claim history). Both paths are read-only views of it; grants are driven by webhooks (or synchronous milestone delivery), never by a read.
Analytics: loyalty webhooks
Every webhook that can change or reflect a player's XP carries a shared loyalty sub-payload, so your backend receives a consistent, campaign-scoped view of XP balance, tier, and progression on each of them. Because these events arrive as they happen, they are the feed for analytics and data pipelines: track earn, tier upgrades and downgrades, milestone claims, and refunds over time. The payload is additive; events serialize exactly as before for shops without an active loyalty program.
Events that carry loyalty
| Event | loyalty payload | XP effect |
|---|---|---|
PURCHASE_SUCCEEDED | Present when the program is live | Positive pointsDelta; tier may upgrade |
PURCHASE_REFUNDED | Present when the program is live | Negative pointsDelta; tier may downgrade |
LOYALTY_MILESTONE_CLAIMED | Always present | No internal XP change; adds milestoneTierId |
FREE_ITEM_REDEEMED | Present when the free gift grants XP | Positive pointsDelta if the gift grants Loyalty XP |
The loyalty payload
The loyalty object (type PlayerLoyaltyState) rides inside each event's payload. It is populated only when the shop has a published, enabled loyalty program and a live campaign. Otherwise the whole object is omitted and the event is byte-identical to a non-loyalty shop's.
"loyalty": {
"campaignId": "spring_2026",
"totalPoints": 1250,
"pointsDelta": 150,
"currentTierId": "silver",
"previousTierId": "bronze",
"pointsMultiplierPermille": 2000,
"pointsToNextTier": 750,
"pointsToNextMilestone": 250,
"nextMilestone": {
"milestoneId": "milestone_uuid",
"rewards": [
{ "itemId": "gold-coins", "quantity": 500, "type": "IN_GAME_CURRENCY" },
{ "itemId": "starter-sword", "quantity": 1, "type": "SKU_ITEM" }
]
}
}Prop
Type
The tier ID fields (currentTierId, previousTierId) and nextMilestone are omitted when they do not apply. Treat a missing field as "no change" / "none", not as an error. nextMilestone.rewards is a list: read every entry, since one milestone can grant several rewards.
Payload examples
The envelope is the standard v1 webhook shape; the loyalty object appears inside the event's payload object.
A purchase awards XP at the current tier's earn rate. pointsDelta is positive, totalPoints is the post-purchase balance, and previousTierId appears only if the purchase moved the player up a tier.
{
"type": "PURCHASE_SUCCEEDED",
"environment": "production",
"purchaseSucceeded": {
"timeMillis": 1748476800000,
"orderId": "order_abc123",
"userId": "player_external_account_id",
"currency": "USD",
"total": "9.99",
"source": "Cart",
"items": [{ "id": "item_456", "quantity": 1, "price": "9.99" }],
"loyalty": {
"campaignId": "spring_2026",
"totalPoints": 1250,
"pointsDelta": 150,
"currentTierId": "silver",
"previousTierId": "bronze",
"pointsMultiplierPermille": 2000,
"pointsToNextTier": 750,
"pointsToNextMilestone": 250,
"nextMilestone": {
"milestoneId": "milestone_uuid",
"rewards": [
{ "itemId": "gold-coins", "quantity": 500, "type": "IN_GAME_CURRENCY" },
{ "itemId": "starter-sword", "quantity": 1, "type": "SKU_ITEM" }
]
}
}
}
}A refund is delivered as an XP-update event. Stash debits XP proportional to the refunded amount, so pointsDelta is negative and totalPoints is the post-refund balance. If the debit drops the player below a tier threshold, previousTierId reflects the downgrade. See Refunds & XP adjustments.
{
"type": "PURCHASE_REFUNDED",
"environment": "production",
"purchaseRefunded": {
"timeMillis": 1748480400000,
"orderId": "order_abc123",
"userId": "player_external_account_id",
"currency": "USD",
"total": "9.99",
"reason": "Customer requested refund",
"source": "Cart",
"items": [{ "id": "item_456", "quantity": 1, "price": "9.99" }],
"loyalty": {
"campaignId": "spring_2026",
"totalPoints": 1100,
"pointsDelta": -150,
"currentTierId": "bronze",
"previousTierId": "silver",
"pointsMultiplierPermille": 1000,
"pointsToNextTier": 400,
"pointsToNextMilestone": 100
}
}
}Fired when a player claims a milestone and the shop uses async webhook delivery. The rewards array is your grant list; each entry is { itemId, quantity, type }, where type is one of the bare reward-kind tokens (IN_GAME_CURRENCY, LOYALTY_CURRENCY, LOYALTY_POINTS, or SKU_ITEM). The loyalty API reports the same values in their prefixed form. Alongside the shared loyalty payload, this event adds milestoneTierId: the tier the claimed milestone belongs to. Claiming does not change the internal XP balance, so pointsDelta is 0 and previousTierId is omitted.
{
"type": "LOYALTY_MILESTONE_CLAIMED",
"environment": "production",
"shopId": "your-shop-uuid",
"loyaltyMilestoneClaimed": {
"timeMillis": 1748476800000,
"userId": "player_external_account_id",
"milestoneId": "milestone_uuid",
"milestoneTierId": "silver",
"rewards": [
{ "itemId": "gold-coins", "quantity": 500, "type": "IN_GAME_CURRENCY" }
],
"loyalty": {
"campaignId": "spring_2026",
"totalPoints": 1250,
"pointsDelta": 0,
"currentTierId": "silver",
"pointsMultiplierPermille": 2000,
"pointsToNextTier": 750,
"pointsToNextMilestone": 250,
"nextMilestone": {
"milestoneId": "milestone_uuid",
"rewards": [
{ "itemId": "power-up", "quantity": 3, "type": "IN_GAME_CURRENCY" }
]
}
}
}
}A free gift can be configured to grant Loyalty XP. When it does, the redemption credits XP and this event carries the loyalty payload with a positive pointsDelta. Any metadata declared on the free gift in your catalog is echoed on the metadata field. See Refunds & XP adjustments.
{
"type": "FREE_ITEM_REDEEMED",
"environment": "production",
"freeItemRedeemed": {
"userId": "player_external_account_id",
"itemId": "item_free_001",
"metadata": { "promotionId": "spring_kickoff" },
"loyalty": {
"campaignId": "spring_2026",
"totalPoints": 300,
"pointsDelta": 100,
"currentTierId": "bronze",
"pointsMultiplierPermille": 1000,
"pointsToNextTier": 200,
"pointsToNextMilestone": 50
}
}
}Receiver guidance
- Key tier logic on the tier ID.
currentTierId,previousTierId, andmilestoneTierIdare Studio tier IDs, stable for the campaign. Display names change and are localized. - React to tier changes from
previousTierId. Its presence means the tier changed this event; its absence means no change. You do not need to track prior tier state yourself. - Use
pointsMultiplierPermillefor tier-relative display, not for granting. It reports how much faster the current tier earns Loyalty XP per USD than the base (first, lowest) tier, as an integer in permille: divide by 1000 (2000= 2x, base tier1000= 1x). It is derived from Studio earn rates for display and messaging (for example "2x XP at this tier"); the authoritative XP change for this event is alwayspointsDelta. - Reconcile on
campaignId. Values are scoped to the active campaign. When a season rolls to a new campaign, thecampaignIdchanges; scope any per-season tier or reward logic to it. - Be idempotent. Webhooks can be re-delivered. De-duplicate purchase grants on
orderIdand milestone grants onuserId+milestoneId. - Treat a missing
loyaltyobject as "no loyalty". It is omitted for shops without a live program, and (on refund only) when an XP debit could not be applied.
For webhook endpoint setup, signature verification, and retry behavior, see Webhooks overview. For every event type Stash sends, see the Webhook list.
In-app integration: the loyalty API
Two read-only endpoints let your backend pull loyalty on demand, so you can show a player's loyalty in your app and enrich offers based on tier:
- Get loyalty program configuration returns the shop's active campaign: its tiers (ordered by starting points) and the milestones inside each tier, with their rewards. Use it to render the tier ladder, milestones, and upcoming rewards in your own surfaces. Cache it; it changes only when you publish in Studio.
- Get a player's loyalty standing returns one player's live state: points balance, current tier, distance to the next tier and milestone, and the effective points multiplier. Use it to show where a player stands, and to enrich offers, for example adding a larger bonus at higher tiers by scaling with the effective multiplier.
Both return NOT_FOUND when the shop has no active loyalty campaign.
The API Reference is the authoritative source for exact paths, parameters, the full response schema, and an interactive try-it console. See Get loyalty program configuration and Get a player's loyalty standing.
Authentication
Call these endpoints from your backend only, with your shop's server API key. The key is shop-scoped, so the tiers, milestones, and standing returned belong to the shop the key authenticates. Never call them from a game client or expose the key.
Reward type values
Reward type takes two string forms for the same set of reward kinds. The loyalty API emits the prefixed enum names: LOYALTY_REWARD_TYPE_IN_GAME_CURRENCY, LOYALTY_REWARD_TYPE_LOYALTY_CURRENCY, LOYALTY_REWARD_TYPE_LOYALTY_POINTS, LOYALTY_REWARD_TYPE_SKU_ITEM, plus LOYALTY_REWARD_TYPE_UNSPECIFIED for the zero value. The webhook payloads above emit the bare token: IN_GAME_CURRENCY, LOYALTY_CURRENCY, LOYALTY_POINTS, SKU_ITEM. They map one-to-one; strip the LOYALTY_REWARD_TYPE_ prefix to line them up. For a SKU reward, itemId is the catalog product's SKU (external ID); for currency and points rewards, itemId is the reward type ID.
When to use which
- Webhooks, for analytics. Consume pushed events to feed analytics and data pipelines, and to react as state changes: earn, tier upgrades and downgrades, milestone claims, and refunds the moment they happen.
- The API, for in-app integration. Pull on demand to render loyalty in your app (tier ladder, current standing, progress, next milestone), to enrich offers by tier, and to reconcile a player after a missed or replayed webhook. Read the configuration once and cache it.
- Grant from webhooks, not reads. The loyalty API is read-only context; grant rewards from the
LOYALTY_MILESTONE_CLAIMEDwebhook (or synchronous milestone delivery), never from a read.
How is this guide?