Real-time Catalog
Learn how to expose REST API endpoints on your game backend to power personalized web shops with real-time offers. This guide covers the Catalog, Player, and Purchase APIs with protobuf-based type-safe integration.
The Stash v1 WebShop APIs enable game studios to power dynamic, player-personalized web shops with special offers. These protobuf-based APIs provide a robust, type-safe integration path that accelerates development while ensuring reliability at scale.
Getting Started
You can integrate the v1 APIs in two ways. Both produce the same endpoints; pick whichever fits your stack, then follow the Integration Steps below to roll it out.
Clone the proto repository and generate type-safe clients automatically:
# Add the public-api repo as a submodule in your backend repo
git submodule add https://github.com/stashgg/public-api.git
# Or just clone it locally
git clone https://github.com/stashgg/public-api.git
# Generate clients using the included script
cd public-api
./gen.shThe gen.sh script generates clients for Go, TypeScript, and OpenAPI specs. Adapt it for your preferred language (C#, Java, Python, etc.) using standard protobuf tooling.
No documentation required: The generated clients provide full type definitions, so your IDE's autocomplete guides the integration.
Integration Steps
Obtain API credentials
Your Stash engineering partner will generate an Egress API key for you in Stash Studio. Set your Game Backend URL in Stash Studio to match where the APIs will be hosted. Custom API endpoint paths can be configured if needed.
Generate clients or review the API reference
Use the proto repository to generate type-safe clients, or review the API Reference for detailed endpoint documentation.
Implement the required endpoints
At minimum, implement these endpoints on your game backend:
Required:
- GetCatalog: Returns the product catalog
- ConfirmPayment: Powers both WebShop and StashPay
Register and cancel (on by default, optional per shop):
Stash calls these two as a pair. Implement both, or ask your Stash engineering partner to turn them off for your shop. See Register and cancel are optional.
- RegisterPayment: Reserves inventory across all game/shop clients
- CancelPayment: Releases inventory reservation locks
Optional:
- GetOfferDetails: Show extra details like drop rates for legal compliance
- GetPlayer: Show player-specific details like avatar, level, in-game currency balances
Test with test credentials
Test your integration with your studio-test.stash.gg credentials before going live.
API Overview
The v1 APIs are organized into three services, exposed as REST endpoints. Stash sends requests to your Game Studio's backend servers:
All endpoints use HMAC authentication for secure server-to-server communication.
JSON wire format
Stash reads your responses leniently:
- Field names: camelCase as shown in the API reference (
productId) or snake_case (product_id). Both are accepted, also within one response. - Enum values: the value's name (
"CONTENT_ITEM_TYPE_WEB_STORE_BONUS") or its number (1). An enum name Stash does not recognize is ignored, and the field keeps its default value. - Unknown fields: ignored. Extra fields in your response do not cause an error.
Catalog API
Retrieve dynamic, player-specific product catalogs.
GetCatalog
GET /api/v1/catalog: returns the complete product catalog for a given platform, region, language, and playerId. The response is structured as sections (grids or carousels) containing PurchasableItem, OfferChainItem, or NonPurchasableItem entries.
For the full request parameters and response schema, see the GetCatalog API Reference.
GetOfferDetails
GET /api/v1/catalog/offer: returns detailed offer information including drop rates for loot boxes, required for legal compliance in many jurisdictions.
For the full request parameters and response schema, see the GetOfferDetails API Reference.
Purchase API
Handle the complete purchase lifecycle with three endpoints.
Purchase Flow
For where the player's money is at each step, why ConfirmPayment is the point where your backend can still decline a purchase, and which checks only your backend can enforce, see Payment Flow.
Register and cancel are optional
RegisterPayment and CancelPayment are on or off together. By default Stash calls RegisterPayment before ConfirmPayment, and CancelPayment when a registered web shop cart purchase does not complete.
If your backend does not need a reserve step, ask your Stash engineering partner to turn both off for your shop. Stash then calls neither, and ConfirmPayment is the first request that carries the purchase's transaction ID. Stash can also turn them off only for checkout link purchases (Stash Pay and Buy Now), so web shop carts keep them.
RegisterPayment
POST /api/v1/purchase/register (Step 1): registers a purchase intent. Your backend should reserve inventory for the player and respond with one of the inline status codes (e.g. SUCCESS_REGISTERED, FAILED_INSUFFICIENT_INVENTORY, FAILED_PURCHASE_LIMIT_EXCEEDED, FAILED_INVALID_PRICE).
For the full list of status codes and the complete request/response schema, see the RegisterPayment API Reference.
ConfirmPayment
POST /api/v1/purchase/confirm (Step 2A): confirms and completes the purchase after successful payment. Your backend grants items to the player and acknowledges the transaction.
Unified Integration: This endpoint is the same API used for StashPay integrations. Implement once to power both your WebShop and StashPay checkout links.
Declining a confirm. To refuse the purchase, respond with an HTTP 4xx status and a body that carries a decline object naming a PurchaseConfirmationStatus, and omit results. Stash never charges the player for a declined confirm. Where the checkout supports it, Stash uses the status to pick the message the player sees (for example PURCHASE_CONFIRMATION_STATUS_FAILED_INSUFFICIENT_INVENTORY, PURCHASE_CONFIRMATION_STATUS_FAILED_PURCHASE_LIMIT_EXCEEDED, PURCHASE_CONFIRMATION_STATUS_FAILED_INVALID_PRICE); otherwise the player sees a generic failure message. A 4xx without a decline still declines the purchase, but carries no structured reason.
{
"decline": {
"status": "PURCHASE_CONFIRMATION_STATUS_FAILED_PURCHASE_LIMIT_EXCEEDED",
"productId": "gold_1000",
"message": "player has reached the purchase limit for this item"
}
}Use PURCHASE_CONFIRMATION_STATUS_FAILED_PACK_LIMIT_EXCEEDED when the purchase allowance shared by every item of a purchase pack is exhausted, and PURCHASE_CONFIRMATION_STATUS_FAILED_PURCHASE_LIMIT_EXCEEDED when only the item's own limit is. Return exactly one of results or decline; a body carrying both is treated as declined.
For the full request/response schema (including the decline object, its status values, and optional fields like extraInGameCurrency, extraLoyaltyPoints, and emailMarketingOptIn), see the ConfirmPayment API Reference.
CancelPayment
POST /api/v1/purchase/cancel (Step 2B, failure path): cancels a pending purchase. Your backend should release reserved inventory so the player can retry.
See the CancelPayment API Reference for the full request/response schema.
Loyalty snapshot on purchase requests
When the shop runs a live loyalty program, the RegisterPayment and ConfirmPayment request bodies carry a loyalty snapshot (type PlayerLoyaltyState): the same object your backend already receives on loyalty webhooks. This gives your backend the player's XP balance, tier, and tier-relative earn multiplier at purchase time, in-band with the register/confirm calls, so it can apply tier-aware logic (for example bonus sizing) without a separate lookup.
"loyalty": {
"campaignId": "spring_2026",
"totalPoints": 1250,
"currentTierId": "silver",
"pointsMultiplierPermille": 2000,
"pointsToNextTier": 750,
"pointsToNextMilestone": 250,
"nextMilestone": {
"milestoneId": "milestone_uuid",
"rewards": [
{ "itemId": "gold-coins", "quantity": 500, "type": "IN_GAME_CURRENCY" }
]
}
}- The snapshot reflects state as of the request. On
RegisterPaymentit is pre-purchase; onConfirmPaymentit reflects the XP the confirmed purchase awards. - It is present only when the shop has a published, enabled loyalty program and a live campaign. Otherwise the field is omitted and the request body is byte-identical to a non-loyalty shop's.
- Treat it as read-only context. It never replaces the authoritative loyalty state your backend receives on the loyalty webhooks; grant XP-linked effects from the webhook, not from this snapshot.
The snapshot fields match the shared PlayerLoyaltyState payload documented in Loyalty webhooks. The signed pointsDelta and previousTierId are event-specific and meaningful only on webhooks; the purchase-request snapshot otherwise carries the same fields, including the balance, tier, progression, and nextMilestone. When the purchase's XP gain crossed one or more milestone thresholds, the snapshot also carries unlockedMilestones, with the same semantics and de-duplication guidance as on the webhook payload.
Player API
Retrieve player profile information for personalized experiences.
GetPlayer
GET /api/v1/player: returns player profile data (name, avatar, language, in-game level, in-game currency balances) for display in the web shop. Stash calls this endpoint with the playerId you supplied to the catalog request.
See the GetPlayer API Reference for the full request/response schema.
Player level
Return the player's level in inGameLevel as a positive integer. The web shop can show it on the player's profile, for example as a power level. Omit it when your game has no player level; the web shop then shows none. Stash treats 0 as an omitted level.
In-game currency balances
Return the player's balances in wallet, one entry per in-game currency. Stash reads the first 100 entries and ignores the rest.
| Field | Value |
|---|---|
bonusId | The ID of the reward type configured on the Reward Types surface in Studio. Matched exactly and case-sensitively. Up to 256 characters: letters, digits, ., _ and -. Required; an entry without it is ignored. If two entries share a bonusId, Stash keeps the first. |
bonusType | WALLET_BONUS_TYPE_IN_GAME_CURRENCY. Stash ignores entries of any other type. |
balance | The amount the player owns, a non-negative 64-bit integer, sent as a JSON integer or a decimal string. Required; an entry without it is ignored. Use a string for values above 9007199254740991. 0 is a real balance. A negative, fractional or non-numeric value fails the player lookup. |
Stash attaches the name and icon of the matching reward type in Studio to each balance. Studio supports one in-game currency reward type per shop today, so a balance for a bonusId that is not a reward type in Studio comes back without a name or icon. An entry whose bonusId names a reward type of another kind, such as loyalty points, is ignored. See Reward types.
{
"player": {
"playerId": "player-123",
"name": "PlayerOne",
"wallet": [
{ "bonusId": "gold-coins", "bonusType": "WALLET_BONUS_TYPE_IN_GAME_CURRENCY", "balance": "12000000000000000" }
]
}
}inGameCurrency is deprecated but keeps working, so an existing integration does not need to change now. Stash shows a nonzero value as the balance of the shop's in-game currency reward type in Studio, and omits it when the shop has none. It holds a single currency, and a value above 4294967295 fails the player lookup, so use wallet for new integrations and for large balances. Whenever wallet has at least one entry, Stash uses it for currency balances and ignores inGameCurrency, even if every wallet entry is ignored.
Key Data Types
Catalog Items
Each catalog item is exactly one of five types (a protobuf oneof; the JSON key is in parentheses):
| Type | Use Case |
|---|---|
PurchasableItem (product) | Standard products and bundle offers with pricing |
PurchasePack (pack) | One offer sold at several prices; see Purchase packs |
OfferChainItem (chain) | Progressive offers that unlock sequentially with CLAIMED / UNLOCKED / LOCKED link statuses |
NonPurchasableItem (info) | Banners, promotions, and informational displays |
FreeItem (free) | Free-to-claim gifts with a claim status, an optional refreshAt window, and claim tracking |
For the full field-level structure of each type, including pricing, contents, expiration, attributes, and offer chain link details, see the GetCatalog API Reference.
Stash skips an item whose type it does not recognize and renders the rest of the catalog.
Prices
Each PurchasableItem carries one price, set in one of two ways:
price | What Stash does |
|---|---|
amount (currency, cents) | Shows and charges this price as sent. No Stash Studio price is looked up for the item. |
priceId | Looks up the price imported in Stash Studio for that ID and the player's platform and region. See Studio price lookup. |
| Omitted | The same Studio lookup, using the item's productId. |
When you send amount:
centsis an integer in the currency's minor units:199inUSDis $1.99, and300inJPYis ¥300. The number of decimal places per currency follows your shop's minor-unit standard.centsmust be greater than0. To give an item away, use aFreeItem.- Use one currency for all priced items in a response. Stash shows one currency per shop page and leaves out priced items in any other currency.
Minor-unit standard
Each shop has one minor-unit standard. It sets the number of decimal places Stash uses for a currency when it reads cents in your catalog prices and when it writes cents, tax, total and decimalPlaces in RegisterPayment, ConfirmPayment and CancelPayment requests.
| Standard | Decimal places per currency |
|---|---|
| Unicode CLDR release 32 (default) | The digits values under fractions in the CLDR release 32 supplemental data. A currency not listed there has 2. |
| Unicode CLDR release 47 | The digits values under fractions in the CLDR release 47 supplemental data. A currency not listed there has 2. |
| ISO 4217 | The minor unit in the ISO 4217 currency list. |
The standards disagree for some currencies, for example:
| Currency | CLDR release 32 | CLDR release 47 | ISO 4217 |
|---|---|---|---|
COP, IDR, PKR, TZS | 0 | 2 | 2 |
MMK, RSD | 0 | 0 | 2 |
IQD | 0 | 0 | 3 |
A price of 5,000 COP is cents: 5000 under CLDR release 32 and cents: 500000 under the other two.
To use a standard other than the default, contact your Stash engineering partner: they can configure it for your shop.
Studio price lookup
Stash looks up an imported price in the currency that is primary for the player's region, and takes the first match in this order:
- The player's region and platform.
- The player's region, from a file imported for all platforms (Universal in Studio).
USinUSD, the player's platform.USinUSD, Universal.
If none of these exists, the item is left out of the catalog. A missing country therefore shows the US price rather than hiding the item, and a row imported for a country in a currency other than its primary one is never matched. When no platform is resolved, only Universal rows match.
To return a different price per platform or country, choose the price from the request's platform and region:
platformcomes from the Unity SDK session, then from the platform stored on the player's account (theuser.platformyour backend sends on Approve custom login), then from the browser's user agent (iPhone, iPad and iPod are iOS; Android is Android). A desktop browser has no platform unless the account has one stored, so senduser.platformwhen you link the player if prices differ by platform.regionis the most recent region your backend sent for the player, asuser.regionCodeon Approve custom login or Generate authenticated URL when the player signs in. When you send none, it is the country of the player's IP address, orUSwhen that is unknown. It is not the country of the player's app store account.
Purchase packs
Use a pack when one offer is sold at several prices and the player buys one of them, for example the same reward at three price points. Each price point is one entry in the pack's items (a PurchasePackItem).
Declare the values the whole offer shares once, on the pack:
| Field | Meaning |
|---|---|
id | Required. Your stable identifier for the offer, unique within the response. A pack without an id is left out of the catalog. |
items | Required. The offer's price points, in display order. Each one has its own productId and price, and optional contents. |
maxPurchasable | Optional. The player's remaining purchases of this offer. Stash applies it to every item in the pack, together with each item's own maxPurchasable. |
expiration | Optional. After this time no item in the pack is available for purchase, except a purchase already in checkout within a checkout grace period agreed for your shop. |
refreshAt | Optional. When the offer's allowance next resets, for display such as a countdown. Purchase checks never use it. |
A PurchasePackItem has no expiration or refreshAt of its own, so the pack's values govern every item in it. It can have its own maxPurchasable: the player's remaining purchases of that price point. The lower of the item's and the pack's maxPurchasable applies to the item.
- Buy the whole offer. To sell one item that buys everything in the offer in a single transaction, give it
type: PURCHASE_PACK_ITEM_TYPE_WHOLE_PURCHASE_PACKand list the combined rewards in itscontents. Use this type on at most one item per pack. - Remaining count. Compute
maxPurchasableper player and send it fresh in every response. Stash keeps no purchase count of its own: it checks each purchase against the value from your catalog. - Purchase calls. For an item bought from a pack,
RegisterPayment,ConfirmPaymentandCancelPaymentcarry the pack'sidaspurchasePackIdon that item.productIdstill identifies the price point. - Unavailable offers. To withdraw an offer or a price point, leave it out of the response. To show a price point as sold out instead, keep it and send
maxPurchasable: 0on it: the player sees it but cannot buy it, and Stash refuses checkout for it.
See PurchasePack in the API reference for every field.
Free items and claim identity
A FreeItem is claimable at no cost. When a player claims one, Stash records the redemption and emits a FREE_ITEM_REDEEMED webhook. Two fields govern how claims are tracked:
status: your response is authoritative for whether the gift is offered (AVAILABLE,CLAIMED, orLOCKED). After a recorded claim, Stash overridesAVAILABLEtoCLAIMEDon catalog reads so a reload cannot show a consumed gift as claimable. It only ever applies that one flip: aCLAIMEDorLOCKEDstatus you send is never changed, and Stash never producesAVAILABLE. The override ends at the item'srefreshAt; from then on your status alone decides the next period.claimId(optional): a stable claim identity, separate fromitemId. Free items that share aclaimIdare the same claim for a claim period: claiming any one of them consumes the claim for all of them. When absent,itemIdis the claim identity, which is the existing behavior; integrations that do not send it change nothing.
Use claimId when the item you serve varies per player, for example a daily gift whose itemId differs by loyalty tier. Keep the claimId stable across the variants and the claim still de-duplicates as one gift per period, whichever variant a player sees.
Rules to follow:
- Return at most one item per
claimIdper response; additional items sharing one may be discarded. A locked preview of a later reward must carry a distinctclaimId, or none at all. - A repeat claim inside the same window returns an idempotent success: no duplicate
FREE_ITEM_REDEEMEDwebhook, and no second Loyalty XP credit for gifts that grant XP. - On the
FREE_ITEM_REDEEMEDpayload,itemIdis the item that was granted;claimIdis the claim it consumed and appears only when it differs fromitemId.
Localization
All user-facing text supports localization. Each text field is a LocalizableText. Send defaultText in every one, since it is the fallback, and add at most one of:
localizationKey: Stash-side lookup in our localization system (requires sharing localization keys with Stash up front).localizedText: Pre-resolved text in the player's language, computed by your backend.
Pick whichever fits your localization pipeline; you can mix the two across different fields in the same response.
Stash shows localizedText when you send it. For a localizationKey, it shows the translation for the player's language, or defaultText when the key has no translation. With neither, it shows defaultText.
Formatted text
A LocalizableText can carry values to insert into its text, so one translated sentence serves every player and each language places the values where its grammar needs them.
| Field | Meaning |
|---|---|
formatArguments | Optional. The values to insert, as a map of keys to strings, for example {"count": "1"}. |
formatSyntax | How the text writes its placeholders: TEXT_FORMAT_SYNTAX_ICU_MESSAGE_FORMAT for {count} or {0}, or TEXT_FORMAT_SYNTAX_PRINTF for %s or %1$s. |
Stash picks the text for the player's language as described in Localization, inserts the values, and shows the result.
Example. A localization entry shared with Stash:
| Key | English | Japanese |
|---|---|---|
TOOLTIP.PACK_GUARANTEE | At least {count} card of {rarity} stars or more | {rarity}つ星以上のカードが{count}枚以上 |
A content item that uses it as its info tooltip:
{
"name": { "defaultText": "Card pack" },
"quantity": "1",
"infoButton": {
"tooltip": {
"text": {
"defaultText": "Contains guaranteed cards",
"localizationKey": "TOOLTIP.PACK_GUARANTEE",
"formatArguments": { "count": "1", "rarity": "3" },
"formatSyntax": "TEXT_FORMAT_SYNTAX_ICU_MESSAGE_FORMAT"
}
}
}
}The tooltip text is At least 1 card of 3 stars or more in English and 3つ星以上のカードが1枚以上 in Japanese.
For printf-style text such as At least %1$s card of %2$s stars or more, send "formatArguments": {"1": "1", "2": "3"} with "formatSyntax": "TEXT_FORMAT_SYNTAX_PRINTF".
- Values are literal. Stash inserts each value as sent, with no number, date or plural formatting, so send values already formatted for the player's language.
- Limits. At most 32 entries. Keys match
^[A-Za-z0-9_]{1,64}$and values are at most 512 bytes. The text before composing is at most 4096 bytes, and the composed text at most 8192 bytes. - Fallback. If the text cannot be composed, for example because a value it reads is missing, Stash composes
defaultTextwith the same syntax and values instead. If that fails too, the text is left out. WritedefaultTextwithout placeholders so the fallback renders, and with printf-style write a literal percent sign as%%. - Optional. A text with neither field shows as is.
formatArgumentswithout aformatSyntaxleaves the text out.
For ICU-style quoting, %d, and the other edge cases, see the GetCatalog API Reference.
Shop banner
catalog.banner is an optional banner shown once, above the first section of the shop, for example a message about a bonus for buying on the web. Its text is a LocalizableText, and because the catalog is requested per player, it can differ per player and use formatted text:
{
"catalog": {
"banner": {
"text": {
"defaultText": "Extra rewards on the web",
"localizationKey": "BANNER.WEB_BONUS",
"formatArguments": { "percent": "10" },
"formatSyntax": "TEXT_FORMAT_SYNTAX_ICU_MESSAGE_FORMAT"
}
}
}
}With the entry +{percent}% on the web, the banner reads +10% on the web. The banner is display only: it never changes what is granted or charged. Omit it, or send text that resolves to blank, for no banner.
Offer templates with named slots
When your offer templates name their slots, send the slot names with each offer, so each reward, each buy button and each piece of text lands in the slot meant for it. Without them, the web shop fills a template's slots by position: rewards in contents order, and text from the offer's fixed fields such as name. Position breaks when a template has more slots than the fixed fields, or when a new template version orders its slots differently.
All four fields are optional. A response without them stays valid: the web shop shows the offer in your shop's default template for its type and fills the slots by position.
| Field | Where | Value |
|---|---|---|
templateKey | A product, a pack, a free item or an info item. On a pack, set it on the pack; its items have no templateKey. | The template that shows the offer, including its version, for example starter-offer-v2. 1 to 128 characters. |
layoutKey | Each reward: an entry in contents of a product, a pack item, a free item or an info item, and an entry in items of a chain link | The reward slot this reward fills. 1 to 128 characters. |
buyButton.layoutKey | The buyButton in attributes of a product or of a pack item | The buy button slot this offer's or price point's buy button sits in. 1 to 128 characters. |
textSlots | A product, a pack, a pack item, a free item or an info item | An object that maps each text slot name to a LocalizableText, for example Title or RibbonText. Keys up to 128 characters, at most 50 entries. |
Agree your templates, their versions and their slot names with your Stash engineering partner. Stash compares every templateKey, layoutKey, buyButton.layoutKey and textSlots key exactly as sent: case-sensitive, untrimmed, and with no prefix matching. Title and title are different slots, and starter-offer-v2 and starter-offer-v3 are different templates.
{
"product": {
"productId": "starter_bundle",
"price": { "priceId": "starter_bundle_499" },
"name": { "defaultText": "Starter Bundle" },
"templateKey": "starter-offer-v2",
"textSlots": {
"Title": { "defaultText": "Spring Starter Bundle", "localizationKey": "offer.starter.title" },
"RibbonText": { "defaultText": "Best value", "localizationKey": "offer.ribbon.best_value" }
},
"contents": [
{ "name": { "defaultText": "Coins" }, "quantity": "5000", "layoutKey": "TopLeftReward" },
{ "name": { "defaultText": "Gems" }, "quantity": "50", "layoutKey": "BottomReward" }
]
}
}Here the offer shows in version 2 of the starter-offer template. Its Title slot shows "Spring Starter Bundle", not the name, and each reward fills the slot its layoutKey names.
Template choice
A templateKey that is missing, empty, longer than 128 characters, or not one of your shop's templates is ignored: the offer shows in your shop's default template for its type. A template that names no reward slots ignores layoutKey, one that names no buy button slots ignores buyButton.layoutKey, and one that names no text slots ignores textSlots.
Reward slots
- Keyed rewards first. A reward whose
layoutKeynames a reward slot of the template fills that slot. When several rewards name the same slot, the first incontentsfills it. - Then the rest, in order. Every other reward fills the remaining reward slots in
contentsorder: a reward without alayoutKey, one whoselayoutKeynames no slot of the template, and a later reward that names an already filled slot. Every reward is still shown. - Empty or long keys. An empty
layoutKey, or one longer than 128 characters, counts as no key. - Rewards and slots that do not match up. With more rewards than reward slots, the template shows the extra rewards as it does for any offer that overflows it. With fewer, the unfilled slots stay empty.
Buy button slots
A template can give each buy button a slot of its own, separate from the slots its rewards fill, for example one buy button slot per price point of a pack. Name it in the buy button's layoutKey, in attributes.buyButton of the product or the pack item.
- The label stays the price.
textis optional on a buy button. SendbuyButtonwith onlylayoutKeyto name the slot and keep the button's default label, the price. - Buy buttons only.
layoutKeyis read on a buy button. On an info button it is ignored. - Empty or long keys. An empty
layoutKey, or one longer than 128 characters, counts as no key. - Buttons without a matching slot. When a buy button has no
layoutKey, has one that names no buy button slot of the template, or names the same slot as another buy button of the offer, the template decides where that button sits. - Display only. The slot never changes what is bought, charged or sent to
ConfirmPayment.
Text slots
- Slot text wins. A text slot shows its
textSlotsvalue. Without one, it shows the fixed field the template fills that slot from, for examplenamefor a title slot. Stash defines that field for each template. A slot with no value in either place stays empty, with no placeholder text. - Localization. Each
textSlotsvalue is aLocalizableText, localized like every other text field (see Localization). When itslocalizationKeyhas no translation for the player's language, the slot shows the entry'sdefaultText. When that is empty too, the slot shows its fixed field. - Ignored entries. An entry whose key is empty, longer than 128 characters or not a text slot of the template is ignored, and so is an entry whose text is empty or resolves to empty. The offer still shows. Text of only spaces counts as a value.
- Limit. Stash keeps at most 50 entries with text, the first 50 by slot name in byte order.
- Repeated names. Send each slot name once. If a name repeats within one
textSlotsobject, Stash uses its first value and the rest of the catalog still loads. - The offer card only.
textSlotschanges only the offer card. Checkout and receipts show the fixed fields, so always sendname(titleon aninfoitem).
Slots on purchase packs
Set templateKey and textSlots on the pack, once for the whole offer. A price point (an item in the pack's items) can add its own textSlots:
- For a slot set on both, the price point's value is used for that price point. The pack's entries fill every other slot.
- A price point entry that is ignored, for example an empty text or a key over 128 characters, leaves the pack's entry for that slot.
- Of the combined entries, Stash keeps the first 50 by slot name in byte order.
A pack has no buy button of its own: each price point names its buy button slot in its own attributes.buyButton. Here each price point's rewards fill a Resources_* slot and its buy button sits in a SKU_* slot:
{
"pack": {
"id": "three_price_offer",
"templateKey": "three-price-offer-v1",
"items": [
{
"productId": "offer_tier_1",
"price": { "priceId": "offer_tier_1_499" },
"name": { "defaultText": "Small" },
"contents": [{ "name": { "defaultText": "Coins" }, "quantity": "1000", "layoutKey": "Resources_Left" }],
"attributes": { "buyButton": { "layoutKey": "SKU_Left" } }
},
{
"productId": "offer_tier_2",
"price": { "priceId": "offer_tier_2_999" },
"name": { "defaultText": "Medium" },
"contents": [{ "name": { "defaultText": "Coins" }, "quantity": "2500", "layoutKey": "Resources_Middle" }],
"attributes": { "buyButton": { "layoutKey": "SKU_Center" } }
},
{
"productId": "offer_tier_3",
"price": { "priceId": "offer_tier_3_1999" },
"name": { "defaultText": "Large" },
"contents": [{ "name": { "defaultText": "Coins" }, "quantity": "6000", "layoutKey": "Resources_Right" }],
"attributes": { "buyButton": { "layoutKey": "SKU_Right" } }
}
]
}
}These fields change only how the offer card shows the offer. What Stash sends to ConfirmPayment, and what your backend grants, do not change.
Slot names belong to a template version. When you move an offer to a new version in templateKey, send that version's slot names. A name the version does not have falls back as above: the reward fills a remaining slot in contents order, and the text slot shows its fixed field.
Web Store Bonuses
Give players extra content for buying on the web:
| Field | What it does |
|---|---|
bonusItems on a product, or on an item in a pack | The bonuses that come with the item, each a type, an id and an amount. On purchase, Stash sends them to your ConfirmPayment. |
contents[].bonusQuantity | A bonus amount shown next to a content item's quantity, for example +25. It is an amount, not a percentage. Display only: Stash does not send it to ConfirmPayment, and when the item also sets bonusItems, those decide what is granted. |
contents[].type: CONTENT_ITEM_TYPE_WEB_STORE_BONUS | Shows a content item as a web store bonus. |
Grant bonuses from ConfirmPayment. The bonusItems amounts on the ConfirmPayment request are the amounts to grant for the bonus IDs they name. Do not also grant your offer's own bonus for an ID listed there, or the player receives it twice.
Drop Rate Compliance
For loot boxes and randomized rewards, the API supports drop rate disclosure required for legal compliance in many jurisdictions.
Info buttons
An info button shows an "i", and tapping it opens its tooltip or a page. It can sit in two places:
| Field | Where the "i" appears |
|---|---|
contents[].infoButton | On one reward, when that reward has a rarity or a drop rate to explain. |
attributes.infoButton on a product, a pack or a free item | On the whole offer, for example when what the player buys as a whole is drawn at random. On a pack it shows with every price point. |
Both are the same Button shape, with the same fields and rules whether the button sits on a reward or on a whole offer:
| Field | Meaning |
|---|---|
informationType | Optional. What the button explains: INFORMATION_TYPE_RARITY for a reward's rarity, such as a guaranteed minimum rarity, or INFORMATION_TYPE_DROP_RATES for drop rates (the odds disclosure). The shop can style each kind differently. When unset, the button is a general info button. |
tooltip.text | Optional. The tooltip text, a LocalizableText that can use formatted text. With INFORMATION_TYPE_DROP_RATES, it can contain links. |
linkUrl | Optional. The URL of a page with more information, such as your full drop rates, starting with https://, opened when the player taps a button that has no tooltip. With a tooltip, link the page from the tooltip text instead. A URL that is not https is ignored. |
iconImageUrl | Optional. The button's icon. A generic info icon shows when absent. |
text | Optional on an info button, which can show its icon alone. |
Tapping an info button opens its tooltip when it has one, and otherwise opens linkUrl. A tooltip whose text cannot be shown, and a linkUrl that is not https, count as not sent, and an info button with neither is not shown. Leave the offer's info button out when there is nothing to explain, for example on coins only.
A reward with a guaranteed minimum rarity, like the card pack in the formatted text example, can set "informationType": "INFORMATION_TYPE_RARITY" on its infoButton. A reward whose own contents are drawn at random can set "informationType": "INFORMATION_TYPE_DROP_RATES" with a linkUrl to its drop rates page, or a tooltip with links. A pack whose rewards are drawn at random can explain its drop rates once for the whole offer:
{
"pack": {
"id": "sticker-pack-offer",
"attributes": {
"infoButton": {
"informationType": "INFORMATION_TYPE_DROP_RATES",
"tooltip": {
"text": {
"defaultText": "For the odds, see [here](https://example.com/odds)",
"localizationKey": "TOOLTIP.DROP_RATES"
}
}
}
},
"items": [
{
"productId": "sticker-pack-small",
"price": { "priceId": "price-small" },
"contents": [{ "name": { "defaultText": "Sticker pack" }, "quantity": "1" }]
}
]
}
}Links in drop rates tooltips
The tooltip text of an info button with INFORMATION_TYPE_DROP_RATES, on a reward or on a whole offer, can contain links written as [label](url), for example For the odds, see [here](https://example.com/odds).
- Link form. A link is
[, the label up to the first], then immediately(, and the URL up to the first), with no spaces in the URL. Write a)inside a URL as%29. - Only links are read. Every other character shows as typed, including a
[or(that does not complete a link. No other formatting is applied. httpsonly. A link whose URL does not start withhttps://followed by a host, or that carries a user name (user@host), shows its label as plain text, so the text is never lost. A link with an empty label shows nothing.- After formatting. Links are read from the text after formatted text values are inserted. A value you send in
formatArgumentsalways shows as typed and never starts or ends a link. A value inside a label shows as part of the label; a link whose URL contains a value shows its label as plain text. - Special characters in a URL. In a printf-style text, write a
%in the URL as%%(so%29becomes%%29). In an ICU-style text, quote any brace. Otherwise the text cannot be composed. - Other tooltips are unchanged. In every other tooltip,
[and(are ordinary characters.
All of these fields are optional, so a response without them stays valid.
Enabling Drop Rate Display
To show drop rates or offer details in a popup:
-
Add a badge with the show details action: Include a badge on the offer with
action: BADGE_ACTION_SHOW_OFFER_DETAILS. This signals to the frontend to display an info button that calls the GetOfferDetails API and opens the corresponding modal. -
Enable per-item details (optional): For individual content items that should show their own reward/drop rate details, include
clickAction: CONTENT_ITEM_CLICK_ACTION_SHOW_DETAILS_BY_IDon thatContentItem.
Example Catalog.PurchasableItem Response
{
"attributes": {
"badge": {
"text": { "defaultText": "Loot Box" },
"action": "BADGE_ACTION_SHOW_OFFER_DETAILS"
}
},
"contents": [
{
"name": "Legendary Hero",
"guid": "xxx",
"image": "https://...",
"quantity": "1",
"dropRate": "2.5%",
"clickAction": "CONTENT_ITEM_CLICK_ACTION_SHOW_DETAILS_BY_ID"
},
{
"name": "Common Item",
"quantity": "1",
"dropRate": "97.5%"
}
]
}If using clickAction, the guid must be supplied (can't be auto-generated)
to cross-reference with the GetOfferDetails API response.
Caching
Stash caches the catalog response your server returns. This reduces load on your catalog server and improves shop load times for players.
Caching is on for all Real-time Catalog integrations. If you wish to disable it temporarily for testing purposes, let your engineering partner at Stash know.
How long are responses cached?
The effective cache lifetime is:
min(5 minutes, earliest upcoming timestamp in your response)Stash inspects these timestamps in your response and caps the cache at the earliest upcoming one:
expirationon aproduct,chainorpackitemrefreshAton aproduct,packorfreeitemdisplayExpirationon a sectionnextResetAton a trigger, for scheduled resets such as a midnight-UTC daily offer rollover
Stash only ever shortens the window from these signals; it never serves a cached response past the point they indicate.
The default cap is 5 minutes when your response contains no timestamp signals.
Recommended pattern for timed catalog resets: set one of the timestamps above equal to your planned reset time. Stash re-fetches exactly at that boundary, so there is no perceptible delay when the catalog changes.
Immediate invalidation after player actions
Stash clears a player's cached catalog immediately after every non-read action:
- Purchases
- Free-gift redemptions
- Loyalty-milestone claims (where integrated)
The next shop load re-fetches live from your server. No special annotation or forced reload is needed. This is automatic.
Always-fresh Stash-side data
Only the raw response from your catalog API is cached. Data that Stash computes (prices, bonuses, offer-chain and purchase-progression state, injected free offers) is recomputed on every request and is never cached. Changes you make to prices or bonuses in Stash Studio are reflected immediately regardless of the cache window.
Force a catalog refresh
Most integrations never need this. The cache window is short, and Stash already clears a player's cached catalog automatically after the actions above.
If something changes outside the web shop and you want the catalog view in the Web Shop to reflect it right away, call Force refresh catalog from your backend. Pass a player_id to refresh a single player, or omit it to refresh every player of your shop. It applies only to shops with a Real-time Catalog integration; for any other integration it is a no-op that still returns success.
This endpoint is optional. Stash checks every Web Shop purchase against the catalog it holds for the player, and that can be a cached response, up to the window in How long are responses cached?. An item's expiration is compared with the current time when the purchase completes, so an expired offer is refused even while a cached view still shows it. A maxPurchasable count is only as current as the cached response, so keep enforcing your purchase limits when you confirm a purchase. Call this endpoint when a change outside the Web Shop, such as an in-game purchase, should reach the catalog view and the counts Stash checks before the cache window ends.
Never omit the player_id unless you know exactly what you are doing. Forcing a catalog refresh for every single player can result in a much slower Web Shop experience for all players across the board and a spike of API calls to your external catalog API server.
| Scenario | Call ForceRefreshCatalog? |
|---|---|
| A player made a purchase in-game (outside the web shop) that changes what they should see | ✅ Yes (optional); refresh so the catalog view in the Web Shop updates right away |
| Your catalog changed outside a normal player action and you want it visible before the cache expires | ✅ Yes (optional); shortens the visible staleness window |
| A player completed a purchase in the web shop | ❌ No; the cache is cleared automatically |
| A player redeemed a free gift or claimed a loyalty milestone | ❌ No; the cache is cleared automatically |
| You changed prices or bonuses in Stash Studio | ❌ No; Stash-computed data is never cached and updates immediately |
| Your shop does not use a Real-time Catalog integration | ❌ No; the call is a no-op |
Frequently asked questions
Does Stash cache the response my server returns?
Yes. Stash caches your catalog/offer response for a short, bounded window. This reduces load on your catalog server and speeds up the shop for players.
What is the worst-case staleness? How long is my data cached?
min(5 minutes, earliest upcoming timestamp in your response), counting only the timestamps listed in How long are responses cached?. Without any of those timestamps in your response, responses may be served for up to 5 minutes.
How do I make a catalog change appear at a known time, such as a daily reset?
Include the planned reset time as one of the timestamps that shorten the cache, for example a trigger's nextResetAt or an item's expiration. Stash re-fetches exactly at that boundary with no delay.
What happens after a player makes a purchase?
The player's cached catalog is cleared immediately on every purchase, free-gift redemption, and loyalty-milestone claim. The next shop visit re-fetches live from your server. This is automatic. No special annotation is needed.
Do I need to send Cache-Control HTTP headers?
No. Stash does not use HTTP Cache-Control headers for this. Control freshness via refresh and expiration timestamps in your catalog payload instead.
Can the cache show a player an offer they already purchased?
Not after a Web Shop purchase: Stash clears the player's cached catalog as soon as it completes. A purchase made outside the Web Shop, such as in-game, shows when the cache window ends or after you force a catalog refresh. Until then Stash still refuses an offer whose expiration has passed, but it checks the quantity against the cached maxPurchasable, so enforce your purchase limits when you confirm a purchase. Accurate expiration and refresh timestamps in your response shorten the window.
My catalog has offers with different refresh times. How should I structure the timestamps?
Stash caps the cache lifetime at the earliest upcoming timestamp among those that shorten the cache, so send accurate values on every item and section. Ideally align all refreshes to a single consistent reset time so the whole catalog expires together.
Can caching be turned off while I test catalog changes?
Yes. Contact your Stash engineering partner and they will disable caching for your test shop, so every request hits your server live.
What are the benefits of caching?
Faster shop loads for players, fewer requests to your catalog server, and improved reliability if your server is briefly slow or unavailable.
Authentication
Stash authenticates outgoing requests to your game backend using a versioned HMAC-SHA256 signature in the x-stash-hmac-signature header:
x-stash-hmac-signature: v1;<appId>;<unixMillisTimestamp>;<base64-hmac>| Field | Description |
|---|---|
v1 | Protocol version |
<appId> | Your immutable App ID (Studio → Project Settings → General) |
<unixMillisTimestamp> | Request timestamp in Unix milliseconds |
<base64-hmac> | Standard base64-encoded HMAC-SHA256 signature |
The signature covers "<unixMillisTimestamp>." + <body>, where <body> is the exact bytes Stash transmits: empty string for GET requests, raw request body for POST requests. The HMAC key is your egress secret base64-decoded: the value shown in Studio → Project Settings → API Keys is base64-encoded; decode it before computing the HMAC (e.g. Buffer.from(secret, 'base64')). Reject requests where <unixMillisTimestamp> is more than 5 minutes from your server clock.
Verifying the signature
For Node.js, Python, and Go implementation code, see API Keys → HMAC Verification.
New shops (created on or after 2026-08-15) receive only x-stash-hmac-signature. The legacy unversioned stash-hmac-signature header is no longer emitted for them. New integrations should verify x-stash-hmac-signature only.
If you've implemented HMAC authentication and your game backend is still
responding to Stash's API requests with 3xx, 4xx, or 5xx error codes, you may
need to allowlist test-api.stash.gg and api.stash.gg on your cloud network
firewall.
Configuration
To configure your Real-time Catalog endpoint in Stash Studio:
Navigate to game settings
In Stash Studio, navigate to your game settings.
Open App Backend
Go to Project Settings → App Backend
Set endpoint URL
Set your Game Backend URL to match where the Catalog & Purchase APIs will be hosted.
Configure authentication
Your Stash engineering partner can configure custom API endpoint paths if needed.
Debugging & Logs
Stash Studio provides comprehensive logging and debugging tools for your Real-time Catalog. Use these tools to monitor your catalog endpoint and troubleshoot issues:
- Check Stash Studio logs for endpoint response times
- Verify your endpoint returns valid JSON
- Ensure all required fields are present
- Test with your studio-test credentials before going live
How is this guide?
Managed Catalog
Learn how to create and publish products, configure Free Gift offers, and schedule product availability in the Stash Webshop managed catalog.
Payment Flow
Understand where the money moves in a Real-time Catalog purchase, why ConfirmPayment is required rather than optional, and which checks only your backend can enforce.