Module 07 · Leçon 3 sur 6
Les Developer Products
Vends un produit achetable plusieurs fois, « Passer l’étape », et traite chaque achat avec ProcessReceipt selon le motif officiel de Roblox, sans double attribution ni achat perdu.
- ⏱️ 45 min
- 📝 Quiz de 7 questions
- 🔄 Vérifié le 24 octobre 2026
Sur cette page
- Créer le Developer Product
- Comment se déroule un achat
- Ce que garantit (et ne garantit pas) ProcessReceipt
- Étape 1 : proposer l’achat de façon sûre
- Étape 2 : ProcessReceipt, idempotent et fiable
- Le principe officiel : donner, noter, sauvegarder, puis confirmer
- Le script Achats
- Pourquoi cet ordre ?
- Et si quelque chose tourne mal ?
- La seule limite qui reste
- Tester sans se ruiner
- Quiz de fin de leçon
🎯 Dans cette leçon, tu vas apprendre à…
- Créer un Developer Product et proposer son achat en jeu
- Écrire un ProcessReceipt unique, idempotent et fiable, qui sauvegarde avant de confirmer
- Choisir entre PurchaseGranted et NotProcessedYet dans chaque situation
- Comprendre les limites et les cas difficiles (joueur parti, serveur qui plante)
« Passer l’étape » : c’est le produit qu’on voit dans beaucoup d’obbys. Le joueur bloqué paie quelques Robux et saute au checkpoint suivant. Il peut l’acheter autant de fois qu’il veut, ce qui en fait un Developer Product. C’est aussi le code le plus délicat du module : si tu te trompes, un joueur peut payer sans rien recevoir, ou recevoir deux fois. Prends ton temps, chaque ligne compte.
Créer le Developer Product
Comme pour un pass, ton jeu doit être publié. D’après la documentation officielle :
- Va dans Creations, sélectionne ton jeu.
- Ouvre Monetization › Developer Products.
- Clique sur Create developer product.
- Ajoute une icône (512 × 512 pixels maximum,
.jpg,.pngou.bmp, contenu important au centre du cercle). - Donne un nom (« Passer l’étape ») et une description claire.
- Choisis une catégorie.
- Fixe le prix en Robux : de 1 à 1 milliard de Robux selon la documentation (octobre 2026).
- Clique sur Save Changes.
Pour l’ID, même méthode que pour un pass : bouton ⋯ sur la vignette, puis Copy Asset ID.
Comment se déroule un achat
Voici le trajet complet d’un achat de Developer Product :
- Ton jeu appelle
MarketplaceService:PromptProductPurchase(player, productId): Roblox affiche sa fenêtre d’achat. - Le joueur confirme, Roblox débite ses Robux.
- Roblox crée un reçu (receipt) et appelle ton callback
MarketplaceService.ProcessReceiptsur le serveur où se trouve le joueur. - Ton callback donne le produit, le sauvegarde, puis répond :
Enum.ProductPurchaseDecision.PurchaseGranted: « c’est bon, le joueur a reçu son produit et c’est enregistré » ;Enum.ProductPurchaseDecision.NotProcessedYet: « je n’ai pas pu le donner ou l’enregistrer, redonne-moi ce reçu plus tard ».
Tant que tu n’as pas répondu PurchaseGranted, l’achat reste non résolu.
Ce que garantit (et ne garantit pas) ProcessReceipt
La référence officielle de l’API apporte des précisions importantes :
- Quand est-il appelé ? Pour chaque achat non résolu : quand l’achat se termine, et quand le joueur rejoint un serveur du jeu (donc souvent avant que ses données soient chargées).
- Il peut attendre (yield) sans limite de temps : sa réponse est acceptée même tardive, tant que le serveur tourne.
- Pas de nouvelle tentative avec le temps : après un
NotProcessedYet, le reçu revient seulement si le joueur lance un autre achat de produit ou rejoint un serveur du jeu. - Le joueur doit être sur le serveur pour que le callback soit appelé.
- Un reçu peut arriver deux fois : sur deux serveurs en même temps si le joueur change de serveur pendant le traitement, ou de nouveau si l’enregistrement côté Roblox échoue après un
PurchaseGranted. - Sans
ProcessReceipt, les reçus sont acquittés automatiquement et tu ne pourras plus les récupérer.
Un achat non résolu n’est ni supprimé ni remboursé : il attend ton PurchaseGranted.
Conclusion : ton callback doit être idempotent. Un mot savant pour une idée simple : si le même reçu arrive deux fois, le joueur ne doit recevoir son produit qu’une seule fois. Pour ça, on note le PurchaseId (l’identifiant unique de l’achat) dans les données sauvegardées du joueur.
Étape 1 : proposer l’achat de façon sûre
On ne veut proposer « Passer l’étape » que si une étape suivante existe. C’est une règle de jeu, donc c’est le serveur qui décide (module 4). Le bouton envoie une demande par un RemoteEvent DemanderPasserEtape, rangé dans ReplicatedStorage.Remotes.
-- Script (côté serveur) dans ServerScriptService, nommé « DemandesAchat »
-- Prérequis : un RemoteEvent « DemanderPasserEtape » dans ReplicatedStorage > Remotes
local MarketplaceService = game:GetService("MarketplaceService")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Workspace = game:GetService("Workspace")
local ID_PASSER_ETAPE = 0 -- remplace 0 par l'ID de ton Developer Product
local DELAI_ENTRE_DEMANDES = 2 -- secondes, contre le spam
local remotes = ReplicatedStorage:WaitForChild("Remotes")
local demanderPasserEtape = remotes:WaitForChild("DemanderPasserEtape") :: RemoteEvent
local checkpoints = Workspace:WaitForChild("Checkpoints")
local derniereDemande: { [Player]: number } = {}
demanderPasserEtape.OnServerEvent:Connect(function(player)
-- Limite de débit : une demande toutes les 2 secondes au maximum
local maintenant = os.clock()
if derniereDemande[player] and maintenant - derniereDemande[player] < DELAI_ENTRE_DEMANDES then
return
end
derniereDemande[player] = maintenant
-- On vérifie qu'il existe bien une étape suivante
local leaderstats = player:FindFirstChild("leaderstats")
local etape = leaderstats and leaderstats:FindFirstChild("Etape")
if etape == nil or not etape:IsA("IntValue") then
return
end
if checkpoints:FindFirstChild(tostring(etape.Value + 1)) == nil then
return -- déjà à la dernière étape : on ne vend rien
end
MarketplaceService:PromptProductPurchase(player, ID_PASSER_ETAPE)
end)
game:GetService("Players").PlayerRemoving:Connect(function(player)
derniereDemande[player] = nil -- on libère la mémoire
end)
Crée le RemoteEvent DemanderPasserEtape dans Remotes (bouton ⊕, comme au module 5) avant de lancer le jeu. Le LocalScript du bouton se résume alors à une ligne utile : remotes.DemanderPasserEtape:FireServer() quand on clique. Note que le client ne choisit ni le produit ni le prix : il demande, le serveur décide.
Le bouton lui-même, BoutonPasserEtape, sera ajouté à la boutique à la leçon 5, avec son prix lu depuis Roblox. En attendant, pour tester, exécute cette ligne dans la Command Bar, en vue client : game.ReplicatedStorage.Remotes.DemanderPasserEtape:FireServer().
Étape 2 : ProcessReceipt, idempotent et fiable
Le principe officiel : donner, noter, sauvegarder, puis confirmer
Le guide officiel de Roblox « Implement player data and purchasing systems » décrit la marche à suivre. Avant de renvoyer PurchaseGranted, il faut, dans cet ordre :
- vérifier que le
PurchaseIdn’est pas déjà noté comme traité ; - donner le produit dans les données du joueur en mémoire ;
- noter le
PurchaseIddans ces mêmes données ; - sauvegarder ces données dans le DataStore.
Si la sauvegarde réussit : PurchaseGranted. Si elle échoue : NotProcessedYet.
L’idée clé : l’étape gagnée et la « preuve » de l’achat (le PurchaseId) sont dans la même table, enregistrée d’un seul bloc. Elles sont donc sauvegardées ensemble, ou pas du tout. Impossible d’avoir l’une sans l’autre.
Bonne nouvelle : tu as déjà tout ce qu’il faut. Ton ModuleScript DonneesJoueurs du module 5 garde en mémoire la table de chaque joueur (DonneesJoueurs.Obtenir) et l’enregistre en entier dans DonneesJoueurs_v1 (DonneesJoueurs.Sauvegarder, qui renvoie true si c’est réussi). On y ajoute simplement un champ achats, qui liste les PurchaseId déjà traités.
Le script Achats
Voici le script le plus important de la leçon (dans ton projet Rojo : src/ServerScriptService/Achats.server.luau). Lis d’abord les commentaires, puis l’explication en dessous. Que tu le tapes toi-même ou que Claude Code l’écrive avec le prompt de l’encadré plus bas, c’est ce niveau de compréhension qui te permettra de repérer un PurchaseGranted mal placé.
-- Script (côté serveur) dans ServerScriptService, nommé « Achats »
-- C'est le SEUL script du jeu qui définit MarketplaceService.ProcessReceipt.
-- Il utilise le ModuleScript « DonneesJoueurs » du module 5 (DataStore « DonneesJoueurs_v1 »).
local MarketplaceService = game:GetService("MarketplaceService")
local Players = game:GetService("Players")
local ServerScriptService = game:GetService("ServerScriptService")
local Workspace = game:GetService("Workspace")
local DonneesJoueurs = require(ServerScriptService:WaitForChild("DonneesJoueurs"))
local ID_PASSER_ETAPE = 0 -- remplace 0 par l'ID de ton Developer Product
local PIECES_DE_SECOURS = 100 -- si le joueur est déjà tout au bout
local ATTENTE_MAX_DONNEES = 60 -- secondes d'attente maximum du chargement des données
type Donnees = { [string]: any }
-- Une fonction « donner » par produit. Deux règles :
-- 1) elle n'attend JAMAIS (pas de task.wait, pas d'appel réseau) ;
-- 2) elle écrit l'effet dans les leaderstats ET dans la table de données du joueur.
local function donnerPasserEtape(player: Player, donnees: Donnees, etape: IntValue, pieces: IntValue)
local checkpoints = Workspace:FindFirstChild("Checkpoints")
local prochain = checkpoints and checkpoints:FindFirstChild(tostring(etape.Value + 1))
if prochain and prochain:IsA("BasePart") then
etape.Value += 1
donnees.etape = etape.Value
local character = player.Character
if character then
character:PivotTo(prochain.CFrame + Vector3.new(0, 4, 0))
end
else
-- Cas rare (le serveur ne propose pas l'achat à la dernière étape) :
-- on ne laisse pas le joueur sans rien
pieces.Value += PIECES_DE_SECOURS
donnees.pieces = pieces.Value
end
end
-- Table « ID du produit -> fonction qui le donne »
local produits: { [number]: (Player, Donnees, IntValue, IntValue) -> () } = {
[ID_PASSER_ETAPE] = donnerPasserEtape,
}
-- Attend que les données du joueur soient chargées (module 5), tant qu'il est sur le serveur.
-- Les achats en attente sont rejoués dès l'arrivée du joueur, AVANT la fin du chargement.
local function attendreDonnees(player: Player): (Donnees?, IntValue?, IntValue?)
local debut = os.clock()
while player.Parent == Players and os.clock() - debut < ATTENTE_MAX_DONNEES do
local donnees = DonneesJoueurs.Obtenir(player)
local leaderstats = player:FindFirstChild("leaderstats")
local etape = leaderstats and leaderstats:FindFirstChild("Etape")
local pieces = leaderstats and leaderstats:FindFirstChild("Pieces")
if donnees and etape and etape:IsA("IntValue") and pieces and pieces:IsA("IntValue") then
return donnees, etape, pieces
end
task.wait(1)
end
return nil, nil, nil -- joueur parti, ou données jamais chargées
end
local function traiterRecu(receiptInfo)
-- 1. Le joueur doit être sur CE serveur, et le produit doit être connu
local player = Players:GetPlayerByUserId(receiptInfo.PlayerId)
if player == nil then
return Enum.ProductPurchaseDecision.NotProcessedYet
end
local donner = produits[receiptInfo.ProductId]
if donner == nil then
warn("Produit inconnu : " .. tostring(receiptInfo.ProductId))
return Enum.ProductPurchaseDecision.NotProcessedYet
end
-- 2. Attendre que ses données soient chargées
local donnees, etape, pieces = attendreDonnees(player)
if donnees == nil or etape == nil or pieces == nil then
return Enum.ProductPurchaseDecision.NotProcessedYet
end
-- 3. Déjà donné ? On regarde l'historique rangé DANS les données du joueur
if donnees.achats == nil then
donnees.achats = {} -- ancienne sauvegarde sans ce champ
end
if not donnees.achats[receiptInfo.PurchaseId] then
-- 4. Donner ET noter l'achat, d'un seul bloc (aucune attente entre les deux)
donner(player, donnees, etape, pieces)
donnees.achats[receiptInfo.PurchaseId] = true
end
-- 5. Sauvegarder MAINTENANT ; PurchaseGranted seulement si la sauvegarde a réussi
if DonneesJoueurs.Sauvegarder(player) then
return Enum.ProductPurchaseDecision.PurchaseGranted
end
warn("Sauvegarde impossible, l'achat " .. tostring(receiptInfo.PurchaseId) .. " reviendra plus tard")
return Enum.ProductPurchaseDecision.NotProcessedYet
end
MarketplaceService.ProcessReceipt = traiterRecu
Pourquoi cet ordre ?
Chaque étape protège contre un problème précis :
| Étape | Ce qu’elle protège |
|---|---|
| 1. Joueur présent ? Produit connu ? | On ne donne rien à un joueur absent : NotProcessedYet, le reçu reviendra quand il rejoindra un serveur. |
| 2. Données chargées ? | Les reçus en attente arrivent souvent avant la fin du chargement (module 5). Il n’y a pas de nouvelle tentative automatique, donc on attend (60 secondes au plus, tant que le joueur est là) : la référence précise que ProcessReceipt peut attendre aussi longtemps que le serveur tourne. Le guide officiel fait de même. |
| 3. Déjà traité ? | Si le PurchaseId est déjà dans les données, on ne redonne rien. |
| 4. Donner et noter | Les deux changements sont faits l’un après l’autre, sans attente : aucune sauvegarde ne peut enregistrer l’un sans l’autre. |
| 5. Sauvegarder, puis confirmer | PurchaseGranted seulement quand l’étape et le PurchaseId sont enregistrés dans le DataStore. Sinon NotProcessedYet. |
Et si quelque chose tourne mal ?
Passons en revue les cas difficiles :
- Le DataStore est en panne au moment de l’achat. Le joueur a son étape pour la partie en cours, mais rien n’est confirmé à Roblox (
NotProcessedYet). Si une sauvegarde suivante réussit (automatique ou au départ), l’étape et lePurchaseIdsont enregistrés ensemble : quand le reçu reviendra, l’étape 3 le reconnaîtra, la sauvegarde réussira et on confirmera, sans redonner l’étape. Si aucune sauvegarde ne réussit, rien n’est enregistré : le reçu reviendra à la prochaine connexion et l’étape sera donnée à ce moment-là. Le guide officiel signale ce cas : le joueur profite alors gratuitement de l’achat pendant la partie en cours, ce qui est sans gravité. - Le joueur quitte le serveur pendant la sauvegarde. La sauvegarde de départ du module 5 enregistre la même table, avec l’étape et le
PurchaseId. Rien n’est perdu, rien n’est donné deux fois. - Le serveur plante. Tant que
PurchaseGrantedn’a pas été renvoyé, Roblox garde le reçu, qui reviendra. Et on ne renvoiePurchaseGrantedqu’après une sauvegarde réussie. - Le reçu arrive deux fois (enregistrement raté côté Roblox, nouvel achat dans la même partie). Le
PurchaseIdest déjà dans les données : on ne fait que sauvegarder et confirmer.
La fonction donnerPasserEtape met à jour les leaderstats (pour l’affichage) et la table donnees. C’est voulu : même si le joueur part au mauvais moment, la table sauvegardée contient bien l’effet de l’achat.
La seule limite qui reste
Ce ProcessReceipt est exactement aussi fiable que ta sauvegarde du module 5. Or celle-ci n’a pas de verrouillage de session : dans le cas rare décrit au module 5 (un joueur qui change de serveur très vite pendant une sauvegarde lente), un serveur peut écraser des données plus récentes. Ce cas peut faire perdre de la progression en général, achats compris. Le guide officiel s’appuie justement sur le verrouillage de session pour garantir que les données en mémoire sont les plus récentes. Pour un petit obby, le risque reste faible ; pour un jeu qui vend beaucoup de monnaie premium ou permet les échanges, passe à une solution qui gère le verrouillage, comme ProfileStore (module 5).
Tester sans se ruiner
Selon une annonce du staff Roblox sur le DevForum, les achats faits dans Studio sont simulés : aucun Robux n’est débité, et un achat de Developer Product déclenche bien ProcessReceipt. Attention, cette annonce date de 2016 et la documentation actuelle ne décrit plus ce comportement : vérifie-le toi-même, et fais un dernier test dans le jeu publié. Des développeurs signalent aussi sur le DevForum (2025) que ProcessReceipt n’est pas appelé en mode Team Test : teste en Play classique. Pour que le DataStore fonctionne en test, active l’accès aux API dans les paramètres du jeu, sur une copie de test (vu au module 5). Teste ces scénarios :
- achat normal : l’étape augmente et tu es téléporté ;
- achat annulé : rien ne se passe ;
- sauvegarde en échec : remplace temporairement
if DonneesJoueurs.Sauvegarder(player) thenparif false then; tu dois recevoir l’étape et voir l’avertissement dans la fenêtre Output (remets la ligne d’origine ensuite) ; - joueur à la dernière étape : la demande ne propose rien (aucune fenêtre d’achat).
Ce qu’il faut retenir
- Un Developer Product s’achète plusieurs fois ; on propose l’achat avec
PromptProductPurchase(player, productId), idéalement après une vérification serveur. - Les achats se traitent uniquement dans
MarketplaceService.ProcessReceipt, défini une seule fois dans un Script serveur. - Motif officiel : vérifier le
PurchaseId, donner le produit et noter lePurchaseIddans les données du joueur, sauvegarder, et seulement alors renvoyerPurchaseGranted. - Sinon, renvoie
NotProcessedYet(joueur absent, données pas prêtes, sauvegarde en échec) : le reçu reviendra, et lePurchaseIdnoté évite de donner deux fois. - Ce système est aussi fiable que ta sauvegarde : sans verrouillage de session (module 5), un cas rare de changement de serveur rapide reste possible.
- Ne te fie jamais à
PromptProductPurchaseFinishedpour accorder un produit.
Sources et pour aller plus loin
- Developer Products — guide officiel (création, vente, traitement des achats)
- MarketplaceService.ProcessReceipt et BindReceiptHandler — référence de l’API
- Implement player data and purchasing systems — motif officiel de traitement des achats (vérifier, donner, noter, sauvegarder, puis confirmer)
- Data stores — guide officiel
- Mock purchases in Studio — annonce du staff Roblox sur le DevForum (2016, ancienne)
- ProcessReceipt non appelé en Team Test — rapport de développeurs sur le DevForum (2025)