Skip to content

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.

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:

  1. NotProcessedYet is 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.
  2. 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.Data is 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 PurchaseId enters the ledger and the purchase is marked pending in-memory. If the save fails, NotProcessedYet goes back — but the grant and ledger entry are already in Profile.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 += 500
end)

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.

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.

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.

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.
  • Developer products only. There is no gamepass path in Receipts — gamepasses aren’t receipts and don’t retry; check UserOwnsGamePassAsync at the point of use instead.
  • One ProcessReceipt per game. Anything else that assigns MarketplaceService.ProcessReceipt after attach silently replaces this logic. Use Bind = false and 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 NotProcessedYet in 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.