# Workshop Vehicle Prep API

This API lets a program on another server prepare FiveM vehicles with Workshop, using an API key instead of a Discord sign-in. That program could be a Discord bot, a web panel or a script. It runs the same tools the website runs, with the same checks, queue and credits: Vehicle Prep, Levels of Detail, and Glass & Windows.

This document is complete on its own, so you can paste it into an AI assistant to build your integration.

## Quick reference

| Item | Value |
| --- | --- |
| Base URL | `https://fivemtool.vertual.dev` |
| Authentication | `Authorization: Bearer <key>` header, HTTPS only |
| Rate limit | 30 requests a minute per key |
| Largest upload | 500 MB |
| Upload token lifetime | 15 minutes |
| Request bodies | `multipart/form-data`. `/api/inspect-url` also accepts JSON |
| Responses | JSON, except `/api/prep`, which returns a zip, and the endpoints that hand back a model, which return the file |
| Hosted media | Images up to 200 MB, video up to 500 MB, through `/api/media` |
| Errors | JSON: `{"error": "a message written for a person"}` |
| Suggested client timeout | 15 minutes for `/api/inspect`, `/api/inspect-url`, `/api/prep`, `/api/prep/report` and `/api/lod/generate` |

## How access works

- **A key acts as the account that created it.** Its requests have that account's access, spend that account's credits and wait in the queue as that account would.
- **These tools only.** A key can call the endpoints in this document and nothing else. Every other endpoint answers `403`, whatever the account can do on the website.
- **Each tool still needs its permission.** Vehicle Prep needs `tab.prep`, Levels of Detail needs `tab.lod`, and Glass & Windows needs `tab.glass`. A key acts as its account, so it can only reach the tools that account already has. `GET /api/key` reports which.
- **Backend keys.** `POST /api/images` and `POST /api/media` write to this site's own storage, so they need more than a key: the key has to be marked as a **backend key** by an owner, and the account needs the `api.images` permission. An ordinary key gets a `403`. They are also the only endpoints a signed-in browser cannot reach at all - they are server-to-server only. One key covers both: a key already marked for images needs nothing further.
- **HTTPS only.** A key sent over plain HTTP is refused. If that ever happens, treat the key as exposed: revoke it and create a new one.
- **Header only.** Send the key in the `Authorization` header. A key in a URL or form field is ignored.
- **Shown once.** Workshop stores only a hash of the key and cannot show it again. If you lose it, create another.
- **Charged like the website.** A tool that is free or unlimited for the account is free through the API too.
- **Administrators can disable a key.** A disabled key stops working at once, and its account cannot create another key until it is re-enabled.
- **What is recorded:** how many requests each key made, to which endpoint, the status code and how long it took. Nothing about where the requests came from.

## Getting a key

1. Sign in to the Workshop portal.
2. Open **API**, then **API Dashboard**. Your account needs the API permission to see it.
3. Give the key a label that names the program using it, such as `Discord bot`, and create it.
4. Copy the key straight away. It starts with `fpk_` and is not shown again.

The key can only prepare vehicles if the account has Vehicle Prep access. The dashboard also shows what each key has done. Revoking a key there stops it working within ten seconds. An account can hold 5 active keys.

## Building it into your tool

### The workflow

Every integration follows the same steps:

1. **Check the key.** Call `GET /api/key` when your program starts. Stop with a clear message if it returns `401` or `403`, or if `canPrep` is `false`.
2. **Load the choices.** Call `GET /api/vehicle-types` and cache the result. It lists the accepted `department`, `vehicle_type`, `engine_sound` and weapon values. Show these to your users as menus rather than asking them to type keys.
3. **Inspect the upload.** Send the file to `POST /api/inspect`, or a download link to `POST /api/inspect-url`. You get back the vehicles found and an `upload_token`.
4. **Pick the vehicle.** A download often holds more than one vehicle, such as an Add-On and a Replace copy, or a pack. If `vehicles` has more than one entry, ask your user which `model` they want. If it is empty, the upload holds no vehicle.
5. **Prepare it.** Send `POST /api/prep` with the `upload_token`, the chosen `select` model and the options. Include a `job_id` if you want to show progress.
6. **Show progress (optional).** While the prep request is still waiting, poll `GET /api/progress/<job_id>` from a second request, at most once every 5 seconds.
7. **Deliver the result.** Save the zip from the response body. Read `X-Prep-Passed`, `X-Prep-Errors` and `X-Prep-Warnings`, and decode `X-Prep-Report` to show the user what changed and what needs attention.

### Where the key lives

- Keep the key on your server, in an environment variable or a secret store. Never commit it to source control.
- Never put the key in a browser, a mobile app, a desktop app or anything else your users download. Anyone holding the key can spend your account's credits.
- A website that lets people prep vehicles must send the files to your own backend first, and your backend calls Workshop. The browser never talks to this API directly.
- Use one key per program, so revoking one never takes down another.

### A Discord bot

- **Answer within 3 seconds.** Discord cancels an interaction that is not acknowledged in 3 seconds, and a prep takes minutes. Defer the reply first, then edit it or send a follow-up once the prep finishes.
- **Attachments.** Either download the attachment on your bot's server and send the bytes to `/api/inspect`, or pass the attachment's `cdn.discordapp.com` link as `source_url` to `/api/inspect-url`. Direct Discord attachment links are supported.
- **Choosing a vehicle.** When inspect finds several vehicles, show a select menu of their `model` and `label` values, and keep the `upload_token` with that interaction. The token expires 15 minutes after the inspect.
- **Returning the zip.** A prepared vehicle can be larger than Discord lets a bot upload. Check the zip's size against the channel's limit, and store large files somewhere else and send a link.
- **Report back.** Post the warnings and errors from `X-Prep-Report` so the person knows whether the vehicle needs more work.
- **Queue your own jobs.** Run one prep at a time per key, and tell users where they are in your bot's queue. Workshop queues too, but a request that waits too long fails with a busy message.

### Errors and retries

| What happened | What to do |
| --- | --- |
| `400` | Show the `error` message to the user. Do not retry the same request, because it will fail the same way. |
| `400` saying the upload has expired | Inspect the file again to get a new `upload_token`. |
| `400` saying the server is busy and the job waited too long | Wait a few minutes, then retry. |
| `401` | The key is missing, wrong or revoked. Stop and fix the configuration. |
| `403` | The key was sent over HTTP, the key has been disabled by an administrator, the account lacks Vehicle Prep, or the endpoint is not open to keys. Stop and tell the key's owner. |
| `413` | The file is over 500 MB. Tell the user. |
| `429` with `Retry-After` | Too many requests. Wait that many seconds, then retry. |
| `429` with `limit_reached` | The account is out of credits. Tell the user. Do not retry. |
| `503` | Vehicle Prep is switched off for now. The message may say why. Retry later. |
| `5xx`, timeout or dropped connection | Retry `GET` requests and inspects after a short wait. Do not blindly retry `/api/prep` on a timeout: it may have finished and been charged. |

Always send a `User-Agent` that names your program, and log the `error` message of every failed request.

## Authentication

Send the key with every request:

```
Authorization: Bearer fpk_xxxxxxxxxxxx_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
```

Check it:

```bash
curl -H "Authorization: Bearer $WORKSHOP_API_KEY" https://fivemtool.vertual.dev/api/key
```

## Limits, queue and credits

- **Rate limit.** Each key can make 30 requests a minute, counting every endpoint, progress polls included. Past that the API answers `429` with a `Retry-After` header in seconds.
- **Upload size.** Up to 500 MB per request.
- **Accepted files.** One archive (`.zip`, `.rar`, `.7z` or `.rpf`), or several loose vehicle files (`.meta`, `.yft`, `.ytd`, `.ycd`, `.ybn`, `.ydr`, `.dds`, `.xml`) sent under the same field name.
- **Download links.** `source_url` accepts a GTA5-Mods vehicle page, a NitroLabs link, a direct Discord attachment, or a public Google Drive, MediaFire, Dropbox or MEGA file link. Nothing else.
- **Upload tokens.** An inspected archive or link is kept for 15 minutes, for the account that inspected it only. A token can be used for more than one prep within that time. An account can hold 8 waiting uploads at once. After that, inspect fails until older ones expire.
- **Queue.** Inspecting and preparing wait for a free worker, as on the website. A prep can take several minutes, and a job that cannot get a worker in time fails with a busy message.
- **Credits.** Each successful `/api/prep` is charged to the key's account at the website's price. It costs nothing when the account's plan makes Vehicle Prep free or its credits unlimited. A prep that fails is not charged. Inspecting, reading and progress are free. `/api/key` and `/api/usage` show the balance.

## Endpoints

### GET /api/key

The key, the account it acts as, whether it can prep, the rate limit and the credit balance.

```json
{
  "key": {"id": "3f9a1c0b2d4e", "label": "Discord bot"},
  "account": {"id": "123456789012345678", "username": "someone"},
  "canPrep": true,
  "rateLimitPerMinute": 30,
  "credits": {
    "allowance": 100,
    "spent": 12,
    "remaining": 88,
    "unlimited": false,
    "costs": {"prep": 10}
  }
}
```

`allowance` and `remaining` are `null` when `unlimited` is `true`. `costs` lists the price of every tool on the site; `prep` is the one that applies here.

### GET /api/vehicle-types

Every accepted value for the prep options. Cache it and build your menus from it.

```json
{
  "departments": ["leo", "civ", "spec"],
  "groups": [
    {"name": "Small Vehicles", "types": [
      {"key": "bike", "label": "Bikes", "group": "Small Vehicles", "mass": 200, "is_maximum": false, "note": ""}
    ]}
  ],
  "engine_sounds": [
    {"name": "Car", "sounds": [{"key": "s63b44", "label": "BMW S63 4.4L V8", "group": "Car"}]}
  ],
  "weapons": {
    "base": [{"hash": "VEHICLE_WEAPON_TANK", "label": "Base Game Tank", "kind": "Tank", "chassis": "ground", "tier": "base"}],
    "t1": [],
    "t2": [],
    "t3": []
  },
  "checks": ["CHECK_BLACKLISTED_FLAGS", "CHECK_MASS"],
  "streamable_limits": {"civ": 4, "leo": 4, "spec": 10}
}
```

- `groups[].types[].key` is a `vehicle_type` value, and `mass` is the mass that type sets.
- `engine_sounds[].sounds[].key` is an `engine_sound` value.
- `weapons` lists the approved `weapon_slot_<n>` hashes by tier: base game, then tiers 1 to 3.
- `checks` names the checks every prep runs.
- `streamable_limits` is how many streamed files (`.yft`, `.ytd`, `.ycd`) a vehicle may have in each department.

### GET /api/usage

The account's plan and what it has used this billing period.

```json
{
  "plan": "subscriber",
  "planLabel": "Subscriber",
  "period": "2026-09-13",
  "renews": "2026-10-13",
  "credits": {"allowance": 100, "spent": 12, "remaining": 88, "unlimited": false, "costs": {"prep": 10}},
  "actions": {
    "prep": {"label": "Vehicles prepared", "used": 3, "limit": null, "remaining": null, "cost": 10, "free": false}
  },
  "free": [],
  "features": []
}
```

`actions` has an entry for every tool on the site, and `prep` is the one that matters here. `free: true` means the plan makes that tool cost nothing. The response carries a few more fields the website uses. Ignore any you do not need.

### POST /api/inspect

Finds the vehicles in an upload. Free. Send `multipart/form-data` with the file in the `vehicle` field. Use one archive, or several loose files each sent as `vehicle`.

```json
{
  "upload_token": "9c1e5b0f2a7d4c3e8b6a1f0d2e4c6a8b",
  "vehicles": [
    {
      "model": "police4",
      "label": "police4",
      "complete": true,
      "missing": [],
      "score": 147,
      "metas": ["carcols.meta", "carvariations.meta", "handling.meta", "vehicles.meta"],
      "asset_count": 8,
      "notes": ["custom modkit '1234_police4_modkit' backed by part models"],
      "oversized_ytds": [{"name": "police4.ytd", "bytes": 24117248, "limit_mb": 16}],
      "custom_modkit": "1234_police4_modkit",
      "weapon_slots": ["VEHICLE_WEAPON_TANK", null],
      "weapon_assets": [],
      "streamables": ["police4.yft", "police4.ytd", "police4_hi.yft"],
      "streamable_count": 3
    }
  ]
}
```

- `vehicles` is sorted best match first, and `score` is how confident the match is. Pass the chosen `model` to `/api/prep` as `select`.
- `label` is a name to show people, and `notes` explains what was found.
- `complete` is `false` when files are missing, and `missing` names them.
- `oversized_ytds` lists texture dictionaries over the size limit. Send `optimize_ytds=1` to shrink them.
- `weapon_slots` has one entry per weapon mount. `null` is an empty mount.
- `upload_token` is `null` when you uploaded loose files rather than one archive. In that case, send the files again with `/api/prep`.

### POST /api/inspect-url

The same as `/api/inspect`, for a vehicle at a supported download link. Free, but the download counts against the upload size limit. Send `source_url` as JSON or as a form field:

```json
{"source_url": "https://www.gta5-mods.com/vehicles/example"}
```

The response is the same as `/api/inspect`, plus `filename` and `size` in bytes. It always returns an `upload_token`.

### POST /api/prep

Prepares one vehicle and returns the finished resource as a zip. Charged to the key's account. Send `multipart/form-data`. Every option is a text field.

| Field | Required | What it does |
| --- | --- | --- |
| `upload_token`, `vehicle` or `source_url` | one of them | A token from inspect (best), the file itself, or a supported download link. |
| `department` | yes | `leo` keeps the light and siren settings. `civ` resets them. `spec` resets them and allows weapon slots. |
| `vehicle_type` | yes | A `key` from `/api/vehicle-types`. Sets the mass. |
| `spawn_name` | yes | The new spawn name: lowercase letters, digits and underscores, such as `someone_police4`. |
| `select` | when several | The `model` to prepare. Without it, the best match is used. |
| `engine_sound` | no | A sound `key` from `/api/vehicle-types`. |
| `game_name` | no | The display name in game. |
| `make_name` | no | The make shown in game. |
| `customer_name` | no | Who the vehicle is for. |
| `customer_discord_id` | no | That customer's Discord ID. |
| `customer_folder_needed` | no | `1` to place the vehicle in a customer folder. Needs both customer fields. |
| `nitrous` | no | `1` to add nitrous. |
| `optimize_ytds` | no | `1` to shrink oversized texture dictionaries. |
| `texture_scale` | no | With `optimize_ytds`: `0.5` halves the texture size (the default), `0.25` quarters it. |
| `barebones` | no | `1` for a minimal resource. |
| `keep_orphan_kits` | no | `1` to keep modkits no model uses. Only the value `1` counts. |
| `keep_modkit_id` | no | `1` to keep the existing modkit ID instead of generating a new one. Only the value `1` counts. |
| `weapon_slot_0`, `weapon_slot_1`, ... | no | `spec` only. A weapon `hash` from `/api/vehicle-types` for that mount, counting from 0 in the order of `weapon_slots`. Leave a slot blank or leave it out to keep it. Empty mounts stay empty. |
| `override_mass` | no | A number used as the mass instead of the vehicle type's value. |
| `override_flags` | no | Replaces the vehicle's flags entirely. |
| `override_handling_name` | no | The handling name and handling ID. |
| `override_light_settings` | no | The light settings ID. |
| `override_siren_settings` | no | The siren settings ID. |
| `override_modkit` | no | The modkit the variations use. |
| `override_audio_hash` | no | The audio name hash. Takes priority over `engine_sound`. |
| `override_game_name` | no | Game name, applied after everything else. |
| `override_make_name` | no | Make name, applied after everything else. |
| `job_id` | no | 1 to 64 letters and digits you choose, unique per job, for `/api/progress`. |

For yes-or-no fields other than `keep_orphan_kits` and `keep_modkit_id`, the values `1`, `on` and `true` mean yes. Anything else means no.

On success the response body is the zip (`application/zip`), named `<spawn_name>.zip`. The result comes back in headers:

| Header | Value |
| --- | --- |
| `X-Prep-Passed` | `1` if no check found an error, otherwise `0`. Warnings do not fail a prep. |
| `X-Prep-Errors` | How many errors the checks found. |
| `X-Prep-Warnings` | How many warnings the checks found. |
| `X-Prep-Report` | Base64-encoded UTF-8 JSON with the full report, described below. |

The decoded `X-Prep-Report`:

```json
{
  "spawn_name": "someone_police4",
  "original_model": "police4",
  "output": "someone_police4",
  "changes": [
    {"kind": "rename", "target": "someone_police4.yft", "detail": "renamed asset", "before": "police4.yft", "after": "someone_police4.yft"}
  ],
  "issues": [
    {"check": "PREP", "severity": "warning", "message": "LEO carcols.meta kept, but it defines no <Sirens> - confirm the lights come from somewhere else", "file": "carcols.meta"}
  ]
}
```

- `changes` lists everything the prep did. `before`, `after` and `file` can be `null`.
- `issues` lists what a person should look at. `severity` is `error` or `warning`.
- `output` is the name of the folder on Workshop's side. Treat it as information only.

A failed prep returns JSON with an `error` and no zip.

### POST /api/prep/report

Takes the same fields as `/api/prep` and runs the same prep, but returns the result as JSON instead of the zip. Use it to preview what a prep would change. The account must be able to afford a prep, but no credits are spent. It does not report progress.

```json
{
  "spawn_name": "someone_police4",
  "original_model": "police4",
  "passed": true,
  "checks_run": ["CHECK_MASS"],
  "changes": [],
  "issues": [],
  "options": {}
}
```

The response also includes Workshop's internal `source_dir` and `output_dir`. Ignore them.

### POST /api/lod/inspect

What levels of detail a model has, and what is missing. Free. Needs `tab.lod`. Send `multipart/form-data` with the `.ydr` or `.yft` in the `model` field.

```json
{
  "name": "poltorencer.yft",
  "kind": "yft",
  "levels": [
    {"name": "high", "meshes": 30, "triangles": 26746},
    {"name": "med", "meshes": 19, "triangles": 11455}
  ],
  "high": 26746,
  "missing": ["low", "vlow"],
  "problems": [],
  "notes": [],
  "can_generate": true,
  "generator": ""
}
```

- `missing` names the levels this model has not got - those are what `/api/lod/generate` would build.
- `can_generate` is `false` when this server cannot build them; `generator` then says what it would need.

### POST /api/lod/generate

Builds the missing levels and returns the model, same name and kind, ready to drop back into the stream folder. Charged to the key's account. Needs `tab.lod`.

| Field | Required | What it does |
| --- | --- | --- |
| `model` | yes | The `.ydr` or `.yft`, as `multipart/form-data`. |
| `replace` | no | `1` rebuilds levels the model already has. Off by default, so only the missing ones are added. |

The body is the model file. `X-Lod-Report` carries the same report as `/api/lod/inspect`, as base64-encoded JSON, so a client can say what was built.

### POST /api/vehglass/inspect

A vehicle's glass: what each window is made of, whether it is registered to break, and anything wrong with it. Free. Needs `tab.glass`. Send the `.yft` in the `vehicle` field.

```json
{
  "name": "poltorencer.yft",
  "windows": [
    {
      "child_index": 9,
      "group": "window_lf",
      "material": 120,
      "material_name": "CAR_GLASS_WEAK",
      "material_flags": 132,
      "registered": true,
      "group_flags": 0,
      "bulletproof": false,
      "made_of_glass": false,
      "problems": []
    }
  ],
  "registered_total": 8,
  "bulletproof": false,
  "notes": ["1 window(s) have collision but no shatter entry, so they will not break"],
  "materials": {"123": "123 - CAR_GLASS_BULLETPROOF"}
}
```

- `registered` is whether the window is in the shatter table. A window with collision but no entry stops bullets and never breaks - that is what `/api/vehglass/fix` repairs.
- `materials` lists every material `/api/vehglass/bulletproof` accepts, keyed by its number.

### POST /api/vehglass/fix

Adds the shatter table a converted vehicle was exported without, so its windows break. Every byte that already existed is carried through unmoved. Charged, unless the vehicle already had the table. Needs `tab.glass`.

| Field | Required | What it does |
| --- | --- | --- |
| `vehicle` | yes | The `.yft`, as `multipart/form-data`. |
| `geometry` | no | Which geometry holds the glass, if the automatic choice was wrong. `X-Glass-Runner-Up` on a previous response names the next candidate. |

The body is the patched `.yft`. The headers say what happened:

| Header | Meaning |
| --- | --- |
| `X-Glass-Windows` | How many windows were written into the table. |
| `X-Glass-Geometry` | Which geometry was used. |
| `X-Glass-Runner-Up` | The next best geometry, when there was one. |
| `X-Glass-Unchanged` | `1` when the vehicle already had a table and nothing was changed - nothing is charged either. |

### POST /api/vehglass/bulletproof

Sets the glass material, adds the collision fix, or both. A byte patch: nothing else in the resource moves. Charged. Needs `tab.glass`.

| Field | Required | What it does |
| --- | --- | --- |
| `vehicle` | yes | The `.yft`, as `multipart/form-data`. |
| `material` | one of these two | The material number from `/api/vehglass/inspect`, such as `123` for `CAR_GLASS_BULLETPROOF` or `122` for `CAR_GLASS_STRONG`. Send `keep` to leave it alone. |
| `fix` | one of these two | `1` also adds the shatter table, as `/api/vehglass/fix` does. |

Sending neither is a `400`, because nothing would change. The body is the patched `.yft`; `X-Glass-Changed` counts the windows whose material changed and `X-Glass-Fixed` counts the windows added to the shatter table.

### POST /api/images

Stores an image and returns a link to it. For a program that has made something worth showing - a livery it generated, a render, a screenshot - and needs somewhere to put it that Discord or a panel can load. Nothing is processed: the bytes go to the store as they arrived.

**Backend keys only.** The key must be marked as a backend key by an owner, and its account must hold `api.images`. A key without the mark gets a `403` saying so.

| Field | Required | What it does |
| --- | --- | --- |
| `image` | yes | The image, as `multipart/form-data`. `file` is accepted as well. |

PNG, JPEG, WebP and GIF, up to 200 MB. The type is read from the file's own first bytes, not its name, and the image has to decode - a file that is only an image at the front is refused.

This endpoint is unchanged and is staying. For anything new, use `POST /api/media`, which takes the same things plus video and links.

```json
{
  "url": "https://cdn.example.com/1234/2026-09-17/143012-Xk2p9vQ1-livery.png",
  "name": "livery.png",
  "bytes": 184320,
  "type": "image/png",
  "expires_in": null
}
```

- `url` is where the image now lives. `expires_in` is `null` when the link is permanent, or the number of seconds it lasts when the store hands out signed links instead.
- `name` is the filename as stored, cleaned up and given the right extension for what the file actually is.
- Hosting is counted in your usage figures but costs no credits by default.
- `501` means this server has no image store configured, and `502` means the store refused it - both are worth retrying later rather than treating as a bad request.

### POST /api/media

Stores an image **or a video** and returns a link to it. Takes either the bytes or a link this server fetches for you, so a bot that has just been handed a URL does not have to download the file and upload it again.

This is the endpoint to build against. `POST /api/images` still works exactly as it did and is not going away, but it only takes image bytes; everything it does, this does too.

**Backend keys only**, the same as `/api/images`: the key must be marked as a backend key by an owner, and its account must hold `api.images`. The same key works for both - nothing has to be marked again.

Send exactly one of these two. Sending both, or neither, is a `400`.

| Field | Required | What it does |
| --- | --- | --- |
| `file` | one of the two | The bytes, as `multipart/form-data`. `image` is accepted as the field name too, so an existing `/api/images` client keeps working. |
| `source_url` | one of the two | An `http`/`https` link this server downloads itself. Sent as a normal form field beside nothing else. |
| `domain` | no | A verified custom domain of yours to write the returned link on, such as `cdn.yourdomain.com`. Left out, your default domain is used, or this site's address if you have none. A domain your account has not verified is a `400`. |

**What is accepted.** PNG, JPEG, WebP and GIF images up to 200 MB, and MP4, MOV, WebM and MKV videos up to 500 MB.

What a file is comes from its own first bytes - never its name, never its extension, and never the `Content-Type` a remote server claims. The container is then walked from end to end to prove it is whole: an MP4's boxes have to run cleanly to the last one, a Matroska's header and segment have to parse, an image has to decode. A file that is a video for its first twelve bytes and something else behind them is refused, exactly as `/api/images` refuses a file that is only an image at the front.

**Nothing is changed.** No transcode, no re-encode, no stripping, no rewriting: the bytes that arrive are the bytes that are stored.

```json
{
  "url": "https://cdn.example.com/images/7jfsctd2ho/2026-09-20/143012-Xk2p9vQ1-clip.mp4",
  "name": "clip.mp4",
  "bytes": 18432000,
  "type": "video/mp4",
  "expires_in": null,
  "width": 1920,
  "height": 1080,
  "duration_seconds": 42.5
}
```

- `url` is where the file now lives. `expires_in` is `null` when the link is permanent, or the number of seconds it lasts when this server hands out signed links instead. It means the same thing for a video as for an image: how long the link works, not how long the file is kept. A file is kept until somebody deletes it.
- `width`, `height` and `duration_seconds` are filled in for video where the container gives them, and `null` where it does not - a sound-only MP4 has no size, and an unusual file may not say how long it runs. For images, `width` and `height` are always filled in and `duration_seconds` is always `null`. A missing field is never a reason to retry.
- `name` is the filename as stored: cleaned up, and given the extension matching what the file actually turned out to be.
- The response is JSON with no special headers. Everything about the file is in the body.

**Cost and limits.** Hosting is counted in your usage figures and costs no credits. It counts as one request against the 30 requests a minute per key, including when this server does the downloading. There is no per-account storage cap and nothing expires on its own; files stay until they are deleted from the Image Hosting page on the site.

**How long it takes.** A `source_url` request holds the connection until the file is stored - there is no job id and nothing to poll. The download is given 5 minutes at the outside, and a link slower than that is answered with `504` rather than being left to run. Allow 15 minutes on your client, as for the other long endpoints. `GET /api/progress/<job_id>` does not apply here.

**Which links this server will fetch.** Any public host, by default: a bot is handed links from wherever its users found them, so `source_url` is not tied to a list of approved sites. What is checked is the link itself - only `http` and `https`, no credentials in the URL, no unusual ports, at most 3 redirects, and every hop, not just the first, has to resolve to a public address. A link that points at this server's own network, a private range, loopback or a cloud metadata address is refused with `403`, including when an allowed link redirects to one.

An owner who wants it narrower can set `FIVEM_PREP_MEDIA_HOSTS` to a list of hosts; a name starting with a dot covers everything under it. With it set, anything else is a `403`. It is empty by default, which means no restriction. A clip page needs both the site and the host its video is served from - for Medal that is `medal.tv` and `cdn.medal.tv`.

**Clip pages.** A `source_url` is normally the file itself. For a few sites it can be the page somebody was given instead, and the video on it is worked out and stored:

| Site | What to send |
| --- | --- |
| Medal | `https://medal.tv/games/<game>/clips/<id>`, with or without an `?invite=` on the end |

The page is read for the video it names, and only a video on that site's own hosts is followed. Where a page offers more than one copy, the original its own player streams is taken first and the copy made for embeds is the fallback - the embed copy is re-encoded, usually larger, often carries the site's branding, and is built on demand, so it can be unfinished when it is first asked for. Everything after that is identical to any other link: the same size cap, the same checks on every hop, and what the file is decided by reading its bytes. A page with no clip on it - private, deleted, or not a clip at all - is a `502` saying so, rather than a stored page of HTML. Any other link is fetched exactly as it is given.

| Status | When |
| --- | --- |
| `400` | Both `file` and `source_url`, or neither. A file that is not one of the accepted types. A video whose container does not parse. A link that is not `http`/`https`, carries credentials, or uses an unusual port. |
| `401` | No key, or the key was not sent as `Authorization: Bearer <key>`. |
| `403` | The key is not marked as a backend key, the account lacks `api.images`, the link resolves to a private, loopback, link-local or unique-local address, or the owner has restricted which hosts may be fetched and this is not one of them. |
| `413` | The file is over the limit for its type. For `source_url` the download is stopped as soon as it passes the limit, whether or not the host sent a `Content-Length`. |
| `429` | Past 30 requests a minute for this key. `Retry-After` says how many seconds to wait. |
| `500` | Something went wrong here. The server log has it. |
| `501` | This server has no media store configured, so there is nowhere to put the file. |
| `502` | The link could not be reached, answered with an error, was not public, had nothing at it, redirected too many times, or the store refused the file. Worth retrying later. |
| `504` | The link took too long to answer, or the download ran past the time limit. |

```bash
# The bytes
curl -H "Authorization: Bearer $WORKSHOP_API_KEY" \
     -F "file=@clip.mp4" \
     https://fivemtool.vertual.dev/api/media

# Or a link, fetched by the server
curl -H "Authorization: Bearer $WORKSHOP_API_KEY" \
     -F "source_url=https://s3.fini.dev/clips/chase.mp4" \
     https://fivemtool.vertual.dev/api/media
```

### Custom domains

A link is a base address plus the file's path, so your files can be served from a domain of your own rather than this site's. It is the same file in the same place - only the address changes - so every link that already exists keeps working.

**Which host.** Whatever you like: `yourdomain.com` itself, `cdn.yourdomain.com`, `i.yourdomain.com`, or something further down like `media.files.yourdomain.com`. Nothing is fixed but the domain having to be yours - there is no required subdomain, and the records you publish follow whatever you typed.

**Setting one up.** On the site, open **Image Hosting** and add the domain under **Your own domain**. In a registrar's panel, whose Host column is relative to your domain, that is one record:

| Type | Host | Value |
| --- | --- | --- |
| `CNAME` | `cdn` (for `cdn.yourdomain.com`) | the store shown on the page |

Publish it, give DNS a minute, then press **Check DNS**. Pointing the name here is itself proof that the domain is yours - only whoever controls it can do that - so nothing else needs typing out, and it is the record the domain needed anyway to serve anything.

**If you cannot point it here yet**, the page also shows a `TXT` record - `_workshop.<your host>`, or the host itself - carrying a secret token. Either one verifies the domain. It is the way round for a domain you want verified before it is switched over, and it can be deleted once the domain is serving.

Once verified, the domain is routed and given a certificate automatically. That takes a minute; until it lands, the page says the domain is not serving yet rather than leaving you to find out from a browser warning.

**What a link looks like on your domain.** Files are served from its root, with none of this site's storage layout in the way:

```
https://cdn.yourdomain.com/7jfsctd2hoXk2p9vQ1sR.png
```

That is the folder your files sit in and a random name, joined. On this site's own address the same file carries the bucket and media folder as well, because there it has to.

**At the root of a domain.** If the host is the domain itself rather than a subdomain of it, the `TXT` record is unchanged - `_workshop.yourdomain.com` is a name below the root like any other - but a `CNAME` cannot sit at the root beside the records that run your mail and the rest of the domain. Use your provider's `ALIAS` or `ANAME` record with the same value instead; Cloudflare flattens a root `CNAME` for you and needs nothing special.

The first domain you verify becomes the one your links are written on. With several, **Write links on this** chooses which. An account can hold 4 domains, and removing one puts your links back on this site's address.

**With several domains.** A file answers on every one of them at once, because they all point at the same storage. Your links are written on the one marked **Write links on this**, and `domain` on `POST /api/media` overrides that per upload. On the site, opening an image gives a dropdown of your domains, so you can copy that file's link on whichever one you want without changing anything about the file.

**What it does not do.** A custom domain doesn't restrict who can open a file. The file still sits in this site's storage, and anyone with a link to it can fetch it on either address.

### GET /api/progress/<job_id>

How far a prep sent with that `job_id` has got. Only the account that started the job can read it.

```json
{"stage": "waiting for a worker", "fraction": 0.5}
```

`fraction` runs from 0 to 1. Both fields are `null` before the job starts, after it ends, and for any ID that is not yours. If another account is already using a `job_id`, your prep still runs, but its progress is not tracked. Use random IDs. Only `/api/prep` reports progress; the Levels of Detail and Glass & Windows endpoints answer when they are done.

## Complete examples

### curl

```bash
export WORKSHOP_API_KEY=fpk_...

# 1. What is in the download?
curl -H "Authorization: Bearer $WORKSHOP_API_KEY" \
     -F vehicle=@police4.zip \
     https://fivemtool.vertual.dev/api/inspect

# 2. Prepare it, reusing the upload
curl -H "Authorization: Bearer $WORKSHOP_API_KEY" \
     -F upload_token=PASTE_THE_TOKEN \
     -F select=police4 \
     -F department=leo -F vehicle_type=sedan -F spawn_name=someone_police4 \
     -D headers.txt -o someone_police4.zip \
     --max-time 900 \
     https://fivemtool.vertual.dev/api/prep
```

### Python

Uses the `requests` package.

```python
import base64
import json
import os
import uuid

import requests

BASE = "https://fivemtool.vertual.dev"
session = requests.Session()
session.headers["Authorization"] = f"Bearer {os.environ['WORKSHOP_API_KEY']}"
session.headers["User-Agent"] = "my-prep-tool/1.0"


def call(method, path, **kwargs):
    response = session.request(method, BASE + path, **kwargs)
    if not response.ok:
        try:
            message = response.json()["error"]
        except ValueError:
            message = response.text
        raise RuntimeError(f"{response.status_code}: {message}")
    return response


me = call("GET", "/api/key").json()
if not me["canPrep"]:
    raise SystemExit("this key's account cannot use Vehicle Prep")

with open("police4.zip", "rb") as upload:
    found = call("POST", "/api/inspect", files={"vehicle": upload}, timeout=900).json()
if not found["vehicles"]:
    raise SystemExit("no vehicle in that upload")

prep = call("POST", "/api/prep", data={
    "upload_token": found["upload_token"],
    "select": found["vehicles"][0]["model"],
    "department": "leo",
    "vehicle_type": "sedan",
    "spawn_name": "someone_police4",
    "job_id": uuid.uuid4().hex,
}, timeout=900)

with open("someone_police4.zip", "wb") as out:
    out.write(prep.content)
report = json.loads(base64.b64decode(prep.headers["X-Prep-Report"]))
print("passed" if prep.headers["X-Prep-Passed"] == "1" else "needs attention")
for issue in report["issues"]:
    print(f"[{issue['severity']}] {issue['message']}")
```

### JavaScript (Node.js 18 or later)

Uses the built-in `fetch`, `FormData` and `Blob`.

```js
import { readFile, writeFile } from "node:fs/promises";
import { randomUUID } from "node:crypto";

const BASE = "https://fivemtool.vertual.dev";
const HEADERS = {
  Authorization: `Bearer ${process.env.WORKSHOP_API_KEY}`,
  "User-Agent": "my-prep-tool/1.0",
};

async function call(path, options = {}) {
  const response = await fetch(BASE + path, {
    ...options,
    headers: HEADERS,
    signal: AbortSignal.timeout(15 * 60 * 1000),
  });
  if (!response.ok) {
    const body = await response.json().catch(() => ({}));
    throw new Error(`${response.status}: ${body.error || response.statusText}`);
  }
  return response;
}

const form = new FormData();
form.append("vehicle", new Blob([await readFile("police4.zip")]), "police4.zip");
const found = await (await call("/api/inspect", { method: "POST", body: form })).json();
if (found.vehicles.length === 0) throw new Error("no vehicle in that upload");

const jobId = randomUUID().replaceAll("-", "");
const options = new FormData();
options.append("upload_token", found.upload_token);
options.append("select", found.vehicles[0].model);
options.append("department", "leo");
options.append("vehicle_type", "sedan");
options.append("spawn_name", "someone_police4");
options.append("job_id", jobId);

const poll = setInterval(async () => {
  const progress = await (await call(`/api/progress/${jobId}`)).json().catch(() => null);
  if (progress?.stage) console.log(progress.stage, Math.round(progress.fraction * 100) + "%");
}, 5000);

try {
  const prep = await call("/api/prep", { method: "POST", body: options });
  await writeFile("someone_police4.zip", Buffer.from(await prep.arrayBuffer()));
  const report = JSON.parse(Buffer.from(prep.headers.get("X-Prep-Report"), "base64").toString("utf8"));
  console.log(prep.headers.get("X-Prep-Passed") === "1" ? "passed" : "needs attention");
  for (const issue of report.issues) console.log(`[${issue.severity}] ${issue.message}`);
} finally {
  clearInterval(poll);
}
```

## Notes for AI assistants

If you are an AI building a tool from this document:

- Use only the endpoints, fields and values written here. Nothing else exists, and an API key cannot reach any other part of Workshop.
- Read the valid `department`, `vehicle_type`, `engine_sound` and weapon values from `GET /api/vehicle-types` at runtime. Do not hard-code them.
- Keep the API key in server-side configuration. Never place it in client code.
- Use a timeout of at least 15 minutes for inspect and prep requests, and stay under 30 requests a minute per key.
- Show the `error` message from failed requests to the user.
- Do not automatically retry `/api/prep` after a timeout, because it may already have been charged.
