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
endThree 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, nilA 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()
endThe 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
endOp.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
endApplied == 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 againWhat 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.