Allow multiple ProcessReceipts with metatables!

Allowing multiple ProcessReceipts is restricted by Roblox for many reasons. However, if you dare to bark on your adventure for multiple well here I am to guide you.

Create an ordinary script within ServerScriptService and paste the following code within there:

shared.MarketplaceService = setmetatable({}, {
    __index = function(self, key)
        return self[key]
    end,

    __newindex = function(self, key, value)
        if key ~= "ProcessReceipt" then return nil end
        rawset(self, tostring(value), value)
        rawset(self, "ProcessReceipt", nil)
    end,
})

game:GetService("MarketplaceService").ProcessReceipt = function(receiptInfo)
    local returnedValue;
    for _, callback in shared.MarketplaceService do
        if returnedValue then
            if (typeof(returnedValue) == "EnumItem" or returnedValue.EnumType == Enum.ProductPurchaseDecision) then
                return returnedValue
            end
        end 

        task.spawn(function()
            returnedValue = callback(receiptInfo)
        end)
    end

    local timeout = tick() + 10
    repeat task.wait() until tick() >= timeout or returnedValue

    local isCorrectType = (typeof(returnedValue) == "EnumItem" and returnedValue.EnumType == Enum.ProductPurchaseDecision)
    return isCorrectType and returnedValue or Enum.ProductPurchaseDecision.NotProcessedYet
end

Now within any server-sided script write the following:

--[[OLD]]
game:GetService("MarketplaceService").ProcessReceipt = function(receiptInfo)
    -- Code
end

--[[NEW]]
shared.MarketplaceService.ProcessReceipt = function(receiptInfo)
    -- Code
end

How ProcessReceipt Is Handled Internally

We use metamethods and a global table to capture all assignments made to the ProcessReceipt property, and store each one as a separate function entry with keys by its stringified representation. This allows multiple ProcessReceipt functions to coexist and be executed safely.

Example result after two assignments:

shared.MarketplaceService = {
    ["function: 0x109da0146f5b1e74"] = function(receiptInfo)
        -- Your logic here
    end,

    ["function: 0x15902a582f928273"] = function(receiptInfo)
        -- Your logic here
    end,
}

Behind the Scenes: Metatable Logic

We override the default behavior of the shared.MarketplaceService global by attaching a metatable. This metatable captures any assignment to the ProcessReceipt key and instead saves the given function under a stringified version of itself, effectively treating it as a unique key. The ProcessReceipt field remains nil.

shared.MarketplaceService = setmetatable({}, {
    __index = function(self, key)
        return self[key]
    end,

    __newindex = function(self, key, value)
        if key ~= "ProcessReceipt" then return end
        rawset(self, tostring(value), value)
        rawset(self, "ProcessReceipt", nil)
    end,
})

For those unaware, rawset() is used to set a value within a metatable without triggering __newindex. We use this so we don’t cause an infinite loop and what we call a C stack overflow.

We disregard attempts to modify keys which isn’t the ProcessReceipt key as we intended.

When the server starts, shared.MarketplaceService is just an empty table:

shared.MarketplaceService = {} -- On launch

Backend Execution Logic

Now that we have a list of ProcessReceipt handlers stored inside shared.MarketplaceService, we can iterate through them each time Roblox triggers the actual purchase callback.

Here’s how we execute those handlers until one returns a valid decision:

game:GetService("MarketplaceService").ProcessReceipt = function(receiptInfo)
    local returnedValue

    -- Iterate over all stored functions
    for _, callback in shared.MarketplaceService do
        if returnedValue then
            -- If already set, check if it’s a valid ProductPurchaseDecision
            if typeof(returnedValue) == "EnumItem" and returnedValue.EnumType == Enum.ProductPurchaseDecision then
                return returnedValue
            end
        end

        -- Call each handler in its own thread
        task.spawn(function()
            returnedValue = callback(receiptInfo)
        end)
    end

    -- Give a wait period to receive decisions
    local timeout = tick() + 10
    repeat task.wait() until tick() >= timeout or returnedValue

    -- Return default if nothing valid was returned
    local isValid = (typeof(returnedValue) == "EnumItem" and returnedValue.EnumType == Enum.ProductPurchaseDecision)
    return isValid and returnedValue or Enum.ProductPurchaseDecision.NotProcessedYet
end

Converting Existing ProcessReceipt Code

Old method using Roblox’s API directly:

game:GetService("MarketplaceService").ProcessReceipt = function(receiptInfo)
    -- Code
end

New method using the extended system:

shared.MarketplaceService.ProcessReceipt = function(receiptInfo)
    -- Code
end

No extra setup is required thanks to the metamethods, this function will automatically be added to the internal list and used by the unified handler.


Disclaimer

Sometimes you’ll need to add a wait before your line incase it runs before the actual global variable is set otherwise it’s attempting to index nil with MarketplaceService.

task.wait(); shared.MarketplaceService.ProcessReceipt = function(receiptInfo)
    -- Code
end
3 Likes

I’m really curious why would someone want to have multiple ProcessRecepits?

2 Likes

Yeah me too. I’ve heard people complain about it. Probably because they have too products in two seperate scripts (ie. a revive dev product in a script that deals w/ player’s health and a money dev product in a script that deals with someone’s money). But I think people are just too lazy to use Bindables

2 Likes

The main reason I personally support using multiple ProcessReceipts is because transitioning from an existing ProcessReceipt setup to an external module can be complicated—especially in systems like Donation Boards or other developer product frameworks.

People with little to no scripting experience often have to migrate ProcessReceipt functions they copied from YouTube tutorials into a ModuleScript provided by the donation board. This process tends to introduce more errors, increasing the likelihood that the new developer gives up on integrating the donation board altogether.

I own a donation board myself, and it’s always a hassle to explain to beginners how to properly move their ProcessReceipt into the provided ModuleScript. In some cases, their code includes logic outside of the function, like this:

local productIds = {
    125125125, 125125125, -- etc...
}

game:GetService("MarketplaceService").ProcessReceipt = function(receiptInfo)
    for _, id in productIds do
        ...
    end
end

With this global variable approach it’s much easier to just ask developers to rewrite a single line in their own script, rather than migrate the entire logic into a new structure.

As a side note, this allows users to keep scripts focused on specific task such as Leaderstats | Game Actions (Revives) | etc…

Love to hear your thoughts on this concept and if you perhaps have any objections or problems with this idea so I can incorporate a more explanatory approach and make it only as a fun educational tutorial for the use of metatables :+1:

1 Like