ADR-0008: Marketplace / MonetizationService wrapper¶
Status¶
Accepted
Context¶
packages/gacha, packages/battle-pass, and packages/pets all assume Robux purchases can be made but none of them process purchases.
Roblox's MarketplaceService requires a server-side ProcessReceipt callback to be registered; without it all developer product purchases silently fail.
Phase 6 economy work (BroCoins, trading) is blocked until a safe monetization layer exists.
Three related concerns must be solved:
- Developer products — one-time purchasable items that can be bought multiple times (coins, gems, loot boxes). Roblox fires
ProcessReceiptfor each; the server must grant the reward and returnPurchaseGranted. - Game passes — permanent ownership grants (VIP, double XP). Ownership must be checked efficiently without hammering
UserOwnsGamePassAsyncevery frame. - Receipt safety —
ProcessReceiptcan be retried by Roblox if the server crashes or returnsNotProcessedYet. Double-granting must be prevented.
Decision¶
Build packages/marketplace as a thin, injectable wrapper with three components:
| Component | Responsibility |
|---|---|
DeveloperProductRegistry |
Registry of product definitions + grant handlers; routes receipts |
GamePassCache |
In-memory TTL cache for UserOwnsGamePassAsync results |
PurchaseValidator |
Idempotent receipt processing — deduplicates on PurchaseId |
All three are composed by createMarketplaceService, which returns a standard Service object compatible with the game's Application lifecycle (onInit, onStart, onDestroy).
Idempotency¶
PurchaseValidatorkeeps an in-memorySet<PurchaseId>of grants within the current server session. If Roblox retries a receipt that was already granted,PurchaseGrantedis returned immediately without re-invoking the handler.- Handlers should also be idempotent on a per-player basis (e.g., check inventory before granting) to cover cross-restart retries where the in-memory set has been cleared.
- This satisfies ADR-0003 (idempotency requirement for all grants).
Ownership cache TTL¶
- Default TTL: 300 seconds (5 minutes).
- Sufficient to avoid repeated Roblox API calls per player, while ensuring that pass revocations (rare, but possible) propagate within a reasonable window.
- The cache entry for a player is invalidated on player leave (
invalidatePlayer). - After a
PromptGamePassPurchaseFinishedsuccess, the caller callshandle.setPassOwned(userId, passId, true)to update the cache immediately without waiting for a re-fetch.
Injection pattern¶
The package does not call game:GetService("MarketplaceService") directly.
Instead, callers inject:
passOwnershipFetcher— wrapsUserOwnsGamePassAsynconSetupReceipt— a callback that wiresMarketplaceService.ProcessReceipt
This keeps the package fully testable without Roblox globals (consistent with ADR-0006 test strategy).
Alternatives considered¶
Inline in gacha / battle-pass / pets¶
Each package independently handling its own purchases would duplicate idempotency logic and make it impossible to share a single ProcessReceipt callback (Roblox only allows one per place). Rejected.
Full DataStore idempotency¶
Storing every PurchaseId in a DataStore would survive server restarts. The added complexity and DataStore budget cost is not justified for Phase 5–6 scope where restart-driven double-grants are extremely rare. Re-evaluate if grant volume or exploit surface warrants it.
Third-party library¶
No suitable roblox-ts library exists that fits our security model (explicit validation, stable error codes, injectable adapters). Custom implementation is the right choice.
Consequences¶
MarketplaceServiceis the singleProcessReceiptowner; other packages callhandle.processReceipt.- Games that need Robux purchases must register their product handlers via
handle.registerProductbefore any player can trigger a purchase. packages/gacha,packages/battle-pass, andpackages/petsshould be updated in Phase 6 to wire their Robux-based hatching/unlock flows through@broblox/marketplace.- This unblocks Phase 6 economy (BroCoins, trading) and removes the prerequisite gap noted in
docs/roadmap/future-phases.md.
Rollout plan¶
- ✅ Add
packages/marketplacewithDeveloperProductRegistry,GamePassCache,PurchaseValidator,createMarketplaceService. - Add
@broblox/marketplacetotsconfig.roblox.jsonandvitest.config.ts. - In the next Phase 6 session: wire
@broblox/marketplaceinto the test-park and obby games. - Update
packages/gacha,packages/battle-pass,packages/petsto delegate Robux flows to@broblox/marketplace. - Write an ADR for Phase 6 BroCoins / economy once the marketplace layer is proven in a deployed game.