DocumentationAPI v1
Guide · Player space

Player space

Let your players find the photos, clips and voice notes they took, by signing in with Discord, Steam or Cfx.re.

What your players get

At /player, a player signs in with Discord, Steam or their Cfx.re account, the accounts FiveM and RedM already know, and finds every photo, clip and voice note your server sent under their name, newest first, server by server. They can link all three accounts to one space, and delete it whenever they want.

A media reaches a player only when all of these hold:

  1. Your organisation opened the space, in Settings, Player space, and shows that kind of media. Images and clips are shown by default; voice notes are your choice, since they can carry other players' voices.
  2. The key that sent it stays on your game server. Tick "Feeds the player space" on that key, on the keys screen. Never tick it on a key a phone script holds: lb-phone sends every upload from the player's game, so whoever holds that key could put files under somebody else's name. Leave an anticheat or staff capture key unticked too.
  3. The upload carries the player's identifiers, as below.

Media uploaded before all three were true do not appear: nothing is attributed after the fact.

Send the identifiers with an upload

Add the player-metadata header. The simplest form forwards what FiveM already gives the server:

JSON
{
    "name": "Ana Kowalska",
    "identifier": "char1:4b1d2a9e...",
    "identifiers": [
        "license:4b1d2a9e...",
        "discord:<DISCORD_ID>",
        "steam:1100001025e4c00"
    ]
}

identifiers is the list GetPlayerIdentifiers(source) returns. Aperture keeps discord, steam, fivem, license and license2, and drops the rest, the player's address first. Named keys ("discord": "1837...") work too and win over the list. name and identifier are what the library shows, as before.

A server-side capture with screenshot-basic, uploaded by the server itself:

Lua
exports['screenshot-basic']:requestClientScreenshot(source, { encoding = 'webp' }, function(err, data)
    if err then return end

    PerformHttpRequest('https://your-instance/api/v1/uploads/base64', function(status, body)
        print(status, body)
    end, 'POST', json.encode({ base64 = data, filename = 'capture.webp' }), {
        ['Content-Type'] = 'application/json',
        ['Authorization'] = 'YOUR_SERVER_TOKEN',
        ['player-metadata'] = json.encode({
            name = GetPlayerName(source),
            identifiers = GetPlayerIdentifiers(source),
        }),
    })
end)

lb-phone: upload tickets

lb-phone uploads from the player's game, so it can neither keep a key secret nor be believed about who took the photo. An upload ticket fixes both: the server, which keeps its key, asks Aperture for a single-use address with the player's identifiers on it, and the phone uploads there without any key.

lb-phone already has the hook for it: GetPresignedUrl(source, uploadType) in server/custom/functions/functions.lua runs on the server, with the player's source, whenever a block's url reads PRESIGNED_URL.

In shared/upload.lua, add a method beside Custom:

Lua
Aperture = {
    Default = {
        url = "PRESIGNED_URL",
        field = "file",
        error   = { path = "status", value = "error" },
        success = { path = "data.url" },
    },
},

No headers: the phone needs none. Point the three kinds at it in config/config.lua:

Lua
Config.UploadMethod.Video = "Aperture"
Config.UploadMethod.Image = "Aperture"
Config.UploadMethod.Audio = "Aperture"

Then, in GetPresignedUrl, before the warning at the end of the function:

Lua
if uploadMethod == "Aperture" then
    return Citizen.Await(promise.new(function(p)
        PerformHttpRequest("https://your-instance/api/v1/upload-tickets", function(status, body)
            if status ~= 201 then
                infoprint("error", "Aperture refused the upload ticket: " .. tostring(body))
                return p:resolve()
            end

            p:resolve(json.decode(body)?.data?.url)
        end, "POST", json.encode({
            kind = string.lower(uploadType),
            player = {
                identifier = GetIdentifier(source),
                name = GetPlayerName(source),
                identifiers = GetPlayerIdentifiers(source),
            },
        }), {
            ["Content-Type"] = "application/json",
            ["Authorization"] = apiKey,
        })
    end))
end

apiKey is API_KEYS[uploadType], from server/apiKeys.lua: with this method it never leaves the server. Give each key its kind's upload scope, tick "Feeds the player space" on all three, and give them to no other script.

This was written against lb-phone 2.9.0 and has not been run on a server yet. Send one photo, one clip and one voice note before you rely on it.

The ticket routes

POST /api/v1/upload-tickets, with your key, body { "kind": "image" | "video" | "audio", "player": { ... } }, the same player object as the header. Answers 201:

JSON
{
    "status": "ok",
    "data": {
        "url": "https://your-instance/api/v1/player-uploads/apu_...",
        "field": "file",
        "kind": "image",
        "expires_at": "2026-10-28T20:10:00+00:00"
    }
}

POST the file to url, multipart, field file, without any key. The answer is exactly what /api/v1/uploads answers. A ticket works once, for ten minutes, for its kind only, and dies with the key that minted it.

Code What it means
upload_ticket_invalid Unknown, expired, used, or its key was revoked. Ask for a new one
unsupported_media_type Not the kind the ticket was minted for (details.expected_kind). The ticket stays usable
insufficient_scope The key cannot upload that kind
feature_not_enabled Upload tickets are not enabled for your organisation