Ledger
Concepts

Once

Naming an op after something outside your game so it only ever applies once.

Every op Ledger writes already has an id, so a retry can't apply twice. The catch is that the id is generated when the op is made, so if your code crashes and rebuilds the op, it's a different op with a different id, and it applies again.

Once fixes that. You give the op a name that comes from outside your game, and Ledger applies it at most one time on that key.

Session:CommitOp({
	Id = HttpService:GenerateGUID(false),
	Kind = "ProductGrant",
	ProductId = 123456,
	Once = `receipt:{Receipt.PurchaseId}`,
}):Wait()

The name is remembered inside the profile, under _Received, so it survives a compaction, a rejoin, and two servers racing the same replay.

ProcessReceipt

This is what Once was built for. Roblox keeps calling ProcessReceipt until you answer PurchaseGranted. There is no timer on it. A receipt you answered NotProcessedYet on comes back when the player buys another developer product on this server, or when they join any server in the experience again.

Roblox can also fail to record your answer after you already said granted, so a retry follows a perfectly healthy grant. Two servers can even run the same receipt at the same time if the player joins the second one before the first answered.

Assign MarketplaceService.ProcessReceipt as early as you can. Roblox acknowledges receipts on its own while no callback is assigned, and an acknowledged receipt can never be handed back. Assign it first and let the callback yield for your store, instead of loading the store and then assigning.

So the grant has to happen at most once, and you still have to say granted on every replay after that.

MarketplaceService.ProcessReceipt = function(Receipt)
	local Player = Players:GetPlayerByUserId(Receipt.PlayerId)
	if not Player then
		return Enum.ProductPurchaseDecision.NotProcessedYet
	end

	local Session = Store:WaitForLoaded(Player)
	if not Session then
		return Enum.ProductPurchaseDecision.NotProcessedYet
	end

	local Name = `receipt:{Receipt.PurchaseId}`
	local _, Why = Session:Commit("ProductGrant", {
		ProductId = Receipt.ProductId,
		Once = Name,
	}):Wait()

	if Why == Ledger.Reason.Unresolved or not Session:DidApply(Name) then
		return Enum.ProductPurchaseDecision.NotProcessedYet
	end

	return Enum.ProductPurchaseDecision.PurchaseGranted
end

Three things in there need explaining.

WaitForLoaded, not Get. The callback fires as the player joins, which can be before their profile is loaded, and a ProcessReceipt callback is allowed to yield for as long as the server runs. Answering NotProcessedYet because the session wasn't ready spends a whole retry, and the next one waits for another purchase or a rejoin.

DidApply, not the Commit result. Commit tells you whether this call changed anything. The first one did. Every replay after it doesn't, and every replay after it is still a granted purchase. DidApply answers the question Roblox is actually asking.

Store:Edit(UserId, "ProductGrant", { ProductId = 123, Once = "receipt:abc" }):Wait()
--> true, nil

Store:Edit(UserId, "ProductGrant", { ProductId = 123, Once = "receipt:abc" }):Wait()
--> false, "Refused"

Store:DidApply(UserId, "receipt:abc"):Wait()
--> true, nil

A replay answers Refused. Your reducer answers Refused too, so the boolean cannot tell you which one happened. DidApply can.

Check Unresolved first. It means there's no settled answer yet, so DidApply might be reading a fold that a stuck transaction can still flip. Answer NotProcessedYet and let the retry ask again once things are settled.

Never answer PurchaseGranted for something you aren't sure went through. An unresolved purchase is never refunded, so the player loses the Robux and gets nothing.

Ids you pick yourself

Once is for any id you didn't make up on the spot. A support tool order, a webhook delivery, a gamepass unlock.

Gamepasses

A gamepass has no ProcessReceipt and no receipt id. Roblox stores whether the player owns the pass, so you don't have to. You store what the pass gave them.

UserOwnsGamePassAsync answers whether they own it. Two things about it cost money.

UserOwnsGamePassAsync throws when the request fails, and it fails often under load. Wrap it in a pcall. Never turn a thrown call into false. false means "does not own it", so you would take the pass away from a player who paid for it.

Roblox caches the answer per player, per pass, per server. A purchase made in your experience updates that cache when PromptGamePassPurchaseFinished fires, so you don't have to track it yourself. A purchase made outside the experience takes several minutes to reach the cache.

So the only thing you have to add is the nil:

-- true owns it, false does not, nil the check failed and you should not act on it
local function OwnsPass(Player: Player, PassId: number): boolean?
	local Ok, Has = pcall(function(): boolean
		return MarketplaceService:UserOwnsGamePassAsync(Player.UserId, PassId)
	end)
	return if Ok then Has else nil
end

MarketplaceService.PromptGamePassPurchaseFinished:Connect(function(Player, PassId, Purchased)
	if Purchased then
		GrantPass(Player, PassId)
	end
end)

Handle nil by doing nothing and asking again later. Don't remove a perk. Don't hide the button. Don't prompt them to buy a pass they already own.

PromptGamePassPurchaseFinished only fires on the server that showed the prompt.

Then grant it:

local function GrantPass(Player: Player, PassId: number)
	local Session = Store:WaitForLoaded(Player)
	if not Session then
		return
	end

	Session:Commit("GrantPass", { Pass = tostring(PassId), Once = `pass:{PassId}` }):Wait()
end

The reducer is what stops a second grant. Ledger forgets a Once name after 30 days and a gamepass lasts forever, so the name only covers the retries:

if Op.Kind == "GrantPass" then
	if State.Passes[Op.Pass] then
		return nil
	end

	local Next = table.clone(State)
	Next.Passes = table.clone(State.Passes)
	Next.Passes[Op.Pass] = true
	Next.Gold += 1000
	return Next
end

Op.Pass is a string. A table with number keys is an array, and an array with gaps cannot be stored. See What you can store.

The event misses a purchase made on another server, on the website, or before you shipped the pass. So check on join too:

for _, PassId in PASSES do
	if OwnsPass(Player, PassId) == true then
		GrantPass(Player, PassId)
	end
end

== true, so a failed check does nothing this time. Call GrantPass for every pass they own. The reducer refuses the ones they already have, so you don't have to work out which are new.

With a reducer guard like that one, the Once name is doing nothing you need. Keep it for a grant your reducer can't check, like a one off currency top up, where the name is the only thing between you and paying twice.

Someone who isn't in this server at all

local function GrantOffline(Store, UserId: number, ProductId: number, OrderId: string): (boolean, Ledger.Reason?)
	local Name = `order:{OrderId}`
	Store:Edit(UserId, "ProductGrant", { ProductId = ProductId, Once = Name }):Wait()

	local Applied, Why = Store:DidApply(UserId, Name):Wait()
	return Applied == true, Why
end

Applied == true rather than if Applied then, so a read that failed comes back as "don't know" with a reason instead of being mistaken for "not granted".

DidApply

Session:DidApply(Name) and Store:DidApply(Key, Name) both answer whether a Once name ever applied on that key. The session version reads live state, returns a plain boolean, and can't fail. The store version reads the record and gives you a Future<boolean?, Reason?>.

Store:DidApply answers nil when it couldn't read the record at all, which is not the same as false. Compare against true, not truthiness, or a datastore hiccup reads as "never granted" and you hand out the reward a second time.

It's asking about the name, not about this call, which is exactly why it's the right thing to check on a replay.

A name lives on one key of one store. Ask DidApply on the store that the name was written to.

A handler can grant on a profile store and record the sale on a second store. It then needs one name for each store. Check each name on its own store.

If you ask the profile store about a name on the record store, the answer is always false. The write is refused because it is a replay. The check says that it never landed. The handler never finishes, and the receipt comes back for ever.

Session:DidApply reads live state. Live state includes the ops that wait for the next save. Apply puts a name in that queue, so the answer is true before the op is written. On a receipt, grant with Commit. You can also Apply and then Flush. A granted answer then means a saved grant.

How long a name is remembered

Long enough that you don't have to think about it, and not so long that the set grows forever.

A name is written into the applied set with the time it was applied. The next time any name is written to that key, anything in the set older than 30 days goes. So the set is a rolling window rather than a pile that only gets bigger.

The check for an already applied name happens before any of that, and against everything still in the set. So a player who wanders off for two months and comes back to a receipt Roblox is still retrying is fine: nothing was written while they were away, so nothing was swept, and the name is still sitting there.

Store:Edit(UserId, "Grant", { Once = "old" }):Wait()

-- 40 days later, nothing written on the key in between
Store:DidApply(UserId, "old"):Wait()          --> true
Store:Edit(UserId, "Grant", { Once = "old" }):Wait()   --> false, "Refused"

-- one new name is written, which sweeps everything past the window
Store:Edit(UserId, "Grant", { Once = "fresh" }):Wait()
Store:DidApply(UserId, "old"):Wait()          --> false
Store:Edit(UserId, "Grant", { Once = "old" }):Wait()   --> true, it applies again

What it does not cover is a receipt still unsettled after 30 days of the same player buying other things. That means a month of your grant failing every time, which is a bigger problem than the dedupe.

So Once is for retry windows, not for names that stay meaningful forever. A support ticket reopened two months later, or an unlock you want to be permanent, is outside the window. Put that rule in the reducer instead.

The gamepass example above shows both halves. pass:{PassId} stops the retries. The reducer refusing on State.Passes[Op.Pass] is what stops a second grant a year later.

Rules

The name has to be a non empty string. Anything else is refused with Invalid on every surface, and nothing applies. A name usually comes from outside your game, so a receipt id that arrives as nil gives you a reason to read.

Names are namespaced, so a Once name can't collide with a transfer id or a transaction id even if they're spelled the same.

Your reducer never has to dedupe. By the time it runs, Once has already decided. Write the branch as if it only ever runs one time, because it does.

Don't put Once on a transaction leg. The transaction id already makes every leg land at most once, and Ledger throws if you pass one anyway.

On this page