Skip to content

Monetization

Twill.Monetization.HandleProduct(1234567, function(player, data)
data.Coins += 1000
end)
if Twill.Monetization.OwnsPass(player, 7654321) then
openTheDoor(player)
end

Server only. Requires Data.

For the workflow and how to test it, see Sell products and passes.

Three problems, none of them optional, all of them easy to get wrong once.

ProcessReceipt can only be assigned once per server. Two systems that both want purchases silently overwrite one another, and the loser stops being paid. This module owns the one assignment and routes each receipt to the handler registered for its product.

Roblox redelivers a receipt until it is told the purchase was granted. 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 not granted a second time. It is still answered only once the save recording it is confirmed, the same as a new one, so a grant that never reached storage is saved again rather than taken on trust.

PurchaseGranted is returned only once the write is confirmed. A save does not wait, so reporting success before the data lands would lose the reward if the server died in between, and the player would have paid for nothing.

Anything that goes wrong answers NotProcessedYet, which asks Roblox to try again later. The record of what was already granted is what makes that retry safe.

SituationWhat is written
The buyer is not on this serverNothing.
No handler is registered for the productA Warn naming the product.
Their data has not loadedNothing.
The handler threwAn Error naming the product and the player.
The save was not confirmed within 30 secondsA Warn naming the purchase.

A grant that succeeds is written at Info.

The record of granted purchases lives in the player’s profile under __twillPurchases, alongside Twill’s other bookkeeping. Do not use the __twill prefix for your own fields, and do not clear it: it is what stops a redelivered receipt from paying twice.

It holds the last fifty purchase ids and drops the oldest past that, so a profile does not grow without limit. That is a bound on the guarantee: a receipt redelivered after the same player has made fifty further purchases would be granted a second time. Roblox retries an unanswered receipt within minutes rather than across fifty purchases, so the bound sits far outside where redelivery happens.

[Server]

Registers what a developer product gives.

function Monetization.HandleProduct(productId: number, grant: Grant)
export type Grant = (player: Player, data: any, receipt: any) -> ()

Parameters

NameTypeDescription
productIdnumberThe product’s id.
grantGrantReceives the player, their live data table, and the raw receipt.

Throws when the id is not a number, when the handler is not a function, and when that product already has a handler. A second registration is refused rather than replacing the first, since a silently replaced handler stops paying without saying so.

The handler writes the reward into data and nothing else. Saving it, recording it, and answering the receipt are not its concern.

[Server]

Answers one receipt.

function Monetization.Process(receipt: any): Enum.ProductPurchaseDecision

Returns

Enum.ProductPurchaseDecision - PurchaseGranted once the reward is saved, NotProcessedYet otherwise. Yields.

This module already receives receipts, so you do not normally call this. It exists for tests and for replaying a receipt by hand, and it still grants at most once.

[Server]

Reports whether a player owns a pass.

function Monetization.OwnsPass(player: Player, passId: number): boolean

Returns

boolean - true when they own it. Yields on the first call per player and pass.

Throws when given anything but a Player and a pass id.

The answer is remembered for the rest of their session, because the underlying call spends web quota, and dropped through Scope.Player. A failed check reads as not owned and is not remembered, so it is asked again rather than settled wrongly against the player.

A pass bought while they are playing is noticed, so the answer does not go stale in the direction that would cost them what they paid for.

[Server]

Forgets what was remembered about a player’s passes.

function Monetization.ForgetPasses(player: Player, passId: number?)

Parameters

NameTypeDescription
playerPlayerThe player to forget.
passIdnumber?One pass, or every pass for that player when left out.

Throws when given anything but a Player, and a pass id or nothing.

The next OwnsPass puts the question to the platform again.

The pass command reaches both this and OwnsPass from the console, which is usually how a support case gets resolved.

Nothing to write. This module listens for the purchase prompt closing and updates what it remembers, so a pass bought during play turns on without a rejoin.

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

LimitValue
Purchase ids kept per player50
Wait for a save to confirm30 seconds
Handlers per product1