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.
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.
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 @-
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.
POST /v1/decompilethe usual requestAccept: application/x-ndjsonplus this header200 OKright away{"stage":"decompiling"}always firstmodelNames 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 responseIf 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.
{
"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. |
| notes | What was marked or refused, and where. See 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, 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. |
| a switch | the preset's | Turns one readability switch 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, 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.
{
"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, likev0.nullfor the main chunk and unnamed functions.functionId: thefunctionsentry it's about.nullfor the main chunk or the whole file.line: where the function starts in your original.nullif the file has no line info.message: what happened.remedy(sometimes): what would fix it. The source prints it asfix:.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. |
| 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.
{ "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 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 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. |
| 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. |
| 422 | container | A .rbxm or .rbxl file. Open it on the site 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 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 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. |
| 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 to wait longer. Otherwise it'll likely time out again, so send us the file 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 |
| 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.
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.
| 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. Make an account and its keys on the account page, then send a key with each request:
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:
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.