Skip to content

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.

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:

  1. 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. NotProcessedYet goes 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.
  2. 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:

src/Server/Game/Services/DataService.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)
end
end
function DataService.start(_self: any) end
function DataService.stop(_self: any) end
return DataService
src/Server/Game/Data/Products.luau
--!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)

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.

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.

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 += 1
end, {
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,
})
  1. Prepare(session, receiptInfo) returns a serializable plan. Receipts stores { ProductId, Plan } in Profile.Data.ReceiptWork[PurchaseId] and saves it before anything else happens. Retries and reloads reuse the stored plan.
  2. 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.
  3. Fulfill(session, receiptInfo, plan) performs the effect. It gets a detached copy of the plan, must not change profile data, and returns false or raises to leave the receipt open.
  4. The grant runs with the plan. It records the purchase in the ledger and removes the ReceiptWork entry, 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.

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.attach errors, 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; attach warns that it bypasses the ledger.
  • A filtered BindReceiptHandler registration, 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 calls BindReceiptHandler or sets ProcessReceipt.
  • destroy() disconnects the catch-all. ProcessReceipt stays set and defers every receipt, because an unset callback acknowledges purchases automatically. After destroy(), a new attach is allowed.

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.
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.

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.

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.
  • 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 NotProcessedYet in 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.

ChloeKernel v0.9.1. Access to the framework is private and granted individually by Chloe. Games built with it credit CryptedChloes in their description.