Features & CustomizationsLoyalty Program

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

Eventloyalty payloadXP effect
PURCHASE_SUCCEEDEDPresent when the program is livePositive xpDelta; tier may upgrade
PURCHASE_REFUNDEDPresent when the program is liveNegative xpDelta; tier may downgrade
LOYALTY_MILESTONE_CLAIMEDAlways presentNo internal XP change; adds milestoneTierId
FREE_ITEM_REDEEMEDPresent when the free gift grants XPPositive 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.

Shared loyalty sub-payload
"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.

Purchase Succeeded with loyalty
{
  "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.

Purchase Refunded with loyalty
{
  "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.

Loyalty Milestone Claimed with loyalty
{
  "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.

Free Item Redeemed with loyalty
{
  "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, and milestoneTierId are 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, the campaignId changes; scope any per-season tier or reward logic to it.
  • Be idempotent. Webhooks can be re-delivered. De-duplicate purchase grants on orderId and milestone grants on userId + milestoneId.
  • Treat a missing loyalty object 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?