API documentation
Programmatic access to VorcaroFiles. Two HTTP surfaces plus a discovery endpoint — everything you need to upload, list, download and rotate files without the browser UI.
/v/{folder_id} can list/modify files in that folder.
If a folder has an additional password, send it in the X-Folder-Password HTTP request header on every JSON call (it's a header, not a query param or body field).
For uploads to a locked folder, exchange the password for an unlock_token via POST /api/v1/folders/{id}/unlock and send that token in the X-Unlock-Token upload header.
Direct file downloads via /d/{file_id} are always open — the file ID alone is the credential.
There is no "list my folders" endpoint — clients are responsible for keeping the IDs they create.
encrypt=true, the file is encrypted at rest using the password, and the server decrypts it on download. Passwords are never stored, but they ARE transmitted to the server. If you need true end-to-end privacy, encrypt the file on the client before uploading and treat the password as out-of-band metadata.
Conventions
read firstDefaults applied to every endpoint. The rest of this page assumes them.
- Encoding
- All strings are UTF-8. Upload metadata headers that may carry non-ASCII (
X-Filename,X-Password) are URL-encoded (percent-encoded) UTF-8. - Timestamps
expires_atand similar Unix-seconds fields are UTC.*_isomirrors are ISO-8601 UTC (2026-05-22T18:00:00Z).- created_day
- Integer: days since
1970-01-01 UTC. To convert to Unix seconds, multiply by86400. - Cache
- JSON responses use
Cache-Control: no-store, except/api/v1/config(public, max-age=60). Binary downloads (HEAD/POST /d/{id}) useprivate, no-store— bytes won't be cached by CDNs or shared proxies. - Errors
- JSON endpoints return
{"error": "human msg", "code": "stable_code"}. The stable code is safe to switch on; the message is for humans. - Relative URLs
download_urlandfolder_urlare relative paths (/d/...,/v/...). Combine withconfig.base_urlfor absolute URLs.- CORS
- Not enabled. Browser-based cross-origin clients won't work — call from a backend or extension context.
- Filenames
- Non-ASCII names in downloads are encoded as
Content-Disposition: attachment; filename*=UTF-8''<percent-encoded>(RFC 5987). - Folder auth
- Always sent as the HTTP request header
X-Folder-Password: <password>. Never as a query param or request body field. - Nosniff
- Download responses (GET/HEAD/POST
/d/{id}) always setX-Content-Type-Options: nosniff.
Meta endpoints
/healthz · /api/v1/configDiscovery and health. Always public, no auth.
Always returns 200 OK with a fixed JSON payload. Use it for load balancer / monitoring health checks.
Response — 200 OK
{ "status": "ok", "service": "masterfiles" }
The endpoint does not touch the database. A 200 only means the HTTP handler is alive — not that downloads will succeed.
Returns the values a client needs to construct a valid upload — without hardcoding any settings. Cached for 60 seconds.
Response — 200 OK
{
"site_domain": "files.example.com",
"base_url": "https://files.example.com",
"allow_anonymous_upload": true,
"max_upload_bytes": 5368709120, // 0 = unlimited
"default_ttl_hours": 168,
"allowed_ttl_hours": [1, 24, 72, 168, 720, 8760, 0],
"download_cap_options": [0, 1, 3, 5, 10, 25, 100],
"empty_folder_days": 7,
"folder_unlock_ttl_hours": 12,
"upload_protocol": "single-request/1.0",
"upload_endpoint": "/upload"
}
Pin client behavior to this response. The accepted X-Ttl-Hours and X-Max-Downloads values in upload headers
come from allowed_ttl_hours and download_cap_options here. 0 in those arrays means
"never expires" / "unlimited downloads" respectively, when those values are allowed.
Upload
post · /upload
One streamed POST /upload: the request body is the raw file, and every option travels in request headers.
No chunking, no handshake, no resumable session — the server writes the bytes straight to storage as they arrive.
Send the file bytes as the body (application/octet-stream). The server validates, optionally encrypts inline, stores the file, and (optionally) creates a new folder.
Required headers
| name | type | description |
|---|---|---|
| Content-Length | int64 | Total body size in bytes (must be ≤ max_upload_bytes for non-premium folders) |
| X-Filename | string | URL-encoded (percent-encoded) original file name. Defaults to untitled.bin if absent. |
Optional metadata headers
report-Q3.pdf stores it as e3a4f9b1...c7.pdf (random 32-hex, original extension preserved if short + alphanumeric). To keep the original name, send X-Anonymize: false.
| header | type | description |
|---|---|---|
| X-Content-Type | string | MIME type (e.g. image/png). Defaults to application/octet-stream. |
| X-Encrypt | bool | true to encrypt the file server-side with X-Password |
| X-Password | string | URL-encoded. Required when X-Encrypt=true. Never stored. Transmitted to the server (not E2E). |
| X-Ttl-Hours | int | Lifetime in hours. 0 = never. Strictly validated against config.allowed_ttl_hours — any other value is rejected with 400. |
| X-Max-Downloads | int | Delete after this many successful downloads. 0 or absent = unlimited. config.download_cap_options is the catalog for UIs; arbitrary positive integers are accepted by the API. |
| X-Anonymize | bool | Default true. Replaces the stored filename with a random 32-hex string, preserving the extension if it's short and alphanumeric. Send false (or 0) to keep the original name. |
| X-Folder-Id | string | Attach to an existing folder. Omit to create a new one. |
| X-Unlock-Token | string | For password-protected folders. Obtain via POST /api/v1/folders/{id}/unlock (JSON response includes the token). |
upload.allow_anonymous=false, OR (b) the target folder is password-protected and no valid unlock cookie / X-Unlock-Token was sent.
Response — 201 Created
JSON body plus these headers (header / JSON key):
| header / json key | description |
|---|---|
| X-File-Id / file_id | Public file ID (used in /d/<id>) |
| X-Folder-Id / folder_id | Parent folder ID |
| X-Folder-New / folder_new | true if a new folder was created |
| X-Download-Url / download_url | Download URL — absolute (https://<domain>/d/...) when site.domain is configured, otherwise relative (/d/...) |
| X-Folder-Url / folder_url | Folder admin URL — same absolute/relative rule. Keep secret. |
| expires_at / expires_iso | Unix seconds + ISO-8601 UTC expiration |
Other responses
400— invalid metadata header,X-Ttl-Hoursnot inconfig.allowed_ttl_hours, empty file, or body length did not matchContent-Length403— anonymous disabled or folder locked (see above)411— missingContent-Length413— body exceedsupload.max_size_bytes(response includesX-Max-Size-Bytes)
Folder API
/api/v1/folders/
Manage folder content programmatically. All responses are application/json.
Password set? Send the password as the HTTP request header
X-Folder-Password: <password> on every request. Wrong/missing password → 401.Repeated failed attempts trigger throttling on every password-checked endpoint (GET, DELETE, password change, unlock) — the server returns
429 with a Retry-After header (seconds).
premium: some folders are flagged premium by an admin (manual, no payment / no self-serve API). Premium folders: (1) are never auto-deleted when empty; (2) bypass upload.max_size_bytes; (3) accept ttl_hours=0 as "never expires" — for non-premium, ttl_hours=0 silently falls back to config.default_ttl_hours (e.g. 168h). Read FolderResource.premium to know which side you're on. There is no public endpoint to toggle this.
Returns folder metadata and the list of non-deleted files inside.
Response — 200 OK
{
"id": "3kQ...",
"created_day": 20596,
"file_count": 2,
"total_bytes": 2048,
"premium": false,
"password_set": true,
"password_set_day": 20597, // omitted when password_set=false
"empty_folder_deletes_at": 1780012800, // only when empty + non-premium + cleanup enabled
"empty_folder_deletes_iso": "2026-05-29T00:00:00Z",
"files": [ /* array of file objects */ ]
}
empty_folder_deletes_at is omitted when the folder is premium, non-empty, or empty-folder cleanup is disabled (config.empty_folder_days = 0).
Synchronous hard delete — no soft-delete window, no recovery. Files are removed from disk and the folder row is deleted before the response returns.
{
"deleted": true,
"folder_id": "3kQ...",
"files_deleted": 2
}
Programmatic alternative to the browser unlock flow. Returns an unlock_token you can send in the X-Unlock-Token header to upload to a locked folder. Same token format as the browser mf_v_<folder_id> cookie. Lifetime matches config.folder_unlock_ttl_hours.
Request body
{ "password": "hunter2" }
Response — 200 OK
{
"unlock_token": "qm9...32 url-safe chars",
"folder_id": "3kQ...",
"expires_at": 1779451200,
"expires_iso": "2026-05-22T06:00:00Z"
}
Other responses
400— missing/malformed body, or the folder has no password set401— wrong password404— folder not found429— rate-limited (shared with all other password-checked endpoints on this folder)
Synchronous hard delete. Removes the file from disk and the DB row before the response returns. The file_id must belong to the given folder, otherwise 404.
{
"deleted": true,
"file_id": "V5pY...",
"folder_id": "3kQ..."
}
Generates a new public file ID. The old /d/{old_file_id} URL stops working immediately.
The underlying encrypted blob is not moved or re-encrypted — only the URL token rotates.
429 Too Many Requests otherwise.
Response — 200 OK
{
"file_id": "newPublicId...",
"old_file_id": "oldPublicId...",
"download_url": "/d/newPublicId..."
}
Manages the folder password (Argon2id, irrecoverable). To change an existing password, you still send the current one in X-Folder-Password.
Request body
{ "new_password": "hunter2" } // or "" to remove
Minimum 4 characters. Removing the password makes the folder publicly accessible again (still requires the folder ID).
Response — 200 OK
{
"password_set": true, // false if new_password was ""
"folder_id": "3kQ..."
}
File API
/api/v1/files/Lookup file metadata by its public ID. No password needed — the file ID itself is the credential.
Returns metadata only (no binary). Use POST /d/{id} to download the bytes or HEAD /d/{id} for a lighter probe.
Response — 200 OK
{
"id": "V5pY...",
"original_name": "file.txt",
"content_type": "text/plain",
"size_bytes": 1024,
"encrypted": true,
"cipher": "age-v1",
"download_count": 0,
"max_downloads": 5, // omitted when unlimited
"expires_at": 1779494400,
"expires_iso": "2026-05-22T18:00:00Z",
"created_day": 20596,
"download_url": "/d/V5pY...",
"regenerated_at": 1779408000 // Unix seconds of last URL rotation; omitted if never rotated
}
Lifetime — first event wins
A file is removed (turns into 404 on download) when any of these triggers fires first:
expires_atreacheddownload_count >= max_downloadsafter a successful download- Explicit delete via Folder API
- Parent folder deleted
download_count increments after a successful byte stream from POST /d/{id}. Aborted downloads don't count. HEAD /d/{id} never increments.
Note: The parent folder_id is intentionally not exposed here. Anyone with the file ID can fetch metadata, but they should not learn which folder it belongs to.
Download
/d/{file_id}
For programmatic downloads, use POST — it streams the raw bytes as Content-Disposition: attachment.
The GET variant returns an HTML page meant for browsers and is not considered API.
Use HEAD to probe a file's status without downloading or incrementing the counter.
All download responses include X-Content-Type-Options: nosniff.
Returns the same status as POST (200 / 404) plus metadata headers. Does not decrypt, does not require a password, does not increment download_count.
Response — 200 OK
| header | description |
|---|---|
| Content-Type | Original MIME type |
| Content-Length | Plaintext size — only when file is not encrypted |
| Content-Disposition | attachment; filename*=UTF-8''<percent-encoded> |
| X-File-Size-Bytes | Plaintext size (always present) |
| X-Encrypted | true or false |
| X-Download-Count | Current download count |
| X-Max-Downloads | Cap value — omitted when unlimited |
| X-Expires-At | Unix seconds — omitted when never expires |
Request body (optional)
Form field password is required when the file is encrypted, ignored otherwise.
password=hunter2
Response
200 OK— binary stream of the file. After a successful stream,download_countincrements by 1; if it reachesmax_downloads, the file is removed.401 Unauthorized— missing or wrong password404 Not Found— file expired, deleted, download cap reached, or unknown
Example
curl -X POST "https://files.example.com/d/V5pY..." \ -d "password=hunter2" \ -o file.txt
End-to-end example
workflowDiscovery → upload → list → rotate URL → download. Replace files.example.com with your domain.
1 · Discover limits
curl -s "https://files.example.com/api/v1/config" # → use allowed_ttl_hours, max_upload_bytes, etc. to validate before uploading
2 · Upload the file
# The body IS the file. Metadata goes in headers. # X-Filename and X-Password are URL-encoded (percent-encoded). curl -i -X POST "https://files.example.com/upload" \ -H "Content-Type: application/octet-stream" \ -H "X-Filename: file.txt" \ -H "X-Encrypt: true" \ -H "X-Password: hunter2" \ -H "X-Ttl-Hours: 168" \ -H "X-Max-Downloads: 5" \ --data-binary @file.txt # → 201 Created + JSON body: # { # "file_id": "V5pY...", # "folder_id": "3kQ...", # "folder_new": true, # "original_name": "file.txt", # "encrypted": true, # "size_bytes": 1024, # "folder_url": "https://files.example.com/v/3kQ...", # "download_url": "https://files.example.com/d/V5pY...", # "expires_at": 1779494400, # "expires_iso": "2026-05-22T18:00:00Z" # }
3 · List the folder later
curl -s "https://files.example.com/api/v1/folders/<folder_id>" # If the folder is password-protected, add the request HEADER: curl -s "https://files.example.com/api/v1/folders/<folder_id>" \ -H "X-Folder-Password: hunter2"
3b · Upload to a password-protected folder (programmatic)
# Exchange password for an unlock_token: curl -s -X POST "https://files.example.com/api/v1/folders/<folder_id>/unlock" \ -H "Content-Type: application/json" \ -d '{"password":"hunter2"}' # → { "unlock_token": "qm9...", "expires_at": ... } # Then send it on the upload: curl -i -X POST "https://files.example.com/upload" \ -H "X-Filename: file.txt" \ -H "X-Folder-Id: <folder_id>" \ -H "X-Unlock-Token: qm9..." \ --data-binary @file.txt
4 · Rotate the download URL
curl -X POST "https://files.example.com/api/v1/folders/<folder_id>/files/<file_id>/regenerate" # → { "file_id": "newId...", "old_file_id": "oldId...", "download_url": "/d/newId..." } # The old /d/oldId... URL now returns 404.
5 · Download
curl -X POST "https://files.example.com/d/<file_id>" \ -d "password=hunter2" \ -o file.txt # download_count increments after the stream completes.
Errors
status codes + stable codesAll JSON endpoints return errors as {"error": "human msg", "code": "stable_code"}. Switch on code, not on the message text.