Receipts
Receipts turns MarketplaceService.ProcessReceipt — an API that will happily double-grant or eat a paid purchase if you get the ordering wrong — into exactly-once product grants over DataDriver profiles. The defining decision is the ordering: grant, persist, then acknowledge. PurchaseGranted is returned only after the grant has landed in storage. Anything less and a server crash between the grant and the save acknowledges a purchase whose reward no longer exists.
Mental model
Section titled “Mental model”Roblox’s receipt contract: return PurchaseGranted to consume the receipt, or NotProcessedYet to make Roblox retry — later, possibly on another server, possibly next session. Two consequences drive the whole design:
NotProcessedYetis free. It is not an error; it is “ask me again”. Every ambiguous situation — player gone, profile not loaded, handler missing, save failed — defers instead of guessing. Deferral can never lose money; a wrong acknowledgement can.- Retries cross sessions and servers, so dedupe state must live where the player’s data lives: a ledger of processed
PurchaseIds inside the profile itself (profile.Data.ReceiptLedger). It rides every save, survives rejoins and server hops, and is protected by the same session locks and atomic writes as everything else in the profile.
The full decision machine, in processing order:
| Situation | Decision | Why |
|---|---|---|
| Buying player not in this server | NotProcessedYet |
Roblox retries where the player is. |
| Profile missing or inactive | NotProcessedYet |
Grants only ever mutate a live, locked profile. |
PurchaseId in the ledger, save landed |
PurchaseGranted |
Duplicate receipt — acknowledge without re-granting. |
PurchaseId in the ledger, save still pending |
Retry the save; PurchaseGranted only if it lands |
Granted in memory but unsaved: acknowledging now would lose a paid grant on a crash. |
| No handler registered for the product | NotProcessedYet (+ warn) |
Deploying the handler later makes the retry succeed. |
| Grant handler errors | NotProcessedYet, partial mutations rolled back |
See below. |
| Grant ran, save failed | NotProcessedYet |
Grant + ledger entry stay in memory; the retry re-saves and acknowledges — granting exactly once. |
| Grant ran, save landed | PurchaseGranted + bus publish |
Done. |
Two details in that table carry the safety proofs:
- Crash-safe grants. The handler runs against the profile under a
pcall, with a deep-copy snapshot taken first. If the handler errors mid-mutation,Profile.Datais restored from the snapshot — a retry can never stack a half-applied grant on top of itself. - The failed-save deferral is the exactly-once mechanism. After a successful grant, the
PurchaseIdenters the ledger and the purchase is marked pending in-memory. If the save fails,NotProcessedYetgoes back — but the grant and ledger entry are already inProfile.Data. Roblox’s retry finds the id in the ledger, skips the grant, retries only the save, and acknowledges once it lands. The player is granted exactly once no matter how many times the save fails first.
local Shop = Receipts.attach(Kernel, { GetProfile = function(player) local Session = Kernel:getSession(player) return Session and Session.Profile end,})
Shop:onProduct(1234567890, function(session, receiptInfo) session.Profile.Data.Coins += 500end)attach binds MarketplaceService.ProcessReceipt (unless Bind = false) — only one binding can exist per game, so attach once. onProduct registers the grant for one ProductId; registering the same product twice is an error. The handler receives the buyer’s kernel session and the raw receiptInfo; mutate the profile and return. Do not save inside the handler — Receipts saves for you, in the position that makes the ordering guarantee true.
Handlers can and should abort by erroring when a grant is impossible (inventory full, unknown SKU variant): the mutation rolls back, the receipt defers, and the retry runs a fixed handler later.
Configuration
Section titled “Configuration”Receipts.attach(kernel, options):
| Field | Default | Description |
|---|---|---|
GetProfile |
— (required) | (player) -> Profile? — where the buyer’s profile lives. With DataDriver attached, Session.Profile. |
GetPlayer |
Players:GetPlayerByUserId |
(userId) -> Player?. Injectable for tests. |
LedgerSize |
2000 |
Max PurchaseIds remembered per profile, evicted FIFO. Invalid values (NaN, < 1, math.huge) fall back to the default. |
Bind |
true |
Bind MarketplaceService.ProcessReceipt on attach. Set false to compose Receipts into your own dispatcher. |
Quiet |
false |
Suppress warns — for tests that exercise failure paths on purpose. |
| Member | Description |
|---|---|
Receipts.attach(kernel, options) → Receipts |
Build the processor; optionally bind ProcessReceipt. |
receipts:onProduct(productId, grant) |
Register grant(session, receiptInfo) for one product. Errors on a duplicate registration. |
The ledger
Section titled “The ledger”profile.Data.ReceiptLedger is { Ids = { [purchaseId] = true }, Order = { purchaseId, ... } } — a set for O(1) dedupe checks plus insertion order for FIFO eviction. Older array-shaped ledgers are normalized into this shape on first touch, so upgrading Receipts never invalidates existing dedupe state.
The cap matters more than it looks: an evicted entry’s late cross-session retry would re-grant, so LedgerSize is sized generously — 2000 GUIDs is still small against the 4MB record limit, and a player 2000 purchases deep since a given receipt is not a realistic retry window.
Bus topics
Section titled “Bus topics”| Topic | Payload | Fired |
|---|---|---|
Commerce.PurchaseGranted |
player, productId, purchaseId |
Exactly once per purchase, only after the grant has persisted. Drive analytics, thank-you toasts, and quest triggers from this — never from inside the grant handler. |
Gotchas
Section titled “Gotchas”- Developer products only. There is no gamepass path in Receipts — gamepasses aren’t receipts and don’t retry; check
UserOwnsGamePassAsyncat the point of use instead. - One
ProcessReceiptper game. Anything else that assignsMarketplaceService.ProcessReceiptafterattachsilently replaces this logic. UseBind = falseand call into Receipts from your dispatcher if you must share the callback. - Grant handlers must be synchronous mutations. The snapshot/rollback protects against errors, not against yields — a handler that yields lets an autosave interleave with a half-applied grant.
- Deferral is not failure. Expect
NotProcessedYetin logs during backend hiccups; that is the system refusing to guess with money on the line. The purchase lands on retry.
For the wider commerce picture — soft-currency shops, gifting, trades — see Transactions and the commerce cookbook.