Why ProcessReceipt should grant by ProductId
Granting the item a player picked before the prompt, instead of the product they paid for, lets them pay for a cheap item and get an expensive one. How to map products to rewards.
What it looks like
ProcessReceipt decides what to grant from something other than the receipt: an item the client chose before the prompt, a "pending purchase" table filled by a remote, or an argument the client sent. The developer product that was actually bought doesn't decide what the player gets.
MarketplaceService.ProcessReceipt = function(receipt) grantItem(receipt.PlayerId, pendingItem[receipt.PlayerId]) return Enum.ProductPurchaseDecision.PurchaseGranted end
How to detect it
- Read what
ProcessReceipt, and every function it calls to grant, uses to choose the reward. Onlyreceipt.ProductIdsays what was paid for. Anything the client set, or any value stored per player before the purchase finished, is a finding. - Look for one developer product shared by several items, such as a "Buy item" or "Robux purchase" product, with the item chosen elsewhere. Products priced differently but granting from the same pending choice are the same problem.
- Look for remotes that store a choice and then prompt, such as
pending[player] = itemIdfollowed byPromptProductPurchase. A second call can change the choice before the first purchase's receipt arrives, and the receipt grants the newer one. - Check what happens when the stored choice is gone. Roblox can deliver the receipt again later, in another server or after the player rejoins, and a choice kept in a script table won't be there.
What an exploiter could do
An exploiter buys the cheapest item, then fires the remote again with the most expensive item before the receipt is processed. The receipt grants the expensive item for the cheap price. With a single shared product, they don't even need the timing: every item costs the same. Honest players can lose out too: a receipt delivered after a rejoin finds no choice and grants nothing, or grants something else.
How to fix it
- Create one developer product per thing you sell, and map each
ProductIdto its reward in a server table. - In
ProcessReceipt, look up the reward fromreceipt.ProductIdalone, and returnNotProcessedYetfor ids you don't know. - If players need to customise a purchase, such as choosing a colour, apply the choice from the player's saved data after the grant, never to decide what they paid for.
Vulnerable example
local MarketplaceService = game:GetService("MarketplaceService") local Players = game:GetService("Players") local ReplicatedStorage = game:GetService("ReplicatedStorage") local PlayerData = require(script.Parent.PlayerData) local robuxItemProduct = 1400001 local wanted: { [Player]: string } = {} ReplicatedStorage.Remotes.BuyItem.OnServerEvent:Connect(function(player: Player, itemId: unknown) if typeof(itemId) ~= "string" then return end wanted[player] = itemId MarketplaceService:PromptProductPurchase(player, robuxItemProduct) end) MarketplaceService.ProcessReceipt = function(receipt) local player = Players:GetPlayerByUserId(receipt.PlayerId) local profile = player and PlayerData.get(player) local itemId = player and wanted[player] if receipt.ProductId ~= robuxItemProduct or not profile or not itemId then return Enum.ProductPurchaseDecision.NotProcessedYet end local key = "Purchase:" .. receipt.PurchaseId if not profile.Claimed[key] then profile.Claimed[key] = true table.insert(profile.Inventory, itemId) end return Enum.ProductPurchaseDecision.PurchaseGranted end
Fixed example
local MarketplaceService = game:GetService("MarketplaceService") local Players = game:GetService("Players") local ReplicatedStorage = game:GetService("ReplicatedStorage") local PlayerData = require(script.Parent.PlayerData) local productFor: { [string]: number } = { Sword = 1400001, Shield = 1400002, } local itemFor: { [number]: string } = {} for itemId, productId in productFor do itemFor[productId] = itemId end ReplicatedStorage.Remotes.BuyItem.OnServerEvent:Connect(function(player: Player, itemId: unknown) if typeof(itemId) ~= "string" or not productFor[itemId] then return end MarketplaceService:PromptProductPurchase(player, productFor[itemId]) end) MarketplaceService.ProcessReceipt = function(receipt) local itemId = itemFor[receipt.ProductId] local player = Players:GetPlayerByUserId(receipt.PlayerId) local profile = player and PlayerData.get(player) if not itemId or not profile then return Enum.ProductPurchaseDecision.NotProcessedYet end local key = "Purchase:" .. receipt.PurchaseId if not profile.Claimed[key] then profile.Claimed[key] = true table.insert(profile.Inventory, itemId) end return Enum.ProductPurchaseDecision.PurchaseGranted end
Common false positives
- A choice that only changes how the reward looks, such as a cosmetic colour applied after the grant, doesn't change what was bought.
- Products that grant something the server picks at random are a different question. See Paid random items with no policy check.
- A pending table used only to show "Thanks for buying" in the UI, with the grant itself based on
ProductId, is fine.