How to grant developer products the right way
Granting products from PromptProductPurchaseFinished or a client remote isn't reliable. Why every grant belongs in ProcessReceipt.
What it looks like
The game gives the reward for a developer product somewhere other than ProcessReceipt: in a PromptProductPurchaseFinished handler, or in a remote the client fires after the purchase window closes.
Luau
MarketplaceService.PromptProductPurchaseFinished:Connect(function(userId, productId, purchased) if purchased then giveCoins(userId, 100) end end)
How to detect it
- Find every connection to
PromptProductPurchaseFinishedandPromptPurchaseFinished, and every remote whose handler grants currency, items or boosts after a purchase. - If any of them grants a developer product's reward, it's a finding. Roblox documents
ProcessReceiptas the way to grant developer products. The finished events only say the purchase prompt closed. - A remote such as "PurchaseComplete" that the client fires after buying is client input. The client can fire it without buying anything.
- Game passes are different: they don't go through
ProcessReceipt. For those, see Game pass perks checked unsafely.
What an exploiter could do
If a remote grants the reward, they fire it and get the product without paying. If the finished event grants it, rewards can be missed or doubled, because it isn't tied to Roblox's confirmed receipt and retries.
How to fix it
- Move every developer product grant into
ProcessReceipt, following the rules in ProcessReceipt that can lose or repeat a purchase. - Keep the finished events and any client remote for the interface only, such as closing a shop window or showing a thank-you message.
Vulnerable example
Vulnerable
local ReplicatedStorage = game:GetService("ReplicatedStorage") local PlayerData = require(script.Parent.PlayerData) local coinPacks: { [number]: number } = { [1111111] = 500, [2222222] = 2500, } ReplicatedStorage.Remotes.CoinPackBought.OnServerEvent:Connect(function(player: Player, productId: unknown) if typeof(productId) ~= "number" then return end local coins = coinPacks[productId] local profile = PlayerData.get(player) if coins and profile then profile.Coins += coins end end)
Fixed example
Fixed
local MarketplaceService = game:GetService("MarketplaceService") local Players = game:GetService("Players") local PlayerData = require(script.Parent.PlayerData) local coinPacks: { [number]: number } = { [1111111] = 500, [2222222] = 2500, } MarketplaceService.ProcessReceipt = function(receipt) local coins = coinPacks[receipt.ProductId] local player = Players:GetPlayerByUserId(receipt.PlayerId) local profile = player and PlayerData.get(player) if not coins or not profile then return Enum.ProductPurchaseDecision.NotProcessedYet end local key = "Purchase:" .. receipt.PurchaseId if not profile.Claimed[key] then profile.Claimed[key] = true profile.Coins += coins end return Enum.ProductPurchaseDecision.PurchaseGranted end
Common false positives
- Handlers on the finished events that only update the interface, play a sound or log the result grant nothing.
- A client remote that asks the server to show the purchase prompt, with
MarketplaceService:PromptProductPurchase, is fine. Prompting isn't granting.