# esx_skin (/docs/core/esx_skin)

`esx_skin` is the ESX Legacy character creator. It opens a Svelte NUI on top of [skinchanger](/docs/core/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 [#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](/docs/core/skinchanger), `esx_lib`, `oxmysql` |
| Database | `users.skin` (JSON) |

## How saving works [#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:save` while they have an **edit session**. Sessions are created on the first login of a player without a skin, by the `/skin` command, and by the `BeginSkinEdit` export. A session expires after `Config.SkinEditSessionTTL` and 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 [#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`) |

<Callout type="warn">
If you add custom components to skinchanger, also add them to `Config.SkinFields`. Otherwise every save fails with `invalid_appearance:<field>:unknown_field`.
</Callout>

## Server exports [#server-exports]

### SaveSkin [#saveskin]

```lua
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>`.

```lua title="server/main.lua"
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 [#saveoutfit]

```lua
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. |

```lua title="server/main.lua"
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 [#getskin]

```lua
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 [#beginskinedit]

```lua
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.

```lua title="server/main.lua"
RegisterCommand('plasticsurgery', function(source)
    if exports.esx_skin:BeginSkinEdit(source) then
        TriggerClientEvent('esx_skin:openSaveableMenu', source)
    end
end, true)
```

### ValidateSkin [#validateskin]

```lua
local clean = exports.esx_skin:ValidateSkin(skin)
```

Returns a decoded copy of the skin if it passes `Config.SkinFields` validation, otherwise `nil`.

### SetBackpackCapacity [#setbackpackcapacity]

```lua
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 [#server-callback]

`esx_skin:getPlayerSkin` returns the stored skin and the job outfits:

```lua title="client/main.lua"
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 [#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.

```lua title="client/main.lua"
-- 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' })
```

<Callout type="error">
Never add an unauthenticated server event that saves whatever skin the client sends. Validate payment or permission first, then call `SaveSkin` with a scope.
</Callout>

## Commands [#commands]

| Command | Permission | Arguments | Description |
| --- | --- | --- | --- |
| `/skin` | `admin` | `playerId` (optional) | Opens a saveable editor for yourself or the target player. |

## Common issues [#common-issues]

- **"Your appearance could not be saved".** Check the F8 console for `[esx_skin] Appearance save failed: <reason>`. `edit_session_missing` means no `BeginSkinEdit` was called; `invalid_appearance:...` means a field is unknown or out of range; `rate_limited` means saves come too fast.
- **Old shop scripts stop saving.** Scripts that open `esx_skin:openSaveableMenu` or fire `esx_skin:save` from the client without a server-side `BeginSkinEdit` no longer work. Move the save to the server and use `SaveSkin` with a scope.
- **Backpack weight does not apply.** With `Config.AllowClothingBackpackCapacity = false` you must call `SetBackpackCapacity`. It never applies when es_extended uses ox_inventory or another custom inventory.
- **`esx_skin:setWeight` does nothing.** It is kept as an empty handler for compatibility.

## Related [#related]

- [skinchanger](/docs/core/skinchanger)
- [esx_identity](/docs/core/esx_identity)
- [esx_multicharacter](/docs/core/esx_multicharacter)
- [Callbacks guide](/docs/guides/callbacks)
