Same routes, same responses

Switch hosts without rewriting your code.

Aperture answers on the paths your scripts already call, with the same response bodies: v3 and v2 compatible routes, the legacy API's routes and lb-upload. In your scripts, you change the host and the key, nothing else. lb-phone moves to its Custom method.

Examples for lb-phone, screenshot-basic and Lua further down, for you to fill in with your keys. To roll back, put back the old URL (for lb-phone, the old method) and the old keys.

Your script sendsField file
  POST /api/v3/file
- Host: old-host.example+ Host: aperture-app.preview.moncompanion.cloud- Authorization: <your old key>+ Authorization: apt_7Hq2kR4n_••••••••  Content-Type: multipart/form-data

  file = screenshot_1730041122.webp
Aperture answers 200 OK
{
  "status": "ok",
  "data": {
    "id": "01K2R7M8QF3V9XZ4B6NCDT0YAH",
    "url": "https://aperture-app.preview.moncompanion.cloud/aperture-game/01K2R7M8QF3V9XZ4B6NCDT0YAH.webp",
    "originalUrl": "https://aperture-app.preview.moncompanion.cloud/aperture-game/01K2R7M8QF3V9XZ4B6NCDT0YAH.webp"
  }
}
Supported Same path, same body. Your script reads data.url in the same place, with no meta block added.
Your script sendsField video
  POST /api/v2/video
- Host: old-host.example+ Host: aperture-app.preview.moncompanion.cloud- Authorization: <your old key>+ Authorization: apt_7Hq2kR4n_••••••••  Content-Type: multipart/form-data

  video = clip_police_poursuite.webm
Aperture answers 200 OK
{
  "data": {
    "id": "01K2R7Q4ZC8M1W6YB0TNHD5XGE",
    "url": "https://aperture-app.preview.moncompanion.cloud/aperture-game/01K2R7Q4ZC8M1W6YB0TNHD5XGE.webm"
  }
}
Supported The kind in the URL picks the file field. The stored kind comes from the bytes, as on every route: a video posted to /api/v2/image is stored as a video.
Your script sendsField recording
  POST /api/audio
- Host: old-host.example+ Host: aperture-app.preview.moncompanion.cloud- Authorization: <your old key>+ Authorization: apt_7Hq2kR4n_••••••••  Content-Type: multipart/form-data

  recording = vocal_dispatch_0412.ogg
Aperture answers 200 OK
{
  "url": "https://aperture-app.preview.moncompanion.cloud/aperture-game/01K2R7V9D3HJ5QK2N8RXA6MFTB.ogg"
}
Supported The oldest shape, read out of an older lb-phone shared/upload.lua: audio field recording, url at the root.
Your script sendsField file
  POST /lb-upload/
- Host: old-host.example+ Host: aperture-app.preview.moncompanion.cloud- Authorization: <your old key>+ Authorization: apt_7Hq2kR4n_••••••••  Content-Type: multipart/form-data

  file = appareil-photo_8812.webp
Aperture answers 200 OK
{
  "filename": "01K2R7XB6P0TGZ4E9JW3SQ1NCV.webp",
  "link": "https://aperture-app.preview.moncompanion.cloud/aperture-game/01K2R7XB6P0TGZ4E9JW3SQ1NCV.webp"
}
Supported Errors keep the { success: false, error } shape the phone reads. /lb-upload and /lb-upload/ are both accepted.

4

families of compatible routes, on the paths and with the responses your scripts already know.

3

ways to present the key: Authorization: Bearer, the raw header, or ?apiKey=.

120

requests per minute per key, by default. Counted per key, not per IP address.

512 MB

per video, 32 MB per image and 64 MB per audio file, checked before anything is written.

Read from this instance's configuration and from its API's code.

01 · In four steps

Only one step touches your scripts. The host and the key, or lb-phone's method.

Detailed guide
New organisationStep 1 of 4

Lowercase letters, digits and hyphens.

Platform
Your files will be served at

https://aperture-app.preview.moncompanion.cloud/aperture-game/01K2R7M8QF3V9XZ4B6NCDT0YAH.webp

The identifier is decorative: renaming the organisation breaks no link already handed out.

The quota, the members and the keys live here.
Create a keyStep 2 of 4
Scopes
Your new key Shown only once

Only its fingerprint is kept: lost, it is replaced, never recovered.

Stricter: lb-phone takes one key per kind in apiKeys.lua. An image key that leaks cannot upload video.
Step 3 of 4
-- v3 and v2 compatible routes, the legacy API's routes: only the host changes
- https://old-host.example/api/v3/file+ https://aperture-app.preview.moncompanion.cloud/api/v3/file- https://old-host.example/api/v2/video+ https://aperture-app.preview.moncompanion.cloud/api/v2/video- https://old-host.example/api/audio+ https://aperture-app.preview.moncompanion.cloud/api/audio-- lb-upload: its whole base URL changes
- https://old-host.example/+ https://aperture-app.preview.moncompanion.cloud/lb-upload/
-- The key: the Authorization header, or server/apiKeys.lua for lb-phone
- <your old key>+ apt_7Hq2kR4n_••••••••
-- lb-phone/config/config.lua, the three kinds move to the Custom method
- Config.UploadMethod.Image = "<your current method>"+ Config.UploadMethod.Image = "Custom"- Config.UploadMethod.Video = "<your current method>"+ Config.UploadMethod.Video = "Custom"- Config.UploadMethod.Audio = "<your current method>"+ Config.UploadMethod.Audio = "Custom"  Config.UploadWhitelistedDomains = {
      -- the hosts already listed stay
+     "aperture-app.preview.moncompanion.cloud", -- delivery host  }
-- lb-phone/shared/upload.lua, each kind's block in UploadMethods.Custom, given in full in the configurations below
+ url = "https://aperture-app.preview.moncompanion.cloud/api/v1/uploads",+ headers = { ["Authorization"] = "API_KEY" },-- lb-phone/server/apiKeys.lua, one Aperture key per kind, in place of the old ones
- Image = "<your old key>",+ Image = "YOUR_IMAGE_KEY",- Video = "<your old key>",+ Video = "YOUR_VIDEO_KEY",- Audio = "<your old key>",+ Audio = "YOUR_AUDIO_KEY",

lb-phone 2.9.0 changes method. Its hosted upload methods have no address to change (url = "PRESIGNED_URL") and send no Authorization header. Point one at this instance and the upload never lands: it still goes to your old host, or arrives here without a key and gets missing_token. The Custom method carries the URL and the key.

The step people forget. Without aperture-app.preview.moncompanion.cloud in lb-phone's list, the upload succeeds, the URL answers, and the photo never appears in the phone.

terminal Step 4 of 4
# What the key can do, without opening the dashboard
curl https://aperture-app.preview.moncompanion.cloud/api/v1/token \
  -H "Authorization: apt_7Hq2kR4n_••••••••"

{
  "status": "ok",
  "data": {
    "name": "lb-phone production",
    "prefix": "apt_7Hq2kR4n",
    "scopes": ["upload-image", "upload-video", "upload-audio"],
    "rate_limit_per_minute": 120,
    "organization": {
      "slug": "aperture-game",
      "name": "Aperture Game"
    }
  }
}
# Excerpt: the response also gives the organisation's remaining quota and features.
AGFirst upload from the gameSample Live
screenshot_1730041122.webplb-phone production · 2 s ago 412 KB Ready
No player on this one: lb-phone's Custom block sends no player-metadata header. A script that sends one gets its player shown here.
02 · Configurations to copy

Your tool, your block. Only the keys are left to fill in.

-- lb-phone/shared/upload.lua, inside UploadMethods.Custom
Image = {
    url = "https://aperture-app.preview.moncompanion.cloud/api/v1/uploads",
    field = "file",
    headers = { ["Authorization"] = "API_KEY" }, -- leave API_KEY as written: lb-phone swaps in the key for that kind
    error   = { path = "status", value = "error" },
    success = { path = "data.url" },
},
-- Video and Audio: the same block, API_KEY included

-- lb-phone/server/apiKeys.lua, server side, out of shared/ which every player downloads; the phone still receives the key at each upload
API_KEYS = {
    Image = "YOUR_IMAGE_KEY",
    Video = "YOUR_VIDEO_KEY",
    Audio = "YOUR_AUDIO_KEY",
}

-- lb-phone/config/config.lua
Config.UploadMethod.Image = "Custom"
Config.UploadMethod.Video = "Custom"
Config.UploadMethod.Audio = "Custom"
Config.UploadWhitelistedDomains = {
    -- add the delivery host to the hosts already listed
    "aperture-app.preview.moncompanion.cloud",
}
-- your resource's client.lua
exports['screenshot-basic']:requestScreenshotUpload(
    'https://aperture-app.preview.moncompanion.cloud/api/v1/uploads',
    'file',
    { headers = { Authorization = 'YOUR_KEY' } },
    function(data)
        local response = json.decode(data)

        if response.status == 'ok' then
            print(response.data.url)
        else
            print('upload refused: ' .. response.error.code)
        end
    end
)
-- server.lua: no multipart here, we send base64
local url = 'https://aperture-app.preview.moncompanion.cloud/api/v1/uploads/base64'
-- one value per upload, reused as is to retry that same upload
local uploadId = ('%s-%d-%d'):format(GetCurrentResourceName(), os.time(), GetGameTimer())

PerformHttpRequest(url, function(status, body)
    local response = json.decode(body)

    if status == 200 then
        print(response.data.url)
    else
        print('refused: ' .. (response.error and response.error.code or status))
    end
end, 'POST', json.encode({
    base64 = captureBase64, -- raw base64 or a data URI
    filename = 'carte_cayo.png',
}), {
    ['Content-Type'] = 'application/json',
    ['Authorization'] = 'YOUR_KEY',
    ['Idempotency-Key'] = uploadId,
})
lb-phone, Custom methodWritten for 2.9.0

The way to move lb-phone 2.9.0. Its hosted upload methods hold url = "PRESIGNED_URL", an address asked of the host at each upload, and send no Authorization header: point one at this instance and the upload never lands. The Custom method takes this URL and the Authorization header, which lb-phone fills from its API_KEY placeholder with the key of that kind. The keys go in server/apiKeys.lua, a server file, rather than in shared/upload.lua, which FiveM sends to every player. They still reach the phone of each player who uploads: give them the upload scope and nothing else.

Route
POST /api/v1/uploads
Scopes
upload-imageupload-videoupload-audioThree keys, one per kind in apiKeys.lua, each limited to its own kind: a key that leaks can then upload only that kind.
Response read
data.url on success, status = "error" on failure.

Add aperture-app.preview.moncompanion.cloud to Config.UploadWhitelistedDomains, or the photos never appear in the phone.

Leave Idempotency-Key and player-metadata out of this block. lb-phone sends the same headers with every upload, so a fixed Idempotency-Key would answer every capture with the first one's URL for a day. And the phone cannot carry a player's name in a header: a character outside Latin-1 blocks the upload, and an accented letter arrives unreadable.

screenshot-basicLua export

Direct upload from a FiveM resource. Branch your logic on response.error.code, never on the message: the codes are stable and never translated.

Route
POST /api/v1/uploads
Scope
upload-image and nothing else.
Useful codes
invalid_tokentoken_revokedinsufficient_scopequota_exceededrate_limitedunsupported_media_type

An old build without headers? Pass the key in the URL with ?apiKey=. It will end up in more logs: prefer the header as soon as you can.

Lua, PerformHttpRequestBase64

For a resource without a helper script: a base64 string or a data URI, in a JSON body.

Route
POST /api/v1/uploads/base64
Scope
The one for the kind sent, for example upload-image.
Limits
Metadata: 16 KB of JSON, 8 levels, 100 entries.

PerformHttpRequest has no dependable timeout: a response can get lost. With Idempotency-Key, the retry returns the first upload instead of storing a second one. One value per upload: reused for another file, it returns the first one for a day.

03 · Compatibility

Every route, its status. Read from the code, not assumed.

  • Supported Same path, same response.
  • Not supported Refused or missing: plan for something else.
Compatible routes and native API, with what each one reads and what each one returns
Route File field Response returned Status
Compatible routes
POST /api/v3/filev3 compatible routes file Success{ status, data: { id, url, originalUrl } } Error{ error } Supported originalUrl carries the same address as url.
POST /api/v3/file/base64v3 compatible routes base64, filename in JSON Success{ status, data: { id, url, originalUrl } } Error{ error } Supported Raw base64 or a data URI.
POST /api/v2/{image,video,audio}v2 compatible routes image, video, audio Success{ data: { id, url } } Error{ error } Supported The kind in the URL picks the file field, the stored kind comes from the bytes: a video posted to /api/v2/image is stored as a video.
POST /api/{image,video,audio}Legacy API compatible routes image, video, recording Success{ url } Error{ error } Supported url at the root, as older lb-phone builds read it. The kind in the URL picks the file field, the stored kind comes from the bytes.
POST /lb-uploadLBUpload file Success{ filename, link } Error{ success: false, error } Supported /lb-upload/ too, with the trailing slash.
Native API, recommended for a new configuration
POST /api/v1/uploadsAperture v1 file Success{ status, data: { id, url, … }, meta } Error{ status, error: { code, message, docs }, meta } Supported Stable error codes, never translated.
POST /api/v1/uploads/base64Aperture v1 base64, filename in JSON Success{ status, data: { id, url, … }, meta } Error{ status, error: { code, message, docs }, meta } Supported For PerformHttpRequest, without multipart.
Not supported
Any other route of your old host None 404 route_not_found Not supported Missing from the rows above.
Webhooks None Planned, not built yet. Not supported
Taking over files already hosted None No import: your old links stay served by your old host. Not supported
Key

3 accepted forms

Authorization: Bearer, the raw header FiveM resources send, or ?apiKey= for screenshot-basic.

Responses

Never altered

Compatible routes get neither the meta block nor any added field: lb-phone reads its response by path, and one extra field can break it.

Rate

120 requests per minute per key

By default, and per key rather than per IP address: on a FiveM server, every player uploads through the same address. Beyond it, the request is refused with HTTP 429, on compatible routes too, with a Retry-After header giving the seconds to wait, and X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset, which the response to every accepted request carries as well. The native API also gives the rate_limited code and error.details.retry_after_seconds, which counts one second less than Retry-After: go by the header.

04 · Your existing files

What about my existing files? They stay where they are.

Aperture does not reach into another host's storage, and the product offers no import. Your old links answer as long as your old account exists; new uploads go to Aperture as soon as the host changes.

Keep the old account open. People often leave a host because the account is about to close, and a link written into the phone's database outlives that decision. Once closed, the account takes those links with it.

No automatic import

For an old file to live here, upload it again through the API like any new upload: it gets a new URL, and the links already saved in game do not change.

Switchover checklist

Tick as you go. Nothing is saved.

0 of 10
Switchover complete. Full migration guide
05 · Questions

What people ask us before switching. Short answers.

No. Aperture answers on the paths your resources already call (/api/v3/file, /api/v2/…, /api/… and /lb-upload), with the same response bodies: you change the host and the key. lb-phone 2.9.0 is the exception, and its change is in its configuration rather than its code: it moves to its Custom method.

Because in lb-phone 2.9.0, the hosted upload methods have no address to change: their block holds url = "PRESIGNED_URL", an address lb-phone asks the host for at each upload, and sends no Authorization header. With this instance's URL typed there, the upload never lands: it still goes to your old host, or arrives here without a key and gets missing_token. The Custom method points at /api/v1/uploads, sends the Authorization header filled from lb-phone's API_KEY placeholder, and reads data.url in the response.

lb-phone only accepts links from the hosts in its Config.UploadWhitelistedDomains list. Add aperture-app.preview.moncompanion.cloud, the host that serves the files. While it is missing, the server console reports a link that is not allowed on every attempt.

No. Create an Aperture key, which starts with apt_. An unknown key is refused with the invalid_token code (HTTP 401).

Put the old URL and the old keys back. For lb-phone, set Config.UploadMethod back to your previous method and put the old keys back in server/apiKeys.lua. Aperture rewrites nothing in your server's database, and files already uploaded here keep their URLs whether or not a script still points here.

The player behind an upload: when a script sends a player-metadata header, Aperture records it and the media library shows the player. And retries without duplicates: with Idempotency-Key, one value per upload, a retry returns the first upload for a day. lb-phone gains neither, since its Custom block sends the same headers with every upload.

New uploads are refused with the quota_exceeded code (HTTP 413). You are warned before, at 80 and 90 %. Files already online stay served: delivery never stops over a quota.

Up to 32 MB per image, 512 MB per video and 64 MB per audio file on this instance. Beyond that, the upload is refused with the file_too_large code, before anything is written.

Still have a question?

The migration guide goes through every step, route by route.

Read the guide
Switch in four steps

Change one host. Keep your scripts.

Create the organisation, generate a key, replace the host and the old key, and move lb-phone to its Custom method. If something is off, put back the old URL, the old method and the old keys: nothing was rewritten on your side.