Why ProcessReceipt must be set in one place
ProcessReceipt is a single callback, so each script that sets it replaces the last. How to handle every developer product from one script.
What it looks like
More than one script assigns MarketplaceService.ProcessReceipt, often one per shop or per product. It's a single callback, so each assignment replaces the last, and only one script's products are ever granted.
Luau
MarketplaceService.ProcessReceipt = handleCoinPacks
How to detect it
- Search every server script and module for
ProcessReceipt. More than one assignment is a finding, even if each one looks correct on its own. - Work out which one runs last. Script start-up order isn't something to rely on, so treat every product outside the surviving callback as at risk.
- Free models and plugins sometimes set their own
ProcessReceipt. Include those in the search.
What an exploiter could do
No exploit is needed. Purchases of the products handled by the replaced callback are never granted. If the surviving callback returns PurchaseGranted for product ids it doesn't know, players are charged and get nothing. If it returns NotProcessedYet, Roblox keeps retrying and the purchase never completes.
How to fix it
- Assign
ProcessReceiptonce, in one server script. - Keep a table from product id to a handler function, and have the single callback look up the product and call its handler. Shops add their products to that table instead of setting the callback.
- Return
NotProcessedYetfor product ids that aren't in the table.
Vulnerable example
Vulnerable
local MarketplaceService = game:GetService("MarketplaceService") local Players = game:GetService("Players") local PlayerData = require(script.Parent.PlayerData) local speedBoostProduct = 3333333 MarketplaceService.ProcessReceipt = function(receipt) if receipt.ProductId ~= speedBoostProduct then return Enum.ProductPurchaseDecision.NotProcessedYet end local player = Players:GetPlayerByUserId(receipt.PlayerId) local profile = player and PlayerData.get(player) if not profile then return Enum.ProductPurchaseDecision.NotProcessedYet end local key = "Purchase:" .. receipt.PurchaseId if not profile.Claimed[key] then profile.Claimed[key] = true profile.Boosts.Luck += 1 end return Enum.ProductPurchaseDecision.PurchaseGranted end
Fixed example
Fixed
local MarketplaceService = game:GetService("MarketplaceService") local Players = game:GetService("Players") local PlayerData = require(script.Parent.PlayerData) type Grant = (profile: PlayerData.Profile) -> () local handlers: { [number]: Grant } = { [1111111] = function(profile) profile.Coins += 500 end, [3333333] = function(profile) profile.Boosts.Luck += 1 end, } MarketplaceService.ProcessReceipt = function(receipt) local grant = handlers[receipt.ProductId] local player = Players:GetPlayerByUserId(receipt.PlayerId) local profile = player and PlayerData.get(player) if not grant or not profile then return Enum.ProductPurchaseDecision.NotProcessedYet end local key = "Purchase:" .. receipt.PurchaseId if not profile.Claimed[key] then local ok = pcall(grant, profile) if not ok then return Enum.ProductPurchaseDecision.NotProcessedYet end profile.Claimed[key] = true end return Enum.ProductPurchaseDecision.PurchaseGranted end
Common false positives
- A single module that owns
ProcessReceiptand lets other scripts register handlers is the fix, not a second assignment. - An assignment inside code that never runs in live servers, such as a test guarded by
RunService:IsStudio(), can't replace the real one.