How to write a safe ProcessReceipt callback

A ProcessReceipt that grants twice, or returns PurchaseGranted before saving, loses or repeats purchases. How to record PurchaseIds and grant exactly once.

criticalLuaudit check: unsafe-process-receipt

What it looks like

MarketplaceService.ProcessReceipt grants a developer product but doesn't keep to Roblox's rules for the callback: it returns PurchaseGranted when nothing was granted, grants the same receipt more than once, or tells Roblox the purchase is done before the grant has been saved.

Luau
MarketplaceService.ProcessReceipt = function(receipt)
	giveCoins(receipt.PlayerId, 100)
	return Enum.ProductPurchaseDecision.PurchaseGranted
end

How to detect it

  • Find the ProcessReceipt assignment and read every path through it.
  • Roblox can call ProcessReceipt more than once for the same purchase, for example after a server closes before the purchase was confirmed. Check that the callback remembers which PurchaseIds it has granted, in saved data, and returns PurchaseGranted without granting again when it sees one twice.
  • Check every path that returns PurchaseGranted. It must only do so once the player has the product and the change is saved or will be saved with the record of the PurchaseId. If the player isn't in this server, the product id is unknown, or the grant fails or errors, it should return NotProcessedYet so Roblox tries again later.
  • Look for errors that escape. An error inside the callback means no decision is returned, so the purchase isn't confirmed. Work that can error belongs inside pcall.
  • Check that the callback finds the player with Players:GetPlayerByUserId(receipt.PlayerId) and grants to that player, not to anyone else.

What an exploiter could do

Mostly no exploit is needed, and paying players are the ones who lose out. If the callback returns PurchaseGranted when the grant didn't happen, the player paid Robux for nothing. If it grants the same receipt twice, a player gets double rewards whenever Roblox retries, which also throws the in-game economy off.

How to fix it

  • Keep a saved list of granted PurchaseIds in the player's profile. If the receipt's id is already there, return PurchaseGranted straight away.
  • Otherwise find the player. If they aren't in this server, return NotProcessedYet.
  • Grant the product and record the PurchaseId in the same profile change, inside pcall, and return PurchaseGranted only if that succeeded.
  • Save the profile straight away if you can. If you rely on the next autosave, a server crash before it lands loses a purchase Roblox already counts as granted.
  • Return NotProcessedYet for any product id you don't recognise.

Vulnerable example

Vulnerable
local MarketplaceService = game:GetService("MarketplaceService")
local Players = game:GetService("Players")
local PlayerData = require(script.Parent.PlayerData)

local products: { [number]: number } = {
	[1111111] = 500,
	[2222222] = 2500,
}

MarketplaceService.ProcessReceipt = function(receipt)
	local coins = products[receipt.ProductId]
	local player = Players:GetPlayerByUserId(receipt.PlayerId)
	local profile = player and PlayerData.get(player)
	if coins and profile then
		profile.Coins += coins
	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)

local products: { [number]: number } = {
	[1111111] = 500,
	[2222222] = 2500,
}

MarketplaceService.ProcessReceipt = function(receipt)
	local coins = products[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 profile.Claimed[key] then
		return Enum.ProductPurchaseDecision.PurchaseGranted
	end
	local ok = pcall(function()
		profile.Coins += coins
		profile.Claimed[key] = true
	end)
	if not ok then
		return Enum.ProductPurchaseDecision.NotProcessedYet
	end
	return Enum.ProductPurchaseDecision.PurchaseGranted
end

Common false positives

  • A well-known profile library's receipt helper handles the purchase id record for you. Check the game calls it and grants inside it.
  • Products that give nothing lasting, such as a one-off server-wide effect, still need the NotProcessedYet rules, but a missing purchase id record matters less there. Mention it rather than reporting it as critical if a repeat does no harm.
  • A profile whose purchase record lives in a separate data store written with UpdateAsync before returning is a valid alternative to keeping it in the profile.