stream
GETwss://api.unreadable.gg/v1/stream
Run lots of decompiles over one WebSocket.
Each job is a JSON request with an id, and you get back the same response or error as over HTTP. It shares your limits and free decompiles with HTTP and the site.
executor client
Run this in your executor to get a decompile that uses the stream:
shared.UNREADABLE_KEY = "ugk_..." -- optional: decompile as your account
loadstring(game:HttpGet("https://unreadable.gg/decompile.lua"))()It replaces your executor's decompile. decompile(script) waits for the source and returns it as a string. You can pass bytecode as a string too.
Your executor needs WebSocket.connect and getscriptbytecode. Without WebSocket.connect, every call returns an unsupported-executor comment.
It never errors. If something fails, you get a comment instead of the source:
-- unreadable.gg could not decompile this script -- out-of-credits: …
A script with no bytecode returns -- unreadable.gg: this script has no bytecode.
All calls share one connection, and the same bytecode is only decompiled once.
options
Set shared.UNREADABLE_OPTIONS before the loadstring line to pick options. For example, readable without modelNames.
shared.UNREADABLE_OPTIONS = { preset = "readable", modelNames = false }The default is { preset = "balanced" }. It turns on modelNames: better names, but slower, and it uses more decompile time.
{} gives you the server's default, faithful.
key
Put a key from the account page in shared.UNREADABLE_KEY to decompile as your account.
Without a membership, you get 100 free decompiles a day. After that, decompile returns an out-of-credits comment until midnight UTC.
writing a client
decompile.lua is a complete client. Use it as a reference.
connecting
Open the WebSocket with no query string and no Authorization header. If the server refuses the upgrade, you get a normal error body.
Every message is one JSON object in a text frame, except binary jobs. Send hello within 10 seconds:
{"type":"hello","protocol":1,"key":"ugk_..."}The key is optional. Without it, you use your connection's free decompiles, like the site. A page on another site needs a key. A wrong key closes the stream with invalid-key.
The server answers ready:
{"type":"ready","protocol":1,"version":"2026.10.21-926ecd4","auth":"anonymous","member":false,"memberUntil":null,"freeRemaining":97,"limits":{"inFlight":16,"perMinute":120,"maxFileBytes":2097152,"maxSeconds":30}}auth: who the stream runs as.key,anonymous, orsessionif you're signed in on the site.limits.inFlight: how many jobs can run at once, across all your streams and HTTP requests.perMinuteandfreeRemaining:nullfor members. A member'sreadyalso hastier, likeGET /v1/account.maxFileBytes: your file limit in bytes. 2 MiB free, 8 MiB for members.
You get a fresh ready when a membership starts or ends, when limits change, and at midnight UTC.
jobs
{"type":"job","id":"17","options":{"preset":"readable"},"bytecode":"DQMMBG1hdGgDYWJz…"}bytecode is standard base64 with padding. You pick the id: 1 to 128 characters from ! to ~, without " or \. Keep ids unique for the whole run, across reconnects. A counter works.
A binary frame is a quarter smaller. It holds these fields in order:
| bytes | field |
|---|---|
| 3 | UGJ |
| 1 | format, 1 |
| 1 | the id's length, L |
| L | the id |
| 2 | the options' length H, big-endian, up to 4096, or 0 |
| H | the options, as UTF-8 JSON |
| the rest | the file |
Each job gets stage lines, then one final line: done or error. Jobs run side by side and finish in any order:
{"id":"17","stage":"decompiling"}
{"id":"17","stage":"renaming","done":0,"total":210}
{"id":"17","stage":"done","source":"local Net = {}\n…","verdict":"decompiled-unverified","notes":[],"setting":{…},"version":"2026.10.21-926ecd4","freeRemaining":96}
{"id":"19","stage":"error","error":{"code":"rate-limited","message":"More than 120 decompiles this minute from this connection."},"retryAfter":12}The final line has what HTTP sends as headers: freeRemaining, memberUntil and retryAfter.
A done over 1 MiB is split. source lines come first, in order, each with a piece of text. Then a done arrives whose parts says how many pieces to join.
Skip any stage you don't know. An unknown field in a job only fails that job, with unknown-field.
flow control
The server never queues. Keep up to limits.inFlight jobs running, largest first. A job over a limit is refused right away with retryAfter. Wait that long, then resend it with a new id.
Don't hammer it. Jobs refused right away (before any stage line) with rate-limited, too-many-at-once or unavailable add up. More than 120 in a minute (1000 for members) closes the stream with rate-limited and a retryAfter of 60.
One stream slows down past a few hundred large jobs at once. For big runs, open more streams as the load grows, and keep limits.inFlight as the total across all of them. The site and the executor client open another stream every 256 jobs, up to 8 for a member, and close extras after 10 idle seconds. You can have 16 streams open. Past that, a new one is refused with too-many-at-once. Wait its retryAfter, then try again.
A job uses a free decompile once it starts, even if the stream closes first.
errors
Send {"type":"ping"} any time and the server answers pong. While jobs run, the server pings you after 30 quiet seconds, and you answer {"type":"pong"}. After 60 quiet seconds, the stream closes with timed-out. An idle stream closes after 10 minutes.
Before closing, the server sends one {"type":"error",…} message, then closes with code 4000 plus the status, like 4429.
These close it with protocol:
- a text message that isn't one JSON object
- an unknown
type - an unknown field in anything but a job
- anything but a job over 4096 bytes
- a bad job
id - a job whose
idis still running - a job before
ready - a second
hello - no
hellowithin 10 seconds
| close reason | what to do |
|---|---|
protocol, invalid-key, unsupported-protocol | Fix your client. Don't reconnect. |
rate-limited, too-many-at-once, unavailable | Reconnect after retryAfter seconds. |
| anything else, or no error | Reconnect with backoff. |
reconnecting
The server forgets a closed stream. Resend every job without a final line, with a new id. Also resend jobs that were told to wait: too-many-at-once, rate-limited and unavailable after their retryAfter, and out-of-credits after midnight UTC.
Back off 1, 2, 4, 8, 16, then 30 seconds, with a bit of randomness, or wait retryAfter. Then send hello again and use the new ready's limits. Old jobs may still be running, so expect some too-many-at-once at first.
A stream lasts through one midnight UTC. At the second, it refuses new jobs with unavailable and closes once its jobs finish. Reconnect and resend.
protocol versions
Protocol 1 may get new fields, messages, stages, codes and options, so skip what you don't know. Anything a client has to send or understand only changes in a new protocol.