unreadable.gg

api

One request decompiles one script and gives you JSON back. For .rbxl/.rbxm files, use the site. For lots of scripts at once, use the stream.

request

POSThttps://api.unreadable.gg/v1/decompile

Send the script's bytecode as the body, with Content-Type: application/octet-stream. Every format on the Luau page works, RSB1-wrapped files included.

copy
curl.exe https://api.unreadable.gg/v1/decompile \`
  -H "Content-Type: application/octet-stream" \`
  --data-binary @module.luac"@module.luac"
import pathlib, requests

r = requests.post(
    "https://api.unreadable.gg/v1/decompile",
    headers={"Content-Type": "application/octet-stream"},
    data=pathlib.Path("module.luac").read_bytes(),
)
body = r.json()
if not r.ok:
    raise SystemExit(f"{body['error']['code']}: {body['error']['message']}")
pathlib.Path("module.luau").write_text(body["source"], encoding="utf-8")
// Node 18 or later, saved as an .mjs file
import { readFile, writeFile } from "node:fs/promises";

const r = await fetch("https://api.unreadable.gg/v1/decompile", {
  method: "POST",
  headers: { "Content-Type": "application/octet-stream" },
  body: await readFile("module.luac"),
});
const body = await r.json();
if (!r.ok) throw new Error(`${body.error.code}: ${body.error.message}`);
await writeFile("module.luau", body.source);

with options

To set options, send JSON instead: the file in base64 as bytecode, and your options in options.

copy
printf '{"bytecode":"%s","options":{"preset":"balanced"}}' "$(base64 < module.luac | tr -d '\n')" |
  curl https://api.unreadable.gg/v1/decompile \
    -H "Content-Type: application/json" \
    --data-binary @-
copy
import base64, pathlib, requests

r = requests.post(
    "https://api.unreadable.gg/v1/decompile",
    json={
        "bytecode": base64.b64encode(pathlib.Path("module.luac").read_bytes()).decode(),
        "options": {"preset": "balanced"},
    },
)

The API is strict. An unknown field, a query parameter or a bad value gets a 400 that names the field.

progress

Send Accept: application/x-ndjson and you get progress while it runs, one JSON object per line. Without it, you get one JSON body at the end.

→ you send
POST /v1/decompilethe usual request
Accept: application/x-ndjsonplus this header
← you get, line by line
200 OKright away
{"stage":"decompiling"}always first
only if modelNames is on
{"stage":"renaming","done":0,"total":210}naming model asked
{"stage":"renaming","done":210,"total":210}naming model answered, once per piece of a large module
{"stage":"done", …}always last: the response

If the decompile fails after the 200, the last line is an error instead, {"stage":"error",…}. The status is already sent, so Retry-After comes as retryAfter.

from a browser

Only unreadable.gg can call the API from a web page. Call it from your server instead, or use the stream with a key.

response

A decompile that runs returns 200 and JSON, whatever the verdict.

copy
{
  "source": "local function area(width: number, height) -- unreadable.gg ~ 12ms\n...",
  "verdict": "decompiled-unverified",
  "notes": [],
  "functions": [{ "id": 1, "name": "area", "line": 1, "endLine": 3, "parent": null, "inputLine": 1 }],
  "calls": [],
  "setting": { "preset": "faithful", "overrides": {}, "switches": { "freeLines": false, "...": false }, "spent": [], "implied": {}, "blocked": {} },
  "version": "2026.10.16-90a500a"
}
fieldwhat it is
sourceThe decompiled source. Line 1 ends with -- unreadable.gg ~ 12ms, how long it took (~ 140ms (+3.2s naming) with modelNames). It never moves a line.
verdictHow much to trust the source. See verdicts.
notesWhat was marked or refused, and where. See notes.
functionsEvery function as source prints it: id (from 1, in the order they start in source), name (null when unnamed), line and endLine in source, parent (the enclosing function's id, or null), and inputLine (where it started in your original, if the file says).
callsCalls that provably reach a printed function (a local function called by name): caller (null for the main chunk), callee and line.
settingThe preset and the switches you sent (overrides). switches has every switch's final value. spent lists what the switches cost (empty for faithful). implied and blocked list switches another switch turned on or kept off.
versionThe decompiler version, also in the Decompiler-Version header.

options

optiondefaultwhat it does
presetfaithfulSets every readability switch at once: faithful, balanced or readable. A switch you send overrides it. See presets.
a switchthe preset'sTurns one readability switch on or off by its API name, like inferredTypes.
stockEnvironmenttrueAssume builtins like math and string haven't been replaced. Turn it off if they might be. For now the source is the same either way.
maxSeconds30, or 300 for a memberThe time limit, from 1 to 30 seconds (300 for a member), counted from when the decompiler starts. A plain JSON answer waits 60 seconds at most, so for longer, ask for progress or use the stream. Set it lower to fail fast. JSON and stream jobs only.

{"preset": "readable", "inferredTypes": false} is readable with inferred types turned off.

notes

Each note explains one thing behind the verdict. If it's about a spot or a function, it's also in the source as a -- unreadable: comment.

copy
{
  "code": "function-refused",
  "function": "label",
  "functionId": 2,
  "line": 6,
  "message": "this function was not decompiled, because it has a loop that can't be written as `while`, `repeat` or `for` yet; calling it raises an error",
  "detail": "proto 2 pc 14: …"
}
  • function: the name the source prints, like v0. null for the main chunk and unnamed functions.
  • functionId: the functions entry it's about. null for the main chunk or the whole file.
  • line: where the function starts in your original. null if the file has no line info.
  • message: what happened.
  • remedy (sometimes): what would fix it. The source prints it as fix:.
  • detail (sometimes): the decompiler's own wording, for bug reports. Not in the source.
codeverdictwhat it means
function-refusedrefusedThe function is a stub that errors when called. Its instructions are listed beside it.
spot-markedmarkedThis spot couldn't be printed exactly. remedy, if there is one, says what would fix it.
decompiler-bugmarkedThe decompiler caught its own mistake here. Please send us the file.
spellingunchangedA value is written differently, like NaN as 0 / 0. The program is the same.
unverifiableunchangedThe file numbers its opcodes differently from stock Luau, so the output can't be checked by recompiling it.
names-partialunchangedWith modelNames, the naming model named some values but not all. The rest keep their default names.
names-unavailableunchangedWith modelNames, the naming model didn't answer, so values keep their default names.

The last three are about the whole file, so they're not in the source. More codes may come later. If you get one you don't know, treat it like spot-marked: that part may not match your original.

errors

Errors are JSON with a code, a message, and the field at fault if there is one. They don't use up a free decompile, except timed-out.

copy
{ "error": { "code": "invalid-option", "message": "Option `inferredTypes` must be true or false.", "field": "options.inferredTypes" } }

For most 4xx, fix the request. For 402, wait for midnight UTC. For 429 and 503, wait Retry-After seconds and try again. 500 is our fault. For 504, see its row.

statuscodewhen
400invalid-jsonThe body isn't valid JSON.
400unknown-fieldAn unknown field or option, or a query parameter.
400invalid-optionAn option isn't true or false, the preset is unknown, maxSeconds is out of range, or a key's label is over 64 characters.
400invalid-base64bytecode isn't valid base64.
400missing-bytecodeThe body is empty, or the JSON has no bytecode.
400protocolStream only: a message it doesn't allow. It closes the stream.
401invalid-keyThe key is malformed, unknown or revoked (in reason). On the stream, origin means another site's page connected without a key, and session means your sign-in on the site ended.
401invalid-accountThe account number is mistyped (bad-character, wrong-length, wrong-check) or unknown.
402out-of-creditsToday's free decompiles are used up. They come back at midnight UTC, and retrying sooner fails. membershipUrl says where to get a membership.
403forbiddenThe path isn't open to you: it's only for the site, or only for the people who run unreadable.gg.
404not-foundNo endpoint at that path, or no key with that id.
405method-not-allowedWrong method. The Allow header lists the right ones.
409too-many-keysThe account already has 20 keys.
413too-largeThe file is over the size limit.
415unsupported-content-typeContent-Type is missing, or isn't application/octet-stream or application/json.
422not-luau-bytecodeNot Luau bytecode. The message says if it looks like Lua source or another Lua version.
422containerA .rbxm or .rbxl file. Open it on the site instead: it reads the file in your browser and only sends the scripts.
422unsupported-versionA Luau bytecode version the decompiler can't read yet.
422damaged-bytecodeThe bytecode is cut short or doesn't hold together.
426upgrade-requiredA stream request that isn't a WebSocket upgrade.
426unsupported-protocolStream only: hello asked for a protocol version the server doesn't speak. protocols lists the ones it does.
429rate-limitedA limit was hit. Wait Retry-After seconds.
429too-many-at-onceToo many decompiles running at once, or a member's key is in use from too many networks right now or today. Wait Retry-After seconds.
500internalSomething failed on our side. If it keeps happening, tell us.
503unavailableThe decompiler is busy, or today's free capacity is used up. Wait Retry-After seconds.
504timed-outThe decompile ran out of time. If you set a low maxSeconds, raise it. Members whose plain JSON answer stopped at 60 seconds can ask for progress to wait longer. Otherwise it'll likely time out again, so send us the file instead of retrying.

limits

limitfreemember
file size2 MiB8 MiB
time per decompile30 s300 s
requests120 a minuteno limit
at once162048
decompile time5 minutes a day10 minutes, 30 minutes or 200 minutes a day, by tier
networks using the accountnot counted2 at once, 5 a day
open streams1616

Free limits count per connection: one IP address (with IPv6, one /64 range). Member limits count per account, across all its keys. Daily limits reset at midnight UTC.

Decompile time is how long your decompiles take on our servers. Naming counts too, with modelNames.

A network is your internet provider, VPN or mobile carrier, so a new address on the same one doesn't count again. A network stays in use for 2 minutes after its last decompile, or as long as a stream from it is open. Open streams are never cut off by the network limit.

The size limit is on the file, so a base64 JSON body can be about a third bigger. Very large files can run out of memory and fail with 500.

free tier

You get 100 free decompiles a day, shared by the site, the executor and the API. The Discord bot has the same limit, counted per Discord account. Errors and refusals don't count, except timed-out. Closing a request mid-decompile still counts.

Most responses have a Free-Remaining header with how many you have left today. Members get it too, but their own decompiles don't lower it.

copy
Free-Remaining: 3

Requests turned away before decompiling, like a wrong key (401), a file that's too big (413) or a wrong Content-Type (415), don't have it.

keys and accounts

A membership lifts the free limits, takes files up to 8 MiB and gives each decompile up to 300 seconds. Its tier sets your decompile time a day. Every other limit is the same for all tiers.

tierper 30 daysdecompile time a dayscripts a day
Pro$510 minutesabout 12,000
Max$1030 minutesabout 36,000
Ultra$30200 minutesabout 240,000

Scripts a day is an estimate at 50 ms a script. Big scripts take longer.

To get a membership, check out #purchase in our Discord server. Make an account and its keys on the account page, then send a key with each request:

copy
curl https://api.unreadable.gg/v1/decompile \
  -H "Authorization: Bearer ugk_..." \
  -H "Content-Type: application/octet-stream" \
  --data-binary @module.luac

Every member response has a Member-Until header that says when the membership ends. After that, the key falls back to the free tier. A wrong key always gets 401, never the free tier.

Programs can manage the account too. The account number (UG-XXXX-XXXX-XXXX-XXXX-C) is the sign-in, so it goes in the JSON body, never the URL:

requestwhat it does
POST /v1/accountsMakes an account. Returns the account number and a first key. Ten a day per connection.
POST /v1/keysMakes a key for {"account": "UG-…", "label": "laptop"}. Up to 20 keys an account, labels up to 64 characters.
GET /v1/accountWith a key, returns paidUntil, membershipUrl, how many keys the account has and, for a member, tier: its id, name, usd, dailyMs (decompile time a day) and scriptsPerDay.
DELETE /v1/keysWith a key, revokes that key. Answers 204.
POST /v1/keys/listFor {"account": "UG-…"}, returns each key's id, label, made and lastUsed.
POST /v1/keys/revokeFor {"account": "UG-…", "id": "…"}, revokes that key. Answers 204.

We show the account number and each key once and only store a hash, so save them somewhere safe. Twenty wrong account numbers in a day gets a connection a 429.

Accounts that never had a membership are deleted after 90 days. Ones that did are deleted a year after the membership ends or a key was last used, whichever is later.

decompiler version

Every response names the decompiler version in version and in this header:

copy
Decompiler-Version: 2026.10.11-4227ea3

The same file, options and version always give the same source and notes. The only exceptions are the time on line 1 and, with modelNames, the names it picks. The changelog lists what changed.

support

If a decompile runs differently from your original, or you find a bug, open a #support ticket in our Discord server or mail support@unreadable.gg. Send the file, the options and the version. We fix wrong programs first.