# unreadable.gg API docs

The unreadable.gg API turns [Luau](https://unreadable.gg/luau) bytecode back into source over HTTP. It runs the same decompiler as [the site](https://unreadable.gg), with the same rule: where it can't reproduce your code exactly, it marks the spot or refuses, and the response's [verdict](https://unreadable.gg/docs#verdicts) 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](https://unreadable.gg/docs#free).

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

```powershell
# Windows, in PowerShell
curl.exe https://api.unreadable.gg/v1/decompile `
  -H "Content-Type: application/octet-stream" `
  --data-binary "@module.luac"
```

```python
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"])
```

```js
// 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 first curl command is for macOS and Linux, or Git Bash and WSL on Windows; the second is the same in PowerShell.

The response is JSON: the source, a [verdict](https://unreadable.gg/docs#verdicts), [notes](https://unreadable.gg/docs#notes) on anything marked or refused, and the [version](https://unreadable.gg/docs#version) of the decompiler that wrote it.

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

## request

`POST https://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](https://unreadable.gg/docs#options): an object in JSON, or query parameters when you send the file itself. Leave them out for the defaults. |

```sh
curl "https://api.unreadable.gg/v1/decompile?inferredTypes=false" \
  -H "Content-Type: application/octet-stream" \
  --data-binary @module.luac
```

```powershell
# Windows, in PowerShell
curl.exe "https://api.unreadable.gg/v1/decompile?inferredTypes=false" `
  -H "Content-Type: application/octet-stream" `
  --data-binary "@module.luac"
```

```json
{
  "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`](https://unreadable.gg/docs#errors) 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](https://unreadable.gg/docs#errors).

| field | type | what it is |
| --- | --- | --- |
| source | string | The decompiled Luau source. |
| verdict | string | How far to trust the source: one of the [verdicts](https://unreadable.gg/docs#verdicts). |
| notes | array | Every function the decompiler refused and every spot it marked, with where it is and why: see [notes](https://unreadable.gg/docs#notes). Empty when there are none. |
| version | string | The [decompiler version](https://unreadable.gg/docs#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`](https://unreadable.gg/docs#notes) 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. The source:

```lua
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 }
```

With `inferredTypes: true`:

```lua
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}
```

With `inferredTypes: false`:

```lua
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:

| verdict | meaning |
| --- | --- |
| 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:`.

```json
"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](https://unreadable.gg/docs#version). |
| 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`](https://unreadable.gg/docs#options) 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
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](https://unreadable.gg/docs#free) 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](https://unreadable.gg/docs#limits). |
| 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](https://unreadable.gg/versions). |
| 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](mailto:support@unreadable.gg). |
| 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](https://unreadable.gg/docs#limits). 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:

```http
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](https://unreadable.gg/changelog.md).

If a decompile runs differently from your original, mail the file, the options and the version to [support@unreadable.gg](mailto: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:

```http
Free-Remaining: 3
```

## openapi

`GET` [`https://api.unreadable.gg/v1/openapi.json`](https://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:

| name | what it is |
| --- | --- |
| llms.txt | [`unreadable.gg/llms.txt`](https://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`](https://unreadable.gg/docs.md). |

An agent that can run commands does best with curl, as in the [quickstart](https://unreadable.gg/docs#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`.
