MonetizationBeginner plusNo Robux needed

Sell Coins with a Roblox Developer Product

Create a repeatable 100-coin product, grant and save it through ProcessReceipt, and prevent one PurchaseId from awarding coins twice.

Published
A Roblox Studio test showing two unique purchase receipts add 100 Score each while a duplicate receipt adds nothing

Process the same Developer Product twice and Score should increase by 100 each time. Process the first PurchaseId again and the value should stay unchanged. The finished test grants exactly 200 for two unique receipts and saves that result.

Two unique receipts adding 100 Score each while a duplicate adds nothing

Figure guide: The top orange marker identifies the 10-Robux product button. The lower marker identifies the two successful grants in Output; the duplicate receipt is processed without a third increase.

Start after completing Save Player Coins with Data Stores. Your published test experience should contain ServerScriptService > SaveScore and restore Score after a player leaves and rejoins.

This guide uses Studio test paths for its completion checks, so you will not spend Robux. A live player would pay the Robux amount shown in the production purchase prompt. This lesson does not create a real purchase, sale, transaction history entry, or persistent mock entitlement.

Tip — A Developer Product can be purchased repeatedly

A Developer Product suits repeatable items such as 100 coins or a health refill. Use a Pass for a one-time privilege such as VIP access. This lesson proves that two separate purchases of the same 100-coin product can both be granted.

Tip — A receipt identifies one purchase attempt

Roblox supplies a PurchaseId with a purchase receipt. The server must grant 100 only once for a given PurchaseId. A different PurchaseId can grant another 100, while a repeated ID must not grant twice.

1. Create the 100 Coins product

Open the experience in Creator Dashboard and go to Monetization > Developer Products. Select Create a Developer Product and enter:

Field Value
Name 100 Coins
Description Adds 100 coins to the in-game Score
Item for Sale on
Price 10
Managed Pricing off

The Developer Product creation form in Creator Dashboard

Figure guide: In this Japanese Dashboard capture, marker 1 surrounds Name and marker 2 surrounds Description. Enter the English values from the table in those two fields.

Managed Pricing is off here so the captured base price remains easy to compare. A production experience may enable it and show different prices to different players. In either case, do not hard-code 10 into the game UI; section 4 loads the current price through the API.

Sale state, base price, Managed Pricing, and save controls

Figure guide: The numbered markers identify Item for Sale (1), the base price of 10 (2), Managed Pricing turned off (3), and the saved state (4).

Select Save Changes, then copy the number in the product list’s Product ID column. If an options menu offers Copy Asset ID, that number is also the Product ID.

The Product ID for the newly created 100 Coins product

Figure guide: The orange outlined row contains the product name, Product ID, current price, and Managed Pricing status. Copy the long number in the Product ID column.

The Product ID identifies the product in code. Replace only the 0 in each PRODUCT_ID = 0 line with your copied number.

2. Replace SaveScore with receipt-aware saving

The server grants Developer Products through MarketplaceService.ProcessReceipt. It validates the Product ID, Player ID, and PurchaseId, then reports completion only after both Score and the processed PurchaseId have been saved.

Open ServerScriptService > SaveScore, delete its current contents, and paste the following code. Replace both later PRODUCT_ID placeholders with the same copied number.

The Product ID constant and SaveScore Script in Roblox Studio

local DataStoreService = game:GetService("DataStoreService")
local HttpService = game:GetService("HttpService")
local MarketplaceService = game:GetService("MarketplaceService")
local Players = game:GetService("Players")
local RunService = game:GetService("RunService")
local ServerStorage = game:GetService("ServerStorage")

local PRODUCT_ID = 0 -- Replace 0 with the copied Product ID
local PRODUCT_COINS = 100
local scoreStore = DataStoreService:GetDataStore("PlayerScore_v1")
local playerStates = {}
local playerLocks = {}

local function getScore(player)
	local leaderstats = player:WaitForChild("leaderstats")
	return leaderstats:WaitForChild("Score")
end

local function getKey(player)
	return "player_" .. player.UserId
end

local function isValidScore(value)
	return typeof(value) == "number"
		and value >= 0
		and value == value
		and value ~= math.huge
end

local function normalizeData(storedData)
	if storedData == nil then
		return {
			score = 0,
			processedReceipts = {},
		}
	end

	if isValidScore(storedData) then
		return {
			score = storedData,
			processedReceipts = {},
		}
	end

	if typeof(storedData) ~= "table" then
		return nil
	end

	if not isValidScore(storedData.score) then
		return nil
	end

	local storedReceipts = storedData.processedReceipts
	if storedReceipts ~= nil and typeof(storedReceipts) ~= "table" then
		return nil
	end

	for purchaseId, productId in pairs(storedReceipts or {}) do
		if typeof(purchaseId) ~= "string"
			or purchaseId == ""
			or (
				typeof(productId) ~= "number"
				and productId ~= true
			)
		then
			return nil
		end
	end

	local data = table.clone(storedData)
	data.score = storedData.score
	data.processedReceipts =
		storedReceipts and table.clone(storedReceipts) or {}
	return data
end

local function withPlayerLock(player, callback)
	while playerLocks[player] do
		task.wait()
	end

	playerLocks[player] = true
	local results = table.pack(pcall(callback))
	playerLocks[player] = nil
	return table.unpack(results, 1, results.n)
end

local function loadScore(player)
	local state = {
		status = "loading",
		persistedScore = 0,
		finalSaveStarted = false,
	}
	playerStates[player] = state

	local success, errorMessage = withPlayerLock(player, function()
		local score = getScore(player)
		local storedData = scoreStore:GetAsync(getKey(player))
		local data = normalizeData(storedData)
		if not data then
			error("Stored score data has an unexpected format")
		end

		score.Value += data.score
		state.persistedScore = data.score
		state.status = "ready"
		print("Score loaded:", player.Name, score.Value)
	end)

	if not success then
		state.status = "failed"
		warn("Score load failed:", errorMessage)
	end

	player:SetAttribute("ScoreDataReady", state.status == "ready")
end

local function waitForReadyState(player)
	while player.Parent == Players and not playerStates[player] do
		task.wait()
	end

	local state = playerStates[player]
	if not state then
		return nil
	end

	while state.status == "loading" and player.Parent == Players do
		task.wait()
	end

	if player.Parent ~= Players or state.status ~= "ready" then
		return nil
	end

	return state
end

local function saveScore(player)
	local state = playerStates[player]
	if not state then
		return
	end

	local success, errorMessage = withPlayerLock(player, function()
		if state.finalSaveStarted then
			return
		end
		state.finalSaveStarted = true

		if state.status ~= "ready" then
			return
		end

		local score = getScore(player)
		local scoreAtStart = score.Value
		local scoreDelta = scoreAtStart - state.persistedScore
		local updatedData = scoreStore:UpdateAsync(
			getKey(player),
			function(storedData)
				local data = normalizeData(storedData)
				if not data then
					return nil
				end

				data.score = math.max(0, data.score + scoreDelta)
				return data
			end
		)

		if updatedData == nil then
			error("Player data update was cancelled")
		end
		updatedData = normalizeData(updatedData)
		if not updatedData then
			error("Stored score data has an unexpected format")
		end

		local changesDuringSave = score.Value - scoreAtStart
		local reconciledScore = math.max(
			0,
			updatedData.score + changesDuringSave
		)
		score.Value = reconciledScore
		state.persistedScore = updatedData.score
		print("Score saved:", player.Name, updatedData.score)
	end)

	if not success then
		warn("Score save failed:", errorMessage)
	end
end

local function processReceipt(receiptInfo)
	if typeof(receiptInfo) ~= "table" then
		return Enum.ProductPurchaseDecision.NotProcessedYet
	end

	if receiptInfo.ProductId ~= PRODUCT_ID then
		warn("Unknown Developer Product:", receiptInfo.ProductId)
		return Enum.ProductPurchaseDecision.NotProcessedYet
	end

	if typeof(receiptInfo.PlayerId) ~= "number"
		or typeof(receiptInfo.PurchaseId) ~= "string"
		or receiptInfo.PurchaseId == ""
	then
		return Enum.ProductPurchaseDecision.NotProcessedYet
	end

	local player = Players:GetPlayerByUserId(receiptInfo.PlayerId)
	if not player then
		return Enum.ProductPurchaseDecision.NotProcessedYet
	end

	local state = waitForReadyState(player)
	if not state then
		return Enum.ProductPurchaseDecision.NotProcessedYet
	end

	local success, decisionOrError = withPlayerLock(player, function()
		if state.status ~= "ready" then
			return Enum.ProductPurchaseDecision.NotProcessedYet
		end

		local score = getScore(player)
		local durableScoreBefore = state.persistedScore
		local purchaseId = receiptInfo.PurchaseId
		local updatedData = scoreStore:UpdateAsync(
			getKey(player),
			function(storedData)
				local data = normalizeData(storedData)
				if not data then
					return nil
				end

				if data.processedReceipts[purchaseId] ~= nil then
					return data
				end

				data.score += PRODUCT_COINS
				data.processedReceipts[purchaseId] =
					receiptInfo.ProductId
				return data
			end
		)

		if updatedData == nil then
			error("Player data update was cancelled")
		end
		updatedData = normalizeData(updatedData)
		if not updatedData then
			error("Stored score data has an unexpected format")
		end

		local unsavedLocalChange =
			score.Value - durableScoreBefore
		local reconciledScore = math.max(
			0,
			updatedData.score + unsavedLocalChange
		)
		score.Value = reconciledScore
		state.persistedScore = updatedData.score
		print("Receipt processed:", purchaseId, score.Value)

		return Enum.ProductPurchaseDecision.PurchaseGranted
	end)

	if not success then
		warn("Receipt save failed:", decisionOrError)
		return Enum.ProductPurchaseDecision.NotProcessedYet
	end

	return decisionOrError
end

Players.PlayerAdded:Connect(loadScore)
Players.PlayerRemoving:Connect(function(player)
	saveScore(player)
	playerStates[player] = nil
	playerLocks[player] = nil
end)

game:BindToClose(function()
	for _, player in Players:GetPlayers() do
		saveScore(player)
	end
end)

MarketplaceService.ProcessReceipt = processReceipt

if RunService:IsStudio() then
	local receiptIds = {
		A = "studio-a-" .. HttpService:GenerateGUID(false),
		B = "studio-b-" .. HttpService:GenerateGUID(false),
	}
	local studioReceiptTest = Instance.new("BindableFunction")
	studioReceiptTest.Name = "StudioReceiptTest"
	studioReceiptTest.Parent = ServerStorage

	studioReceiptTest.OnInvoke = function(label)
		if not receiptIds[label] then
			return "Use A or B"
		end

		local players = Players:GetPlayers()
		if #players ~= 1 then
			return "Run this in a one-player Play test"
		end

		local player = players[1]
		local decision = processReceipt({
			PlayerId = player.UserId,
			ProductId = PRODUCT_ID,
			PurchaseId = receiptIds[label],
		})
		return decision.Name, getScore(player).Value
	end
end

Press Play after pasting. Wait for the saved Score to appear and for Output to show Score loaded. If Output shows a red error, stop and check that only one SaveScore Script exists, the store remains PlayerScore_v1, and the code has no red underline.

The previous guide stored a number by itself. normalizeData() converts that number into a table containing score and an empty processedReceipts map, preserving the existing balance. Unexpected data fails closed instead of being overwritten with zero.

withPlayerLock() serializes loading, leaving, and receipt processing for one player. Concurrent work for a different player continues independently.

3. Grant and save only through ProcessReceipt

The pasted code assigns the one Developer Product handler to MarketplaceService.ProcessReceipt. Roblox also directs developers to grant through ProcessReceipt rather than the prompt-close event.

For a new PurchaseId, one UpdateAsync() writes both the new Score and the receipt ID. A successful write returns PurchaseGranted; a failed or unverifiable write returns NotProcessedYet.

Roblox’s player purchasing implementation follows the same order: check for a processed PurchaseId, apply the grant, record the ID, save, and then acknowledge the receipt.

Do not grant coins from PromptProductPurchaseFinished. That event only reports that the prompt flow ended; it is not proof that a receipt was processed. Never trust a client-supplied price, balance, or “purchase succeeded” flag.

Important — Assign ProcessReceipt in one place

MarketplaceService.ProcessReceipt holds one callback. When you add more products, route additional Product IDs inside this handler instead of overwriting it from another Script.

Tip — Larger games need a scalable receipt ledger

This one-product lesson keeps processed PurchaseIds in each player’s Data Store record. A game with many products or purchases should plan a receipt ledger around Data Store size and players moving between servers.

4. Build a button that displays the current price

Following the card structure from the Pass purchase button guide, add a ScreenGui named CoinProductGui to StarterGui. Add a TextButton named Buy100Coins, then add a LocalScript named ProductButton inside it.

CoinProductGui
└─ Buy100Coins (TextButton)
   └─ ProductButton (LocalScript)

Tip — A LocalScript controls only the player’s screen

This LocalScript loads the displayed price and opens the prompt for the local player. It never adds coins. The server’s ProcessReceipt handler owns the grant.

Replace ProductButton with this code and use the same Product ID:

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

local PRODUCT_ID = 0 -- Replace 0 with the copied Product ID
local player = Players.LocalPlayer
local button = script.Parent
local productReady = false
local canRetry = false
local loading = false
local promptOpen = false

local function updateButton()
	button.Active =
		(productReady or canRetry)
		and not loading
		and not promptOpen
	button.AutoButtonColor = button.Active
end

local function loadProduct()
	loading = true
	productReady = false
	canRetry = false
	button.Text = "Loading price..."
	updateButton()

	local success, productInfo = pcall(function()
		return MarketplaceService:GetProductInfoAsync(
			PRODUCT_ID,
			Enum.InfoType.Product
		)
	end)

	if not success then
		warn("Product info failed:", productInfo)
		button.Text = "Reload price"
		canRetry = true
	elseif not productInfo.IsForSale
		or typeof(productInfo.PriceInRobux) ~= "number"
	then
		button.Text = "Currently unavailable"
	else
		button.Text =
			productInfo.Name
			.. " / "
			.. productInfo.PriceInRobux
			.. " Robux"
		productReady = true
	end

	loading = false
	updateButton()
end

button.Activated:Connect(function()
	if canRetry then
		loadProduct()
		return
	end

	if not productReady or promptOpen then
		return
	end

	promptOpen = true
	updateButton()
	local success, errorMessage = pcall(function()
		MarketplaceService:PromptProductPurchase(player, PRODUCT_ID)
	end)
	if not success then
		warn("Product prompt failed:", errorMessage)
		promptOpen = false
		updateButton()
	end
end)

MarketplaceService.PromptProductPurchaseFinished:Connect(
	function(userId, productId, _isPurchased)
		if userId == player.UserId and productId == PRODUCT_ID then
			promptOpen = false
			updateButton()
		end
	end
)

loadProduct()

The regional pricing guidance uses GetProductInfoAsync() from a LocalScript to display the current PriceInRobux. If loading fails, the button offers a retry. If opening the prompt fails, the button becomes active again.

A purchase button displaying the product name and current API price

Figure guide: Marker 1 surrounds the server-owned Score display. Marker 2 surrounds the button that shows the product name and current API price.

The _isPurchased result in PromptProductPurchaseFinished is deliberately unused for the grant. It only releases the prompt-open guard.

5. Test two unique receipts and one duplicate

Press Play, wait for Score to load, record its starting value, and select the purchase button. Close the first prompt without completing it. Score must remain unchanged.

A Studio Developer Product prompt with the no-charge test message

Open the prompt again. Continue once only if it explicitly identifies a Studio test or mock purchase and says your account will not be charged. Wait until Score rises by 100 and Output shows Receipt processed. If either result is missing, do not repeat the completion action; stop and start a fresh Play session.

If the prompt lacks either safety statement, close it without purchasing. The Studio-only server test below can verify the same receipt handler without a checkout.

Do not use Creator Dashboard’s external purchase Test mode. Roblox’s Developer Product Test mode documentation states that this separate flow uses real Robux. This guide uses only Studio’s no-charge mock path or the server-owned Studio harness.

Run the same handler from the Server Command Bar

During Play, open Window > Script > Command Bar and switch it to the green Server context, following the Server Command Bar prerequisite.

Invoke receipt A:

local test = game.ServerStorage.StudioReceiptTest
print(test:Invoke("A"))

Score should rise by 100 and Output should include PurchaseGranted. Invoke receipt B:

local test = game.ServerStorage.StudioReceiptTest
print(test:Invoke("B"))

After another 100, invoke receipt A again:

local test = game.ServerStorage.StudioReceiptTest
print(test:Invoke("A"))

Receipt A adding 100, receipt B adding another 100, and duplicate A adding zero

Compare differences rather than fixed totals. If the starting value before A is 3, the three checks should show 103, 203, and 203. A previous safe mock grant may change the baseline, but not this pattern.

Press Stop, then begin a fresh Play session. The final Score should load before you touch anything, proving that the granted coins and receipt IDs were persisted together.

The final Score restored in a new Play session

Troubleshooting

  • Unknown Developer Product: Put the same Product ID in both Scripts.
  • The button says Currently unavailable: Confirm that the product is for sale and the query uses Enum.InfoType.Product.
  • Closing the prompt does not add coins: That is correct. Only ProcessReceipt or the Studio server harness grants.
  • A duplicate receipt adds coins: Keep the processedReceipts[purchaseId] check before data.score += PRODUCT_COINS.
  • Output says Receipt save failed: The handler returns NotProcessedYet. Fix API access or the reported error, then allow Roblox to retry the same receipt.
  • The Score disappears after restarting Play: Remove duplicate SaveScore Scripts and match the exact store name PlayerScore_v1.
  • A LocalScript changes Score: Remove that grant. Only the server’s receipt handler may add purchased coins.

Completion check

  • Closing or cancelling the prompt leaves Score unchanged.
  • One unique receipt adds and saves exactly 100.
  • A second unique receipt adds and saves another 100.
  • Repeating the first PurchaseId adds zero.
  • A save failure returns NotProcessedYet.
  • A fresh Play session restores the final Score.
  • No real Robux was spent or requested.

The repeatable Developer Product now grants once per receipt and keeps the balance durable. Next, create a Tool that a player can hold.

Official sources

Expanded image