Receipts
Grants developer products exactly once over DataDriver profiles by granting, saving and only then acknowledging, with a purchase ledger in the profile and declined grants that leave the receipt open.
- Realm
- Server
- Require
ServerScriptService.ChloeKernelServer .Commerce .Receipts - Since
- v0.1.0
- Source
- Commerce/Receipts.luau
Receipts turns Roblox’s developer product receipts into exactly-once grants over DataDriver profiles. The defining decision is the order: grant, persist, then acknowledge. PurchaseGranted goes back to Roblox only after the grant has been saved, so a crash between the grant and the save leaves the receipt open, and a ledger of purchase IDs inside the profile stops its redelivery from granting twice. Game passes are not receipts and live in Passes. Trades between players use Transactions, and paid random items need Policy.
Mental model
Section titled “Mental model”Roblox’s contract is simple: return PurchaseGranted to consume a receipt, or NotProcessedYet to leave it open. Roblox has no timed retry after NotProcessedYet. An open receipt is redelivered on the buyer’s next purchase or join (with BindReceiptHandler, also later in the same session), and Roblox accepts a late answer. Two consequences shape the module:
- The callback stays alive while the buyer stays. A save that fails is retried in place with backoff, and a buyer whose profile is still loading is waited for.
NotProcessedYetgoes back only once the buyer leaves, the profile is released, or a grant is declined. A receipt deferred early would sit unprocessed until the player happened to buy something else. - Deduplication lives in the profile. Redelivery can happen on another server in another session, so the record of processed purchases is
Profile.Data.ReceiptLedger. It rides every save and is protected by the same session lock as everything else in the profile.
The full decision path, in processing order:
| Situation | Decision |
|---|---|
| The processor was destroyed | NotProcessedYet |
The same PurchaseId is already being processed on this server |
NotProcessedYet for the duplicate call |
| The buyer is not in this server | NotProcessedYet |
| The buyer’s profile has not loaded | Wait while the buyer stays, then continue, or NotProcessedYet if they leave |
| A trade holds the buyer’s profile | Wait for the trade to end while the buyer stays |
| The profile is blocked for trade recovery | NotProcessedYet |
The codec would drop ReceiptLedger (or ReceiptWork for a phased product) |
NotProcessedYet, with a warning |
PurchaseId in the ledger and saved |
PurchaseGranted without granting again, plus Commerce.PurchaseGranted if that grant was never announced |
PurchaseId in the ledger, save still pending |
Retry the save while the buyer stays; PurchaseGranted once it lands |
| No grant registered for the product | NotProcessedYet, with a warning; deploying the grant later lets a redelivery succeed |
| The buyer’s Policy lookup is in flight | Wait for it to settle while the buyer stays |
| The grant declines, raises or yields | Profile rolled back; NotProcessedYet |
| The grant ran and the save failed | Retry in place while the buyer stays; NotProcessedYet if they leave, with the grant and ledger entry kept in memory |
| The grant ran and the save landed | PurchaseGranted, then Commerce.PurchaseGranted on the Bus |
The failed-save row is the exactly-once mechanism. After a successful grant the purchase ID is in the ledger and marked pending. If the buyer leaves before a save lands, the grant and the ledger entry are still in Profile.Data, and the leave save usually persists both. The redelivery finds the ID in the ledger, skips the grant and acknowledges. The grant was never announced on the Bus, because its own save failed, so the ledger keeps each grant in ReceiptLedger.Unannounced until Commerce.PurchaseGranted publishes for it; the redelivery sees the mark and announces the grant once, on whichever server gets it. Before the Unreleased fix after 0.9.1, that redelivery acknowledged silently and spend totals missed the purchase.
python tools/scaffold.py data creates a DataService that attaches DataDriver and Receipts together and registers every grant in Data/Products.luau:
--!strict-- DataService: owns player persistence (DataDriver) and developer product receipts.-- Other services read and change profile data through session.Profile.Data only.
local ServerScriptService = game:GetService("ServerScriptService")
local DataDriver = require(ServerScriptService.ChloeKernelServer.Data)local Products = require(script.Parent.Parent.Data.Products)local Profile = require(script.Parent.Parent.Data.Profile)local Receipts = require(ServerScriptService.ChloeKernelServer.Commerce.Receipts)
local DataService = { Name = "DataService",}
function DataService.init(self: any, kernel: any) self.Kernel = kernel self.Driver = DataDriver.new(Profile :: any) self.Driver:attach(kernel) self.Receipts = Receipts.attach(kernel, { GetProfile = function(player: Player) local Session = kernel:getSession(player) return Session and Session.Profile end, }) for ProductId, Grant in Products do self.Receipts:onProduct(ProductId, Grant) endend
function DataService.start(_self: any) end
function DataService.stop(_self: any) end
return DataService--!strict-- Developer product grants: [productId] = function(session, receiptInfo).-- Grants must not yield and may only change session.Profile.Data; Receipts saves before acknowledging.
return { [1234567890] = function(session: any, _receiptInfo: any): boolean? session.Profile.Data.Coins += 500 return nil end, [1234567891] = function(session: any, _receiptInfo: any): boolean? local Pets = session.Profile.Data.Pets if #Pets >= 50 then return false -- declined: the profile rolls back and the receipt stays open end table.insert(Pets, "Dragon") return nil end,} :: { [number]: (session: any, receiptInfo: any) -> boolean? }A grant that goes through a kit is declined by the kit’s refusal, with no extra code:
self.Receipts:onProduct(SwordProductId, function(session, _receiptInfo) -- An Inventory.Overflow for this buyer during the grant declines the purchase self.Inventory:grant(session, "Sword", 1)end)Grants
Section titled “Grants”A grant is grant(session, receiptInfo, plan?) -> boolean?. It receives the buyer’s kernel session and Roblox’s receiptInfo, changes session.Profile.Data, and returns. Receipts saves afterwards; never save inside a grant.
What the return value means:
| Return | Result |
|---|---|
nil |
Acknowledge after the save, unless a kit refused something for this buyer during the grant. |
false |
Decline. The profile rolls back, the ledger does not get the ID and the receipt stays open. |
true |
Acknowledge even if a kit refused something, for example after mailing the item that did not fit. |
| raises | Same as false, with a warning naming the error. |
While the grant runs, Receipts watches the Bus for Loot.Refused (a Paid loot table the buyer’s policy restricts) and Inventory.Overflow (an item that did not fit) aimed at the buyer’s session. Either one declines the purchase unless the grant returns true. Before 0.9.0 those purchases were acknowledged with nothing delivered.
The grant, the ledger insertion and the cleanup of a phased product’s plan form one protected, non-yielding unit. The purchase is marked pending before anything changes, the profile data is snapshotted, and any error in the unit restores Profile.Data in place (a table a faulty grant froze is replaced). A grant that yields is cancelled and rolled back with grant handlers must not yield, because a yield would let an autosave write a half-applied grant. Effects outside the profile cannot be rolled back.
Waiting for the buyer
Section titled “Waiting for the buyer”Every wait lasts only while the buyer stays in the server:
| Wait | Behavior |
|---|---|
| Profile | A receipt can arrive before the buyer’s profile loads. The processor waits, woken by Kernel.ProfileLoaded or the buyer’s Kernel.SessionEnd, and re-checks GetProfile every second in case a custom GetProfile does not follow that event. ProfileWaitSeconds caps the wait. |
| Policy | With Policy attached, a receipt that arrives while the buyer’s lookup is in flight waits for Policy.Loaded or Policy.Failed, so a paid roll is never refused just because the lookup had not finished. |
| Trade | A receipt that arrives while Transactions holds the buyer’s profile waits for Commerce.TransactionEnded. A profile left blocked for recovery defers instead. |
| Save | A failed save is retried after SaveRetrySeconds (default 2), doubling up to 30 s. The wait wakes early when the buyer leaves. |
Phased products
Section titled “Phased products”A product that needs a yielding read or an external effect (a gift to another player’s mailbox, a clan-wide boost) passes a third argument with Prepare and an optional Fulfill:
receipts:onProduct(GiftProductId, function(session, _receiptInfo, _plan) -- Runs after Fulfill succeeded: non-yielding, profile only session.Profile.Data.GiftsSent += 1end, { Prepare = function(session, _receiptInfo) -- Read-only and may yield. Freeze the recipient before anything else yields. return { Recipient = session.Data.GiftTarget } end, Fulfill = function(_session, receiptInfo, plan) -- May yield. Must be idempotent by PurchaseId: a crash before the final save runs it again. -- GiftMailbox is the game's own module; it keys each entry by the purchase ID. return GiftMailbox.putOnce(plan.Recipient, receiptInfo.PurchaseId, receiptInfo.ProductId) end,})Prepare(session, receiptInfo)returns a serializable plan. Receipts stores{ ProductId, Plan }inProfile.Data.ReceiptWork[PurchaseId]and saves it before anything else happens. Retries and reloads reuse the stored plan.- The codec must keep the complete plan exactly. Receipts encodes the profile with the plan and compares the result before inserting it; a codec that drops plan fields refuses the purchase before any external effect runs.
Fulfill(session, receiptInfo, plan)performs the effect. It gets a detached copy of the plan, must not change profile data, and returnsfalseor raises to leave the receipt open.- The grant runs with the plan. It records the purchase in the ledger and removes the
ReceiptWorkentry, and one save persists both.
Prepare and Fulfill run only while the session still owns the active profile; a profile replaced during a yield defers the receipt. Completed plans are removed, and unresolved ones stay in ReceiptWork for retries and support. A crash after Fulfill and before the final save runs Fulfill again, which is why it must deduplicate by PurchaseId.
Binding
Section titled “Binding”attach sets MarketplaceService.ProcessReceipt. Where the engine offers MarketplaceService:BindReceiptHandler, it also binds one catch-all DeveloperProduct handler that maps PurchaseGranted to Enum.ReceiptDecision.Processed and everything else to NotProcessedYet, and keeps ProcessReceipt set as the fallback.
- Only one processor can be attached to a MarketplaceService. A second
Receipts.attacherrors, because it would replace the first one’s callback and strand every product registered there. Register every product on one processor. - A catch-all bound earlier by other code takes precedence;
attachwarns that it bypasses the ledger. - A filtered
BindReceiptHandlerregistration, yours or a plugin’s, takes precedence for the products it lists and bypasses the ledger and the persist-before-acknowledge rule for them. Game code never callsBindReceiptHandleror setsProcessReceipt. destroy()disconnects the catch-all.ProcessReceiptstays set and defers every receipt, because an unset callback acknowledges purchases automatically. Afterdestroy(), a newattachis allowed.
Configuration
Section titled “Configuration”Receipts.attach(kernel, options):
| Field | Default | Description |
|---|---|---|
GetProfile |
required | (player) -> Profile?. With DataDriver attached, the session’s Profile. |
GetPlayer |
Players:GetPlayerByUserId |
(userId) -> Player?. Injectable for specs. |
LedgerSize |
2000 |
Purchase IDs remembered per profile, oldest evicted first. NaN, values below 1 and math.huge fall back to the default; fractions are floored. |
ProfileWaitSeconds |
nil |
Cap on the wait for a joining buyer’s profile. Unset, the wait lasts while the buyer stays. |
SaveRetrySeconds |
2 |
First delay between failed save attempts, doubling up to 30 s. |
Bind |
true |
false binds nothing; specs drive the processor directly. |
Quiet |
false |
Suppress warnings, for specs that exercise failure paths. |
Marketplace |
MarketplaceService |
Injectable for specs. |
API reference
Section titled “API reference”| Member | Description |
|---|---|
Receipts.attach(kernel, options) -> Receipts |
Builds the processor and binds it. Errors when another live processor is attached to the same MarketplaceService. |
receipts:onProduct(productId, grant, phases?) |
Registers grant(session, receiptInfo, plan?) -> boolean? for one product, with optional { Prepare, Fulfill? }. Errors on a second registration for the same product. |
receipts:destroy() |
Disconnects the catch-all handler and makes every later receipt defer. |
The ledger
Section titled “The ledger”Profile.Data.ReceiptLedger is { Ids = { [purchaseId] = true }, Order = { purchaseId, ... }, Unannounced = { [purchaseId] = true }? }: a set for lookups, the insertion order for eviction, and the grants not yet announced on the Bus (removed once empty, and trimmed with the entries it names). Older array-shaped ledgers are converted on first use, so upgrading never loses existing deduplication state.
The ledger is a bounded replay window. A redelivery of a purchase older than the newest LedgerSize IDs would grant again; 2,000 IDs is small next to the 4 MB record limit, and a player 2,000 purchases past an open receipt is not a realistic case. Games that need lifetime history keep it separately.
Bus topics
Section titled “Bus topics”| Topic | Payload | Direction |
|---|---|---|
Commerce.PurchaseGranted |
player, productId, purchaseId, purchaseChannel?, currencySpent? |
Published once per grant, after it has persisted, including a grant that only a later save (the leave save) persisted, which its redelivery announces. purchaseChannel is receiptInfo.ProductPurchaseChannel and can be nil. currencySpent is receiptInfo.CurrencySpent, the Robux the buyer actually paid after Roblox Plus and other discounts, and can be nil; added in 0.9.1. Pricing turns it into Commerce.RobuxSpent for the buyer’s session. Drive analytics, toasts and quests from here, never from inside the grant. |
Kernel.ProfileLoaded, Kernel.SessionEnd |
session, ... |
Consumed: wake a receipt waiting for the buyer’s profile or departure. |
Policy.Loaded, Policy.Failed |
session, ... |
Consumed: wake a receipt waiting for the buyer’s policy. |
Commerce.TransactionEnded |
profileA, profileB, ok |
Consumed: wake a receipt waiting for a trade on the buyer’s profile. |
Loot.Refused, Inventory.Overflow |
session, ... |
Consumed during a grant: a refusal for the buyer declines the purchase unless the grant returns true. |
Gotchas
Section titled “Gotchas”- Receipts handles developer products only. Game passes are not receipts and are never redelivered; use Passes.
- A grant changes the profile and nothing else: no yields, no HTTP, no DataStore calls, no saves. Use a phased product for external effects.
- Expect
NotProcessedYetin logs during backend trouble or after a declined grant. The purchase completes on a later delivery. - A declined receipt is only redelivered on the buyer’s next purchase or join, so declining costs the player time. When the reason will not clear by itself (a full inventory), deliver another way and return
true. - A game that sells paid random items must attach Policy and mark those loot tables
Paid = true. Receipts then waits for the buyer’s policy instead of refusing too early.