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.
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.
{
"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.
| field | in | what it is |
|---|---|---|
| Content-Type | header | application/octet-stream or application/json. Required. |
| bytecode | JSON | The compiled file, base64-encoded. Required in JSON. |
| options | JSON or query | Any of the options: an object in JSON, or query parameters when you send the file itself. Leave them out for the defaults. |
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.
| field | type | what it is |
|---|---|---|
| source | string | The decompiled Luau source. |
| verdict | string | How far to trust the source: one of the verdicts. |
| notes | array | Every function the decompiler refused and every spot it marked, with where it is and why: see notes. Empty when there are none. |
| version | string | The decompiler version that wrote the response, also sent as the Decompiler-Version header. |
options
| option | default | what it does |
|---|---|---|
| inferredTypes | true | Print the type annotations the decompiler infers beside the ones the bytecode records. false keeps only the recorded ones. |
| stockEnvironment | true | Assume 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. |
| unverifiedWarning | true | Add 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.
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-unverified | Every function decompiled cleanly. The API doesn't recompile the result, so it hasn't been checked against your bytes. |
| marked | Somewhere in the source is a spot the decompiler couldn't reproduce exactly. It's marked with a comment instead of guessed. |
| refused | The 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:.
"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.
| code | verdict | what it means |
|---|---|---|
| function-refused | refused | A 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-marked | marked | A spot the decompiler couldn't print exactly. remedy says what would fix it. |
| decompiler-bug | marked | The decompiler caught a mistake of its own at this spot. Please send us the file. |
| spelling | unchanged | A value printed another way than it was written, such as NaN as 0 / 0. The program is the same. |
| unverifiable | unchanged | The 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.
HTTP/1.1 400 Bad Request { "error": { "code": "invalid-option", "message": "option `inferredTypes` must be true or false", "field": "options.inferredTypes" } }
| status | code | when |
|---|---|---|
| 400 | invalid-json | The body isn't valid JSON. |
| 400 | unknown-field | A field, option or query parameter the API doesn't know. |
| 400 | invalid-option | An option that isn't true or false. |
| 400 | invalid-base64 | bytecode isn't valid base64. |
| 400 | missing-bytecode | The body is empty, or the JSON has no bytecode. |
| 401 | invalid-key | The request has an Authorization header. There are no keys yet, so leave it out. |
| 402 | out-of-credits | Today's free decompiles are used. 5 more come at midnight UTC. |
| 404 | not-found | There's no endpoint at that path. |
| 405 | method-not-allowed | The endpoint doesn't take that method. The Allow header lists the ones it does. |
| 413 | too-large | The file is over the size limit. |
| 415 | unsupported-content-type | Content-Type is missing, or isn't application/octet-stream or application/json. |
| 422 | not-luau-bytecode | The file isn't Luau bytecode. The message says what it looks like when it can tell: Lua source, or bytecode from another Lua version. |
| 422 | unsupported-version | Luau bytecode of a version the decompiler doesn't read yet. The message names the versions it does. |
| 422 | damaged-bytecode | Luau bytecode that ends early or doesn't hold together, such as a file cut short. |
| 429 | rate-limited | Too many requests this minute. Retry-After says how many seconds to wait. |
| 429 | too-many-at-once | Two decompiles from this connection are still running. Send the next one when one of them finishes. |
| 500 | internal | Something failed on our side. If it happens again with the same file, tell us. |
| 503 | unavailable | The decompiler is busy or restarting, or today's free capacity is used up. Retry-After says how many seconds to wait. |
| 504 | timed-out | The decompile ran past the time limit. The same file will take as long again, so send it to us instead of retrying. |
limits
| limit | value | past it |
|---|---|---|
| file size | 64 MB | 413 too-large. Measured on the file itself, so a base64 body may be a third larger. |
| time | 30 s | 504 timed-out. |
| requests | 60 a minute | 429 rate-limited. Counted per connection. |
| at once | 2 decompiles | 429 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:
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:
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.txt | unreadable.gg/llms.txt lists these docs and what's on each page, in a form a model reads in one go. |
| markdown | Each 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.