How to send gifts between servers without losing them

MessagingService doesn't promise delivery, so gifts and staff changes sent only as messages can vanish. How to save them first and use messages to speed things up.

warningLuaudit check: messaging-only-deliveries

What it looks like

Something that must arrive is sent to other servers with MessagingService and nothing else: coins or items gifted to a friend in another server, a trade's payout, a staff member's removal, an unban or a refund. The sending server takes the cost or makes the change, publishes a message, and trusts that the receiving server will apply it.

Luau
profile.Coins -= amount
MessagingService:PublishAsync("Gifts", { to = friendId, coins = amount })

How to detect it

  • Find every MessagingService:PublishAsync call and what its SubscribeAsync callback does. Flag callbacks that give currency or items, change saved data, or grant or remove powers.
  • Roblox's MessagingService reference says delivery is best effort and not guaranteed, and to design games so a failed delivery isn't critical. A successful PublishAsync only means the message was sent.
  • Messages only reach servers that are running and subscribed at the time. Check what happens when the recipient is offline, between servers, or in a server that's starting up.
  • Check whether the sender pays before the message is published. A refund when PublishAsync errors doesn't cover a message that was sent but never arrived.
  • The safe pattern is to write the change to a data store first, such as a gift in the recipient's inbox or a removal in a saved list, and use the message only to tell running servers to apply it sooner. Servers then read the saved copy when a player joins or a server starts.
  • Bans kept only in messages are reported under Bans that don't last.

What an exploiter could do

Nobody has to do anything. Gifts sent to an offline friend, or lost in delivery, disappear after the sender has paid for them. Staff removals and other changes reach only the servers that were listening, so a removed moderator keeps their powers in any server that missed the message and in every server that starts later.

How to fix it

  • Save the change to a data store keyed by the recipient, and apply it when they join, as a mailbox.
  • Keep the message as a fast path that tells running servers to load the saved change.
  • Take the sender's payment and add the saved gift together, and give the payment back if the save fails.

Vulnerable example

Vulnerable
local MessagingService = game:GetService("MessagingService")
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")

local coins: { [Player]: number } = {}

ReplicatedStorage.Remotes.GiftFriend.OnServerEvent:Connect(function(player: Player, friendId: unknown, amount: unknown)
	if typeof(friendId) ~= "number" or typeof(amount) ~= "number" or amount ~= math.floor(amount) or amount < 1 then
		return
	end
	if (coins[player] or 0) < amount then
		return
	end
	coins[player] -= amount
	pcall(MessagingService.PublishAsync, MessagingService, "Gifts", { to = friendId, coins = amount })
end)

MessagingService:SubscribeAsync("Gifts", function(message)
	local data = message.Data
	local recipient = Players:GetPlayerByUserId(data.to)
	if recipient then
		coins[recipient] = (coins[recipient] or 0) + data.coins
	end
end)

Players.PlayerRemoving:Connect(function(player)
	coins[player] = nil
end)

Fixed example

Fixed
local DataStoreService = game:GetService("DataStoreService")
local MessagingService = game:GetService("MessagingService")
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")

local inboxes = DataStoreService:GetDataStore("GiftInboxes")
local coins: { [Player]: number } = {}

local function collect(player: Player)
	local gifted = 0
	local ok = pcall(inboxes.UpdateAsync, inboxes, `Gifts_{player.UserId}`, function(saved: any): number?
		gifted = if typeof(saved) == "number" then saved else 0
		return 0
	end)
	if ok and gifted > 0 then
		coins[player] = (coins[player] or 0) + gifted
	end
end

ReplicatedStorage.Remotes.GiftFriend.OnServerEvent:Connect(function(player: Player, friendId: unknown, amount: unknown)
	if typeof(friendId) ~= "number" or friendId ~= math.floor(friendId) or friendId < 1 or friendId == player.UserId then
		return
	end
	if typeof(amount) ~= "number" or amount ~= math.floor(amount) or amount < 1 or amount > 1000 then
		return
	end
	if (coins[player] or 0) < amount then
		return
	end
	coins[player] -= amount
	local saved = pcall(inboxes.UpdateAsync, inboxes, `Gifts_{friendId}`, function(current: any): number
		return (if typeof(current) == "number" then current else 0) + amount
	end)
	if not saved then
		coins[player] += amount
		return
	end
	pcall(MessagingService.PublishAsync, MessagingService, "GiftSent", friendId)
end)

pcall(MessagingService.SubscribeAsync, MessagingService, "GiftSent", function(message)
	local recipient = if typeof(message.Data) == "number" then Players:GetPlayerByUserId(message.Data) else nil
	if recipient then
		collect(recipient)
	end
end)

Players.PlayerAdded:Connect(collect)

Players.PlayerRemoving:Connect(function(player)
	coins[player] = nil
end)

Common false positives

  • Messages that only show something, such as announcements, rare-find shout-outs or server lists, can be lost without harm. Judge their volume with Players can flood cross-server messages.
  • A message that tells servers to reload something already saved in a data store is the fix, not the finding.
  • Changes that only matter in running servers and are meant to end with them, such as a live event starting, are fine to send by message alone.