Driving NextExplorer from the API
Everything the interface does, it does over HTTP. There is no separate or reduced API for automation: the endpoints below are the ones the application itself calls, so anything you can do by clicking, you can do with curl.
Every example on this page was run against a running instance as written.
Authentication
Two ways in. A session cookie is what the interface uses. An API token is what a script should use: it is narrower than the account it belongs to, it can be taken away on its own, and it does not stop working when somebody changes their password.
API tokens
Issue one from Settings → API tokens. The value is shown once, at that moment, and stored hashed — losing it costs a new token, which is the property that makes a stolen database worth nothing here.
curl -H 'Authorization: Bearer nxe_…' https://your-host/api/volumesThat header is the only place a token may be presented. Not a query parameter, which lands in every access log and every history; not a cookie, which a browser attaches to requests another site made.
What a token may do is its scope, chosen when it is issued:
| Scope | What it reaches |
|---|---|
read | GET and HEAD, plus POST /api/download — downloading a selection is a read that arrives as a POST because a hundred file names do not fit in a URL |
write | everything the account itself can do with files: upload, move, rename, delete |
What no token ever reaches, whatever its scope and whoever owns it:
/api/auth/*— the account. It cannot change a password, add a passkey, take off a second factor, or issue another token. A credential that can issue credentials is one revocation that revokes nothing.GET /api/auth/meis the exception, so a script can ask who it is without being able to change who it is;- every administrative route, even when the account is an administrator;
- the terminal.
A token follows its account as the account changes: a role taken away applies to the next request, and deleting the account takes its tokens with it.
Managing them — from a session, never from a token:
| Call | What it does |
|---|---|
GET /api/auth/tokens | the live ones: name, scope, when issued, when last used and from where, when it expires |
POST /api/auth/tokens | { name, scope, expiresInDays, password } — answers { token, secret }, the only time secret exists |
PATCH /api/auth/tokens/:id | { name } |
DELETE /api/auth/tokens/:id | revokes it, and it stops working on the next request |
Issuing one asks for the account's password when the account has one: a browser left unlocked on a desk should not be enough to walk away with a credential that outlives the session. Revoking asks for nothing — somebody who has just realised a token leaked must be able to stop it now. expiresInDays is a number of days up to 3650, or null for a token that lasts until it is revoked. Fifty per account.
Refusals are deliberately uninformative. Every unusable token — forged, unknown, revoked, expired — is answered 401 with AUTH_TOKEN_INVALID, and never with which of the four it was: telling them apart would tell somebody holding a stolen value that they have the right server. Which it was is written in the activity log instead, once an hour per token rather than once per request. Reaching for a closed door answers 403, with AUTH_TOKEN_READ_ONLY or AUTH_TOKEN_NOT_ALLOWED.
A session, the way the interface signs in
curl -c cookies.txt -X POST https://your-host/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"email":"you@example.com","password":"your-password"}'Pass -b cookies.txt on everything afterwards. The session lasts as long as SESSION_MAX_AGE_DAYS (30 by default), so a long-running job does not have to sign in repeatedly. It is as wide as the account, which is why a token is the better credential for anything that runs unattended.
An account with two-factor authentication answers that call with {"totpRequired": true} and no user: the password was right, and the session is holding the sign-in rather than granting it. Finish it within five minutes with the code from the authenticator, or one of the recovery codes:
curl -b cookies.txt -c cookies.txt -X POST https://your-host/api/auth/login/totp \
-H 'Content-Type: application/json' \
-d '{"code":"123456"}'Six digits are worth one sign-in and one recovery code is worth one use, so a script cannot keep either. A wrong code answers 401 with AUTH_INVALID_TOTP_CODE and counts against the same lockout a wrong password does. GET /api/auth/totp says whether an account asks for one and how many recovery codes it has left; POST /api/auth/totp/start and /confirm set one up, POST /api/auth/totp/recovery-codes and DELETE /api/auth/totp need the account's password, and DELETE /api/users/:id/two-factor is an administrator taking it off an account that has lost both the phone and the paper.
Passkeys
A passkey is a browser ceremony, so there is no useful curl for it: the signature has to come from an authenticator that was handed a challenge this server drew. The endpoints are POST /api/auth/login/passkey/start and /finish to sign in, GET /api/auth/passkeys to list the ones on an account, POST /api/auth/passkeys/register/start and /finish to add one, PATCH /api/auth/passkeys/:id to rename it and DELETE /api/auth/passkeys/:id — with the account's password — to remove it. DELETE /api/users/:id/passkeys is an administrator taking every passkey off an account whose device is gone, beside DELETE /api/users/:id/two-factor, which does the same for its second factor.
The start calls answer { options, origins }: options is what navigator.credentials expects, with every byte as base64url, and the challenge inside it is spent by the matching finish and worth one ceremony. A refusal is 401 with AUTH_PASSKEY_REJECTED — the same answer for a wrong site, a stale challenge, a signature that does not hold and a credential this server has never seen, because telling them apart would say which passkeys exist here. A passkey that was unlocked signs in outright; one that was not answers {"totpRequired": true} on an account that asks for a second factor, and finishes at /api/auth/login/totp like a password does.
The activity log
GET /api/activity (administrators) reads what was recorded, newest first: action, outcome, user, from, to, q, limit narrow it, and before — the nextBefore of the page before — is where to carry on from, so rows arriving while somebody reads do not shift a page. DELETE /api/activity empties it, answers how many rows went (removed) and writes one last line — admin.activity-clear, naming who asked and that count — after the deletion, so it is the only row to survive it. The answer also says whether the log is on (enabled) and what kinds of event exist (actions); with it off, events is empty because nothing was recorded. It is switched on at PATCH /api/settings with {"activity": {"enabled": true, "retentionDays": 90}}.
GET /api/activity/address (administrators) answers why a line carries the address it carries: recorded, the peer at the other end of the socket, trustsPeer, the trustProxy rule in force, and every forwarding header that arrived. No headers means nobody announced a client — see the reverse proxy guide.
It ends sooner if the account's password changes. POST /api/auth/password signs out every other session of the account, and moves the one that made the change to a new cookie, which its response sets — keep writing to the cookie file (-c cookies.txt -b cookies.txt) on that call, or the next one answers 401. An administrator's reset, POST /api/users/:id/password, signs out every session of that account. A job holding a session of an account whose password changes has to sign in again.
The practical consequence is worth stating plainly: there is no way to issue a credential scoped to a script. An automation holds a full user session, so give it an account whose permissions match what it is meant to do, rather than an administrator's.
Listing what is there
# The volumes this user can see
curl -b cookies.txt https://your-host/api/volumes
# The contents of a folder
curl -b cookies.txt https://your-host/api/browse/Documentsbrowse answers with the entries, the resolved path, and what this user is allowed to do there — canWrite, canUpload, canDelete and the rest — so a script can check a permission instead of discovering it through a failure.
Uploading a file
Uploads go through TUS, in two steps: create the upload, then send the bytes. Metadata is a comma-separated list of key base64-value pairs.
b64() { printf '%s' "$1" | base64 | tr -d '\n'; }
# 1. Create it. The response carries the upload's URL in the Location header.
curl -b cookies.txt -D - -X POST https://your-host/api/upload/tus \
-H 'Tus-Resumable: 1.0.0' \
-H 'Upload-Length: 23' \
-H "Upload-Metadata: filename $(b64 'report.txt'),uploadTo $(b64 'Documents'),relativePath $(b64 'report.txt')"
# 2. Send the bytes to the URL it returned.
curl -b cookies.txt -X PATCH https://your-host/api/upload/tus/<id> \
-H 'Tus-Resumable: 1.0.0' \
-H 'Upload-Offset: 0' \
-H 'Content-Type: application/offset+octet-stream' \
--data-binary @report.txtuploadTo is the destination folder and relativePath the path within it, which is how a whole directory tree is sent: one upload per file, each carrying its own relativePath, and the folders are created as they are needed.
Because it is TUS, an interrupted transfer resumes rather than restarts — ask the upload URL for its Upload-Offset with a HEAD and continue from there. That matters for large files over a connection you do not control.
Once every byte has arrived, the file is put in its folder, under name (1) when the name is taken, never over a file already there. A HEAD answers complete only once that is done. When the file cannot be put there, the PATCH that finished it answers 500 (507 when the volume is full), and a later HEAD answers 423, both with an Upload-Finalize-Error header holding the reason as a URI-encoded sentence; a HEAD also tries the move again, and answers complete once it succeeds. An upload placed before a restart is still answered complete, to the person who sent it, for as long as an unfinished upload would be kept (TUS_INCOMPLETE_UPLOAD_TTL_MS).
Chunked uploads must be enabled on the server (UPLOAD_CHUNKED_ENABLED=true). Where they are not, POST /api/upload takes an ordinary multipart body, with the files in filedata fields. A file larger than MAX_DIRECT_UPLOAD_SIZE, or more files than MAX_FILES_PER_UPLOAD in one request, is refused with 413 and a message naming the limit; a file in any other field is refused with 400. Nothing sent in a refused request is kept.
Sharing
curl -b cookies.txt -X POST https://your-host/api/shares \
-H 'Content-Type: application/json' \
-d '{"sourcePath":"Documents/report.txt","sharingType":"anyone"}'The reply carries shareUrl and shareToken. The URL is built from PUBLIC_URL when it is set, and from the request host otherwise — which is the reason to set it: a link built from an internal hostname is useless to whoever receives it.
sharingType is anyone for a public link or users with a userIds list for named people. password, expiresAt and the allow* flags narrow it further.
Delete with DELETE /api/shares/:id — the share's id, not its token.
Moving and removing
# Copy or move
curl -b cookies.txt -X POST https://your-host/api/files/move \
-H 'Content-Type: application/json' \
-d '{"items":[{"path":"Documents","name":"report.txt"}],"destination":"Archive"}'
# Delete
curl -b cookies.txt -X DELETE https://your-host/api/files \
-H 'Content-Type: application/json' \
-d '{"items":[{"path":"Documents","name":"report.txt"}]}'Deleting a path also forgets what was bound to it — favourites, recent destinations, per-folder view and sort preferences, shares — for every user, not only the one who asked. Renames and moves carry those bindings across instead of dropping them, so a script that reorganises a tree does not leave dead favourites behind it.
POST /api/files/delete-impact says what a deletion would affect before you commit to it, and POST /api/files/delete-stream reports progress as it goes, which is what the interface uses for large selections.
Reading and saving a text file
GET /api/editor?path=Documents%2Fnotes.md answers { "content": "…" }, the path encoded in the query string. POST /api/editor with { "path": … } in the body answers exactly the same and remains for clients written against it; GET /api/raw?path= answers the text alone, as text/plain. PUT /api/editor with { "path", "content" } saves, in the encoding the file already had. A file larger than EDITOR_MAX_FILESIZE is refused with 400, a binary one with 415.
The reads by GET can be kept and asked again. They carry an ETag and Cache-Control: private, no-cache; send the ETag back in If-None-Match, and while the file is unchanged the answer is 304 Not Modified with no body — the server does not even read the file to give it. The ETag changes whenever the file does: a save, another file moved over it, a write in place, including one that puts the modification time back. Permission is checked first, so someone who may no longer read the file is refused as before, whatever they send. A browser does all of this on its own. PUT /api/editor answers with the ETag the next read will carry, when the file it wrote is still the one in place.
The shared editor, GET /api/share/:token/editor, answers the same way, and its ETag also changes when what the link allows does — canWrite, canDownload — so a visitor's copy never outlives a permission change. PUT to the same address saves it back, when the link was made writable; what a visitor replaces is kept as a version for the owner.
Responses that carry a whole text file are compressed when they are over 32 KB and the request's Accept-Encoding allows it: brotli when it is offered, which browsers do only over HTTPS, otherwise gzip. That covers the reads above, the shared editor, and the text of a file in the trash or of an earlier version (GET /api/trash/items/:id/text, GET /api/versions/:id/text), and every one of them varies on Accept-Encoding. curl --compressed asks for it. Nothing else is compressed by the application — downloads, previews, media and progress streams go as they are, most of them already compressed.
Looking inside an archive
GET /api/archive/list?path=Work/backup.zip answers what is at the top of an archive, and &inside=docs what is in one of its folders. The archive is named by its path like any other file, and where you are looking inside it is a separate parameter — never one path with the archive in the middle of it.
{
"path": "Work/backup.zip",
"name": "backup.zip",
"inside": "docs",
"entries": [
{ "name": "deep", "path": "docs/deep", "isDirectory": true, "size": null, "modified": null },
{
"name": "report.txt",
"path": "docs/report.txt",
"isDirectory": false,
"size": 4096,
"modified": "2026-09-16 11:22:33",
"encrypted": false
}
],
"total": 12,
"outside": 0
}total is how many entries the whole archive holds; outside is how many were left out because their names point outside it — a crafted archive can carry ../../etc/passwd, and no answer here ever presents one as a place.
GET /api/archive/entry?path=Work/backup.zip&entry=docs/report.txt writes that one file back, as an attachment, without unpacking the rest. The name is looked up in the listing first, so what comes back is an entry the archive holds under exactly that name, or nothing.
It is always an attachment, and always X-Content-Type-Options: nosniff, even where the answer is what the panel is about to show on screen: nothing out of somebody else's archive is ever opened as a page on this origin. A client that wants to display an entry reads the answer as data and draws it itself.
POST /api/archive/extract takes part of an archive out onto the volume, into the folder the archive is in. The body names the archive and the entries — { "path": "Work/backup.zip", "entries": ["docs", "notes.txt"] } — with an optional "destination": "Work/Elsewhere" for somewhere else on the volume, authorized exactly as the default folder is, and refused before anything is written if it is not a folder this caller may write in. A folder stands for everything under it. It answers with the same stream of events the other archive operations write (start, progress, done, error), and the done event carries the name each entry landed under, which is not always the name it had: nothing is ever replaced, so a name already held becomes “name (1)”.
Reading an archive is not the right to write beside it: extraction is refused unless the caller may create files and folders in the archive's own folder.
Both refuse with a code the caller can act on: ARCHIVE_ENCRYPTED (409) for an archive or entry behind a password, ARCHIVE_UNREADABLE (422) for a damaged one, ARCHIVE_ENTRY_NOT_FOUND (404), ARCHIVE_BAD_POSITION (400) for a name that points outside the archive, ARCHIVE_TOO_MANY_ENTRIES (413), and ARCHIVE_TOO_LARGE_TO_BROWSE (413) for a compound archive past MAX_BROWSABLE_ARCHIVE_SIZE.
Endpoint reference
Every operation — its parameters, what it takes and what it answers, and who may call it — is in the API explorer, read from the API's OpenAPI description. That description is also a file a generated client or an API tool can take, and a running instance serves its own, for the release it runs, at /api/openapi.json, to anybody, as it does /api/features.
It is held to the application by tests rather than kept up by hand: a route added without being described, or described and not mounted, fails the build.
The health probes are outside /api: GET /healthz and GET /readyz.
ONLYOFFICE and Collabora endpoints exist only where those integrations are configured; the routes are not mounted otherwise, and asking for them returns 404.
What to expect back
Errors are JSON and carry a requestId that also appears in the server log, which is what to quote when something needs explaining:
{
"success": false,
"error": {
"message": "Source path is required",
"statusCode": 400,
"requestId": "000ecace-e638-402c-af94-df6b0cdfe4a7"
}
}This API is not versioned. It is the interface's own API, and it changes with the interface — pin an image version if you build something that depends on the exact shape of a response.
