Monetize Your Game

Developer products: ProcessReceipt is what separates a sale from a loss

A developer product is what the player buys more than once. Creating one takes five minutes; what decides whether you deliver or lose the purchase is ProcessReceipt, and it has a response rule almost everybody discovers late.

by Gustavo Cantino9 min readBeginner

Produced with AI from the sources cited below, and checked by automated verification against those same sources. No person read this text before publication. Editorial Policy

Typographic plate with the Monetize Your Game category in large type, the beginner level below it, and a field of vertical lime green bars on a dark background.

Creating a developer product takes five minutes in the dashboard. What decides whether the purchase becomes an item in the player's hand or becomes a complaint is a single function, written on the server, and it has a response rule almost everybody discovers after their first loss.

This guide covers the whole route — what the product is, how to create one, how to sell it from a script — but its heart is ProcessReceipt, because that is where the sale actually happens.

The problem

First, choosing the right thing. A developer product is an item or ability the user can buy more than once: the documentation's own examples are in-game currency, ammunition and potions. For anything bought only once — a special weapon, a permanent power-up — the documentation tells you to see Passes.

That choice is structural and expensive to undo: passes and products have different APIs, different screens and different ways for the player to get their money back. Switching later means rebuilding the shop.

The second problem is the one nobody sees in the dashboard. When the player buys, Roblox delivers nothing — it issues a receipt and asks your game what to do with it. If your game does not answer properly, the money leaves the player's account and the item does not appear. The dashboard still shows the sale.

That design has a reason. The purchase happens on Roblox infrastructure, which has no way of knowing what "a hundred coins" means inside your game — your code knows. So it charges, issues the receipt and hands the question back. Delivery is your responsibility from beginning to end, and the only sign that it happened is what your server answers.

The practical consequence is uncomfortable and worth saying out loud: the sales dashboard does not prove delivery. It shows that Roblox charged. If your game had a bug at that moment, the sale line is there all the same, and there is nothing in the interface that distinguishes the purchase that arrived from the one that did not.

Step by step

1. Publish the game. The documentation is explicit: the game has to be published and accessible before you create the product. You cannot prepare a shop in a place that does not exist yet.

2. Create the product. In the dashboard, under Creations, pick the game and go to Monetization and then Developer Products. Click create and fill in:

  • Icon. It must not exceed 512x512 pixels, must be in .jpg, .png or .bmp, and important detail must not sit outside the circular boundaries.
  • Name and description.
  • Category. It is there to organise the items in the in-game shop — it is not dashboard decoration, it is what groups the storefront.
  • Price in Robux. The minimum is 1 Robux and the maximum is 1 billion Robux. The range is wide on purpose; the price that works does not come out of it, it comes out of your game.

3. Take the id. To use scripting you need the product id. It comes from hovering over the thumbnail, clicking the three dots and choosing Copy Asset ID from the context menu. It is the number that will appear in the code — and it is worth keeping in a constants module, not scattered through every script.

4. Display the product. GetProductInfo returns product information, like name and price, so you can build the storefront without retyping the text by hand. One detail that costs an hour of debugging to anyone who did not read it: for a developer product, the second parameter has to be Enum.InfoType.Product. With the wrong enum the call does not return what you expect, and the error does not say so.

If you have many products, GetDeveloperProductsAsync returns every product associated with the game, in a Pages object you can walk and filter to build a whole shop without listing anything by hand.

That difference between the two functions decides the maintenance of your shop. With GetProductInfo you choose item by item, which is predictable but forces a code change every time a product comes or goes. With GetDeveloperProductsAsync the storefront builds itself from what exists in the dashboard — creating a new product becomes dashboard work, not a deploy. The price is having to deal with pagination and with the fact that a badly configured product shows up in the shop on its own.

Both calls talk to the Roblox server and can fail. The official examples wrap both in pcall for exactly that reason, and it is not decorative caution: without pcall, a momentary instability brings down the whole storefront build and the player sees an empty shop instead of a shop with one item missing.

5. Open the purchase. PromptProductPurchase is what opens the purchase window inside the game — wired to a button, to a vendor NPC, to whatever makes sense.

If you use price optimization, there is a requirement that changes where the code lives: the script has to run on the client for the user to see the personalised price. A server script building the storefront shows the standard price to everyone.

6. Process the receipt. This is the part that is not optional, and it is the next section.

The artifact

The script below is the complete ProcessReceipt, with the order that matters: check whether the receipt was already delivered, deliver, record the receipt id, and only then confirm.

It also makes explicit a detail that brings games down in production: MarketplaceService.ProcessReceipt is a field, not an event. Two scripts assigning that field do not coexist — the last one erases the first, silently. A game has one ProcessReceipt, and it lives in a single file.

Common mistakes

The common mistake has two halves, and both come from the same line of the documentation.

The first half is not answering. The documentation describes four steps: use the ProcessReceipt API to check the receipts, validate each receipt by the user id, the product id and the status, deliver when the status is Open, and respond to ProcessReceipt confirming the receipt and validating that the item was delivered.

Anyone who delivers and forgets to answer creates a receipt that never closes. Roblox keeps re-presenting the purchase, and the game keeps delivering — the player gets the item again every session, for free. It is the bug you discover through an inflated economy, not through the log.

The second half is the opposite: confirming before you are sure. If your code returns the confirmation and only then tries to record that it delivered, a DataStore failure in that interval produces the worst possible outcome — the receipt is closed as far as Roblox is concerned, and your game has no record of anything. The player paid and has nowhere to appeal.

The correct order is the artifact's, and it is conservative on purpose: never confirm before recording. The risk that remains is delivering twice, which is recoverable; the risk avoided is charging without delivering, which is not.

There is a third, quieter way to lose the purchase: delivering inside a function that can fail halfway and not checking whether it succeeded. If delivery depends on a leaderstats that does not exist yet, or on a folder the character only gets after loading, the function "runs" without error and delivers nothing. That is why the artifact requires the delivery to return true: without that confirmation, confirming the receipt is a gamble.

It is also worth treating ProcessReceipt as what it is: a point that runs for purchases made outside the game too. The documentation says it automatically reads and recognises a purchase made outside the game — meaning it can be called for a player who has just joined, with a receipt from days ago. Code that assumes the purchase just happened in the current session breaks exactly there.

Next step

Once delivery is reliable, there is a date for the calendar: the documentation marked 30 May 2026 for disabling cross-game product sales. Anyone depending on that was told to integrate Robux transfers before the date to avoid interruption.

The test is direct: look in your code for any PromptProductPurchase or GetProductInfo whose id belongs to another game of yours. If there is none, the date does not affect you and you can carry on. If there is, the work exists and it has a deadline — and it is better to find that out reading your own code than by finding out the shop stopped working.

Sources

How we correct mistakes

Ready-to-use artifact

ProcessReceipt with idempotent delivery and the receipt recorded before confirming (ServerScriptService)
lua
-- ServerScriptService: the ONE AND ONLY ProcessReceipt in the game.
--
-- MarketplaceService.ProcessReceipt is a field, not an event: whoever
-- writes last erases the previous one. Two scripts assigning it is the
-- most common way for "the purchase vanished" to happen.
--
-- The order here is deliberate:
--   1. have I already delivered this receipt? -> confirm again, no double delivery
--   2. deliver
--   3. RECORD the receipt id
--   4. only then return PurchaseGranted
--
-- If step 3 fails, we do NOT confirm. Roblox re-presents the receipt
-- later and the player receives it. Confirming before recording trades
-- "delivering twice" (recoverable) for "charging and not delivering"
-- (not recoverable).

local MarketplaceService = game:GetService("MarketplaceService")
local DataStoreService = game:GetService("DataStoreService")
local Players = game:GetService("Players")

local receipts = DataStoreService:GetDataStore("ProcessedReceipts")

-- product id -> what it does. Everything you sell lives here.
local PRODUCTS = {
	[000000] = function(player, quantity)
		local coins = player:FindFirstChild("leaderstats")
			and player.leaderstats:FindFirstChild("Coins")
		if not coins then
			return false
		end
		coins.Value = coins.Value + quantity
		return true
	end,
}

local QUANTITY = {
	[000000] = 100,
}

local function receiptKey(info)
	return info.PlayerId .. "_" .. info.PurchaseId
end

local function process(info)
	local player = Players:GetPlayerByUserId(info.PlayerId)
	if not player then
		-- player left: do NOT confirm. The receipt comes back next session.
		return Enum.ProductPurchaseDecision.NotProcessedYet
	end

	local key = receiptKey(info)

	-- 1. idempotence
	local alreadyDone
	local okRead = pcall(function()
		alreadyDone = receipts:GetAsync(key)
	end)
	if not okRead then
		return Enum.ProductPurchaseDecision.NotProcessedYet
	end
	if alreadyDone then
		return Enum.ProductPurchaseDecision.PurchaseGranted
	end

	-- 2. delivery
	local deliver = PRODUCTS[info.ProductId]
	if not deliver then
		warn("product with no delivery registered: " .. tostring(info.ProductId))
		return Enum.ProductPurchaseDecision.NotProcessedYet
	end

	local okDelivery, delivered = pcall(deliver, player, QUANTITY[info.ProductId] or 1)
	if not okDelivery or not delivered then
		return Enum.ProductPurchaseDecision.NotProcessedYet
	end

	-- 3. record BEFORE confirming
	local okWrote = pcall(function()
		receipts:SetAsync(key, os.time())
	end)
	if not okWrote then
		return Enum.ProductPurchaseDecision.NotProcessedYet
	end

	-- 4. confirm
	return Enum.ProductPurchaseDecision.PurchaseGranted
end

MarketplaceService.ProcessReceipt = function(info)
	local ok, decision = pcall(process, info)
	if not ok then
		warn("ProcessReceipt raised: " .. tostring(decision))
		return Enum.ProductPurchaseDecision.NotProcessedYet
	end
	return decision
end
In this article

Keep reading