# unreadable.gg API

One request decompiles one script and gives you JSON back. For `.rbxl`/`.rbxm` files, use [the site](https://unreadable.gg). For lots of scripts at once, use the [stream](https://unreadable.gg/docs/stream).

## request

`POST https://api.unreadable.gg/v1/decompile`

Send the script's bytecode as the body, with `Content-Type: application/octet-stream`. Every [format](https://unreadable.gg/luau#reads) on the Luau page works, RSB1-wrapped files included.

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

```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);
```

### with options

To set [options](https://unreadable.gg/docs/api#options), send JSON instead: the file in base64 as `bytecode`, and your options in `options`.

```sh
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 @-
```

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

| | message | when |
| --- | --- | --- |
| → you send | `Accept: application/x-ndjson` | With the usual request. |
| ← you get | `200` | Right away, before the decompile starts. |
| ← you get | `{"stage":"decompiling"}` | Always first. |
| ← you get | `{"stage":"renaming","done":0,"total":210}` | Only with `modelNames`: the naming model was asked. |
| ← you get | `{"stage":"renaming","done":210,"total":210}` | Only with `modelNames`: it answered, once per piece of a large module. |
| ← you get | `{"stage":"done",…}` or `{"stage":"error",…}` | Always last. `done` carries the [response](https://unreadable.gg/docs/api#response). An [error](https://unreadable.gg/docs/api#errors) can't change the status anymore, so it comes as this line, with `Retry-After` 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](https://unreadable.gg/docs/stream) with a key.

## response

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

```json
{
  "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"
}
```

| field | what it is |
| --- | --- |
| `source` | The decompiled source. Line 1 ends with `-- unreadable.gg ~ 12ms`, how long it took (`~ 140ms (+3.2s naming)` with `modelNames`). It never moves a line. |
| `verdict` | How much to trust the source. See [verdicts](https://unreadable.gg/docs#verdicts). |
| `notes` | What was marked or refused, and where. See [notes](https://unreadable.gg/docs/api#notes). |
| `functions` | Every 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). |
| `calls` | Calls that provably reach a printed function (a local function called by name): `caller` (`null` for the main chunk), `callee` and `line`. |
| `setting` | The `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. |
| `version` | The [decompiler version](https://unreadable.gg/docs/api#version), also in the `Decompiler-Version` header. |

## options

| option | default | what it does |
| --- | --- | --- |
| preset | faithful | Sets every readability switch at once: `faithful`, `balanced` or `readable`. A switch you send overrides it. See [presets](https://unreadable.gg/docs/readability#presets). |
| a switch | the preset's | Turns one [readability switch](https://unreadable.gg/docs/readability#switches) on or off by its API name, like `inferredTypes`. |
| stockEnvironment | true | Assume 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. |
| maxSeconds | 30, or 300 for a member | The time limit, 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](https://unreadable.gg/docs/api#progress) or use the [stream](https://unreadable.gg/docs/stream#jobs). 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.

```json
{
  "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.

| code | verdict | what it means |
| --- | --- | --- |
| function-refused | refused | The function is a stub that errors when called. Its instructions are listed beside it. |
| spot-marked | marked | This spot couldn't be printed exactly. `remedy`, if there is one, says what would fix it. |
| decompiler-bug | marked | The decompiler caught its own mistake here. Please [send us the file](https://unreadable.gg/docs/api#support). |
| spelling | unchanged | A value is written differently, like NaN as `0 / 0`. The program is the same. |
| unverifiable | unchanged | The file numbers its opcodes differently from stock Luau, so the output can't be checked by recompiling it. |
| names-partial | unchanged | With `modelNames`, the naming model named some values but not all. The rest keep their default names. |
| names-unavailable | unchanged | With `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`.

```json
{ "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.

| status | code | when |
| --- | --- | --- |
| 400 | invalid-json | The body isn't valid JSON. |
| 400 | unknown-field | An unknown field or option, or a query parameter. |
| 400 | invalid-option | An option isn't `true` or `false`, the `preset` is unknown, `maxSeconds` is out of range, or a key's `label` is over 64 characters. |
| 400 | invalid-base64 | `bytecode` isn't valid base64. |
| 400 | missing-bytecode | The body is empty, or the JSON has no `bytecode`. |
| 400 | protocol | Stream only: a message it doesn't allow. It closes the stream. |
| 401 | invalid-key | The [key](https://unreadable.gg/docs/api#accounts) 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. |
| 401 | invalid-account | The account number is mistyped (`bad-character`, `wrong-length`, `wrong-check`) or `unknown`. |
| 402 | out-of-credits | Today's [free decompiles](https://unreadable.gg/docs/api#free) are used up. They come back at midnight UTC, and retrying sooner fails. `membershipUrl` says where to get a membership. |
| 403 | forbidden | The path isn't open to you: it's only for the site, or only for the people who run unreadable.gg. |
| 404 | not-found | No endpoint at that path, or no key with that `id`. |
| 405 | method-not-allowed | Wrong method. The `Allow` header lists the right ones. |
| 409 | too-many-keys | The account already has 20 keys. |
| 413 | too-large | The file is over the [size limit](https://unreadable.gg/docs/api#limits). |
| 415 | unsupported-content-type | `Content-Type` is missing, or isn't `application/octet-stream` or `application/json`. |
| 422 | not-luau-bytecode | Not Luau bytecode. The message says if it looks like Lua source or [another Lua version](https://unreadable.gg/versions). |
| 422 | container | A `.rbxm` or `.rbxl` file. Open it on [the site](https://unreadable.gg) instead: it reads the file in your browser and only sends the scripts. |
| 422 | unsupported-version | A Luau bytecode version the decompiler can't read yet. |
| 422 | damaged-bytecode | The bytecode is cut short or doesn't hold together. |
| 426 | upgrade-required | A [stream](https://unreadable.gg/docs/stream) request that isn't a WebSocket upgrade. |
| 426 | unsupported-protocol | Stream only: `hello` asked for a protocol version the server doesn't speak. `protocols` lists the ones it does. |
| 429 | rate-limited | A [limit](https://unreadable.gg/docs/api#limits) was hit. Wait `Retry-After` seconds. |
| 429 | too-many-at-once | Too 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. |
| 500 | internal | Something failed on our side. If it keeps happening, [tell us](https://unreadable.gg/docs/api#support). |
| 503 | unavailable | The decompiler is busy, or today's free capacity is used up. Wait `Retry-After` seconds. |
| 504 | timed-out | The 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](https://unreadable.gg/docs/api#progress) to wait longer. Otherwise it'll likely time out again, so [send us the file](https://unreadable.gg/docs/api#support) instead of retrying. |

## limits

| limit | free | member |
| --- | --- | --- |
| file size | 2 MiB | 8 MiB |
| time per decompile | 30 s | 300 s |
| requests | 120 a minute | no limit |
| at once | 16 | 2048 |
| decompile time | 5 minutes a day | 10 minutes, 30 minutes or 200 minutes a day, by [tier](https://unreadable.gg/docs/api#accounts) |
| networks using the account | not counted | 2 at once, 5 a day |
| open streams | 16 | 16 |

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.

```http
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](https://unreadable.gg/docs/api#limits) is the same for all tiers.

| tier | per 30 days | decompile time a day | scripts a day |
| --- | --- | --- | --- |
| Pro | $5 | 10 minutes | about 12,000 |
| Max | $10 | 30 minutes | about 36,000 |
| Ultra | $30 | 200 minutes | about 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](https://discord.gg/8gXdq43sK8). Make an account and its keys on the [account page](https://unreadable.gg/account), then send a key with each request:

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

| request | what it does |
| --- | --- |
| `POST /v1/accounts` | Makes an account. Returns the account number and a first key. Ten a day per connection. |
| `POST /v1/keys` | Makes a key for `{"account": "UG-…", "label": "laptop"}`. Up to 20 keys an account, labels up to 64 characters. |
| `GET /v1/account` | With 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/keys` | With a key, revokes that key. Answers `204`. |
| `POST /v1/keys/list` | For `{"account": "UG-…"}`, returns each key's `id`, `label`, `made` and `lastUsed`. |
| `POST /v1/keys/revoke` | For `{"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:

```http
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](https://unreadable.gg/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](https://discord.gg/8gXdq43sK8) or mail [support@unreadable.gg](mailto:support@unreadable.gg). Send the file, the options and the version. We fix wrong programs first.
