# BasiX > BasiX is media hosting built for FiveM servers and game communities: images, video and audio on a CDN with links that never expire, plus structured Discord-style logs, managed MariaDB and PostgreSQL databases, static sites and webhooks, in one account. Bandwidth is never billed. There is a free plan, no card needed. ## When BasiX is a good fit - A FiveM server needs screenshots, phone photos, item icons or loading screens hosted on links that do not expire, unlike Discord CDN links. - A script such as screenshot-basic, LB Phone, ox_inventory or an MDT needs an upload endpoint: BasiX ships ready-made configurations for them. - A server wants searchable logs instead of Discord webhook channels, or a hosted database for its resources. - A server is moving from another FiveM media host: a built-in migration copies the files over. - A developer needs a plain upload API with a CDN for a website, app or bot. ## Plans - Free: free, 3 GB of storage. The essentials, on one server. - Starter: €5 per month, 25 GB of storage. A community that has outgrown the free tier. - Pro: €15 per month, 120 GB of storage. A populated roleplay server with a phone and a gallery. - Business: €49 per month, 500 GB of storage. Several servers, or one large one carrying video. - Ultra: €89 per month, 1000 GB of storage. A network. Past this, talk to us. - Bandwidth is included on every plan, never billed. Yearly billing gives two months free. ## Start - [Pricing](https://basix.media/#pricing) - [Create a free account](https://basix.media/login?mode=signup) - [Ready-made script integrations](https://basix.media/docs/integrations) - [Documentation](https://basix.media/docs) # BasiX documentation BasiX hosts media for FiveM servers and game communities. You upload images, video and audio. You get a fast CDN address back. The same account also stores structured logs, runs databases, hosts static sites and sends webhooks. This page is the full reference. It is also served as plain Markdown at https://basix.media/llms.txt and https://basix.media/docs.md, so you can hand it to an AI coding agent. ## Quick facts - **API base URL:** `https://upload.basix.media/api/v1`. Every endpoint answers there. - **Auth:** `Authorization: Bearer bx_live_...` on every request. - **CDN addresses:** `https://cdn.basix.media//`. Your `publicId` is 8 characters, returned by `GET /api/v1/me`. - **Uploads:** multipart `POST https://upload.basix.media/api/v1/upload` with a `file` field and an optional `path` field. - **Errors:** `{ "error": { "code": "...", "message": "..." } }` with a matching HTTP status on `/api/v1` endpoints. The compatible endpoints use their own shape (see below). - **Sizes:** strings in list, detail and stats responses (`"size": "15320"`). Numbers in upload responses (`"size": 15320`). - **Max file size:** 2048 MB. Images, video and audio only. The type is detected from the bytes, never from the name. - `https://basix.media/api/v1/...` also answers the `/api/v1` endpoints, but uploads above 100 MB fail there and the compatible endpoints are not served there. Use `upload.basix.media` for everything. ## Getting started 1. Create an account at https://basix.media. 2. Create an API key in **Dashboard → API** (https://basix.media/dashboard/api). The key is shown once. Store it in an environment variable or a server convar. 3. Check the key and read your `publicId`: ```bash curl curl https://upload.basix.media/api/v1/me \ -H "Authorization: Bearer $BASIX_KEY" ``` ```js JavaScript const res = await fetch('https://upload.basix.media/api/v1/me', { headers: { Authorization: `Bearer ${process.env.BASIX_KEY}` }, }) const me = await res.json() console.log(me.organization.publicId) ``` ```lua Lua PerformHttpRequest('https://upload.basix.media/api/v1/me', function(status, body) print(status, body) end, 'GET', '', { ['Authorization'] = 'Bearer ' .. GetConvar('basix_key', '') }) ``` Response: ```json { "organization": { "name": "My Server", "publicId": "a7k2m9x4" }, "key": { "scope": "upload" }, "quota": { "bytes": "10737418240", "canUpload": true, "reason": null }, "used": { "bytes": "52428800", "files": 42, "fileBytes": "50331648", "logBytes": "2097152", "databaseBytes": "0" }, "plan": null, "subscription": null } ``` 4. Upload a file: ```bash curl curl -X POST https://upload.basix.media/api/v1/upload \ -H "Authorization: Bearer $BASIX_KEY" \ -F "path=items/burger.png" \ -F "file=@burger.png" ``` 5. Use the `url` from the response anywhere: NUI, loading screen, Discord, a website. ## API keys and scopes Keys look like `bx_live_` followed by 32 characters. Send them as `Authorization: Bearer `. A key can have an expiry date, set when you create it. A revoked or expired key answers `401 UNAUTHORIZED`. There are two scopes: - **upload**: uploads files, reads everything, sends logs. Cannot delete. Works on every plan, free included. Use this scope in anything you distribute or put in a resource folder. - **full**: everything. Needs a paid plan. On the free tier a full key answers `403 PLAN_REQUIRED`. | Action | upload | full | |---|---|---| | Upload (direct, presigned, base64, compatible endpoints, zip import) | yes | yes | | Read files, stats, trash, import status, `/me` | yes | yes | | Create a folder | yes | yes | | Send and search logs | yes | yes | | Read log categories, read and update player profiles | yes | yes | | Grant or revoke site access (player-only sites) | yes | yes | | Delete a file, restore or purge from the trash | no | yes | | Set folder retention, delete a folder | no | yes | | Create, edit or delete log categories | no | yes | | Saved views and alert rules (read and write) | no | yes | | Delete a player profile | no | yes | | Deploy a site | no | yes | The number of active keys depends on your plan (see Plans and limits). On the free tier, keys are upload scope only. Each key belongs to the team member who created it. Endpoints that check the person's role (database access) or need an owner (personal saved views) use that member. Keys created before keys were tied to their creator get `403 FORBIDDEN` there: create a new key. `GET /api/v1/me` returns `key.scope`, so a script can check what it may do before it tries. Never put a key in browser code or in a client script. Every player could read it. Keep it on your server. To let a client upload, give it a presigned URL (see below). ## Upload a file ### Direct upload `POST https://upload.basix.media/api/v1/upload`, `multipart/form-data`. | Field | Required | Meaning | |---|---|---| | `file` | yes | The file. | | `path` | no | Where it goes. Omit it and the file lands at the root under its own name. | ```bash curl curl -X POST https://upload.basix.media/api/v1/upload \ -H "Authorization: Bearer $BASIX_KEY" \ -F "path=shop/potions/potion_x2.png" \ -F "file=@potion.png" ``` ```js JavaScript import { openAsBlob } from 'node:fs' const form = new FormData() form.append('path', 'shop/potions/potion_x2.png') form.append('file', await openAsBlob('potion.png'), 'potion.png') const res = await fetch('https://upload.basix.media/api/v1/upload', { method: 'POST', headers: { Authorization: `Bearer ${process.env.BASIX_KEY}` }, body: form, }) const body = await res.json() if (!res.ok) throw new Error(`${body.error.code}: ${body.error.message}`) console.log(body.url) ``` ```lua Lua exports.basix:upload({ data = base64Png, name = 'potion.png', folder = 'shop/potions' }, function(err, file) if err then return print(err) end print(file.url) end) ``` Response, `201 Created` (or `200 OK` when it replaced a file): ```json { "id": "k7x2m9q4pz1a", "path": "shop/potions/potion_x2.png", "url": "https://cdn.basix.media/a7k2m9x4/shop/potions/potion_x2.png", "size": 15320, "mime": "image/png", "name": "potion.png", "replaced": false } ``` **Path rules.** - `path` ending in `/` is a folder: `shop/potions/` stores the file under its own name with the right extension. - Any other `path` is the exact file path. Its extension must match the real type of the file, or you get `400 INVALID_PATH`. - Paths are lowercased. Allowed characters: `a-z`, `0-9`, `.`, `_`, `-`. Each segment starts with a letter or digit. - At most 200 characters, 8 levels, 64 characters per segment. **Same path, new file.** Uploading to a path that already exists replaces the file. The URL stays the same. The response says `"replaced": true` with status `200`. The swap is atomic: a reader gets the old file or the new one, never half of each. ### Presigned upload (let a client upload without the key) Your server asks for a one-time URL. The client sends the bytes to it. The key never leaves your server. `POST https://upload.basix.media/api/v3/file/presigned-url`, JSON body: | Field | Required | Meaning | |---|---|---| | `type` | no | `image`, `video` or `audio`. Restricts what the URL accepts. | | `path` | no | Folder for the file. Spaces and capitals are repaired. | ```bash curl curl -X POST https://upload.basix.media/api/v3/file/presigned-url \ -H "Authorization: Bearer $BASIX_KEY" \ -H "Content-Type: application/json" \ -d '{ "type": "image", "path": "reports" }' ``` ```js JavaScript const res = await fetch('https://upload.basix.media/api/v3/file/presigned-url', { method: 'POST', headers: { Authorization: `Bearer ${process.env.BASIX_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ type: 'image', path: 'reports' }), }) const { presignedUrl } = await res.json() ``` ```lua Lua exports.basix:presign({ type = 'image', folder = 'reports' }, function(err, url) if err then return print(err) end TriggerClientEvent('myresource:upload', source, url) end) ``` Response: ```json { "status": "ok", "success": true, "presignedUrl": "https://upload.basix.media/api/v3/file/presigned-url/eyJhbGciOi...", "url": "https://upload.basix.media/api/v3/file/presigned-url/eyJhbGciOi...", "expiresAt": "2026-10-25T14:15:00.000Z", "expiresIn": 900, "data": { "presignedUrl": "...", "url": "...", "expiresAt": "..." } } ``` Then `POST` a multipart form (any field name) or `PUT` the raw bytes to `presignedUrl`. No auth header. The URL works **once** and expires after **15 minutes**. The file is stored as `/.`. The upload answers with the compatible response shape shown below. Need several uploads from one page, or a drag-and-drop widget? `POST https://upload.basix.media/api/upload-token` with your key and an optional JSON body `{ "folder": "user-uploads", "maxSizeMb": 10, "expiresIn": 600 }` returns `{ "token": "..." }`. The browser sends it as `Authorization: Bearer ` to `/api/v1/upload`. It can only upload, only into that folder, as `/.`. Guides: https://basix.media/docs/recipes/browser-upload and https://basix.media/docs/recipes/upload-widget. ### Base64 upload For bytes you already hold as base64 or a data URI (for example a screenshot in a server script). `POST https://upload.basix.media/api/v3/file/base64`, JSON body: `base64` (required, a base64 string or `data:` URI), `filename` (optional), `path` (optional folder), `retentionExempt` (optional, `true` keeps the file out of folder retention). Body limit: 48 MB. The file is stored as `/.`. ```bash curl curl -X POST https://upload.basix.media/api/v3/file/base64 \ -H "Authorization: Bearer $BASIX_KEY" \ -H "Content-Type: application/json" \ -d '{ "base64": "iVBORw0KGgo...", "filename": "shot.png", "path": "screenshots" }' ``` ### Compatible endpoints (ShareX, LB Phone, screenshot-basic, other scripts) Many FiveM scripts already know how to upload to a media host. Point them at these endpoints instead of changing their code. They live on `upload.basix.media` only. | Method | Path | Use | |---|---|---| | POST | `/api/v3/file` | Upload. Multipart, the file part can have any name. | | POST | `/api/v2/image`, `/api/v2/video`, `/api/v2/audio` | Upload, restricted to one media type. | | POST | `/v1/media/images`, `/v1/media/videos`, `/v1/media/audios` | Same, older path style (also under `/api/v1/media/...`). | | GET, POST | `/api/v3/file/presigned-url` | Presigned URL (see above). `/api/v2/presigned-url?fileType=image` also works. | | POST | `/api/v3/file/base64` | Base64 upload (see above). | | GET | `/api/v3/file?page=1&limit=50&type=image&path=reports/` | List files, page-based, `limit` 1-100. | | GET, DELETE | `/api/v3/file/` | Read or delete one file. Delete needs a full key. | | POST | `/api/v3/logs` | Send logs: a JSON array of events. Dataset from the `X-Basix-Dataset` header or `?dataset=`. | How they differ from `/api/v1/upload`: - The key is accepted as `Authorization: Bearer `, `Authorization: Key `, `Authorization: `, `X-Api-Key: `, or `?apiKey=` (upload endpoints only, for scripts that cannot send headers). - `path` is always a folder. Invalid characters are repaired (`Player Photos` becomes `player-photos`). The file name is always `.`, so two uploads never overwrite each other. - Optional fields: `filename` or `name` (kept as the original name), `retentionExempt`. Success response (the URL is both at the top level and under `data`): ```json { "status": "ok", "success": true, "url": "https://cdn.basix.media/a7k2m9x4/reports/k7x2m9q4pz1a.png", "id": "k7x2m9q4pz1a", "data": { "id": "k7x2m9q4pz1a", "url": "...", "originalUrl": "..." }, "filename": "k7x2m9q4pz1a.png", "original_filename": "screenshot.png", "mime_type": "image/png", "size": 182344, "hash": "", "path": "reports/k7x2m9q4pz1a.png", "replaced": false, "created_at": "2026-10-25T14:00:00.000Z", "updated_at": "2026-10-25T14:00:00.000Z" } ``` Error response on these endpoints: ```json { "status": "error", "success": false, "code": "UNAUTHORIZED", "error": "Missing or invalid token.", "message": "Missing or invalid token.", "data": null } ``` **ShareX.** Save this as `BasiX.sxcu` and open it. Or use **Download for ShareX** on the API keys page, which creates the key for you. ```json { "Version": "15.0.0", "Name": "BasiX Media", "DestinationType": "ImageUploader, FileUploader", "RequestMethod": "POST", "RequestURL": "https://upload.basix.media/api/v3/file", "Headers": { "Authorization": "Bearer YOUR_BASIX_KEY" }, "Body": "MultipartFormData", "FileFormName": "file", "URL": "{json:url}", "ErrorMessage": "{json:message}" } ``` **LB Phone.** Use its built-in `Custom` upload method: ```lua Config.UploadMethod = { Image = "Custom", Video = "Custom", Audio = "Custom", } Custom = { url = "https://upload.basix.media/api/v3/file", field = "file", headers = { ["Authorization"] = "Key API_KEY", }, success = { path = "url" }, error = { path = "success", value = false }, } ``` Replace `API_KEY` in LB Phone's server-side API key file, not in a client file. **screenshot-basic.** From a server script: ```lua exports['screenshot-basic']:requestClientScreenshotUpload( source, 'https://upload.basix.media/api/v3/file', { headers = { Authorization = 'Bearer ' .. GetConvar('basix_key', '') }, fileName = 'file' }, function(err, data) print(json.decode(data).url) end ) ``` Step-by-step guides for more scripts: https://basix.media/docs/integrations. ### Zip import Unpack a zip archive into your account in one call. Media files keep the folder structure of the archive. Anything else is skipped and reported. The archive itself is never stored. ```bash curl curl -X POST https://upload.basix.media/api/v1/import \ -H "Authorization: Bearer $BASIX_KEY" \ -F "path=shop" \ -F "file=@assets.zip" ``` It answers `202 Accepted` at once and works in the background: ```json { "id": "k7x2m9q4pz1a", "fileName": "assets.zip", "status": "pending" } ``` Poll `GET /api/v1/import/` until `status` is `done` or `failed`: ```json { "id": "k7x2m9q4pz1a", "fileName": "assets.zip", "status": "done", "total": 312, "extracted": 248, "skipped": 61, "refused": 3, "bytes": "184320512", "report": [ { "path": "resource.lua", "reason": "not an image, video or audio file" } ], "error": null, "createdAt": "2026-10-25T14:00:00.000Z", "finishedAt": "2026-10-25T14:01:10.000Z" } ``` One import runs at a time per account. A second one answers `409 PATH_CONFLICT`. ### Accepted types | Type | Extension | |---|---| | `image/jpeg` | `.jpg` | | `image/png` | `.png` | | `image/gif` | `.gif` | | `image/webp` | `.webp` | | `image/avif` | `.avif` | | `video/mp4` | `.mp4` | | `video/webm` | `.webm` | | `video/quicktime` | `.mov` | | `audio/mpeg` | `.mp3` | | `audio/ogg` | `.ogg` | | `audio/wav` | `.wav` | | `audio/flac` | `.flac` | | `audio/aac` | `.aac` | | `audio/mp4` | `.m4a` | SVG, PDF, text and archives are refused with `415 UNSUPPORTED_TYPE` (zip archives go through the import endpoint). ## CDN URLs Every file has a public address: ``` https://cdn.basix.media// ``` - `publicId`: 8 lowercase characters, the same for every file of your account. Get it from `GET /api/v1/me` or the dashboard. - `path`: the path you chose at upload, lowercased. So your code can **build** the address instead of asking the API: ```lua Lua local CDN = 'https://cdn.basix.media/a7k2m9x4' local function itemImage(name) return ('%s/items/%s.png'):format(CDN, name) end ``` ```js JavaScript const CDN = 'https://cdn.basix.media/a7k2m9x4' const itemImage = (name) => `${CDN}/items/${name}.png` ``` **Custom domain.** On plans that include it, point a CNAME (for example `cdn.myserver.com`) at `cname.basix.media` and add the domain in **Dashboard → Domain**. Files then also answer at `https://cdn.myserver.com/`, without the `publicId`. **Caching.** Files are served with `Cache-Control: public, max-age=300`, an `ETag`, `Access-Control-Allow-Origin: *` and range requests (video seeking works). After you replace a file, expect the new version everywhere within 5 minutes. **Missing images.** If you set a placeholder image in **Dashboard → Files**, a missing image address under your `publicId` returns that placeholder instead of a 404. ### Thumbnails and variants Add `?w=` (width) and/or `?f=` (format) to an image address on `cdn.basix.media`: ``` https://cdn.basix.media/a7k2m9x4/avatars/john.png original https://cdn.basix.media/a7k2m9x4/avatars/john.png?w=128 128 px wide, WebP https://cdn.basix.media/a7k2m9x4/avatars/john.png?w=128&f=png ``` - Works on `.png`, `.jpg`, `.jpeg`, `.webp`, `.avif`. GIF, video and audio are served as uploaded, the query is ignored. - Widths snap **up** to this ladder: 16, 32, 64, 96, 128, 160, 192, 256, 320, 384, 448, 512, 640, 768, 896, 1024, 1280, 1536, 2048. `?w=400` gives 448. - Formats: `webp` (default), `jpeg` (or `jpg`), `png`. - Images are never enlarged. The applied width is in the `X-Basix-Width` header. - The original is never modified. Variants are generated on first request, then cached. - An invalid value (for example `?w=abc` or `?f=gif`) answers `400`. - Variants are only served on `cdn.basix.media`, not on custom domains. ## Files, folders and stats All endpoints below are under `https://upload.basix.media/api/v1`. | Method | Path | What it does | Scope | |---|---|---|---| | GET | `/files` | List files, newest first. `limit` 1-100 (default 50), `cursor`, `search` (matches the original file name). | upload | | GET | `/files/` | One file, with `sha256`, `createdAt`, `updatedAt`. | upload | | DELETE | `/files/` | Move the file to the trash. Answers `204`. | full | | GET | `/files//stats?days=30` | Daily requests and bytes served. `days` 1-90. | upload | | GET | `/files/top?days=7&by=views&limit=10` | Most served files. `days` 7 or 30, `by` `views` or `bandwidth`, `limit` 1-100. | upload | | POST | `/folders` | Create a folder: `{ "path": "screenshots" }`. Returns `{ id, path }`. Calling it again returns the same folder. | upload | | PATCH | `/folders/` | Set retention: `{ "retentionDays": 30 }`, or `null` to clear. | full | | DELETE | `/folders/` | Delete an empty folder. | full | | GET | `/trash` | Deleted files that can still be restored, with `purgesAt`. | upload | | POST | `/trash/` | Restore a deleted file. | full | | DELETE | `/trash/` | Destroy it now. | full | List response. Pass `nextCursor` back as `cursor`. It is `null` on the last page. ```json { "files": [ { "id": "k7x2m9q4pz1a", "name": "potion.png", "path": "shop/potions/potion_x2.png", "url": "https://cdn.basix.media/a7k2m9x4/shop/potions/potion_x2.png", "size": "15320", "mime": "image/png", "createdAt": "2026-10-25T14:00:00.000Z" } ], "nextCursor": "MjAyNi0xMC0yNVQxNDowMDowMC4wMDBafGs3eDJtOXE0cHoxYQ" } ``` ```bash curl curl "https://upload.basix.media/api/v1/files?limit=100" \ -H "Authorization: Bearer $BASIX_KEY" ``` Stats responses: ```json { "id": "k7x2m9q4pz1a", "days": 30, "totals": { "requests": 1840, "bytes": "92160000" }, "daily": [ { "date": "2026-10-24", "requests": 61, "bytes": "3050000" } ] } ``` ```json { "days": 7, "by": "views", "files": [ { "id": "k7x2m9q4pz1a", "path": "items/burger.png", "url": "...", "requests": 420, "bytes": "21000000" } ] } ``` Stats are compiled nightly from CDN traffic and kept for 90 days. **Trash.** A deleted file stops being served and leaves your quota at once. It stays restorable for the trash window of your plan (see Plans and limits). `GET /trash` returns it as `windowDays`. Restoring can fail with `NOT_FOUND` (window passed), `QUOTA_EXCEEDED` (no room) or `PATH_CONFLICT` (a live file now uses that path). **Retention.** Off by default. Set it per folder: 7, 30, 90, 180 or 365 days. Expired files go to the trash, not to nothing. Files uploaded with `retentionExempt: true` (base64 and compatible endpoints) are never expired. ```bash curl curl -X POST https://upload.basix.media/api/v1/folders \ -H "Authorization: Bearer $BASIX_KEY" -H "Content-Type: application/json" \ -d '{ "path": "screenshots" }' curl -X PATCH https://upload.basix.media/api/v1/folders/ \ -H "Authorization: Bearer $BASIX_KEY" -H "Content-Type: application/json" \ -d '{ "retentionDays": 30 }' ``` ## Logs Send structured events from your server. Search them in the dashboard or through the API. Logs are included on every plan and count toward your storage quota. They are kept for the retention of your plan (see Plans and limits). ### Send events `POST https://upload.basix.media/api/v1/logs`, JSON. ```bash curl curl -X POST https://upload.basix.media/api/v1/logs \ -H "Authorization: Bearer $BASIX_KEY" \ -H "Content-Type: application/json" \ -d '{ "dataset": "economy", "events": [ { "level": "warn", "message": "Suspicious money add", "player": "license:3f9a2b...", "resource": "esx_core", "metadata": { "amount": 999999, "source": "admin" } } ] }' ``` ```js JavaScript await fetch('https://upload.basix.media/api/v1/logs', { method: 'POST', headers: { Authorization: `Bearer ${process.env.BASIX_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ dataset: 'economy', events: [{ level: 'warn', message: 'Suspicious money add', player: 'license:3f9a2b...', metadata: { amount: 999999 } }], }), }) ``` ```lua Lua exports.basix:warn('Suspicious money add', { player = GetPlayerIdentifierByType(source, 'license'), metadata = { amount = 999999, source = 'admin' }, }) ``` Response: ```json { "accepted": 1, "used": { "bytes": "2097152" }, "quota": { "bytes": "10737418240" } } ``` Body: `{ "dataset": "...", "events": [ ... ] }`, a bare array of events, or a single event. `dataset` can also be passed as `?dataset=`. Default: `default`. | Event field | Type | Notes | |---|---|---| | `message` (or `body`) | string | Required. Up to 8,000 characters. Events without one are skipped. | | `level` | string | `trace`, `debug`, `info`, `warn`, `error`, `fatal`. Anything else becomes `info`. | | `player` | string | Any identifier, up to 120 characters. Builds player profiles. | | `resource` | string | Up to 120 characters. | | `metadata` | object | Any JSON, up to 8,000 bytes once serialized. Larger metadata is dropped. | | `timestamp` | ISO string or ms | Optional. Must be within the last 365 days and not more than 1 day ahead, otherwise the server time is used. | Limits: 500 events per request, 240 requests per minute per key. Batch your events. A full quota answers `413 QUOTA_EXCEEDED`. ### Search events `GET /api/v1/logs` with any of: `dataset` (repeatable), `level` (repeatable), `player`, `resource`, `search` (text in the message), `from`, `to` (ISO dates), `limit` (1-200, default 50), `cursor`. ```bash curl curl "https://upload.basix.media/api/v1/logs?dataset=economy&level=warn&level=error&player=license:3f9a2b...&limit=100" \ -H "Authorization: Bearer $BASIX_KEY" ``` ```json { "events": [ { "id": "...", "dataset": "economy", "level": "warn", "message": "Suspicious money add", "resource": "esx_core", "player": "license:3f9a2b...", "metadata": { "amount": 999999 }, "timestamp": "2026-10-25T14:00:00.000Z" } ], "nextCursor": null } ``` ### Categories A category gives a dataset a label, color, icon, order, group and columns read from metadata. Datasets without a category are listed with `"auto": true`. | Method | Path | Scope | |---|---|---| | GET | `/logs/categories` | upload | | POST | `/logs/categories` (body includes `key`) | full | | GET | `/logs/categories/` | upload | | PATCH | `/logs/categories/` (creates it if missing) | full | | DELETE | `/logs/categories/` | full | Fields: `label` (1-60 chars), `color` (`#rrggbb`), `icon` (`activity`, `home`, `files`, `key`, `card`, `user`, `book`, `shield`, `upload`, `database`, `link`, `play`, `lock`, `bolt`, `code`, `layers`, `check`, `sparkle`), `order` (integer), `group` (up to 40 chars or `null`), `columns` (up to 30 of `{ key, label, type, visible }`, `type` one of `text`, `number`, `bool`, `date`, `player`, `link`; `key` is a metadata path such as `vehicle.plate`). Up to 100 categories. ```bash curl curl -X PATCH https://upload.basix.media/api/v1/logs/categories/economy \ -H "Authorization: Bearer $BASIX_KEY" -H "Content-Type: application/json" \ -d '{ "label": "Economy", "color": "#22c55e", "icon": "card", "group": "Gameplay", "columns": [ { "key": "amount", "label": "Amount", "type": "number", "visible": true } ] }' ``` ### Players Every `player` value gets a profile: first and last seen, event counts per category, and identifiers found in metadata (`license`, `license2`, `discord`, `steam`, `fivem`, `xbl`, `live`, `rockstar`, `citizenid`). | Method | Path | Scope | |---|---|---| | GET | `/logs/players?search=&page=0` | upload | | GET | `/logs/players/` | upload | | PATCH | `/logs/players/` | upload | | DELETE | `/logs/players/` | full | PATCH accepts `displayName`, `notes` (up to 10,000 chars), `identifiers` and `fields` (objects, `null` removes a key, up to 50 each), or a single `key` + `value`. URL-encode the player in the path. ```bash curl curl -X PATCH "https://upload.basix.media/api/v1/logs/players/license%3A3f9a2b" \ -H "Authorization: Bearer $BASIX_KEY" -H "Content-Type: application/json" \ -d '{ "displayName": "John", "identifiers": { "discord": "1234" }, "fields": { "job": "police" } }' ``` ### Saved views Stored filters and columns, shown in the dashboard. Full key required. | Method | Path | |---|---| | GET, POST | `/logs/views` | | GET, PATCH, DELETE | `/logs/views/` | Body: `name` (required on create), `filters` (`categories`, `levels`, `player`, `resource`, `text`, `range` one of `15m`, `1h`, `6h`, `24h`, `7d`, `30d`, or `from`/`to`), `columns` (metadata keys). `shared` defaults to `true` (visible to the whole team). `false` makes the view personal to the key's creator. ### Alerts A rule fires when `threshold` matching events arrive within `windowMinutes`. It then stays quiet for `cooldownMinutes`. It posts to a Discord webhook, to your webhook endpoints (event `log.alert`), or both. Rules are checked after each ingest. Full key required. | Method | Path | |---|---| | GET, POST | `/logs/alerts` | | GET, PATCH, DELETE | `/logs/alerts/` | | Field | Default | Notes | |---|---|---| | `name` | required | 1-80 chars. | | `enabled` | `true` | | | `conditions` | match all | `categories`, `levels`, `contains` (text), `metadata` (up to 10 `{ key, equals }`). | | `threshold` | `1` | 1-10,000 events. | | `windowMinutes` | `5` | 1-1,440. | | `cooldownMinutes` | `5` | 0-10,080. | | `discordWebhookUrl` | `null` | `https://discord.com/api/webhooks//`. | | `webhookIds` | `[]` | Ids of your webhook endpoints. At least one target is required. | ```bash curl curl -X POST https://upload.basix.media/api/v1/logs/alerts \ -H "Authorization: Bearer $BASIX_KEY" -H "Content-Type: application/json" \ -d '{ "name": "Money exploit", "conditions": { "categories": ["economy"], "levels": ["warn", "error"], "contains": "money", "metadata": [ { "key": "source", "equals": "admin" } ] }, "threshold": 3, "windowMinutes": 5, "cooldownMinutes": 15, "discordWebhookUrl": "https://discord.com/api/webhooks/123456789/your-webhook-token" }' ``` The number of rules depends on your plan. Over the limit, creating one answers `403 PLAN_REQUIRED`. ## Databases Managed MariaDB and PostgreSQL databases for your server. Create them in **Dashboard → Databases**. - Host: `db.basix.media`. Port `3306` (MariaDB) or `5432` (PostgreSQL). Connect with TLS: the connection string from the dashboard enables it. - The dashboard shows the user, password and a ready connection string. - Database size counts toward your storage quota. Over quota, databases switch to read-only until you free space. - In the dashboard you can restrict access to a list of IP addresses and cap connections and queries, import or export a SQL dump, and migrate from another server. For oxmysql, put the connection string in `server.cfg`: ``` set mysql_connection_string "mysql://user:password@db.basix.media:3306/dbname?ssl={}" ``` ### Access rules through the API `GET` or `PATCH https://upload.basix.media/api/v1/databases//access`. Needs a full key created by an owner or an admin of the team. The database `id` is shown in the dashboard URL (`/dashboard/databases/`). | Field | Meaning | |---|---| | `allowedIps` | Up to 50 IPv4/IPv6 addresses or CIDR ranges. Empty list = open to any address. MariaDB accepts single IPv6 addresses only, no IPv6 ranges. | | `maxConnections` | Simultaneous connections, 1 to 1,000,000, or `null` for no limit. | | `maxQueriesPerHour` | MariaDB only. 1 to 1,000,000 or `null`. | | `maxConnectionsPerHour` | MariaDB only. 1 to 1,000,000 or `null`. | ```bash curl curl -X PATCH https://upload.basix.media/api/v1/databases//access \ -H "Authorization: Bearer $BASIX_FULL_KEY" -H "Content-Type: application/json" \ -d '{ "allowedIps": ["203.0.113.10", "198.51.100.0/24"], "maxConnections": 50 }' ``` Both methods answer `{ "access": { "allowedIps": [...], "maxConnections": 50, "maxQueriesPerHour": null, "maxConnectionsPerHour": null } }`. Fields you leave out keep their value. An invalid value answers `400 BAD_REQUEST` with the reason. ## Sites Static sites (HTML, CSS, JS) at `https://.sites.basix.media/`. Good for NUI pages, loading screens and small web panels. Create the site in **Dashboard → Sites**, then deploy a zip. ### Deploy `POST https://upload.basix.media/api/v1/sites//deploy` with the raw zip as the body. Full key required. ```bash curl cd dist && zip -r ../site.zip . && cd .. curl -X POST https://upload.basix.media/api/v1/sites/my-ui/deploy \ -H "Authorization: Bearer $BASIX_KEY" \ -H "Content-Type: application/zip" \ --data-binary @site.zip ``` ```json { "ok": true, "deploy": "...", "files": 42, "bytes": 1048576, "url": "https://my-ui.sites.basix.media/" } ``` Rules: zip up to 200 MB, 300 MB unpacked, 5,000 files. `index.html` must be at the root of the zip, or inside one top folder. Unknown paths fall back to `/index.html`, so client-side routing works. ### Player-only sites Mark a site as player-only in the dashboard. Then only allowed IP addresses can open it. Everyone else gets `403 Access denied`. `POST https://upload.basix.media/api/v1/sites/access`, JSON: `grant` (IPs to allow), `revoke` (IPs to remove), `sync` (the full list, replaces everything). Up to 2,048 IPs each. Answers `{ "allowed": }`. An allowed IP expires after 3 minutes unless it is granted or synced again. The list applies to all sites of your account. The FiveM resource does this for you: it allows each connected player and syncs every 60 seconds. ```lua ui_page 'https://my-ui.sites.basix.media/' ``` ## Webhooks BasiX POSTs a signed JSON body to your HTTPS endpoint when something happens. Create endpoints in **Dashboard → Webhooks** and pick the events. Each endpoint has a secret starting with `whsec_`. Events: - `file.uploaded`: A file landed — whatever sent it: dashboard, API key or presigned link. - `file.deleted`: A file went to the trash. It is still recoverable at this point. - `import.finished`: A zip import finished, successfully or not. - `quota.warning`: Storage crossed 80%, then 90%, then 95%. Once per threshold. - `log.alert`: A log alert rule fired. Sent only to the endpoints that rule targets. - `ping`: Sent only when you press Test. Every delivery uses the same envelope: ```json { "id": "clx8f2...", "event": "file.uploaded", "createdAt": "2026-10-25T14:03:11.204Z", "account": "a7k2m9x4", "data": { "id": "k7x2m9q4pz1a", "path": "avatars/john.png", "url": "https://cdn.basix.media/a7k2m9x4/avatars/john.png", "mime": "image/png", "sizeBytes": 15320, "sha256": "...", "originalName": "john.png", "replaced": false } } ``` `data` per event: - `file.uploaded`: `id`, `path`, `url`, `mime`, `sizeBytes`, `sha256`, `originalName`, `replaced`. - `file.deleted`: `id`, `path`, `url`, `mime`, `sizeBytes`. - `import.finished`: `id`, `fileName`, `prefix`, `status`, `total`, `extracted`, `skipped`, `refused`, `bytes`, `error`. - `quota.warning`: `threshold` (80, 90 or 95), `percent`, `usedBytes`, `quotaBytes`. - `log.alert`: `rule` (`id`, `name`), `count`, `windowMinutes`, `events` (up to 10). Headers: ``` X-BasiX-Event: file.uploaded X-BasiX-Delivery: clx8f2... X-BasiX-Attempt: 1 X-BasiX-Signature: t=1790000000,v1= ``` **Verify the signature.** `v1` is the hex HMAC-SHA256 of `.` with your secret. Use the raw body, before any JSON parsing. Reject old timestamps. ```js JavaScript import crypto from 'node:crypto' import express from 'express' const app = express() app.post('/basix', express.raw({ type: 'application/json' }), (req, res) => { const parts = Object.fromEntries(req.get('X-BasiX-Signature').split(',').map((p) => p.split('='))) if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) return res.sendStatus(400) const expected = crypto .createHmac('sha256', process.env.BASIX_WEBHOOK_SECRET) .update(`${parts.t}.${req.body}`) .digest() const given = Buffer.from(parts.v1, 'hex') if (given.length !== expected.length || !crypto.timingSafeEqual(given, expected)) return res.sendStatus(400) res.sendStatus(200) handle(JSON.parse(req.body)) }) ``` **Delivery rules.** - Answer `2xx` within 5 seconds. Do the work after answering. - Failed deliveries are retried after 1 min, 5 min, 30 min, 2 h and 6 h. A `4xx` other than `429` is not retried. - After 20 deliveries that ran out of attempts, the endpoint is disabled. The dashboard shows it. - The same delivery can arrive twice. Use `X-BasiX-Delivery` (stable across retries) to ignore duplicates. - The URL must be HTTPS with a valid certificate, on port 443 or 8443, on a public address. Redirects are not followed. ## FiveM resource The `basix` resource uploads screenshots and files, sends logs and keeps player-only sites open for connected players. Everything runs server-side. The key never reaches a client. ### Install 1. Download the resource from **Dashboard → SDK** (https://basix.media/dashboard/sdk) and unzip it into `resources/basix`. 2. Create an **upload** scope key and add it to `server.cfg`: ``` set basix_key "bx_live_yourkeyhere" ensure basix ``` 3. In the server console, run `basix test`. It uploads a 1-pixel image and prints its URL. `screenshot-basic` must be started for `exports.basix:screenshot`. Server artifacts 7290 or newer are required. ### Convars | Convar | Default | Meaning | |---|---|---| | `basix_key` | none | Required. Must start with `bx_live_`. | | `basix_folder` | empty | Prefix for every upload, for example `myserver`. | | `basix_dataset` | `default` | Dataset for logs. | | `basix_log_level` | `info` | Minimum level sent: `trace`, `debug`, `info`, `warn`, `error`, `fatal`. | | `basix_sites` | `true` | Allow connected players on player-only sites. `false` turns it off. | | `basix_endpoint` | `https://upload.basix.media` | For testing only. | ### Exports All exports are server-side. **screenshot(source, options, cb)**: captures a player's screen and uploads it from the player's machine through a presigned URL. Options: `folder`, `encoding` (default `jpg`), `quality` (default `0.85`). Times out after 30 seconds. ```lua exports.basix:screenshot(source, { folder = 'reports' }, function(err, file) if err then return print('[basix] ' .. err) end print(file.url, file.id, file.path) end) ``` **upload(options, cb)**: uploads bytes the server already has. Options: `data` (base64 string or data URI, required), `name`, `folder`, `keep` (`true` = exempt from folder retention). ```lua exports.basix:upload({ data = base64Data, name = 'clip.webm', folder = 'clips' }, function(err, file) if err then return print(err) end print(file.url, file.size) end) ``` **presign(options, cb)**: returns a one-time upload URL for a client. Options: `type` (`image`, `video`, `audio`; default `image`), `folder`. ```lua exports.basix:presign({ type = 'video', folder = 'clips' }, function(err, url) if err then return print(err) end TriggerClientEvent('myresource:recordAndUpload', source, url) end) ``` **log(level, message, options)** and the shortcuts **info**, **warn**, **error**, **debug** `(message, options)`. Options: `player`, `resource` (defaults to the calling resource), `metadata`. Events are buffered and sent every 2 seconds in one request. Up to 400 events are kept in memory if the API cannot be reached. ```lua exports.basix:log('info', 'Player bought a car', { player = GetPlayerIdentifierByType(source, 'license'), metadata = { model = 'sultan', price = 25000 }, }) exports.basix:error('Payment failed', { metadata = { reason = 'insufficient funds' } }) ``` `cb` is always `function(err, result)`. `err` is `nil` on success, otherwise a readable message. ## Errors and rate limits Native endpoints answer errors with this shape: ```json { "error": { "code": "QUOTA_EXCEEDED", "message": "Storage quota exceeded." } } ``` `INVALID_PATH` also carries `reason` (for example `extMismatch`, `invalidSegment`, `tooDeep`) and `params`. | Status | Code | When | |---|---|---| | 401 | `UNAUTHORIZED` | Key missing, malformed, revoked or expired. | | 403 | `FORBIDDEN` | The key scope does not allow this action, for example an upload key deleting a file. | | 403 | `ACCOUNT_BANNED` | The account is suspended. | | 403 | `READ_ONLY` | The account is read-only, usually over quota after a downgrade. Uploads are refused. | | 403 | `PAYMENT_REQUIRED` | No active plan and no free storage to fall back on. | | 403 | `PAST_DUE` | The last payment failed. Uploads are suspended until it is settled. | | 403 | `CAPACITY_UNAVAILABLE` | Free storage is temporarily full. Try later or pick a plan. | | 503 | `BILLING_CLOSED` | Paid plans cannot be bought right now. | | 403 | `PLAN_REQUIRED` | A full key on the free tier, or a plan limit reached (alert rules). | | 413 | `FILE_TOO_LARGE` | File above 2048 MB, or a base64 body above 48 MB. | | 413 | `QUOTA_EXCEEDED` | Storage quota full. Files, logs, databases and sites count together. | | 415 | `UNSUPPORTED_TYPE` | Type not accepted, detected from the bytes. Also sent when a typed endpoint gets another media type. | | 400 | `NO_FILE` | Multipart body without a file part. | | 400 | `BAD_REQUEST` | Malformed body or parameter. The message says which. | | 400 | `INVALID_PATH` | Path malformed, or its extension does not match the real file type. | | 409 | `PATH_CONFLICT` | Two writes raced for one path, a restore hit a live file, or an import is already running. | | 404 | `NOT_FOUND` | No such file, folder, rule or route on this host. | | 429 | `RATE_LIMITED` | Too many requests. Wait for Retry-After. | | 500 | `INTERNAL` | Server error. Retry with backoff. | Rate limits (per key): | What | Free | Paid plan | |---|---|---| | Uploads (all upload endpoints together) | 30 per minute, 2 GB per hour | 600 per minute, 60 GB per hour | | `GET /api/v1/files` | 600 per minute | 600 per minute | | `POST /api/v1/logs` | 240 per minute | 240 per minute | | Base64 uploads | 48 MB body | 48 MB body | A `429` carries `Retry-After` (seconds) and, for uploads, `X-RateLimit-Limit` and `X-RateLimit-Remaining`. `GET /api/v1/files` sends `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset` on every response. On `429`, wait and retry. Do not retry other `4xx` errors. ## Plans and limits | Plan | Price | Storage | Log retention | Databases | Sites | Site domains | Webhooks | Alert rules | Share links | Custom CDN domain | Upload widget | API keys | Trash | |---|---|---|---|---|---|---|---|---|---|---|---|---|---| | Free | free | 3 GB | 3 days | 0 | 1 | 0 | 0 | 0 | 3 | no | no | 1, upload only | 7 days | | Starter | €5 | 25 GB | 30 days | 3 | 3 | 1 | 2 | 5 | 25 | no | yes | 5 | 30 days | | Pro | €15 | 120 GB | 90 days | 10 | 10 | 1 | 5 | 20 | 100 | yes | yes | 20 | 90 days | | Business | €49 | 500 GB | 90 days | 25 | 25 | 5 | 20 | 50 | 500 | yes | yes | 50 | 90 days | | Ultra | €89 | 1000 GB | 180 days | 50 | 50 | 5 | 50 | 100 | 2000 | yes | yes | 100 | 90 days | Prices are per month, VAT included. Storage covers files, logs, databases and sites together. Every plan has the API, logs, the CDN and image variants. Bandwidth is never billed. Paying yearly costs ten months: two months free. Pro can be tried free for 14 days, no card, once per account. Max file size: 2048 MB on every plan. ## Recipes ### Connect my FiveM server to the CDN 1. Create an upload key. Upload your item images into one folder: ```bash for f in ./images/*.png; do curl -s -X POST https://upload.basix.media/api/v1/upload \ -H "Authorization: Bearer $BASIX_KEY" \ -F "path=items/" -F "file=@$f" > /dev/null && echo "ok $f" done ``` 2. Get your `publicId` from `GET /api/v1/me`. Your images are now at `https://cdn.basix.media//items/.png`. 3. Point your inventory at that folder. For ox_inventory, in `server.cfg`: ``` setr inventory:imagepath "https://cdn.basix.media//items" ``` 4. In NUI or HTML, use the URL directly. Add `?w=128` for small icons: ```html ``` ### Upload screenshots from LB Phone 1. Create an upload key. 2. In LB Phone's config, set `Config.UploadMethod` to `Custom` for Image, Video and Audio. 3. Set the `Custom` table to `url = "https://upload.basix.media/api/v3/file"`, `field = "file"`, header `Authorization = "Key API_KEY"`, `success = { path = "url" }`. 4. Put your key where LB Phone keeps its server-side API keys. Full config: see "Compatible endpoints" above. ### Screenshot a player on a report ```lua RegisterCommand('report', function(source, args) local reason = table.concat(args, ' ') exports.basix:screenshot(source, { folder = 'reports' }, function(err, file) exports.basix:warn('Player report', { player = GetPlayerIdentifierByType(source, 'license'), metadata = { reason = reason, screenshot = file and file.url or nil, error = err }, }) end) end) ``` ### Send logs From a FiveM resource: install the `basix` resource, then call `exports.basix:info/warn/error/debug(message, { player, metadata })`. From anything else: `POST https://upload.basix.media/api/v1/logs` with `{ dataset, events: [...] }`, batched up to 500 events per request. ```lua PerformHttpRequest('https://upload.basix.media/api/v1/logs', function(status, body) if status ~= 200 then print('basix logs failed', status, body) end end, 'POST', json.encode({ dataset = 'admin', events = { { level = 'info', message = 'Ban issued', player = 'license:3f9a2b...', metadata = { by = 'admin1' } } }, }), { ['Content-Type'] = 'application/json', ['Authorization'] = 'Bearer ' .. GetConvar('basix_key', ''), }) ``` ### Get alerted on Discord Create an alert rule with `discordWebhookUrl` (see Alerts). Example: 3 `warn` or `error` events in the `economy` dataset within 5 minutes. ### Upload from a Discord bot or web backend Use `POST /api/v1/upload` from the server with an upload key (JavaScript example under Direct upload). For browser uploads, your backend calls `POST /api/v3/file/presigned-url` and gives the `presignedUrl` to the browser, which `PUT`s the file to it. ### Deploy a NUI site from CI ```bash zip -r site.zip dist curl -X POST https://upload.basix.media/api/v1/sites/my-ui/deploy \ -H "Authorization: Bearer $BASIX_FULL_KEY" \ -H "Content-Type: application/zip" \ --data-binary @site.zip ``` ### Use a BasiX database with oxmysql Create a MariaDB database in the dashboard, copy its connection string, then in `server.cfg`: ``` set mysql_connection_string "mysql://user:password@db.basix.media:3306/dbname?ssl={}" ``` ## Pages - [FiveM image hosting and CDN for screenshots](https://basix.media/fivem-image-hosting): Host FiveM screenshots, phone photos, item icons and loading screens on a CDN. Works with screenshot-basic, ScreenCapture, LB Phone and ox_inventory. - [FiveM logs with player history and Discord alerts](https://basix.media/fivem-logs): Searchable FiveM server logs: player profiles, categories, saved views and Discord webhook alerts. Replaces log channels that nobody can search. Free tier. - [FiveM database hosting: MariaDB for oxmysql](https://basix.media/fivem-database-hosting): Managed MariaDB (MySQL-compatible) for FiveM: oxmysql connection string, TLS, nightly backups, SQL import and migration. Included in every plan, free tier. - [Discord image links expire: the fix for FiveM](https://basix.media/guides/discord-image-links-expire): Why cdn.discordapp.com attachment links stop working outside Discord, why FiveM phone photos and report screenshots break, and how to fix it for good. - [Imgur not working on FiveM? Causes and the fix](https://basix.media/guides/imgur-not-working-fivem): Imgur uploads failing or images not loading on your FiveM server: rate limits, regional blocks and removed content explained, and how to switch hosts in one line. - [Sending FiveM server logs to Discord, step by step](https://basix.media/guides/fivem-discord-logs): Create a Discord webhook, keep its URL server-side, post FiveM log embeds from Lua, and know when a log channel stops being enough. - [FiveM media host comparisons](https://basix.media/compare) - [BasiX vs Fivemanage](https://basix.media/compare/fivemanage) - [Fivemerr alternative](https://basix.media/compare/fivemerr) - [Discord image link checker](https://basix.media/tools/discord-link-checker): free, no account; shows whether Discord CDN or Imgur links are expired, signed or alive - [Savings calculator, BasiX vs Fivemanage](https://basix.media/tools/savings-calculator): monthly cost for a given storage and bandwidth, from published prices