API
No WebSocket connection is required for uploading or downloading. Every endpoint below is a plain HTTP request. Progress is tracked entirely on the client from bytes already sent - the server never pushes progress events.
Limits
Max file size: 128GB total. Files are sent to the server in raw pieces of at most 100MB each (this is a network-transfer limit only, unrelated to how the file is stored). Rate limiting is per IP, applied when an upload is started, based on the size of the file being uploaded:
< 25MB -> 10s between uploads < 100MB -> 30s between uploads >= 100MB -> 60s between uploads
Upload endpoints live under /a/1/u (api/v1/upload).
0. Log in
POST /a/1/a/l (accounts/login)
Body (JSON): { "password": string }. No username - accounts are created ahead of time by the site operator (see accountManager.js), and any of their passwords logs in. On success, sets an HttpOnly JWT session cookie and returns your API key.
Response 200: { "apiKey": string, "isAdmin": boolean }
Response 401: wrong password. Response 429: too many failed attempts from this IP recently (5 failures locks it out for 15 minutes). Response 503: no accounts exist yet.
Authenticate later requests either with the session cookie (automatic in a browser) or by sending your API key as the X-Api-Key header - useful for scripts that can't hold cookies.
POST /a/1/a/p (accounts/password) - change your password; the change password page uses it.
Body (JSON): { "currentPassword": string, "newPassword": string }, sent logged in (cookie or X-Api-Key). The new password must be 8 to 256 characters. Every other session of the account is signed out; this one gets a new cookie. Your API key stays the same.
Response 200: { "ok": true }. Response 400: the new password isn't allowed (too short or long, unchanged, or - since logins are by password alone - one that can't be used). Response 401: not logged in, or wrong current password. Response 429: too many failed attempts - wrong current passwords count with failed logins.
1. Start an upload
POST /a/1/u/i (init)
Body (JSON): { "filename": string, "size": number, "options"?: { "maxDownloads"?: number, "expiresAt"?: number | string, "password"?: boolean, "originalHash"?: string, "customHash"?: string }, "chunkSize"?: number }
chunkSize is optional too: set it (in bytes, 1MB to 100MB) to send the chunks in parallel - see Sending chunks in parallel.
options is optional, as is each option in it - leave any out for unlimited:
maxDownloads - whole number, at least 1. Once reached, the link stops working.expiresAt - when the link stops working, as a Unix timestamp in milliseconds or an ISO 8601 date (e.g. 2026-12-31T23:59:00Z). Must be in the future.password - true if the file you're uploading was already encrypted client-side with a password (see Password-protected files). Requires originalHash.originalHash - SHA-256 (hex) of the file before you encrypted it, so downloaders can verify their decrypted copy. Only allowed with password.customHash - pick the link's name instead of a random one, e.g. my-file.v2 for https://owf.gay/my-file.v2 (no extension is added). 1-32 characters of a-z, 0-9, - and ., starting and ending with a letter or number; uppercase is lowercased. It can't be a path the site itself uses (like a, s or logo.png), or exactly as many letters/numbers as a generated link (hash_length in /cf, 8 by default) optionally followed by .something (that shape is reserved for generated links). A name is free once the link using it has expired or run out of downloads. Links with a customHash always expire within 24 hours: with no expiresAt it's set to 24 hours from now, and a later expiresAt is rejected.
Response 200: { "uploadId": string, "chunkSize": number }
Response 400: an invalid option value, or an unknown option name.
Response 409: the customHash is already used by a live link. The name is checked again when the upload completes, so the complete request can also return 409 if someone else took it in the meantime.
Response 401: upload requires being logged in (cookie) or an X-Api-Key header, when accounts are enabled.
Response 429 (rate limited): { "error": string, "retryAfterMs": number }, also sent as a Retry-After header (seconds).
Response 413: file exceeds the 128GB limit.
Response 507: the server is low on storage space right now - try again later. The same can come back from a chunk upload or the single-shot upload if space runs out partway.
2. Send the file
PUT /a/1/u/ch/{uploadId}/{index} (chunk)
Body: raw binary. Split the file client-side into pieces no larger than chunkSize (100MB) and send them in order, starting index at 0. Each request's body is streamed straight to disk server-side, so pieces can be sent one after another without waiting on any processing.
Response 200: { "receivedBytes": number } - total bytes received so far for this upload.
If a chunk request fails partway (dropped connection, timeout, a 5xx from a proxy), just send the same index again: resending a chunk replaces whatever part of it arrived before, so retries can never duplicate or corrupt data. Resending the most recent chunk after it succeeded is also harmless. Smaller pieces (the site itself uses 32MB) mean less to resend on a flaky connection.
Response 409: the index skips ahead - chunks must go 0, 1, 2, ..., and a later one can't arrive before an earlier one. The body says where to carry on from: { "error": string, "nextIndex": number, "receivedBytes": number }. Normally that only happens by mistake, but it can also follow a server restart that lost the last chunks before they reached its disk - go back to nextIndex and send from there. (If another request for the same chunk is still arriving, the newest one replaces it.)
Sending chunks in parallel: with chunkSize set at init, chunk index is the bytes from index × chunkSize, every chunk exactly chunkSize long except the last. They can then be sent in any order and many at once (the site sends up to 32). A chunk of the wrong length gets 400. Resending a chunk is always safe - a newer attempt replaces one still arriving, and one already received in full is just answered again. Completing before every chunk is in gets 409 with "missing": [index, ...] (the first 100 missing) - send those and complete again; that's also how a server restart that lost some chunks shows up.
Uploads survive server restarts: the server keeps how far each one got and picks it up again when it's back, so keep retrying through errors for a few minutes rather than giving up. A 404 "Unknown upload session" means the upload is gone for good (e.g. nothing was sent for 10 minutes) - start again from init.
3. Finish the upload
POST /a/1/u/co/{uploadId} (complete)
This is where the real work happens: the server hashes the full file, splits it into encrypted chunks, uploads them, and writes the file record. That runs in the background, since it can take minutes for large files: the request waits up to 20 seconds, then answers with the result if it's done, or with a progress report if not. Keep sending the same request (every couple of seconds is fine) until it answers with the link - repeating it never starts the work twice. GET on the same URL reports the same way.
Response 200: { "hash": string, "url": string } - url is the permanent link to the file, e.g. https://owf.gay/ab012345. For password-protected uploads it's the decrypt page instead, e.g. https://owf.gay/p/ab012345.
Response 202: { "status": "processing", "progress": number } - still storing; progress runs from 0 to 1. Ask again. While sending a piece to Discord is being retried (the server keeps trying for a few minutes before giving up), it also has "retrying": { "reason": string, "retryInSeconds": number }, e.g. "Discord error 502".
4. Download
GET /{hash}
Streams the original file back with its original filename, reassembled and decrypted server-side. No auth required - anyone with the link can download it.
A single Range: bytes=start-end (or start-, or -suffix) gets 206 with just those bytes, e.g. to carry on a broken-off download or to seek in a video - Accept-Ranges: bytes says it's available. Files with a maxDownloads limit ignore ranges and always send the whole file (every request counts as a download). A range past the end gets 416.
Response 410: the file has expired or reached its maxDownloads limit. Every request counts as a download, including link previews fetched by Discord and other embedders.
Password-protected files
The password never reaches the server. The uploader encrypts the file in the browser, uploads the encrypted result like any other file (it still gets split and encrypted server-side as usual), and sets password: true plus originalHash. The returned link is /p/{hash}, a page that downloads the encrypted file, asks for the password, decrypts it in the browser, and only saves it if its SHA-256 matches originalHash - otherwise it says "Incorrect key".
Anyone with the link can still fetch the encrypted bytes from GET /{hash}; without the password they're useless. The encryption format (PBKDF2-SHA256 key derivation, AES-256-GCM in 4MB records, with the real filename encrypted too) is documented at the top of /owfCrypto.js, which API clients can use or reimplement.
5. Single-shot upload (ShareX etc.)
POST /a/1/u/s
For tools that upload in one request instead of using the init/chunk/complete flow. The body is streamed to disk, so the same 128GB limit applies - though proxies in front of the site may cap how big one request can be (Cloudflare's free plan stops at 100MB), so the chunked flow is the dependable way to send anything larger. Send a normal multipart/form-data body with the file in a field named f. Auth works the same way as the regular upload flow (cookie or X-Api-Key header).
Since tools like ShareX can't poll, a big upload that's still being stored after about a minute is answered with its link anyway. Until storing finishes, that link answers 503 ("still being uploaded", with Retry-After); if storing fails, the link is removed.
The same options can be set here too (same format as above), but since a multipart body can't nest an object, each one is sent as its own form field or query parameter instead, e.g. /a/1/u/s?maxDownloads=5.
Both upload flows (this one and init/chunk/complete) also work on any of the instance's alternate domains (see domains in /cf) - the returned link then uses the domain the upload was sent to (for the chunked flow, the domain of the complete request). When subdomains in /cf is true, subdomains of any of the domains (e.g. ilove.paws.baby), including the main one, behave the same way.
Response 200: the file's URL as plain text (not JSON) - e.g. https://owf.gay/ab012345.
POST /a/1/u/se is the same upload, but responds with the meta embed link (https://owf.gay/e/ab012345.png), which Discord and other apps show as the file itself. A password-protected upload still gets its /p/ link, since there's nothing to embed. Ready-made configs for both are on the uploader setup page.
6. Status
GET /st
No auth required. Returns basic liveness info as plain JSON:
{"system":"hostii","commit":"a0cd247","uptime_seconds":318753} system is the server's hostname (left out when the operator hides it), commit is the short git commit hash currently running, and uptime_seconds is how long this process has been up - useful for confirming a deploy actually took (it resets to ~0 right after a restart).
7. Config
GET /cf
No auth required. Returns this instance's non-sensitive config as plain JSON:
{"accounts":true,"prod":true,"maintenance":false,"hash_length":8,"max_upload_bytes":1073741824,"upload_chunk_bytes":104857600,"storage_chunk_bytes":8000000,"upload_options":["maxDownloads","expiresAt","password","originalHash"],"domains":["https://owf.gay"],"subdomains":false,"link_proxy":{"enabled":true,"active":true,"latency_ms":348}} link_proxy is about link downloads: enabled is whether the server downloads links at all, active whether it has a working proxy right now, and latency_ms how long that proxy took to answer its last health check.
maintenance is true while the site is down for maintenance: new uploads (and link downloads) are answered 503 with {"error": "...", "maintenance": true}, while downloads, and uploads already under way, carry on as normal.
accounts is whether uploading requires logging in. prod is false when the server is in local test mode (no Discord bot configured, files kept in a temp folder for an hour). upload_chunk_bytes is the most you can send per chunk request, storage_chunk_bytes is how the server splits files for storage, upload_options lists the option names accepted on upload, and domains lists every domain this instance serves files on - the first is the main one, and any file link works on any of them with just the domain swapped. subdomains is whether subdomains of those domains (e.g. ilove.owf.gay) work the same way.
8. Funding
GET /f
No auth required. Returns this month's funding progress and where to donate as plain JSON:
{"month":"2026-09","currency":"USD","received":32,"target":25,"percentage":128,"addresses":{"BTC":"bc1q...","ETH":"0x...","USDT":"0x...","LTC":"L...","XRP":"r..."}} month is the current calendar month in the server's timezone, received is the USD value of the payments received at those addresses this month (checked every few minutes, valued at the day's price, USDT as 1 USD), and target is what the month needs (null if none is set). percentage is received over target, rounded down and not capped, so it can go past 100 (null with no target). Each month starts again from 0. addresses are public receiving addresses, null for any not set up; USDT is ERC-20, so it uses the ETH address.
9. Link download (proxy)
POST /a/1/p
Downloads a file from a link on the server's side, for links a browser isn't allowed to fetch itself (the site doesn't allow cross-origin requests). It's what the upload page's "or input a link" falls back to. It only downloads - to store the result, upload it as usual. Requires login or X-Api-Key if accounts are enabled, same as uploading.
{"url":"https://example.com/file.zip"} The download never comes from this server directly: it goes through a public HTTP proxy (tunnelled, so the proxy can't add anything that identifies the server, and HTTPS stays encrypted end to end), so the linked site only ever sees the proxy's address. Links to private or local addresses (localhost, 192.168.x.x and so on) and anything other than http/https are refused. Redirects are followed, up to 5.
Response 200: the file's bytes, always as application/octet-stream, with what the linked site said about it in URI-encoded headers: X-Original-Type (its Content-Type), X-Original-Disposition (its Content-Disposition, which may carry a file name) and X-Final-Url (where it ended up after redirects).
Response 403: this instance has server-side link downloads turned off (link_proxy.enabled in /cf). 400: invalid, non-http(s) or private link. 413: the file is over 500MB. 429: one link download per IP every 10 seconds - wait retryAfterMs (also in Retry-After). 502: the link couldn't be reached or returned an error. 503: no working proxy right now (the server keeps looking in the background) or too many downloads at once. 504: the link was too slow.
The file is held in the server's temp folder only while it's being sent back, then deleted (anything left over is swept within an hour).
Storage
Files are split into chunks (size configurable server-side, 8MB by default), each encrypted with its own randomly generated key before being stored. A file's hash and per-chunk encryption keys are looked up server-side only; they are never exposed to clients directly - only the short link.
Any temp files created during upload or large-file downloads live in a local tmp folder and are swept away automatically after an hour.
Examples
Minimal upload examples using each language's own standard HTTP client (no third-party dependency) against the single-shot endpoint above. Each page has a code block with Copy and Download buttons.