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.

warningLuaudit check: multiple-process-receipt

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 ProcessReceipt once, 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 NotProcessedYet for 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 ProcessReceipt and 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.