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.
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
ProcessReceiptassignment and read every path through it. - Roblox can call
ProcessReceiptmore than once for the same purchase, for example after a server closes before the purchase was confirmed. Check that the callback remembers whichPurchaseIds it has granted, in saved data, and returnsPurchaseGrantedwithout 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 thePurchaseId. If the player isn't in this server, the product id is unknown, or the grant fails or errors, it should returnNotProcessedYetso 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, returnPurchaseGrantedstraight away. - Otherwise find the player. If they aren't in this server, return
NotProcessedYet. - Grant the product and record the
PurchaseIdin the same profile change, insidepcall, and returnPurchaseGrantedonly 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
NotProcessedYetfor 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
NotProcessedYetrules, 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
UpdateAsyncbefore returning is a valid alternative to keeping it in the profile.