DocumentationAPI v1
Guide · Server assets

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 as lspd.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.

Lua
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',
    })
end

Create a category

Shell
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.

Lua
-- 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:

Shell
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:

JSON
{
    "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:

Lua
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:

Lua
RegisterNetEvent('admin:logoTicket', function(url)
    SendNUIMessage({ action = 'logoTicket', url = url })
end)

In the NUI, once the admin has cropped the image on a canvas:

JavaScript
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 answers 403 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):

JSON
{
    "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

Lua
-- 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.