Everything the web app does is available over a small JSON API, so you can script Open Transfer with curl, shortcuts or other tools.
http://192.168.1.24:5000.{"error": {"code": "too_large", "message": "File is larger than the 500 MB limit."}} with a matching HTTP status.curl are unaffected.When started with --pin, every endpoint except /api/health, /api/info and /api/auth requires a session cookie.
curl -c jar -H 'Content-Type: application/json' -d '{"pin":"4821"}' http://HOST:5000/api/auth
curl -b jar http://HOST:5000/api/files
| Status | Meaning |
|---|---|
401 pin_required |
No valid session |
401 wrong_pin |
Wrong PIN |
429 too_many_attempts |
5 wrong PINs in a minute; see Retry-After |
GET /api/health{"status": "ok", "version": "3.0.0"} — for monitoring. Never requires a PIN.
GET /api/infoServer name, version, auth state and — once authenticated — share URLs, permissions and limits.
{
"app": "Open Transfer", "version": "3.0.0", "device": "studio-mac",
"auth": {"required": false, "authenticated": true, "numeric": false},
"share_url": "http://192.168.1.24:5000",
"urls": ["http://192.168.1.24:5000"],
"permissions": {"upload": true, "browse": true, "delete": true},
"limits": {"max_upload_size": 0, "chunk_size": 2097152, "chunk_threshold": 4194304},
"storage": {"free": 182736451584},
"pin": null
}
GET /api/filesNewest first. Supports If-None-Match → 304 Not Modified.
{
"files": [
{"name": "Q3 Report.pdf", "size": 2412000, "modified": 1790276747.18,
"mime": "application/pdf", "kind": "document"}
],
"total_size": 2412000,
"storage": {"free": 182736451584}
}
kind is one of image video audio archive document spreadsheet presentation code app other.
POST /api/files — uploadRaw body (recommended, streams any size):
curl -T "Holiday video.mp4" -X POST \
-H "X-Filename: $(python3 -c 'import urllib.parse,sys;print(urllib.parse.quote(sys.argv[1]))' 'Holiday video.mp4')" \
http://HOST:5000/api/files
The file name goes in X-Filename (percent-encoded UTF-8) or a ?name= query parameter.
Multipart (one or more files):
curl -F file=@photo.jpg -F file=@notes.txt http://HOST:5000/api/files
/transfer is kept as an alias for older scripts. Returns 201 with the saved files — names may get a ` (1)` suffix if taken:
{"files": [{"name": "photo (1).jpg", "size": 48213, "modified": 1790276747.2, "mime": "image/jpeg", "kind": "image"}]}
Errors: 400 invalid_name, 400 incomplete_upload, 403 upload_disabled, 413 too_large, 507 insufficient_storage.
Chunked upload (files over ~4 MB): the browser splits the file into 2 MiB
pieces (up to 3 in flight) so a dropped connection loses pieces, not the file.
Each piece is a POST with Content-Range: bytes <start>-<end>/<total> and an
X-Upload-Id the client picks (same id across retries, so a reload resumes):
curl -X POST --data-binary @chunk1 \
-H "Content-Range: bytes 0-2097151/5242880" -H "X-Upload-Id: abc123" \
-H "X-Filename: big.bin" http://HOST:5000/api/files
While incomplete the server answers 308 with {"received", "total", "ranges"};
201 carries the saved file once the last byte arrives. Pieces may arrive in
any order and rewrites are idempotent. GET /api/uploads/<id>/status reports
what arrived, so an interrupted upload resumes with the missing pieces only.
GET /files/<name> — downloadAlways an attachment, with Range support for resuming (curl -C - -O). ?inline=1 displays images, audio and video in the browser. /download/<name> is a legacy alias.
DELETE /api/files/<name>Moves the file to the trash: {"undo_token": "…", "undo_seconds": 30}. 403 delete_disabled if turned off.
POST /api/trash/<token>/restoreRestores a deleted file within undo_seconds: {"file": {…}}, or 404.
GET /api/archiveStreams a ZIP of all files, or only those given as ?name=a.txt&name=b.jpg.
GET /api/qr.svgQR code (SVG) for the share URL; includes the PIN when one is set.
POST /api/logoutClears the session.
These power the device grid. “Owner” means a request from the device itself
(127.0.0.1); everyone else is a visitor. App-to-app endpoints
(/api/p2p/v1/*) are documented in protocol.md.
| Endpoint | Who | What |
|---|---|---|
GET /api/state |
anyone | {me, host, devices, incoming, outgoing, discovery, pairing?, inbox?} with an ETag (poll with If-None-Match) |
POST /api/me {name, form?, platform?} |
anyone | Rename this device (owner) or how this browser appears (visitor) |
POST /api/send {to: [ids], files: [{name, size, mime}]} |
anyone | Offer files to devices → 201 {job} |
PUT /api/send/<job>/files/<n> |
job creator | The file’s bytes (Content-Length required), streamed to every receiver that accepted. Big files arrive as Content-Range chunks (see above) staged under relay-chunks/, then fanned out when complete |
GET /api/send/<job>/files/<n>/status?upload_id=… |
job creator | Staged bytes/ranges for one chunked file (resume + pause support) |
POST /api/invites {} → {token, url, expires_in} · GET /api/invites |
owner | Mint a single-use guest link (/?invite=…): lets one visitor past the PIN as a trusted guest, then expires (10 min default) |
DELETE /api/send/<job> · DELETE /api/send/<job>/targets/<id> |
job creator | Cancel everything / one receiver |
POST /api/send/<job>/retry {to?: [ids]} |
job creator | Re-offer the job’s files to declined/failed/expired targets → 201 {job} (receiver is asked again) |
POST /api/incoming/<id>/accept · /decline · DELETE /api/incoming/<id> |
the recipient | Answer or stop an incoming transfer |
GET /api/inbox/files/<name> · DELETE … · GET /api/inbox/archive |
visitor | Files sent to this browser |
POST /api/pair {code, address?} |
owner | Pair with the app showing code (found by multicast, or at address) |
POST /api/devices {address} |
owner | Add an app by host:port |
DELETE /api/pairs/<id> · POST /api/pair/new-code · GET /api/pair/qr.svg |
owner | Unpair · new code · QR with the code |
Sending from a script, end to end:
APP=http://127.0.0.1:5000 # your own app (owner)
TO=$(curl -s $APP/api/state | jq -r '.devices[] | select(.name=="Gaming PC") | .id')
JOB=$(curl -s -X POST $APP/api/send -H 'Content-Type: application/json' \
-d "{\"to\":[\"$TO\"],\"files\":[{\"name\":\"report.pdf\",\"size\":$(stat -c%s report.pdf)}]}" | jq -r .job.id)
# …wait until .outgoing[].targets[].state is "accepted", then:
curl -X PUT --data-binary @report.pdf $APP/api/send/$JOB/files/0