API
Optimize YDR, YDD, YFT and YTD files from a script or a CI job.
Max planv1JSON over HTTPSNever costs creditsKeys at /app/api
https://zoov.dev/api/public/v1 On this page
Two hosts
Each file takes two calls. The first goes to zoov.dev with your key and the file's name and size; it checks the plan, opens a run and answers an upload link. The second sends the file to that link on the conversion box, which answers with the optimized file.
/api/public/v1 Checks the key, the plan and the size. Opens the run.zv_ key Two calls per file.upload.url Optimizes the file. One upload per link.Authentication
Send your key on every call to zoov.dev as Authorization: Bearer zv_…. Make keys at /app/api; only a hash is kept, so a lost key is revoked and replaced. The upload link carries its own one-time ticket and takes no header.
Keys work from servers and CI, never from a browser page: anyone who opens the page can read it. Keep the key in an environment variable such as ZOOV_KEY.
curl https://zoov.dev/api/public/v1/me \
-H "Authorization: Bearer $ZOOV_KEY"Optimize a folder
Every YDR, YDD, YFT and YTD in a folder, as many at once as the plan allows, written to a second folder. A file whose original was the better one is left out of the second folder. Run as it is with your key in ZOOV_KEY.
#!/usr/bin/env bash
# ./optimize.sh ./stream ./optimized (curl and jq, ZOOV_KEY in the environment)
API=https://zoov.dev/api/public/v1
src=${1:-./stream}; out=${2:-./optimized}; mkdir -p "$out"
for f in "$src"/*.{ydr,ydd,yft,ytd}; do
[ -e "$f" ] || continue
name=$(basename "$f")
run=$(curl -s "$API/optimize" -H "Authorization: Bearer $ZOOV_KEY" \
-H "Content-Type: application/json" \
-d "{\"name\":\"$name\",\"bytes\":$(wc -c < "$f")}")
url=$(jq -r '.upload.url // empty' <<< "$run")
[ -n "$url" ] || { echo "$name: $(jq -r .error <<< "$run")"; continue; }
type=$(curl -s "$url" -F "file=@$f" -o "$out/$name" -w '%{content_type}')
case "$type" in
*json*) echo "$name: kept the original"; rm "$out/$name" ;;
*) echo "$name: done" ;;
esac
doneCheck the key
GET /me
Who the key belongs to, the plan, the credit balance and the Optimize allowance. optimize.concurrent is how many uploads a script should keep in flight; maxFileBytes is the largest file.
curl https://zoov.dev/api/public/v1/me \
-H "Authorization: Bearer $ZOOV_KEY"{
"account": {
"id": "11111111-2222-4333-8444-555555555555",
"name": "Your account"
},
"plan": "max",
"key": {
"id": "66666666-7777-4888-9999-aaaaaaaaaaaa",
"scopes": [
"*"
],
"requestsPerMinute": 300
},
"credits": {
"balance": 12480,
"permanent": 0,
"temporary": 12480
},
"optimize": {
"perDay": null,
"used": 0,
"active": 0,
"concurrent": 5,
"maxFileBytes": 104857600,
"resetAt": null
}
}Start an optimize
POST /optimize
Checks the plan, the size and how many files are running, opens a run and answers 201 with the run and where to send the file. The link takes one upload and lapses at run.expiresAt, 15 minutes on. Ask for it when the file is ready: a waiting run holds one of the plan's five slots.
namestring required- The file name, ending in .ydr, .ydd, .yft or .ytd. The upload must carry a file of the same type.
bytesinteger required- The file size. Checked against the plan before anything is uploaded.
kindstring- ydr, ydd, yft or ytd. Only for a name without an extension.
curl https://zoov.dev/api/public/v1/optimize \
-H "Authorization: Bearer $ZOOV_KEY" \
-H "Content-Type: application/json" \
-d '{"name":"prop_crate.ydr","bytes":989341}'{
"run": {
"id": "84862bbf-c87d-4320-95f8-d00659d32f60",
"state": "waiting",
"name": "prop_crate.ydr",
"kind": "ydr",
"maxBytes": 104857600,
"expiresAt": "2026-10-01T12:11:45.000Z"
},
"upload": {
"method": "POST",
"url": "https://v2.winapi.zoov.dev/public/v1/optimize/zt_eyJ2IjoxLCJ1c2Ui…",
"contentType": "multipart/form-data",
"fields": {
"file": "the file",
"settings": "optional, JSON"
}
}
}Send the file
POST upload.url
A multipart POST to the link from the last call, on the conversion box, with no header. Two answers are a success:
application/octet-stream: the optimized file, with the result as JSON inX-Optimize-Result.application/json:{ result }withresult.keptset. The original was the better file; keep yours.
filefile required- The resource, up to 100 MB, of the type the run was opened for.
settingsJSON string- What to change. Leave it out for the defaults; see Settings.
curl "$UPLOAD_URL" \
-F file=@prop_crate.ydr \
-F 'settings={"textures":{"maxSize":1024}}' \
-D headers.txt \
-o prop_crate.optimized.ydrHTTP/1.1 200 OK
content-type: application/octet-stream
content-disposition: attachment; filename="prop_crate.ydr"
content-length: 972633
x-optimize-result: {"kind":"ydr","gameVersion":"gen8","bytesIn":989341,"bytesOut":972633,"outcome":"smaller","kept":null,"textures":{"count":7,"changed":1,"resized":0,"recompressed":1,"failed":0,"dropped":0,"list":[{"name":"dt1_13_build2_pal","from":[256,4,"A8R8G8B8"],"to":[256,4,"DXT5"]}],"more":0},"meshes":null,"warnings":[],"notes":[],"durations":{"textures":1052},"settings":{…},"name":"prop_crate.ydr"}
<972,633 bytes of the optimized file>{
"result": {
"kind": "ydr",
"gameVersion": "gen8",
"bytesIn": 788596,
"bytesOut": 484775,
"outcome": "smaller",
"kept": "nothing_to_do",
"textures": {
"count": 7,
"changed": 0,
"resized": 0,
"recompressed": 0,
"failed": 0,
"dropped": 0,
"list": [],
"more": 0
},
"meshes": null,
"warnings": [],
"notes": [],
"durations": {
"textures": 32
},
"settings": {
"textures": {
"enabled": true,
"maxSize": 1024,
"forceSize": false,
"format": "auto",
"mips": true
},
"meshes": {
"enabled": false,
"detail": 1,
"lods": true
},
"keepSmaller": true
},
"name": "prop_bench.ydr"
}
}Read a run
GET /optimize/:id
What the ledger knows about one run. For a script that lost the upload's answer and needs to know whether the file went through.
statestring- waiting (no upload yet), running, done (a file was delivered), kept (the original was the better file), failed, expired (no upload within 15 minutes).
bytesIn · bytesOutinteger- Sizes before and after. bytesOut is null until the box has answered.
outcomestring- smaller, larger or same.
codestring- Why a file was kept (nothing_to_do, not_smaller, would_drop_textures) or why it failed (cancelled, timeout, encrypted...).
msinteger- Time the box spent on the file.
curl https://zoov.dev/api/public/v1/optimize/$RUN_ID \
-H "Authorization: Bearer $ZOOV_KEY"{
"run": {
"id": "84862bbf-c87d-4320-95f8-d00659d32f60",
"state": "done",
"name": "prop_crate.ydr",
"kind": "ydr",
"bytesIn": 989341,
"bytesOut": 972633,
"outcome": "smaller",
"code": null,
"ms": 1412,
"createdAt": "2026-10-01T11:56:44.901862+00:00",
"finishedAt": "2026-10-01T11:56:46.638903+00:00"
}
}Cancel a run
DELETE /optimize/:id
Gives back a run whose file will not be sent, so its slot frees now instead of after 15 minutes. Answers 409 in_progress once the box has the file.
curl -X DELETE https://zoov.dev/api/public/v1/optimize/$RUN_ID \
-H "Authorization: Bearer $ZOOV_KEY"{
"run": {
"id": "bf2b34ed-b5f0-40b7-bd0c-52b528450715",
"state": "failed",
"name": "later.ytd",
"kind": "ytd",
"bytesIn": 541242,
"bytesOut": null,
"outcome": null,
"code": "cancelled",
"ms": null,
"createdAt": "2026-10-01T11:56:51.348156+00:00",
"finishedAt": "2026-10-01T11:56:52.943315+00:00"
}
}Settings
The settings field of the upload, as JSON; the same settings as the Optimize tool in the workspace. Send only what differs from the defaults. The defaults shrink a file without touching a vertex.
textures.enabledboolean · true- Recompress and resize textures. Every file type.
textures.maxSize256 to 4096 · 1024- The longest side a texture keeps; larger ones are scaled down. 256, 512, 1024, 2048 or 4096.
textures.forceSizeboolean · false- Scale every texture to maxSize, smaller ones too.
textures.formatstring · auto- auto only ever moves a texture to a lighter format. dxt1, dxt5 or bc7 force one.
textures.mipsboolean · true- Rebuild mipmaps.
meshes.enabledboolean · false- Work on geometry. YDR, YDD and YFT only.
meshes.detail0.4 to 1 · 1- The share of triangles kept.
meshes.lodsboolean · true- Add missing LODs. A file that gains LODs can grow and is still delivered.
keepSmallerboolean · true- Answer with JSON instead of a file when the new file is not smaller.
The result
The object in X-Optimize-Result, or in result when the original was kept.
kind · namestring- The file type and the name it was sent with.
bytesIn · bytesOutinteger- Sizes before and after the rebuild.
outcomestring- smaller, larger or same.
keptstring or null- null when the file was delivered. nothing_to_do: nothing needed changing, so the original is kept even when the rebuild came out a little smaller. not_smaller: the new file was no smaller. would_drop_textures: textures without readable mipmaps would have been lost.
texturesobject- count, changed, resized, recompressed, failed, dropped, and list: each changed texture with its size and format before and after (the first few; more counts the rest).
meshesobject or null- When geometry ran: trianglesBefore, trianglesAfter, lodsAdded, lodsKept.
warningsstring[]- meshes_skipped, no_textures, textures_dropped, textures_failed, high_kept, hi_fragment, drawables_skipped, over_budget (the file is over 16 MB).
durations · settingsobject- Milliseconds per stage, and the settings as applied.
Errors
Branch on code, never on the message. zoov.dev answers { error, code } with the facts beside them; the conversion box answers { detail: { code, error, retryable } }.
A refusal about the file itself (type, size, empty, unreadable header) leaves the link usable for the right file. Once the box has started work, a failure closes the run: ask for a new link to try again.
{
"error": "File is larger than this plan allows",
"code": "file_too_large",
"maxBytes": 104857600,
"tier": "max"
}{
"detail": {
"code": "ticket_used",
"error": "This upload link was used already or has expired",
"retryable": false
}
}zoov.dev/api/public/v1
401
unauthorizedThe key is missing, mistyped, revoked or expired.
403
plan_requiredThe account is not on Max.
403
scope_missingThe key has scopes, and optimize is not one of them.
403
account_suspendedThe account is suspended.
404
not_foundNo run with that id on this account.
409
concurrency_limitFive files are running already, in the workspace or here. Wait for one and ask again.
409
in_progressDELETE after the box took the file.
413
file_too_largeOver the plan’s size. maxBytes says the limit.
429
rate_limitedOver 300 calls a minute for this key. retryAfter says when.
503
unavailableOptimize is down for a moment. Try again.
The uploadupload.url
400
wrong_kindThe file is not the type the run was opened for. The link still takes the right file.
400
empty · settingsAn empty file, or settings that are not valid JSON for this shape.
401
ticket_invalid · ticket_expiredThe link was altered or is past 15 minutes. Ask for a new one.
409
ticket_usedThe link took a file already. One upload per link.
413
too_large · too_complexOver 100 MB, or a part with more than 32,767 vertices.
415
unsupportedNot a YDR, YDD, YFT or YTD.
422
not_resource · encryptedNot a GTA V resource, or an escrow-protected one. These cannot be read.
422
legacy_names · unnamed_textures · no_geometry · crashThe file could not be worked on as it is.
503
busy · texture_tool · site_unavailableretryable: true. Ask for a new link and send the file again.
504
timeoutTook too long and was stopped. retryable: true.
Limits
- 5 files at once
- Shared with the Optimize tool in the workspace. A waiting link holds a slot until its file is done, it is cancelled or 15 minutes pass.
- 100 MB per file
- Checked when the run opens and again on upload.
- No daily cap
- Optimize never costs credits.
- 300 calls a minute
- Per key, on zoov.dev. Uploads count on the box: 300 a minute per address.
- One upload per link
- Each link lasts 15 minutes.
- 10 keys
- Active keys per account.
Resource API
For the zoovdev_sync FiveM resource, on any plan: it turns an image into a 3D prop and deploys it to your linked server. The resource makes the calls for you; scripts on the server call its exports with a key. Base https://zoov.dev/api/v1, the same Authorization header.
Routes
POST /fivem/props/stage-uploadJSON- An upload slot for a PNG or JPG, good for 10 minutes.
POST /fivem/props/from-imageJSON- Starts the prop and sends it to the linked server when it is ready. Answers taskId and status.
Exports
createPropFromImage- Queue, watch and deploy. The default.
queuePropFromImageTask- Queue only.
getPropTaskStatus- pending, ready or failed.
watchPropTask- Watch in the background.
deployPropFromTask- Deploy a finished task.
stopWatchingPropTask- Stop watching.
recoverPropTask- Resume a task after a restart.
Arguments
apiKeystring required- A zv_ key from /app/api. Any plan.
imagePathstring required- A local PNG or JPG.
propNamestring required- Letters, digits and underscores, 64 at most.
optionstable- { scale }, default 1.0, 100 at most.
callbackfunction- (success, result)
Setup
Link the server at /app/api, and start it with +set nodejs_allowFsWrite zoovdev_sync. Without that argument, deploys fail with "Permission denied". "No linked server" means the link command needs running again in the server console.
exports.zoovdev_sync:createPropFromImage(
'zv_your_key',
'images/crate.png',
'my_crate',
{ scale = 1.0 },
function(success, result)
print(success, json.encode(result))
end
)