Identity and build switches of the running daemon. The web interface polls it for the connection state and to know which features the payload was compiled with, so views can hide what the build does not have.
{"ok":true,"version":"1.6.0","instance_id":"15114b8a2c31d9f0",
"start_monotonic_ns":463980095296000,"pid":74,"platform":"ps4",
"features":{"pkg_install":false}}instance_idchanges when the payload is re-injected: clients treat it as a restart and drop their server-side handles.features.pkg_installmirrors theENABLE_PKG_INSTALLbuild flag. When it isfalsethe package installer endpoints answer409 PKG installation is disabled for this buildand the UI hides the installer entirely.
List directory contents.
Query Parameters:
path(required): Directory path
Response:
{
"path": "/home",
"entries": [
{
"name": "file.txt",
"type": "file",
"size": 1024
},
{
"name": "subdir",
"type": "directory",
"size": 0
}
]
}Errors:
404: Directory not found500: Internal error
Download file (TODO).
Lists the removable volumes that are really connected (/mnt/usb0…/mnt/usb7,
/mnt/ext0, /mnt/ext1). The console creates those directories even with
nothing plugged in, so zftpd checks whether a filesystem is mounted on them
(different st_dev from the parent directory) and reports size and free space.
The file manager's Places list is built from this, so empty slots no longer
appear as drives.
{"ok":true,"mounts":[{"path":"/mnt/usb0","name":"usb0","kind":"usb","total":32007397376,"free":12561055744}]}Streams a ZIP archive of the selection — the bulk download used by the web UI for "Download as ZIP". Entries are produced while the response is sent (no temporary archive on the console), stored uncompressed, and folders are walked recursively. ZIP64 records are emitted automatically for entries, offsets or counts past the 32-bit limits, so multi-gigabyte selections stay valid.
The response announces the exact Content-Length (no chunked encoding), so
console browsers show progress and complete the download reliably.
Query Parameters:
path(required, repeatable): file or folder, confined to the HTTP root.name(optional): download name, defaultzftpd-<timestamp>.zip.
Up to 64 paths per request; unreadable or unconfined entries are skipped, and
when the selection exceeds the entry limit the response carries
X-Zftpd-Truncated: entry-limit.
GET /api/archive/zip?path=/data/games&path=/data/saves/config.bin&name=backup.zip
Streams a file to the client. By default the raw on-disk bytes are sent
(zero-copy sendfile), exactly like every other transfer path.
Query Parameters:
path(required): file to send, confined to the HTTP root.decrypt(optional):1asks for the decrypted content when the file is a SELF container (PS4/PS5). The kernel pager is swapped in for the transfer (MAP_SELF), so the client receives the plain executable instead of the encrypted container. Other files are always sent untouched, and the response carriesX-Zftpd-Decrypted: selfplus chunked encoding.
The web UI exposes this as Settings → Downloads → Decrypt protected files, which appends the flag to every download and preview link.
GET /api/file/get?path=/mnt/disc/CUSA00001/sce_sys/eboot.bin&decrypt=1
Decrypted dump of an installed title, read from the running title's sandbox
mount (/mnt/sandbox/<TITLEID>-app0[-patch0-union] on PS4, _000/app0 and
friends under /mnt/sandbox on PS5), where the kernel exposes the application
in the clear; SELF/SPRX entries are decrypted through the self pager while they
are read. There is no fallback to the encrypted /user/app image, so a title
that is not running is launched first and the job waits for its sandbox to
mount. One dump runs at a time.
POST /api/dump/start
{"title_id":"CUSA00001","target":"download","format":"zip","decrypt":1}
{"title_id":"CUSA00001","target":"local","dest":"/mnt/usb0/homebrew","format":"files"}
GET /api/dump/status # state, source, entries, size, bytes_done
GET /api/dump?id=N # streams the prepared archive (Content-Length)
POST /api/dump/cancel # {"id":N} optional; the single slot is implied
target is download (default; a streamed ZIP the client then fetches with
GET /api/dump?id=N) or local. format is zip (default) or files
(console targets only — a streamed dump is always a ZIP). decrypt defaults
to 1; 0 keeps the raw SELF containers.
POST /api/dump/start answers immediately with the slot id and the initial
state; source, entries and size become available through the status as
soon as the job worker has walked the source tree:
{"ok":true,"id":1,"state":"launching","title_id":"CUSA00001","target":"download"}GET /api/dump/status (the response the web UI polls):
{"ok":true,"active":true,"id":1,"state":"running","title_id":"CUSA00001",
"source":"/mnt/sandbox/CUSA00001_000/app0","entries":1234,"size":32212254720,
"bytes_done":1073741824,"started":1790778983,"to_console":false,
"cancelled":false,"message":""}state is launching, running, ready (streamed dump prepared, waiting for
the download), done or failed; message carries the failure reason
(Cancelled after a cancel request). A slot that stops advancing is retired by
its deadline and reported as failed, so a broken dump never blocks the next
one.
POST /api/dump/cancel stops the job — the local writer gives up at the next
chunk, a prepared archive is dropped, a streamed download ends early:
{"ok":true,"id":1,"state":"running","cancelled":true}Ejects the Blu-ray disc from the console's optical drive (PS4/PS5). The drive
is a FreeBSD cd(4) device: zftpd sends CDIOCALLOW + CDIOCEJECT to
/dev/cd0 and shows a toast on success.
{"ok":true,"status":"ok","message":"Disc ejected","code":0}Failures answer HTTP 200 with ok:false and the driver reason, so a client can
tell a Digital Edition console (ENOENT) from a busy drive (EBUSY):
{"ok":false,"status":"error","message":"Drive busy: a disc game is running","code":-16}Reports whether the console has an optical drive, so the UI can hide the action on Digital Edition consoles.
{"ok":true,"present":true}Show a custom system notification on PS4/PS5 (toast). On non-console builds the message is forwarded to syslog. Designed for local-network automation such as Home Assistant.
Query Parameters:
text(required): Notification message (URL-encoded). Max 1023 bytes after decode.icon(optional): PS notification texture suffix, defaulticon_system. Allowed characters:[A-Za-z0-9_].
Examples:
GET /api/notify?text=Washing%20machine%20is%20done
GET /api/notify?text=Doorbell&icon=icon_system
Home Assistant (rest_command):
rest_command:
ps5_notify:
url: "http://{{ states('sensor.ps5_ip') }}:2121/api/notify?text={{ text | urlencode }}"
method: GETResponse:
{
"ok": true,
"text": "Washing machine is done",
"icon": "icon_system"
}Errors:
400: Missing/emptytext, control characters, or invalidicon405: Method other than GET/POST
Prefer GET for automation clients. POST is subject to CSRF validation when web upload support is enabled.
GET /→index.htmlGET /style.css→ CSSGET /app.js→ JavaScript
Edit src/http/http_api.c:
http_response_t* http_api_handle(const http_request_t *request) {
if (strncmp(request->uri, "/api/custom", 11) == 0) {
return handle_custom(request);
}
// ... existing handlers
}GET|POST /api/admin/games/install
| Parameter | Meaning |
|---|---|
path |
Package already on console storage. |
url |
Package the console downloads by itself (http/https). |
dest |
internal (default) or extended. |
overwrite |
1 replaces an existing installation of the same title. |
Exactly one of path and url is required. When the package is already
installed the request answers 409 with
{"error":"already_installed","title_id":…,"can_overwrite":true} and nothing is
changed; the caller repeats it with overwrite=1 to replace the installation.
GET /api/admin/games/install_status reports the running task:
{"ok":true,"active":true,"task_id":-1,"progress":42,"error":0,
"length":0,"transferred":0,"title_id":"","path":"https://…/package.pkg"}On PS5 the installation is driven by a helper program that zftpd delivers to the loader on loopback, because the system installer API is only usable from a process the loader spawned. On PS4 the download service performs it directly.