Loyalty Webhooks
The shared loyalty webhook payload and the four events that carry it: field reference, per-event JSON examples, when the payload is present, and receiver guidance for consuming loyalty state.
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. 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 xpDelta; tier may upgrade |
PURCHASE_REFUNDED | Present when the program is live | Negative xpDelta; 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 xpDelta if the gift grants Loyalty XP |
The loyalty payload
The loyalty object 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,
"xpDelta": 150,
"currentTierId": "silver",
"previousTierId": "bronze",
"xpToNextTier": 750,
"xpToNextMilestone": 250,
"nextMilestoneReward": {
"itemId": "gold-coins",
"quantity": 500,
"type": "IN_GAME_CURRENCY"
}
}Prop
Type
previousTierId and nextMilestoneReward are omitted when they do not apply. Treat a missing field as "no change" / "none", not as an error.
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. xpDelta 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,
"xpDelta": 150,
"currentTierId": "silver",
"previousTierId": "bronze",
"xpToNextTier": 750,
"xpToNextMilestone": 250,
"nextMilestoneReward": { "itemId": "gold-coins", "quantity": 500, "type": "IN_GAME_CURRENCY" }
}
}
}A refund is delivered as an XP-update event. Stash debits XP proportional to the refunded amount, so xpDelta 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,
"xpDelta": -150,
"currentTierId": "bronze",
"previousTierId": "silver",
"xpToNextTier": 400,
"xpToNextMilestone": 100
}
}
}Fired when a player claims a milestone and the shop uses async webhook delivery. The rewards array is your grant list. 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 xpDelta 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,
"xpDelta": 0,
"currentTierId": "silver",
"xpToNextTier": 750,
"xpToNextMilestone": 250,
"nextMilestoneReward": { "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 xpDelta. 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,
"xpDelta": 100,
"currentTierId": "bronze",
"xpToNextTier": 200,
"xpToNextMilestone": 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. - 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.
How is this guide?
Configure in Stash Studio
Step-by-step walkthrough for configuring a loyalty program in Stash Studio: reward types, program settings, tiers, milestones, validation rules, and publishing.
Refunds & XP Adjustments
How Stash reverses Loyalty XP on refund: proportional clamp-to-zero debit, tier downgrade, and idempotency across partial refunds; plus free-gift XP grants and campaign/season boundary behavior.