esx_skin
esx_skin is the ESX Legacy character creator and appearance saver. Server exports, client menu events, config keys, the /skin command and save errors explained.
esx_skin is the ESX Legacy character creator. It opens a Svelte NUI on top of skinchanger, lets players edit their ped, and stores the result in users.skin. Use its server exports when your own resource (clothing shop, barber, outfit wardrobe) needs to read or save a player's appearance.
Overview
| Resource | esx_skin |
| Version | 1.16.3 (fxmanifest, ships with ESX Legacy 1.16.4) |
| Side | Client (menu, camera) + server (validation, storage) |
| Dependencies | es_extended, skinchanger, esx_lib, oxmysql |
| Database | users.skin (JSON) |
How saving works
1.16 moved all skin storage to the server and hardened it:
- Every skin is validated against
Config.SkinFields. Unknown keys, non-integers or out-of-range values are rejected. - Clients can only save through
esx_skin:savewhile they have an edit session. Sessions are created on the first login of a player without a skin, by the/skincommand, and by theBeginSkinEditexport. A session expires afterConfig.SkinEditSessionTTLand is consumed by a successful save. - Saves are rate limited per player (
Config.SkinSaveCooldown) and queued per identifier. - Shops should save on the server with a scope (
clothes,barber,outfit) so the player cannot change fields the shop does not sell.
Configuration
| Key | Default | Description |
|---|---|---|
Config.Locale | GetConvar('esx:locale', 'en') | Language. |
Config.BackpackWeight | { [40] = 16, [41] = 20, [44] = 25, [45] = 23 } | Extra carry weight per bags_1 drawable. Ignored when es_extended uses a custom inventory. |
Config.AllowClothingBackpackCapacity | false | true: any worn bag adds weight. false: only bags granted with SetBackpackCapacity count. |
Config.SkinMaxBytes | 8192 | Maximum JSON size of a skin. |
Config.SkinSaveCooldown | 2000 | Rate limiter refill interval for saves, in ms (burst of 2). |
Config.SkinCacheTTL | 60000 | How long a loaded skin stays cached, in ms. |
Config.SkinFields | table | Allowed skin keys with { min, max }, for example sex = { 0, 1 }, mom = { 0, 45 }, nose_1 = { -10, 10 }, torso_1 = { 0, 4095 }. |
Config.SkinEditSessionTTL | 900000 | Lifetime of an edit session, in ms (15 minutes). |
Config.SkinEditFields | built in config | Field whitelists per scope: clothes, barber, outfit. |
Scopes are built in config.lua:
| Scope | Fields |
|---|---|
clothes | tshirt, torso, decals, pants, shoes, bags, chain, helmet, glasses, watches (_1 and _2), arms, arms_2 |
barber | beard_1-4, eyebrows_1-4, makeup_1-4, lipstick_1-4, hair_1, hair_2, hair_color_1, hair_color_2, ears_1, ears_2 |
outfit | all clothes fields plus mask, bproof, bracelets, ears (_1 and _2) |
If you add custom components to skinchanger, also add them to Config.SkinFields. Otherwise every save fails with invalid_appearance:<field>:unknown_field.
Server exports
SaveSkin
exports.esx_skin:SaveSkin(playerId, skin, callback, scope)| Name | Type | Description |
|---|---|---|
playerId | number | Server ID of a loaded player. |
skin | table | Full skin table (must include sex). |
callback | fun(success: boolean, reason?: string) | nil | Called when the write finished or failed. |
scope | "clothes" | "barber" | "outfit" | nil | Limits which fields may differ from the stored skin. nil saves the whole skin. |
Returns false when the request was rejected immediately, otherwise true (queued). With barber, only barber fields are copied onto the stored skin. With clothes or outfit, any change outside the scope is rejected with appearance_scope_mismatch:<field>.
xLib.callback.register('myshop:buyHaircut', function(source, skin)
local p = promise.new()
exports.esx_skin:SaveSkin(source, skin, function(success, reason)
p:resolve({ success = success, reason = reason })
end, 'barber')
return Citizen.Await(p)
end)SaveOutfit
exports.esx_skin:SaveOutfit(playerId, clothes, callback)| Name | Type | Description |
|---|---|---|
playerId | number | Server ID. |
clothes | table | Clothing values. Only outfit scope fields are copied; everything else is ignored. |
callback | fun(saved: boolean, skin?: table) | nil | Receives the merged skin on success. |
local policeUniform = { tshirt_1 = 58, tshirt_2 = 0, torso_1 = 55, torso_2 = 0, arms = 41, pants_1 = 25, pants_2 = 0, shoes_1 = 25, shoes_2 = 0 }
exports.esx_skin:SaveOutfit(source, policeUniform, function(saved, skin)
if saved then
TriggerClientEvent('skinchanger:loadSkin', source, skin)
end
end)GetSkin
local skin, ready = exports.esx_skin:GetSkin(playerId)Returns the stored skin table (or nil) and ready, which is false when the player is not loaded or the database read failed. Uses the cache when possible.
BeginSkinEdit
local ok = exports.esx_skin:BeginSkinEdit(playerId)Opens an edit session so the client may save its full skin once through the saveable menu. Returns false if the player is not loaded or a save is in flight.
RegisterCommand('plasticsurgery', function(source)
if exports.esx_skin:BeginSkinEdit(source) then
TriggerClientEvent('esx_skin:openSaveableMenu', source)
end
end, true)ValidateSkin
local clean = exports.esx_skin:ValidateSkin(skin)Returns a decoded copy of the skin if it passes Config.SkinFields validation, otherwise nil.
SetBackpackCapacity
local ok = exports.esx_skin:SetBackpackCapacity(playerId, bag)| Name | Type | Description |
|---|---|---|
playerId | number | Server ID. |
bag | number | nil | A key from Config.BackpackWeight, or nil to remove the bonus. |
Stores authorizedBackpack in the player's metadata and sets max weight to ESX MaxWeight + Config.BackpackWeight[bag]. Returns false for unknown bags.
Server callback
esx_skin:getPlayerSkin returns the stored skin and the job outfits:
xLib.callback('esx_skin:getPlayerSkin', false, function(skin, jobSkin)
if skin then
TriggerEvent('skinchanger:loadSkin', skin)
end
-- jobSkin = { skin_male = ..., skin_female = ... } from the player's job grade
end)It is also registered as a compat callback, so ESX.TriggerServerCallback('esx_skin:getPlayerSkin', cb) keeps working.
Client events
All menu events take Lua functions as arguments, so trigger them locally with TriggerEvent (or from the server without callbacks).
| Event | Arguments | Description |
|---|---|---|
esx_skin:openMenu | submitCb, cancelCb | Opens the editor without saving. |
esx_skin:openRestrictedMenu | submitCb, cancelCb, restrict | Same, limited to the component names in restrict. |
esx_skin:openSaveableMenu | submitCb?, cancelCb? | Opens the editor and saves on submit. Needs an edit session. |
esx_skin:openSaveableRestrictedMenu | submitCb?, cancelCb?, restrict | Saveable and restricted. |
esx_skin:getLastSkin | cb(skin) | Returns the skin before the menu was opened. |
esx_skin:setLastSkin | skin | Overrides the last skin, for example after a paid change. |
esx_skin:playerRegistered | none | Fired by esx_identity; loads the saved skin or opens the creator. |
esx_skin:resetFirstSpawn | none | Marks the next spawn as first spawn (used by identity and multicharacter). |
esx_skin:saveFailed | none | Server tells the client the legacy esx_skin:save net event failed. |
submitCb(data, menu) and cancelCb(data, menu) receive data.current (focused element), data.elements, and a menu handle with close(), update(_, element) and refresh(). When you cancel, the last skin is restored automatically.
-- shop: only show clothes, let the server decide whether to save
TriggerEvent('esx_skin:openRestrictedMenu', function(data, menu)
menu.close()
TriggerEvent('skinchanger:getSkin', function(skin)
TriggerServerEvent('myshop:buyClothes', skin) -- server calls SaveSkin(..., 'clothes')
end)
end, function(data, menu)
menu.close()
end, { 'tshirt_1', 'tshirt_2', 'torso_1', 'torso_2', 'arms', 'pants_1', 'pants_2', 'shoes_1', 'shoes_2' })Never add an unauthenticated server event that saves whatever skin the client sends. Validate payment or permission first, then call SaveSkin with a scope.
Commands
| Command | Permission | Arguments | Description |
|---|---|---|---|
/skin | admin | playerId (optional) | Opens a saveable editor for yourself or the target player. |
Common issues
- "Your appearance could not be saved". Check the F8 console for
[esx_skin] Appearance save failed: <reason>.edit_session_missingmeans noBeginSkinEditwas called;invalid_appearance:...means a field is unknown or out of range;rate_limitedmeans saves come too fast. - Old shop scripts stop saving. Scripts that open
esx_skin:openSaveableMenuor fireesx_skin:savefrom the client without a server-sideBeginSkinEditno longer work. Move the save to the server and useSaveSkinwith a scope. - Backpack weight does not apply. With
Config.AllowClothingBackpackCapacity = falseyou must callSetBackpackCapacity. It never applies when es_extended uses ox_inventory or another custom inventory. esx_skin:setWeightdoes nothing. It is kept as an empty handler for compatibility.
Related
Frequently asked questions
Independent documentation
Last updated on
esx_progressbar
esx_progressbar reference for ESX Legacy. Show timed progress bars with animations, freeze, onFinish and onCancel via ESX.Progressbar or the export.
skinchanger
skinchanger applies and reads ESX Legacy ped appearance on the client. All exports, events, skin keys and component config for freemode peds.