Skip to content

Pricing and Roblox Plus

Reads each player's own pass and product prices on the client, mirrors Roblox Plus on session.RobloxPlus, and publishes the Robux each purchase cost, with client pass quotes capped at the list price.

Realm
Server + Client
Require
ServerScriptService.ChloeKernelServer.Commerce.Pricing
Since
v0.9.1
Source
Commerce/Pricing.luauPrices.luau

Pricing is a server module with a client half. The client module, Prices, reads the local player’s own price for each configured game pass and developer product, and reads every price again when the player’s Roblox Plus status changes. The server module, Commerce.Pricing, mirrors Roblox Plus on session.RobloxPlus and publishes Commerce.RobuxSpent on the Bus with the Robux each purchase cost. The defining decision is where prices come from: only a client call to GetProductInfoAsync applies Roblox Plus discounts, regional pricing and price tests, so prices are read on the client, and the server, which sees list prices, accepts a client’s pass price only up to the list price. Developer product grants live in Receipts and pass ownership in Passes.

Roblox Plus subscribers pay 10% less on passes and developer products (20% from their third month). Roblox covers the difference, so the experience’s earnings are unchanged. Regional pricing and price tests also change prices per player, so a hard-coded price shows some players the wrong number.

The server’s GetProductInfoAsync call returns the list price, with no personal discount. Prices therefore runs on the client and refuses to start without a local player, the only player whose personalized price a client can read. AGENTS.md makes this a rule for game code: never hard-code Robux prices in UI, show them from a Prices created at boot, and count Robux spent from Commerce.RobuxSpent.

The server needs a player’s price for one job: counting what a pass purchase cost, which Roblox does not report. It takes that price from the client and bounds it by the list price (see Robux spent).

A quote is a frozen table built from one GetProductInfoAsync reply:

Field Meaning
Name The name given in Passes or Products.
Id The pass or product id.
Kind "Pass" or "Product".
Price What this player pays: the reply’s PriceInRobux, or 0 when it has none.
BasePrice The price before any discount: UserBasePriceInRobux, falling back to Price, and never below Price.
Discounts The reply’s PriceDiscountDetails in Roblox’s order, each a frozen { Type, Percent, Amount }. Amount is the Robux that discount takes off (AmountInRobux). A missing Percent or Amount reads as 0.
PlusPercent The sum of Percent over discounts whose Type is "RobloxPlusSubscription". 0 without Roblox Plus.
ForSale false only when the reply has IsForSale = false.

get(name) returns nil until that quote has loaded. Show the list price until then.

Prices.new reads every configured price at once: one GetProductInfoAsync call per item, each on its own thread, so the engine can batch them into fewer requests. Passes are read with Enum.InfoType.GamePass and products with Enum.InfoType.Product. Every quote is read again in three cases:

  • The local player’s HasRobloxSubscription changes. The server sets that property, so its change is the authoritative signal.
  • PromptRobloxSubscriptionPurchaseFinished fires for the local player with didTryPurchasing = true. This covers a Plus prompt that closes while the property change is still replicating.
  • Game code calls refresh().

Each refresh starts a new generation. A reply from an earlier generation is dropped when it lands, and so is its retry, so a slow full-price reply from before a Plus purchase cannot overwrite the discounted price read after it.

A failed lookup (an error or a reply that is not a table) is retried after RetrySeconds * 2^attempt (2 s, then 4 s by default), up to Retries times. After the last failure Prices warns no price for <Kind> "<name>" (<id>) after <n> attempts and keeps the quote it already had for that name, if any.

A quote is stored, and onChanged(name, quote) fires, when it is the first for that name or when its Price, BasePrice, PlusPercent or ForSale differ from the stored one. A re-read that returns the same values fires nothing. When every lookup of a generation has finished, successful or not, Prices reports the pass prices to the server (see Quote reports).

Commerce.RobuxSpent(session, kind, id, amount, source) is published once per purchase, after the buyer’s profile has loaded:

Purchase kind amount source
A developer product "Product" The receipt’s CurrencySpent: the Robux paid after Roblox Plus and every other discount. "Receipt"
A pass the player’s client quoted "Pass" The quoted price, capped at the pass’s list price. "ClientQuote"
A pass without a quote "Pass" The pass’s list price. "ListPrice"
A pass whose list price stayed unreadable "Pass" The quoted price, uncapped. "UnverifiedQuote"

Products come from Receipts. Pricing subscribes to Commerce.PurchaseGranted, which Receipts publishes once a grant has persisted and which carries currencySpent as its fifth argument since 0.9.1. Nothing is published when the receipt has no numeric CurrencySpent or the buyer has no session, and a game that grants products without Receipts gets no "Product" amounts.

Roblox reports no paid price for a pass. Pricing listens to the server-side PromptGamePassPurchaseFinished and counts every pass bought with wasPurchased = true, configured or not. It takes the latest quote report from that player’s session and the pass’s list price, which is cached or read with GetProductInfoAsync. A failed read is tried again Retries times (default 2), after RetrySeconds (default 2) doubling per attempt. If the list price is still unreadable, a quote counts as it is with source "UnverifiedQuote", since there is nothing to cap it with, and handlers decide whether to trust it. With no quote either, nothing is published and Pricing warns Robux spent on pass <id> is unknown.

The cap exists because the client is not trusted. A quote can only lower what counts: the list price bounds a client’s claim, so a client that reports 5000 for a 100 Robux pass counts 100. A price test that charges more than the list price also counts as the list price.

Handlers usually write the amount into the profile, so a spend waits for it. A purchase made while DataDriver is still loading the buyer’s profile (session.ProfilePending) is held and published once Kernel.ProfileLoaded fires for that session. A session that ends first drops its held spends with the warning <n> purchase(s) by <name> not counted in Commerce.RobuxSpent: the session ended before its profile loaded, and a purchase that arrives after the session ended is dropped the same way. A game without DataDriver has no pending profile, so spends publish at once.

When a session starts, Pricing sets session.RobloxPlus to the player’s HasRobloxSubscription and binds a property-changed connection to the session. Sessions already live when attach runs are covered too, because kernel:onSession replays them. When the property changes to a different value, session.RobloxPlus follows and Commerce.PlusChanged(session, subscribed) is published. The value at session start publishes nothing, and neither does a change event that leaves the value as it was.

pricing:hasPlus(session) reads session.RobloxPlus == true, so it is false for a session Pricing has not started. On the client, prices:hasPlus() reads the local player’s HasRobloxSubscription directly.

The ids live in one shared module so the server and the client use the same names:

src/Shared/Game/Config/Store.luau
--!strict
-- Store: the game's passes and developer products by name. Ids only; prices come from Roblox per player.
return {
Passes = { VIP = 123456, DoubleCoins = 234567 },
Products = { Gems100 = 987654 },
}
src/Server/Game/Services/StoreService.luau
--!strict
-- StoreService: Roblox Plus status and the Robux each player has spent.
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local ServerScriptService = game:GetService("ServerScriptService")
local Logger = require(ReplicatedStorage.ChloeKernel.Logger)
local Pricing = require(ServerScriptService.ChloeKernelServer.Commerce.Pricing)
local Store = require(ReplicatedStorage.Game.Config.Store)
local Log = Logger.scope("StoreService")
local StoreService = {
Name = "StoreService",
Dependencies = { "DataService" },
}
function StoreService.init(self: any, kernel: any)
self.Kernel = kernel
-- Reads each pass's list price now, so a purchase does not wait for the lookup
self.Pricing = Pricing.attach(kernel, { Passes = Store.Passes })
end
function StoreService.start(self: any)
self.Spent = self.Kernel.Bus:subscribe(
"Commerce.RobuxSpent",
function(_topic: string, session: any, kind: string, id: number, amount: number, source: string)
local Profile = session.Profile -- loaded: Pricing holds spends until it is (nil only without DataDriver)
if Profile then
Profile.Data.RobuxSpent += amount -- RobuxSpent = 0 in the Data/Profile.luau defaults
end
Log.info(`{session.Player.Name} spent {amount} Robux on {kind} {id} ({source})`)
end
)
self.PlusChanged = self.Kernel.Bus:subscribe("Commerce.PlusChanged", function(_topic: string, session: any, subscribed: boolean)
Log.info(`{session.Player.Name} {if subscribed then "started" else "ended"} Roblox Plus`)
end)
end
-- For support tools: Roblox Plus now, and the Robux spent across visits
function StoreService.spender(self: any, session: any): (boolean, number)
local Profile = session.Profile
return self.Pricing:hasPlus(session), if Profile then Profile.Data.RobuxSpent else 0
end
function StoreService.stop(self: any)
if self.Spent then
self.Spent:disconnect()
end
if self.PlusChanged then
self.PlusChanged:disconnect()
end
if self.Pricing then
self.Pricing:destroy()
end
end
return StoreService

Passes preloads list prices; it does not limit which passes are counted. Products are counted through Receipts, so attach it as well. The DataService that python tools/scaffold.py data creates already does (Scaffolding).

With Report set, Prices fires PR_Report each time every lookup of a refresh has finished. The payload maps each pass id, as a string, to this player’s price, for every pass quote Prices holds that is on sale with a price of at least 1: { ["700"] = 80 }. Product prices are never sent, because products count from the receipt, and nothing is sent when no pass quote qualifies. The fire runs on its own thread; a failure warns pass prices were not reported: <error>.

Pricing.attach defines the channel on the NetDriver unless Intents = false or the kernel has no network:

Channel Kind Schema Wire name Notes
PR_Report Intent { "Any" } CKI_PR_Report RateLimit = 1 per second per player, Burst = 3. Validated by Pricing’s hook on Intent.PR_Report at priority 50.

The Any wire type does not constrain the payload’s shape, so the validator checks all of it:

Rule Rejected in the spec
The payload is a table. "700=80"
At most 64 entries. 65 entries
Every key is a string that tonumber reads as a whole number of at least 1. { VIP = 80 }
Every price is a number, not NaN, whole, from 1 to 1,000,000,000 (a pass on sale costs at least 1 Robux; 1,000,000,000 is the highest price a pass can have). { ["700"] = 0 / 0 }, { ["700"] = 12.5 }, { ["700"] = 0 }

A report that breaks any rule is rejected whole. An accepted report replaces that session’s stored quotes; it is never merged with the previous one. The quotes are dropped at Kernel.SessionEnd.

Member Description
Pricing.attach(kernel, options?) -> pricing Starts list price reads for Passes, tracks Roblox Plus for current and future sessions, subscribes to Commerce.PurchaseGranted and Kernel.SessionEnd, connects PromptGamePassPurchaseFinished, and defines PR_Report with its validator unless Intents = false.
pricing:hasPlus(session) -> boolean session.RobloxPlus == true.
pricing:listPrice(passId) -> number? The pass’s list price as the server sees it, with no personal discount. Cached after the first successful read; a cache miss yields on GetProductInfoAsync. Returns nil and warns no list price for pass <id> when the call fails or the reply has no price. Failures are not cached.
pricing:destroy() Disconnects the purchase event and the Bus subscriptions, removes the PR_Report validator and drops every stored quote. session.RobloxPlus stops updating. A second call does nothing.
session.RobloxPlus boolean. Mirrors Player.HasRobloxSubscription from the session’s start.

Pricing.attach(kernel, options?):

Field Default Description
Passes {} { [name] = passId }. List prices load at attach. Every id must be a positive number; attach errors otherwise.
Intents true Defines PR_Report and its validator. Needs a kernel with a network.
Retries 2 List price reads retried at purchase time after a failure. Added after 0.9.1 (Unreleased).
RetrySeconds 2 First retry delay in seconds, doubling per attempt. Added after 0.9.1 (Unreleased).
Marketplace MarketplaceService Injectable for specs.

Lives at ReplicatedStorage.ChloeKernel.Prices.

Member Description
Prices.new(options) -> prices Registers the names, connects the Plus property and the Plus prompt, and starts the first refresh. Errors without a local player, on an id that is not a positive whole number, and on a name used twice across Passes and Products.
prices:refresh() Reads every price again as a new generation. Replies to earlier generations are dropped. Does nothing after destroy().
prices:get(name) -> Quote? The player’s quote, or nil until it has loaded.
prices:all() -> { [string]: Quote } A copy of the quote table, keyed by name. The quotes are frozen.
prices:hasPlus() -> boolean Whether the local player’s HasRobloxSubscription is true.
prices:onChanged(fn) -> connection fn(name, quote) for each quote that loads or changes. Does not replay loaded quotes.
prices:destroy() Disconnects the Plus and prompt listeners and destroys the change signal, which disconnects every onChanged handler. Lookups in flight discard their replies.

Prices.new(options):

Field Default Description
Passes {} { [name] = passId }.
Products {} { [name] = productId }. Names are shared with Passes.
Report nil true for a NetClient of its own, or a NetClient to share. Pass prices go to Commerce.Pricing through PR_Report. Anything else errors.
Retries 2 Lookups retried after a failure.
RetrySeconds 2 First retry delay, doubling per attempt.
Marketplace MarketplaceService Injectable for specs.
Player Players.LocalPlayer Injectable for specs.

The Quote fields are listed under Quotes. A Discount is { Type: string, Percent: number, Amount: number }.

Name Kind Payload or context Notes
Commerce.RobuxSpent Bus session, kind, id, amount, source Once per purchase, after the buyer’s profile has loaded. kind is "Product" or "Pass"; source is "Receipt", "ClientQuote", "ListPrice" or "UnverifiedQuote".
Commerce.PlusChanged Bus session, subscribed session.RobloxPlus changed. Not published for the value at session start.
Commerce.PurchaseGranted Bus, consumed player, productId, purchaseId, purchaseChannel?, currencySpent? Published by Receipts. A numeric currencySpent becomes a "Product" amount.
Kernel.SessionEnd Bus, consumed session, ... Drops the session’s stored quotes and any spends held for its profile, with a warning.
Kernel.ProfileLoaded Milestone, consumed session Publishes the spends held while the profile loaded.
Intent.PR_Report hook, fail-closed context.Args = { report } Pricing’s validator at 50. Game rules at lower numbers run first.
  • Prices.new errors on the server (Prices runs on the client: personalized prices exist only for the local player). Create it in a controller.
  • onChanged does not replay. Connect it, then read all() for quotes that loaded before, as the controller above does.
  • The first report goes out only after every lookup has finished, including the retries of a failing one (2 s and 4 s by default). A pass bought before a report reaches the server counts "ListPrice".
  • Pricing counts every pass purchase event; Passes only preloads list prices. A pass missing from Passes costs a GetProductInfoAsync call on its first purchase. Failed reads are not cached, so the next purchase reads again.
  • destroy() removes the validator but leaves PR_Report defined, so the fail-closed rule rejects every later report.
  • With Intents = false, or no Pricing on the server, a client with Report waits up to 10 s for the channel on every report and then warns that the prices were not reported.
  • Products count only through Receipts and only when the receipt carries CurrencySpent.

ChloeKernel v0.9.1. Access to the framework is private and granted individually by Chloe. Games built with it credit CryptedChloes in their description.