Server assets
Logos, interface images and sounds your server owns: categories, versions, stable URLs and NUI uploads.
What a server asset is
Player media (phone photos, screenshots, voice notes) are disposable by design: retention may purge them and quota caps them. A company logo or an interface image is neither. Server assets are the second family of files Aperture keeps, for everything your server itself owns:
- Never purged by retention, whatever the organisation's retention policy.
- Out of quota. An optional storage alert warns you when they grow past a threshold you choose (dashboard, Assets page).
- Grouped in categories you create (for example
logos,ui,sounds), each with its own upload rules, and addressed by a readable key such aslspd.logo. - Versioned without limit. Every upload is a new version; you can serve an older one again at any time.
- Two URLs. A stable URL that always serves the current version, and an immutable URL per version.
Images are stored exactly as sent, byte for byte: crop and export them the way you want them shown. Video and audio are converted to WebM (VP9 and Opus) or Ogg Opus when a NUI could not play them as they are.
Before you start
- 1. A platform admin enables the Server assets feature for your organisation.
- 2. Create an API key with the Manage server assets scope (Tokens screen). Granting that scope requires the Manage server assets member permission, on top of Manage API tokens to create a key at all.
- 3. Keep that key on the game server only (a server convar, never a client file). A NUI runs on the player's machine and its code can be read; a NUI that needs to upload gets a single-use ticket instead, see below.
Every route below sits under /api/v1, answers in the usual envelope
(status, data or error, meta) and needs that key in the
Authorization header, except the ticket upload route which takes no key.
Addresses and naming
| Thing | Format | Example |
|---|---|---|
| Category slug | lowercase letters, digits, hyphens, starting with a letter or digit, up to 64. Permanent | logos |
| Asset key | lowercase letters, digits, ., _, -, starting with a letter or digit, up to 128 |
lspd.logo |
| Stable URL | https://your-delivery-host/{organisation}/assets/{category}/{key} |
.../aperture-game/assets/logos/lspd.logo |
The slug of a category cannot be renamed later: it is part of every stable URL inside it. Its display name and its rules can change at any time.
The organisation slug is part of the stable URL too, so renaming the organisation moves every stable asset URL. The per-version URLs are not affected.
A small Lua helper
The examples below use this helper on the server side.
local APERTURE = GetConvar('aperture_url', 'https://your-instance')
local TOKEN = GetConvar('aperture_assets_token', '')
local function aperture(method, path, body, cb)
PerformHttpRequest(APERTURE .. path, function(status, response)
cb(status, response and json.decode(response) or nil)
end, method, body and json.encode(body) or '', {
['Authorization'] = 'Bearer ' .. TOKEN,
['Content-Type'] = 'application/json',
['Accept'] = 'application/json',
})
endCreate a category
curl -X POST https://your-instance/api/v1/asset-categories \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"slug":"logos","name":"Company logos","rules":{"kinds":["image"],"aspect_ratio":"1:1","max_bytes":2000000}}'Every rule is optional:
| Rule | Meaning |
|---|---|
kinds |
Accepted kinds: any of image, video, audio. Empty accepts all |
max_bytes |
Largest file accepted, in bytes |
max_width, max_height |
Largest image size in pixels (images only) |
aspect_ratio |
Expected ratio such as 1:1 or 16:9, with a one percent tolerance (images only) |
A file that breaks a rule is refused with asset_rule_violated, and
error.details.rule names the rule (kinds, max_bytes, max_width,
max_height, aspect_ratio) with expected and actual next to it.
GET /asset-categories lists the categories, PATCH /asset-categories/{slug}
changes the display name or the rules of one (sending "rules": {} clears
them), and DELETE /asset-categories/{slug} removes an empty one. New rules
apply from the next upload: versions already stored keep being served.
Upload from the server
The first upload to a key creates the asset; every later one adds a version.
-- base64 is what PerformHttpRequest can send without building a multipart body
aperture('POST', '/api/v1/assets/logos/lspd.logo/base64', {
base64 = imageBase64,
filename = 'lspd.png',
}, function(status, res)
if status ~= 201 then
print('asset upload failed: ' .. (res and res.error.code or status))
return
end
print('served at ' .. res.data.asset.url .. ', version ' .. res.data.version.version)
end)The same with a file from a terminal:
curl -X POST https://your-instance/api/v1/assets/logos/lspd.logo \
-H "Authorization: Bearer $TOKEN" -F "file=@lspd.png"The answer (201) carries the asset and the version just stored:
{
"status": "ok",
"data": {
"asset": {
"id": "01K2...",
"category": "logos",
"key": "lspd.logo",
"url": "https://cdn.example/aperture-game/assets/logos/lspd.logo",
"current_version": 3,
"latest_version": 3
},
"version": {
"version": 3,
"status": "ready",
"kind": "image",
"mime": "image/png",
"width": 512,
"height": 512,
"url": "https://cdn.example/aperture-game/01K2....png"
}
}
}Upload from a NUI with a ticket
This is the flow for an in-game admin panel where the admin picks and crops a file in the browser UI:
- 1. The NUI asks your server for an upload slot.
- 2. Your server checks the player is allowed, then asks Aperture for a ticket bound to one key. Aperture cannot make that check: it does not know who is in game.
- 3. Your server sends the ticket URL to that player's NUI.
- 4. The NUI posts the file to the ticket URL, with no API key.
Server side:
RegisterNetEvent('admin:requestLogoUpload', function(company)
local src = source
if not IsPlayerAceAllowed(src, 'admin.assets') then
return
end
local key = ('%s.logo'):format(company) -- must be lowercase
aperture('POST', ('/api/v1/assets/logos/%s/tickets'):format(key), nil, function(status, res)
if status ~= 201 then
return
end
TriggerClientEvent('admin:logoTicket', src, res.data.upload_url)
end)
end)Client side, hand it to the NUI:
RegisterNetEvent('admin:logoTicket', function(url)
SendNUIMessage({ action = 'logoTicket', url = url })
end)In the NUI, once the admin has cropped the image on a canvas:
window.addEventListener("message", async (event) => {
if (event.data.action !== "logoTicket") return;
const blob = await new Promise((resolve) =>
croppedCanvas.toBlob(resolve, "image/png"),
);
const form = new FormData();
form.append("file", blob, "logo.png");
const response = await fetch(event.data.url, {
method: "POST",
body: form,
});
const body = await response.json();
if (body.status === "ok") {
// the version in the query string skips the minute of cache, see below
showLogo(`${body.data.asset.url}?v=${body.data.asset.current_version}`);
} else if (body.error.code === "asset_rule_violated") {
showError(
`Rule ${body.error.details.rule}: expected ${body.error.details.expected}`,
);
} else {
showError(body.error.code);
}
});The API answers cross-origin requests from any origin, so the NUI (whose
origin is https://cfx-nui-<resource>) can post to the ticket URL and read the
answer without any extra setup.
What a ticket guarantees:
- It uploads to one category and key, once, and must be used within 60 seconds of being issued. The ticket is checked when Aperture starts handling the request, which is once the whole file has been received, so the transfer itself has to finish inside that minute. Ask for it when the admin confirms, not when the form opens.
- A refused file gives the ticket back: the admin can fix the crop and resend within the same minute. Once a version is stored the ticket is spent.
- It dies with the key that issued it: revoking the key voids its tickets.
- Unknown, expired, used and voided tickets all answer
401 upload_ticket_invalid. If the Server assets feature is switched off meanwhile, the ticket answers403 feature_not_enabled. - The ticket route carries no key, so it is limited per address instead: more than 30 attempts in a minute from one IP answer
429 rate_limited.
The ticket response (201):
{
"status": "ok",
"data": {
"ticket": "apt_...",
"upload_url": "https://your-instance/api/v1/asset-uploads/apt_...",
"field": "file",
"category": "logos",
"key": "lspd.logo",
"expires_at": "2026-10-24T18:02:11+00:00"
}
}Use the stable URL in your UIs
Store and display asset.url, never a version URL. It always serves the
current version with a one minute cache, so a replaced or rolled back logo
reaches every player within about a minute. An unchanged file revalidates with
a body-less 304.
To show a new version immediately, for example right after an admin uploaded
it, add the version as a cache buster: asset.url .. '?v=' .. asset.current_version.
The stable URL ignores the query string and serves the current version as
usual.
asset.url is null while no version can be served yet: the first upload of a
video or a sound is still being converted (or its conversion failed), or every
version is in the recycle bin.
Read the catalogue
-- every logo, for example at resource start
aperture('GET', '/api/v1/assets?category=logos&per_page=100', nil, function(status, res)
for _, asset in ipairs(res.data) do
Logos[asset.key] = asset.url
end
end)The list is cursor paginated: follow pagination.next_cursor with
&cursor=... until it is null. GET /assets/{category}/{key} returns one
asset with its whole version history.
Versions, rollback and deletion
| Request | Effect |
|---|---|
POST /assets/{category}/{key}/rollback with {"version": 2} |
Serve version 2 at the stable URL. Nothing is copied or renumbered |
DELETE /assets/{category}/{key}/versions/{n} |
Delete one version: its own URL stops answering at once and its file is erased after 7 days. The version being served is refused with asset_version_current: roll back first |
DELETE /assets/{category}/{key} |
The stable URL and every version URL stop answering at once. The asset and its versions stay in a 7 day recycle bin |
POST /assets/{category}/{key}/restore |
Bring a deleted asset back with the versions deleted along with it, within those 7 days. A version deleted on its own before stays deleted |
Uploading to a deleted key also brings it back, history included.
Video and audio
A video or audio version answers with "status": "processing" and a null
version URL while it is converted on the queue. It reaches the stable URL only
once it is ready, so players never receive a file their NUI cannot play. If a
newer version is ready first, or an admin rolled back meanwhile, the finished
conversion does not take the stable URL back. A conversion that fails leaves
the version failed, with the reason in failure_reason, and it never
reaches the stable URL.
Errors you will meet
| Code | Status | What to do |
|---|---|---|
feature_not_enabled |
403 | Ask the platform admin to enable Server assets |
insufficient_scope |
403 | The key lacks manage-assets. A new key only has it if you tick it, so a phone key does not |
asset_category_not_found |
404 | Create the category first, or check the slug |
asset_not_found |
404 | No asset with that category and key |
asset_version_not_found |
404 | The asset has no version with that number |
asset_category_exists |
409 | That slug is taken in your organisation |
asset_category_not_empty |
409 | Delete its assets first; deleted ones block it for their 7 days |
asset_version_current |
409 | Roll back to another version before deleting this one |
asset_version_not_ready |
409 | The version is still converting, or failed |
asset_rule_violated |
422 | Read error.details.rule, expected and actual |
invalid_metadata |
422 | A field is malformed; error.details.fields says which |
upload_ticket_invalid |
401 | Ask your server for a new ticket |
rate_limited |
429 | Wait error.details.retry_after_seconds, then retry |
The full reference, with every field, lives in the Assets section of the API reference on this page.