Features & CustomizationsLoyalty Program

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.

Loyalty XP is not static after a purchase. Refunds reverse it, and free gifts can grant it. Both are delivered to your backend as loyalty-carrying webhook events so your game state stays in sync with the player's XP.

Refunds are XP-update events

When a webshop purchase is refunded, Stash does not just drop the loyalty state. It debits the player's Loyalty XP, recomputes the tier, and delivers PURCHASE_REFUNDED with the updated loyalty payload and a negative xpDelta.

Proportional debit

The XP debited is proportional to how much of the original purchase was refunded:

debit = round( earnedXP x refundAmount / originalTotal )
  • earnedXP is the XP the original purchase awarded.
  • Rounding is half-up.
  • A full refund debits the full earned XP; a partial refund debits proportionally.

Clamp to zero

The balance is floored at zero. A debit never drives XP negative, even if the player has already spent XP progression or the balance has otherwise moved since the purchase.

Tier downgrade

After the debit, Stash recomputes the tier from the new balance. If the balance falls below the current tier's threshold, currentTierId reflects the lower tier and previousTierId reports the tier the player just left. React to the downgrade the same way you would any tier change.

Idempotency and partial refunds

  • Each refund is applied once. Duplicate refund notifications for the same refund are de-duplicated and do not double-debit.
  • Multiple partial refunds on one order each debit proportionally and independently. Because the balance is clamped at zero, a sequence of partials that sums to the full amount can never over-debit.

The XP debit is a real balance change, so its failure handling is stricter than webhook field population. If the debit cannot be applied, Stash still sends PURCHASE_REFUNDED (the money is already refunded) but omits the loyalty payload rather than report an inconsistent balance. Treat a refund with no loyalty object as "XP unchanged for this event".

Free-gift XP

A free gift can grant Loyalty XP in addition to its item. The XP amount is declared on the free gift in your catalog. On redemption, Stash credits the XP to the player and FREE_ITEM_REDEEMED carries the loyalty payload with a positive xpDelta.

  • If a free gift declares no XP reward, no XP is credited and the event carries no loyalty payload; it serializes exactly as before.
  • Any metadata declared on the free gift is passed through to the metadata field on the webhook, the same way product metadata is. Use it for opaque correlation keys, not for display or business logic.

See the free item redeemed example.

Campaign and season boundaries

Loyalty state is keyed by campaign: a generation of the program scoped to a season. Every loyalty payload carries the campaignId it belongs to.

The XP wallet is a single running balance, not a per-campaign balance. This matters when a refund lands after a new season has started:

  • The debit still applies to the one XP wallet.
  • The earnedXP used for the proportional debit comes from the original purchase, so the math is stable across the boundary.
  • The reported currentTierId and campaignId reflect the current campaign's tiers, not the campaign the purchase was made in.

Scope any per-season reconciliation on your side to the campaignId on each event, and expect a late refund to be reported against the live campaign.

How is this guide?