unreadable.gg

docs

The unreadable.gg API turns Luau bytecode back into source over HTTP. It runs the same decompiler as the site, with the same rule: where it can't reproduce your code exactly, it marks the spot or refuses, and the response's verdict says which.

quickstart

Send the compiled file as the request body. No key or account is needed: every connection gets 5 free decompiles a day.

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")
print(body["verdict"])
// 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);
console.log(body.verdict);

The commands follow your system, which the page guesses; switch it above any command. On Windows they're for PowerShell; in Git Bash or WSL, pick linux.

The response is JSON: the source, a verdict, notes on anything marked or refused, and the version of the decompiler that wrote it.

copy
{
  "source": "local function area(width: number, height): number\n...",
  "verdict": "decompiled-unverified",
  "notes": [],
  "version": "2026.10.11-4227ea3"
}

request

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

The body is the file itself, or JSON with the file in base64. Content-Type says which.

fieldinwhat it is
Content-Typeheaderapplication/octet-stream or application/json. Required.
bytecodeJSONThe compiled file, base64-encoded. Required in JSON.
optionsJSON or queryAny of the options: an object in JSON, or query parameters when you send the file itself. Leave them out for the defaults.
copy
curl.exe "https://api.unreadable.gg/v1/decompile?inferredTypes=false" \`
  -H "Content-Type: application/octet-stream" \`
  --data-binary @module.luac"@module.luac"
{
  "bytecode": "DQMMBG1hdGgDYWJzBGFyZWEFd2lkdGgGaGVp...",
  "options": { "inferredTypes": false }
}

The schema is strict. An unknown field or query parameter, an option that isn't true or false, or bytecode that isn't valid base64 gets a 400 that names the field. Nothing quietly falls back to a default.

response

A decompile that runs returns 200 and a JSON body, whatever its verdict. Anything else is an error.

fieldtypewhat it is
sourcestringThe decompiled Luau source.
verdictstringHow far to trust the source: one of the verdicts.
notesarrayEvery function the decompiler refused and every spot it marked, with where it is and why: see notes. Empty when there are none.
versionstringThe decompiler version that wrote the response, also sent as the Decompiler-Version header.

options

optiondefaultwhat it does
inferredTypestruePrint the type annotations the decompiler infers beside the ones the bytecode records. false keeps only the recorded ones.
stockEnvironmenttrueAssume the builtins, such as math and string, are the stock ones. Turn it off for code that runs where they may have been replaced. Today it changes only what the decompiler assumes while reading: the printed source is the same either way.
unverifiedWarningtrueAdd an unverifiable note when the output can never be checked by recompiling it.

inferredTypes on one module: its source, and what the decompiler prints from its bytecode (luau-compile -O1 -g2 -t1) with the option on and off.

copy
local function area(width: number, height)
    local w = math.abs(width)
    return w * math.abs(height)
end

local function label(name, count)
    return string.rep(name, count) .. "!"
end

return { area = area, label = label }
local function area(width: number, height): number
	local w: number = math.abs(width)
	return w * math.abs(height)
end

local function label(name, count): string
	return string.rep(name, count) .. "!"
end
return {area = area, label = label}
local function area(width: number, height)
	local w: number = math.abs(width)
	return w * math.abs(height)
end

local function label(name, count)
	return string.rep(name, count) .. "!"
end
return {area = area, label = label}

With false, w: number stays: the compiler recorded that one in the bytecode. The return types were inferred, so they go.

verdicts

From best to worst:

decompiled-unverifiedEvery function decompiled cleanly. The API doesn't recompile the result, so it hasn't been checked against your bytes.
markedSomewhere in the source is a spot the decompiler couldn't reproduce exactly. It's marked with a comment instead of guessed.
refusedThe decompiler stopped at a function rather than guess, and left a stub in its place. The rest of the source is still there.

notes

Each note explains one thing behind the verdict: a fixed code a program can act on, the function and line it's about, a message for people and sometimes a remedy. The same text is written into the source as a comment at that spot, starting with -- unreadable:.

copy
"notes": [
  {
    "code": "function-refused",
    "function": "label",
    "line": 6,
    "message": "Function `label` (line 6) was not decompiled: … so it is a stub that raises when called.",
    "remedy": "Needs the decompiler to structure this function, which this build cannot; its instructions are listed beside the stub."
  }
]

function is the name the source prints for the function (v0 when the bytecode kept no names), or null for the main chunk and unnamed functions. line is where the function starts in your original source, or null for the main chunk.

codeverdictwhat it means
function-refusedrefusedA function wasn't decompiled. In its place is a stub that raises an error if it's called, with the function's instructions listed above it.
spot-markedmarkedA spot the decompiler couldn't print exactly. remedy says what would fix it.
decompiler-bugmarkedThe decompiler caught a mistake of its own at this spot. Please send us the file.
spellingunchangedA value printed another way than it was written, such as NaN as 0 / 0. The program is the same.
unverifiableunchangedThe output can never be checked by recompiling it. Only with unverifiedWarning on.

New codes may be added. Treat one you don't know like spot-marked.

errors

Every error has the same shape: a fixed code, a message for people, and the field at fault when there is one. Errors don't count against your free decompiles.

copy
HTTP/1.1 400 Bad Request

{
  "error": {
    "code": "invalid-option",
    "message": "option `inferredTypes` must be true or false",
    "field": "options.inferredTypes"
  }
}
statuscodewhen
400invalid-jsonThe body isn't valid JSON.
400unknown-fieldA field, option or query parameter the API doesn't know.
400invalid-optionAn option that isn't true or false.
400invalid-base64bytecode isn't valid base64.
400missing-bytecodeThe body is empty, or the JSON has no bytecode.
401invalid-keyThe request has an Authorization header. There are no keys yet, so leave it out.
402out-of-creditsToday's free decompiles are used. 5 more come at midnight UTC.
404not-foundThere's no endpoint at that path.
405method-not-allowedThe endpoint doesn't take that method. The Allow header lists the ones it does.
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-bytecodeThe file isn't Luau bytecode. The message says what it looks like when it can tell: Lua source, or bytecode from another Lua version.
422unsupported-versionLuau bytecode of a version the decompiler doesn't read yet. The message names the versions it does.
422damaged-bytecodeLuau bytecode that ends early or doesn't hold together, such as a file cut short.
429rate-limitedToo many requests this minute. Retry-After says how many seconds to wait.
429too-many-at-onceTwo decompiles from this connection are still running. Send the next one when one of them finishes.
500internalSomething failed on our side. If it happens again with the same file, tell us.
503unavailableThe decompiler is busy or restarting, or today's free capacity is used up. Retry-After says how many seconds to wait.
504timed-outThe decompile ran past the time limit. The same file will take as long again, so send it to us instead of retrying.

limits

limitvaluepast it
file size64 MB413 too-large. Measured on the file itself, so a base64 body may be a third larger.
time30 s504 timed-out.
requests60 a minute429 rate-limited. Counted per connection.
at once2 decompiles429 too-many-at-once. Counted the same way.

decompiler version

Every response names the version of the decompiler that wrote it, in its version field and a header:

copy
Decompiler-Version: 2026.10.11-4227ea3

The same file and options at the same version always give the same body, byte for byte, so a result you saved can be reproduced. What changed in each version is on the changelog.

If a decompile runs differently from your original, mail the file, the options and the version to support@unreadable.gg. A wrong program is fixed before anything else.

free tier

Every connection gets 5 free decompiles a day, on the site and through the API together. The day starts at midnight UTC. Errors and refused decompiles don't count. Every response says how many are left:

copy
Free-Remaining: 3

openapi

GEThttps://api.unreadable.gg/v1/openapi.json

The whole request and response schema, with the options, note codes and error codes. Generate a client from it, or check a request against it before you send it.

agents

Agents use the same API as any other client. Two things spare them from scraping these pages:

llms.txtunreadable.gg/llms.txt lists these docs and what's on each page, in a form a model reads in one go.
markdownEach page of text is also plain markdown: add .md to its address, as in unreadable.gg/docs.md.

An agent that can run commands does best with curl, as in the quickstart: the file goes straight from disk, so its bytes never pass through the model's context, where a large module costs many tokens. Add -o result.json to keep the response out of it too, and read only the fields you need, such as verdict and notes.