Not translated yet — showing English.
Catalog & Commerce
Items, prices, wallets, storefronts, purchases, entitlements. Real store integrations where the platforms allow them (Stripe, App Store, Google Play, Steam, Xbox); scheduled, audience-targeted storefronts; and a purchase flow whose every step is hookable.
When to use it
- You sell things, for real money through Stripe, App Store, Google Play, Steam or Xbox, or for wallet currency.
- Storefronts must resolve per player: schedule, audience and price computed server-side, never eligibility math in the client.
- Pricing rules belong in one testable hook: discounts, repricing and vetoes run before any charge.
- Receipts must be replay-proof, and a refund must revoke the entitlement through the same events the grant used.
- Skip it when items are never sold. Rewards still land through commerce's one
Grantwith originreward(Leaderboards cycle chests arrive that way), so even a shopless game keeps a single auditable grant ledger.
Who does what
| Actor | On this page |
|---|---|
player | browses storefronts, purchases, manages wallet, redeems codes |
seller | configures catalog, prices and storefront schedules |
backend-service | validates receipts; reprices or grants via purchase hooks |
At a glance
main storefront, already resolved for this player, and purchase from the wallet// client — the storefront arrives already resolved for this player
var front = await playserv.Commerce.Storefront("main");
var order = await playserv.Commerce.Purchase(front.Items.First(), pay: Pay.Wallet("gems"));// client — the storefront arrives already resolved for this player
const front = await playserv.commerce.storefront('main');
const order = await playserv.commerce.purchase(front.items[0], { pay: Pay.wallet('gems') });# client — the storefront arrives already resolved for this player
front = await playserv.commerce.storefront("main")
order = await playserv.commerce.purchase(front.items[0], pay=Pay.wallet("gems"))Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
// client — the storefront arrives already resolved for this player
Client->Commerce->Storefronts->Select().Then(
TPSOnResult<TPSPage<FPSStorefront>>::CreateWeakLambda(this, [this](const TPSResult<TPSPage<FPSStorefront>>& Result)
{
if (!Result.HasValue()) { return; }
const FPSStorefront& Front = Result.Value().Rows[0];
Client->Commerce->Orders->Create(FPSIdempotencyKey(CartId), Front.Items[0], FPSPay::Wallet(TEXT("gems")));
}));
// co_await support ships later: a built-in SDK coroutine wrapper that respects Unreal's GC and threading.// client — the storefront arrives already resolved for this player
var front = await playserv.Commerce.Storefront("main");
var order = await playserv.Commerce.Purchase(front.Items.First(), pay: Pay.Wallet("gems"));before reprices the first buy, after grants the item[Before(Commerce.Purchase)] // veto or reprice
public static Verdict FirstBuyDiscount(PurchaseIntent p) =>
p.Player.Purchases == 0 ? Hook.Continue(p.WithPrice(p.Price * 0.5m)) : Hook.Continue(p);
[After(Commerce.Purchase)] // grant — side effects only
public static Task Grant(Purchase done) =>
done.Player.Inventory.Grant(done.Item, done.Count);// veto or reprice
export const firstBuyDiscount = before(Commerce.purchase, (p: PurchaseIntent) =>
p.player.purchases === 0 ? Hook.continue(p.withPrice(p.price * 0.5)) : Hook.continue(p));
// grant — side effects only
export const grant = after(Commerce.purchase, (done: Purchase) =>
done.player.inventory.grant(done.item, done.count));@before(commerce.purchase) # veto or reprice
def first_buy_discount(p: PurchaseIntent) -> Verdict:
return Hook.continue_(p.with_price(p.price * 0.5)) if p.player.purchases == 0 else Hook.continue_(p)
@after(commerce.purchase) # grant — side effects only
async def grant(done: Purchase):
await done.player.inventory.grant(done.item, done.count)Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.
A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — Unreal subscribes to the purchased / entitlement-changed events. Engine-authored functions are planned: Blueprint graphs, and C++ (translated to a platform-validated script target). Both are analyzed and pushed like any declaration; execution stays on the platform.
A hook is a cloud function: it executes on the platform, not in the engine. Write it in C#, TypeScript or Python — Unity subscribes to the purchased / entitlement-changed events.
The model
What a catalog item declares.
| Declares | What it is |
|---|---|
key | it is authored content, addressed by a key so a rename in code is a rename |
kind | consumable — it is spent; or durable — owned once |
prices | a price is a monetary quantity: an integer in minor units plus a currency code, never a float. An item may carry several — game currency and real currency both |
external identifier per provider | one slot per provider, declared, because a store knows the item by its own id |
what it points at | optionally an entity of any declared kind, and buying the item then grants ownership of that entity |
What composition is allowed to be.
| What it is | |
|---|---|
what is purchasable | a catalog item, never an arbitrary entity, because a purchase needs someone who grants the right and answers for the refund |
two levels, no third | a bundle is a catalog item made of items; a storefront is a set of offers, and an offer points at an item and may override its price and a bundle's contents |
a territorial price | is expressed by a storefront rather than on the item |
What a storefront declares.
| Declares | What it is |
|---|---|
offers | the set, each pointing at an item |
schedule | in wall-clock time, always UTC: when the window opens and closes |
audience | a predicate, not a list of players — so the audience is a rule that keeps being true rather than a snapshot |
A storefront whose audience a player does not fall into does not exist for that player.
An order's states.
| From | To |
|---|---|
created | awaiting payment |
awaiting payment | paid · declined · expired |
paid | granted |
paid or granted | refunded |
| Always | What it is |
|---|---|
the price | is fixed in the order at the moment it is created, so a price change afterwards cannot alter what was agreed |
awaiting payment | has a declared deadline, declared per provider, because they differ |
granting | is separate from payment: paid and granted are different states: money arriving and the thing appearing are two facts, and conflating them hides which one failed |
an entitlement | carries its origin — a purchase, a promo code, a reward, a gift — so "where did this come from" is answerable a year later |
a consumable entitlement | accumulates: it changes by an increment with an idempotency key, never by overwriting what was read |
ownership | is an owner predicate: an entitlement belongs to a player by the same mechanism as any owned row |
the catalog | is declared in code and reaches the panel under the seed ownership mode by default: code creates what is absent, and a designer's edits survive the next push |
provider secrets | live in the operator plane, never in the declaration, and never in a repository |
a provider's capabilities | are declared: whether it has a usable API at all, and what it can do — so a catalogue does not promise a flow the store cannot serve |
A refund can arrive at any time
A refund is an external transition: it arrives without any request from the game. What happens to what was granted is declared: there are three answers and no default.
Each extension point names the type it hands the hook: a purchase intent before the purchase — player, offer, provider, price — and the purchase itself after. A hook never receives an untyped bag.
Errors
- Outside the audience answers not found, and repeating is pointless. Outside the schedule also answers not found, but is worth repeating once the window opens.
- The provider is unavailable and the provider declined the payment are deliberately different answers: the first is unavailable and retryable with backoff, the second a conflict that retrying will not fix.
- An invalid receipt is a validation refusal; a receipt already consumed by another order or another player is a conflict — that is what makes replay useless.
- The price changed between reading the storefront and buying is a precondition failure: re-read and decide again, rather than being charged the new price silently.
- Insufficient game currency is a conflict, not forbidden: the permission to buy is held, the balance isn't enough. Worth repeating after topping up.
- A durable entitlement already held is a conflict.
- The region or age does not permit the purchase answers forbidden, and repeating is pointless.
- The order's deadline has passed is a conflict: create a new order.
- The spending limit exhausted answers as a conflict or a rate limit depending on which limit it was, and it states when the limit resets.
Limits
The numbers arrive with the platform limits chapter; what happens at each ceiling is fixed now.
- The catalog's size: publishing another item is refused as a conflict.
- Storefronts per project: creation is refused.
- Offers in a storefront: an addition is refused; the storefront is never truncated silently.
- The lifetime of an order awaiting payment: a transition to
expired, with an event. - The rate of purchase attempts: a rate limit with a deadline.
- The spending limit per period: a conflict that states when the limit resets.
- Retention of orders: past the declared period an order is no longer readable, rather than vanishing without explanation.
- Entitlements per player: a grant is refused, and those already granted are never evicted.
- A price's precision is not a limit but a type: an integer in minor units.