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.
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
{ "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" } }
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
{ "data": { "id": "01K2R7Q4ZC8M1W6YB0TNHD5XGE", "url": "https://aperture-app.preview.moncompanion.cloud/aperture-game/01K2R7Q4ZC8M1W6YB0TNHD5XGE.webm" } }
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
{ "url": "https://aperture-app.preview.moncompanion.cloud/aperture-game/01K2R7V9D3HJ5QK2N8RXA6MFTB.ogg" }
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
{ "filename": "01K2R7XB6P0TGZ4E9JW3SQ1NCV.webp", "link": "https://aperture-app.preview.moncompanion.cloud/aperture-game/01K2R7XB6P0TGZ4E9JW3SQ1NCV.webp" }
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.
Only one step touches your scripts. The host and the key, or lb-phone's method.
Lowercase letters, digits and hyphens.
https://aperture-app.preview.moncompanion.cloud/aperture-game/01K2R7M8QF3V9XZ4B6NCDT0YAH.webp
The identifier is decorative: renaming the organisation breaks no link already handed out.
Only its fingerprint is kept: lost, it is replaced, never recovered.
-- 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.
# 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.
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, })
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.
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.
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.
Every route, its status. Read from the code, not assumed.
- Supported Same path, same response.
- Not supported Refused or missing: plan for something else.
| 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 |
3 accepted forms
Authorization: Bearer, the raw header FiveM resources send, or ?apiKey= for screenshot-basic.
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.
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.
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.
What people ask us before switching. Short answers.
Still have a question?
The migration guide goes through every step, route by route.
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.