Skip to content

Sell products and passes

Selling things is the one part of a Roblox game where a bug costs real money in both directions. Grant too eagerly and a redelivered receipt pays twice. Report success too early and a server that dies mid-save takes the reward with it, while the player has already been charged.

Both failures come from the same place: the reward, the record that it happened, and the answer given to Roblox must agree with each other. Twill keeps them in step, and the shape of your code is what lets it.

Register what a product gives during Init. The handler writes the reward and nothing else.

function ShopService.Init()
-- One handler per product. A second registration for the same id is
-- refused rather than replacing the first, because a silently replaced
-- handler stops paying without telling anyone.
Twill.Monetization.HandleProduct(1234567, function(player, data)
-- Mutate the live profile. Saving it, recording the purchase, and
-- answering the receipt are not this function's concern.
data.Coins += 1000
end)
Twill.Monetization.HandleProduct(1234568, function(player, data)
table.insert(data.Inventory, "starter_pack")
end)
end

The reward and the record of the purchase are written together, and Roblox is told the purchase succeeded only after that write is confirmed.

That ordering is the whole point. A save does not wait, so reporting success first would lose the reward if the server died in between, and the player would have paid for nothing.

Do not assign ProcessReceipt yourself. It can only be assigned once per server, and two systems that both want it silently overwrite one another. Twill owns the one assignment and routes each receipt to its registered handler.

Do not write your own duplicate check. Roblox redelivers a receipt until it is told the purchase was granted, so the same purchase can arrive more than once. Granted purchase ids are recorded in the player’s own saved data, in the same write as the reward, and a receipt already on that list is answered without paying again.

Do not answer PurchaseGranted yourself. Anything that goes wrong answers NotProcessedYet instead, which asks Roblox to retry. The record above is what makes that retry safe.

A handler that throws results in NotProcessedYet, so the purchase is retried rather than lost. Failing loudly is the correct behaviour here, and an assert is a reasonable way to reach it.

Twill.Monetization.HandleProduct(1234569, function(player, data)
local pet = Catalog.Pets[currentOffer]
-- Better to refuse the receipt and have Roblox bring it back than to
-- grant a nil pet and record the purchase as honoured.
assert(pet, "no offer active")
table.insert(data.Pets, pet.Id)
end)
if Twill.Monetization.OwnsPass(player, 7654321) then
multiplier *= 2
end

The answer is remembered for the rest of the player’s session, because the underlying call spends web quota. The first call per player and pass yields; the rest do not.

A failed check reads as not owned and is not remembered, so it is asked again rather than settled wrongly against the player.

Nothing to write. Twill listens for the purchase prompt closing and updates what it remembers, so a pass bought mid-session turns on without a rejoin.

ForgetPasses exists only for the case that listener cannot see, such as ownership granted from outside the running server:

-- Forget one pass, or every pass for that player when the id is left out.
Twill.Monetization.ForgetPasses(player, 7654321)

Purchases cannot be simulated in Studio. Publish to a private test place, buy the product with a real account, and confirm two things:

  1. The reward landed and survived a rejoin.
  2. Buying twice grants twice, and a redelivered receipt grants once.

The second is the one that costs money to get wrong, and the one this module exists for.

Buy the product, then stop the server before the save is confirmed. Roblox brings the receipt back on the next join, and a correct setup grants nothing the second time while the player keeps what they paid for.

This module needs Data. Idempotency is only worth anything if the record of what was granted outlives the server that wrote it.