API

Optimize YDR, YDD, YFT and YTD files from a script or a CI job.

Max planv1JSON over HTTPSNever costs creditsKeys at /app/api

Base URL 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.

zoov.dev /api/public/v1 Checks the key, the plan and the size. Opens the run.
Your script zv_ key Two calls per file.
Conversion box upload.url Optimizes the file. One upload per link.
Files never pass through zoov.dev, and your key never reaches the conversion box. The link answers one upload; the two hosts settle the run between themselves.

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.

Request
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.

Whole folder
#!/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
done

Check 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.

Request
curl https://zoov.dev/api/public/v1/me \
  -H "Authorization: Bearer $ZOOV_KEY"
Example response · 200
{
  "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.

name string required
The file name, ending in .ydr, .ydd, .yft or .ytd. The upload must carry a file of the same type.
bytes integer required
The file size. Checked against the plan before anything is uploaded.
kind string
ydr, ydd, yft or ytd. Only for a name without an extension.
Request
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}'
Example response · 201
{
  "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 in X-Optimize-Result.
  • application/json: { result } with result.kept set. The original was the better file; keep yours.
file file required
The resource, up to 100 MB, of the type the run was opened for.
settings JSON string
What to change. Leave it out for the defaults; see Settings.
Request
curl "$UPLOAD_URL" \
  -F file=@prop_crate.ydr \
  -F 'settings={"textures":{"maxSize":1024}}' \
  -D headers.txt \
  -o prop_crate.optimized.ydr
Example response · 200
HTTP/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>
Example response · 200 · kept
{
  "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.

state string
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 · bytesOut integer
Sizes before and after. bytesOut is null until the box has answered.
outcome string
smaller, larger or same.
code string
Why a file was kept (nothing_to_do, not_smaller, would_drop_textures) or why it failed (cancelled, timeout, encrypted...).
ms integer
Time the box spent on the file.
Request
curl https://zoov.dev/api/public/v1/optimize/$RUN_ID \
  -H "Authorization: Bearer $ZOOV_KEY"
Example response · 200
{
  "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.

Request
curl -X DELETE https://zoov.dev/api/public/v1/optimize/$RUN_ID \
  -H "Authorization: Bearer $ZOOV_KEY"
Example response · 200
{
  "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.enabled boolean · true
Recompress and resize textures. Every file type.
textures.maxSize 256 to 4096 · 1024
The longest side a texture keeps; larger ones are scaled down. 256, 512, 1024, 2048 or 4096.
textures.forceSize boolean · false
Scale every texture to maxSize, smaller ones too.
textures.format string · auto
auto only ever moves a texture to a lighter format. dxt1, dxt5 or bc7 force one.
textures.mips boolean · true
Rebuild mipmaps.
meshes.enabled boolean · false
Work on geometry. YDR, YDD and YFT only.
meshes.detail 0.4 to 1 · 1
The share of triangles kept.
meshes.lods boolean · true
Add missing LODs. A file that gains LODs can grow and is still delivered.
keepSmaller boolean · 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 · name string
The file type and the name it was sent with.
bytesIn · bytesOut integer
Sizes before and after the rebuild.
outcome string
smaller, larger or same.
kept string 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.
textures object
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).
meshes object or null
When geometry ran: trianglesBefore, trianglesAfter, lodsAdded, lodsKept.
warnings string[]
meshes_skipped, no_textures, textures_dropped, textures_failed, high_kept, hi_fragment, drawables_skipped, over_budget (the file is over 16 MB).
durations · settings object
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.

Example response · 413 · zoov.dev
{
  "error": "File is larger than this plan allows",
  "code": "file_too_large",
  "maxBytes": 104857600,
  "tier": "max"
}
Example response · 409 · upload
{
  "detail": {
    "code": "ticket_used",
    "error": "This upload link was used already or has expired",
    "retryable": false
  }
}

zoov.dev/api/public/v1

  • 401 unauthorized

    The key is missing, mistyped, revoked or expired.

  • 403 plan_required

    The account is not on Max.

  • 403 scope_missing

    The key has scopes, and optimize is not one of them.

  • 403 account_suspended

    The account is suspended.

  • 404 not_found

    No run with that id on this account.

  • 409 concurrency_limit

    Five files are running already, in the workspace or here. Wait for one and ask again.

  • 409 in_progress

    DELETE after the box took the file.

  • 413 file_too_large

    Over the plan’s size. maxBytes says the limit.

  • 429 rate_limited

    Over 300 calls a minute for this key. retryAfter says when.

  • 503 unavailable

    Optimize is down for a moment. Try again.

The uploadupload.url

  • 400 wrong_kind

    The file is not the type the run was opened for. The link still takes the right file.

  • 400 empty · settings

    An empty file, or settings that are not valid JSON for this shape.

  • 401 ticket_invalid · ticket_expired

    The link was altered or is past 15 minutes. Ask for a new one.

  • 409 ticket_used

    The link took a file already. One upload per link.

  • 413 too_large · too_complex

    Over 100 MB, or a part with more than 32,767 vertices.

  • 415 unsupported

    Not a YDR, YDD, YFT or YTD.

  • 422 not_resource · encrypted

    Not a GTA V resource, or an escrow-protected one. These cannot be read.

  • 422 legacy_names · unnamed_textures · no_geometry · crash

    The file could not be worked on as it is.

  • 503 busy · texture_tool · site_unavailable

    retryable: true. Ask for a new link and send the file again.

  • 504 timeout

    Took 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-upload JSON
An upload slot for a PNG or JPG, good for 10 minutes.
POST /fivem/props/from-image JSON
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

apiKey string required
A zv_ key from /app/api. Any plan.
imagePath string required
A local PNG or JPG.
propName string required
Letters, digits and underscores, 64 at most.
options table
{ scale }, default 1.0, 100 at most.
callback function
(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.

Lua
exports.zoovdev_sync:createPropFromImage(
  'zv_your_key',
  'images/crate.png',
  'my_crate',
  { scale = 1.0 },
  function(success, result)
    print(success, json.encode(result))
  end
)