Skip to main content

Custom UI

Use ReplicatedStorage.Traderie.TradeSystem.Client when you want Traderie to handle trade requests, offers, escrow, rollback, commit, and recovery, but you want to build your own UI.

The first-party Trade UI package is optional. A custom UI should require the Trade System client directly instead of depending on ReplicatedStorage/Traderie/TradeUIConfig/TradeClient, which is only the adapter used by Traderie's shipped UI.

Before You Build UI

Install Trade System from the Studio plugin. You do not need to install Trade UI.

Your server must also have a working ServerScriptService/Traderie/TradeSystemConfig/InventoryAdapter. The client can only display and offer entries returned by that adapter's getTradableEntries method. See Inventory Adapter for the server-side contract.

If you also install and configure Traderie API, completed Trade System trades are reported automatically. Your custom UI does not need to call SaveTradeAsync for Trade System trades.

Require The Client

Require the client from a LocalScript, usually your own UI bootstrap under StarterPlayerScripts.

local ReplicatedStorage = game:GetService("ReplicatedStorage")

local Traderie = ReplicatedStorage:WaitForChild("Traderie")
local TradeSystem = Traderie:WaitForChild("TradeSystem")
local TradeClient = require(TradeSystem:WaitForChild("Client"))

TradeClient.initialize()

initialize() connects the client to the Trade System remotes and is safe to call more than once.

Send A Trade Request

Build your player picker from Players:GetPlayers(), then call requestTrade with the target player's UserId.

local response = TradeClient.requestTrade(targetPlayer.UserId)

if response.ok then
print("Trade request sent:", response.requestId)
else
warn("Trade request failed:", response.code)
end

Common failure codes include target_not_found, cannot_trade_self, request_cooldown, and trade_already_active.

Handle Incoming Requests

Listen for onTradeRequest and show your own accept or decline UI. The packet includes requestId, fromUserId, and fromName.

TradeClient.onTradeRequest:Connect(function(packet)
showIncomingTradePrompt(packet.fromName, function(accepted)
local response = TradeClient.respondToRequest(packet.requestId, accepted)
TradeClient.clearPendingRequest(packet.requestId)

if not response.ok then
warn("Trade response failed:", response.code)
end
end)
end)

Accepting a request starts a trade and returns a snapshot in the response.

Render Trade State

The server pushes every trade change to both participants. Use onTradeUpdated for active trades and onTradeClosed for terminal trades.

local Players = game:GetService("Players")
local localPlayer = Players.LocalPlayer

local currentTradeId = nil

local function getLocalSlot(snapshot)
if snapshot.initiatorUserId == localPlayer.UserId then
return "A"
elseif snapshot.recipientUserId == localPlayer.UserId then
return "B"
end

return nil
end

local function renderTrade(snapshot)
currentTradeId = snapshot.tradeId

local localSlot = getLocalSlot(snapshot)
local yourOffer = if localSlot == "A" then snapshot.offerA else snapshot.offerB
local theirOffer = if localSlot == "A" then snapshot.offerB else snapshot.offerA

renderOfferList("YourOffer", yourOffer)
renderOfferList("TheirOffer", theirOffer)
setConfirmState(snapshot.readyA, snapshot.readyB)
setTradeButtonsEnabled(snapshot.state == "NEGOTIATING")
end

local function closeTrade(snapshot)
currentTradeId = nil
hideTradeWindow()
showTradeResult(snapshot.state, snapshot.lastError)
end

TradeClient.onTradeUpdated:Connect(renderTrade)
TradeClient.onTradeClosed:Connect(closeTrade)

You can also read a known trade snapshot with TradeClient.getCachedTrade(tradeId).

Load Tradable Inventory

Use getTradableInventory() when the player opens your item picker. The response only includes client-safe data.

local response = TradeClient.getTradableInventory()

if not response.ok then
warn("Inventory unavailable:", response.code)
return
end

for _, entry in ipairs(response.inventory) do
local payload = entry.clientPayload or {}
local label = payload.displayName or payload.name or entry.entryId

addInventoryButton(label, function()
addEntryToCurrentOffer(entry.entryId, 1)
end)
end

Inventory entries have this shape:

{
entryId = "item-instance-guid-or-stack-key",
kind = "instance" or "stack" or "currency",
amount = 1,
clientPayload = {
name = "Golden Egg",
icon = "rbxassetid://...",
},
}

entryId is the exact key returned by your server-side InventoryAdapter:getTradableEntries map. For kind = "instance", use a unique item instance id, GUID, or UID so two copies of the same catalog item can be selected independently. For kind = "stack" or kind = "currency", entryId can be the stable stack or currency key because amount carries the quantity.

serverPayload is intentionally not sent to clients. Keep display data in clientPayload and private inventory data in serverPayload inside the server adapter.

How Inventory Entries Connect To setOffer

getTradableInventory() and setOffer() are two halves of the same server-owned inventory contract:

  1. Your server-side InventoryAdapter:getTradableEntries(userId, snapshot) returns a map of tradable entries keyed by entryId.
  2. The Trade System sends the client-safe parts of those entries to your custom UI through getTradableInventory().
  3. Your UI lets the player pick entries, then calls setOffer() with only { entryId, amount } for each selected entry.
  4. The server receives those offer entries and looks up each entryId against the latest entries from InventoryAdapter:getTradableEntries.
  5. After that lookup, the server uses the full authoritative entry data, including private serverPayload, for validation, reservation, rollback, and commit.

That means your UI does not need to know how to load or mutate inventory directly. It only preserves the entryId values returned by getTradableInventory() and sends those same ids back in setOffer().

Update The Local Offer

setOffer replaces the local player's full offer. Keep your UI's selected offer in local state, then send the whole list each time it changes.

selectedOffer is not the same shape as a full TradeEntry. It is a list of smaller offer entries that only identify what the player wants to offer and how much of it:

type OfferEntry = {
entryId: string,
amount: number,
}

Build each OfferEntry from a tradable inventory entry by copying entryId and choosing the amount. Do not include kind, clientPayload, or serverPayload in selectedOffer; the server resolves those fields from the player's current inventory before it reserves or commits the trade.

local selectedOffer = {
{ entryId = "item_8f3a2c", amount = 1 },
{ entryId = "coins", amount = 500 },
}

local response = TradeClient.setOffer(currentTradeId, selectedOffer)

if not response.ok then
warn("Offer update failed:", response.code)
return
end

renderTrade(response.snapshot)

For kind = "instance" entries, send amount = 1. For stack and currency entries, send an amount no larger than the inventory entry's available amount.

If the same entryId appears more than once, the server merges the amounts before validation. amount must be a positive whole number.

Changing an offer clears both players' ready state, so your UI should show both participants as unconfirmed after a successful offer update.

Confirm Or Cancel

When the local player is done editing their offer, call setReady.

local response = TradeClient.setReady(currentTradeId)

if not response.ok then
warn("Confirm failed:", response.code)
return
end

renderTrade(response.snapshot)

When both players are ready, the trade moves out of NEGOTIATING and the server begins reservation and commit work. Lock offer editing once the snapshot state is not NEGOTIATING.

To cancel:

local response = TradeClient.cancelTrade(currentTradeId)

if not response.ok then
warn("Cancel failed:", response.code)
return
end

if response.snapshot then
renderTrade(response.snapshot)
end

Cancelling is allowed while a trade is still negotiable or reserved. The server rejects cancellation once the trade is actively processing.

Method Reference

MethodUse
initialize()Connects client remotes and event listeners. Call once during UI startup.
requestTrade(targetUserId, commandId?)Sends a trade request to another player.
respondToRequest(requestId, accepted, commandId?)Accepts or declines an incoming trade request.
setOffer(tradeId, offerEntries, commandId?)Replaces the local player's full offer with an OfferEntry[] list.
setReady(tradeId, commandId?)Marks the local player ready.
cancelTrade(tradeId, commandId?)Cancels the trade when cancellation is still allowed.
getTradableInventory()Loads client-safe tradable inventory entries for the local player.
getCachedTrade(tradeId)Returns the last snapshot received for a trade, if one is cached.
getPendingRequest(requestId)Returns a cached incoming request packet.
clearPendingRequest(requestId)Removes a request from the local pending request cache.
createCommandId()Creates a GUID you can pass to a mutating method for idempotent retry handling.

The optional commandId arguments are useful when you retry a request after an uncertain client-side failure. Reuse the same command id only for the same attempted action.

Signal Reference

SignalPayload
onTradeRequest{ requestId, fromUserId, fromName }
onTradeUpdatedTradeSnapshot for active trade changes.
onTradeClosedTradeSnapshot for COMMITTED, CANCELLED, EXPIRED, or FAILED trades.

Response Shape

TradeClient methods return tables.

{
ok = true,
code = "offer_updated",
tradeId = "...",
snapshot = tradeSnapshot,
}

Failed responses use the same shape with ok = false and a code value.

{
ok = false,
code = "trade_locked",
tradeId = "...",
state = "RESERVED",
}

Most successful trade-mutating responses include snapshot. Render from that snapshot immediately, then keep listening for pushed updates.

Trade Snapshot Shape

{
tradeId = "...",
state = "NEGOTIATING",
initiatorUserId = 123,
initiatorName = "PlayerA",
recipientUserId = 456,
recipientName = "PlayerB",
offerA = {},
offerB = {},
readyA = false,
readyB = false,
revision = 0,
expiresAt = 1710000000,
reservedAt = nil,
commitAfter = nil,
readyDelay = 3,
serverNow = 1710000000,
lastError = nil,
}

offerA belongs to the initiator and offerB belongs to the recipient. Compare the snapshot user ids to Players.LocalPlayer.UserId before deciding which side is "you" in your UI.

State Guide

StateUI behavior
NEGOTIATINGOffers are editable. Players can confirm or cancel.
RESERVINGThe server is locking offered inventory. Disable offer editing and cancel controls.
RESERVEDInventory is locked and the ready delay is running. Show a countdown from commitAfter if you want one.
COMMITTINGThe server is moving items. Disable trade controls.
COMMITTEDTrade completed successfully. Close the UI and show success.
CANCELLEDTrade was cancelled. Close the UI.
EXPIREDTrade timed out or a participant left too long. Close the UI.
FAILEDTrade failed. Close the UI and show lastError when present.