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 needstab.lod, and Glass & Windows needstab.glass. A key acts as its account, so it can only reach the tools that account already has.GET /api/keyreports which. - Backend keys.
POST /api/imagesandPOST /api/mediawrite 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 theapi.imagespermission. An ordinary key gets a403. 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
Authorizationheader. 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
- Sign in to the Workshop portal.
- Open API, then API Dashboard. Your account needs the API permission to see it.
- Give the key a label that names the program using it, such as
Discord bot, and create it. - 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:
- Check the key. Call
GET /api/keywhen your program starts. Stop with a clear message if it returns401or403, or ifcanPrepisfalse. - Load the choices. Call
GET /api/vehicle-typesand cache the result. It lists the accepteddepartment,vehicle_type,engine_soundand weapon values. Show these to your users as menus rather than asking them to type keys. - Inspect the upload. Send the file to
POST /api/inspect, or a download link toPOST /api/inspect-url. You get back the vehicles found and anupload_token. - Pick the vehicle. A download often holds more than one vehicle, such as an Add-On and a Replace copy, or a pack. If
vehicleshas more than one entry, ask your user whichmodelthey want. If it is empty, the upload holds no vehicle. - Prepare it. Send
POST /api/prepwith theupload_token, the chosenselectmodel and the options. Include ajob_idif you want to show progress. - 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. - Deliver the result. Save the zip from the response body. Read
X-Prep-Passed,X-Prep-ErrorsandX-Prep-Warnings, and decodeX-Prep-Reportto 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'scdn.discordapp.comlink assource_urlto/api/inspect-url. Direct Discord attachment links are supported. - Choosing a vehicle. When inspect finds several vehicles, show a select menu of their
modelandlabelvalues, and keep theupload_tokenwith 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-Reportso 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:
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
429with aRetry-Afterheader in seconds. - Upload size. Up to 500 MB per request.
- Accepted files. One archive (
.zip,.rar,.7zor.rpf), or several loose vehicle files (.meta,.yft,.ytd,.ycd,.ybn,.ydr,.dds,.xml) sent under the same field name. - Download links.
source_urlaccepts 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/prepis 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/keyand/api/usageshow the balance.
Endpoints
GET /api/key
The key, the account it acts as, whether it can prep, the rate limit and the credit balance.
{
"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.
{
"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[].keyis avehicle_typevalue, andmassis the mass that type sets.engine_sounds[].sounds[].keyis anengine_soundvalue.weaponslists the approvedweapon_slot_<n>hashes by tier: base game, then tiers 1 to 3.checksnames the checks every prep runs.streamable_limitsis 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.
{
"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.
{
"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
}
]
}
vehiclesis sorted best match first, andscoreis how confident the match is. Pass the chosenmodelto/api/prepasselect.labelis a name to show people, andnotesexplains what was found.completeisfalsewhen files are missing, andmissingnames them.oversized_ytdslists texture dictionaries over the size limit. Sendoptimize_ytds=1to shrink them.weapon_slotshas one entry per weapon mount.nullis an empty mount.upload_tokenisnullwhen 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:
{"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:
{
"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"}
]
}
changeslists everything the prep did.before,afterandfilecan benull.issueslists what a person should look at.severityiserrororwarning.outputis 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.
{
"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.
{
"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": ""
}
missingnames the levels this model has not got - those are what/api/lod/generatewould build.can_generateisfalsewhen this server cannot build them;generatorthen 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.
{
"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"}
}
registeredis 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/fixrepairs.materialslists every material/api/vehglass/bulletproofaccepts, 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.
{
"url": "https://cdn.example.com/1234/2026-09-17/143012-Xk2p9vQ1-livery.png",
"name": "livery.png",
"bytes": 184320,
"type": "image/png",
"expires_in": null
}
urlis where the image now lives.expires_inisnullwhen the link is permanent, or the number of seconds it lasts when the store hands out signed links instead.nameis 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.
501means this server has no image store configured, and502means 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.
{
"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
}
urlis where the file now lives.expires_inisnullwhen 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,heightandduration_secondsare filled in for video where the container gives them, andnullwhere it does not - a sound-only MP4 has no size, and an unusual file may not say how long it runs. For images,widthandheightare always filled in andduration_secondsis alwaysnull. A missing field is never a reason to retry.nameis 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. |
# 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.
{"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
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.
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.
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_soundand weapon values fromGET /api/vehicle-typesat 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
errormessage from failed requests to the user. - Do not automatically retry
/api/prepafter a timeout, because it may already have been charged.