v1 · REST · JSON
API reference
Uploads, file management, share links and webhooks over one REST API. Every endpoint lives under https://filemv.com/api, with JSON bodies.
Quickstart — your first upload in three steps
An upload is two separate calls. POST /api/uploads reserves a session and returns an uploadUrl. Send the file's bytes to that URL with a PUT request. That request needs no API key: the session id authorizes it. Finally, call GET /api/uploads/{sessionId}/status until the status is COMPLETE.
The bytes always go to https://filemv.com/files/…. For files over 100 MiB, the resumable /files/tus protocol is recommended.
import { readFile } from "node:fs/promises";
const base = "https://filemv.com";
const auth = { Authorization: `Bearer ${process.env.FILEMV_API_KEY}` };
const bytes = await readFile("video.mp4");
// 1. Reserve an upload session
const res = await fetch(`${base}/api/uploads`, {
method: "POST",
headers: { ...auth, "Content-Type": "application/json" },
body: JSON.stringify({ fileName: "video.mp4", contentType: "video/mp4", fileSizeBytes: bytes.length }),
});
const { sessionId, uploadUrl } = await res.json();
// 2. Send the bytes to uploadUrl (no API key needed: the session id authorizes it)
await fetch(uploadUrl, { method: "PUT", body: bytes, headers: { "Content-Length": String(bytes.length) } });
// 3. Poll until COMPLETE
const status = await fetch(`${base}/api/uploads/${sessionId}/status`, { headers: auth }).then((r) => r.json());
console.log(status.status, status.fileId);Authentication
External applications should use an API key: send it on every request in the Authorization: Bearer fmv_… header. The FileMv web app signs in with a session cookie. Every endpoint accepts both.
API keyrecommended
Authorization: Bearer fmv_…
600 requests/minute per key. Available on every plan, at most 10 active keys per user. Revocable at any time.
Browser session (cookie)
auth_token · X-XSRF-TOKEN
The web app signs in with an HttpOnly cookie. Requests that change data need an X-XSRF-TOKEN header. Don't use this for your own integration.
Create a key in the app
- Open Settings → API keys (
/settings/api-keys). - Optionally enter a label (up to 80 characters), then click Create key.
- The full key is shown only once, so copy it right away. After that the list shows only its start (for example
fmv_AbC12x…), the label, when it was created and when it was last used. - Revoke a key from its row. You can have at most 10 active keys; beyond that, creating one fails with
409 api_key_limit_reached.
Create a key via the API
The body of POST /api/me/api-keys is optional, for example {"label": "CI server"}. The response is 201 and contains the rawKey field. You can never retrieve it again, because the server stores only a keyed hash of the key. GET lists your keys, and DELETE /api/me/api-keys/{id} revokes one with 204.
Format and usage
A key is the fmv_ prefix followed by 40 URL-safe base64 characters (~240 bits). The server treats only tokens starting with Bearer fmv_ as API keys. With an unknown or revoked key, protected endpoints return 401 authentication_required, and public endpoints run as if you had sent no key. Send one credential per request: either a key or a cookie.
A key carries full authority
There are no scopes: a key can do everything its owner can. It reaches files, shares, webhooks, key management and account actions — and, if the owner is an admin, the admin endpoints too. Treat it like a password.
Good practice
- Keep it in an environment variable or a secret store. Never put it in client-side or mobile code, or in a repository.
- To rotate: create a new key, switch the integration to it, then revoke the old one.
- Watch
lastUsedAtto spot unexpected use. Give every integration its own key.
Rate limits
| Tier | Limit | Counted per |
|---|---|---|
| apikey | 600 / min | API key |
| api | 60 / min | IP address (requests without a key) |
| auth | 10 / min | IP address, /api/auth/** |
Every /api response carries X-RateLimit-Limit (budget per minute), X-RateLimit-Remaining (requests left) and X-RateLimit-Reset (seconds until the budget is full again). Over the limit you get 429 with a Retry-After header and the rate_limited code.
Some features have their own limits on top, answered with 429 too_many_requests:
- Anonymous upload: 5 per hour per IP (up to 10 files per batch, up to 100 MiB per file).
- Unlocking a password-protected share: 5 attempts per 15 minutes per share and IP.
- Resolving a share: 40 per minute per IP; refreshing download URLs: 30 per minute.
- Creating, testing and rotating webhooks: 10 per minute per user.
Error codes
Every error has the same shape: { "timestamp", "status", "error", "code", "message" }. Validation errors add an errors list. Branch on the code field in your code.
| HTTP | code | When |
|---|---|---|
| 400 | validation_failed, bad_request | A field in the request is missing or invalid |
| 401 | authentication_required | Missing or invalid API key or session |
| 403 | access_denied | Another user's resource |
| 404 | *_not_found | Unknown id or token |
| 409 | storage_quota_exceeded, api_key_limit_reached, vanity_slug_taken, webhook_limit_reached | Storage quota, key or webhook limit, slug already taken |
| 413 | payload_too_large | The file is larger than allowed |
| 429 | rate_limited, too_many_requests | Rate limited — wait as Retry-After says |
Uploads
Create an upload session for an authenticated user
Reserves an upload session for the authenticated user and returns the uploadUrl to PUT the file's bytes to. When parentFolderId is supplied, the folder is validated to be owned by the caller before any session is reserved. When autoShare is set, a link-only (SHARED) share token and its short link are minted up front and returned with the reservation. Adding publicListing=true to autoShare makes that share PUBLIC (listed: the share page may be indexed by search engines) instead of SHARED.
Request bodyapplication/json
- autoShareboolean Optional. When true, a link-only (SHARED) share is minted for this upload, and the response carries its token and short link before the bytes move. Defaults to false: the file stays PRIVATE.
- contentTypestringrequired MIME content type as detected by the client. Required.
- fileNamestringrequired Display name of the file being uploaded. Required.
- fileSizeBytesinteger Declared file size in bytes. Must be > 0.
- parentFolderIdstring Optional Drive folder UUID to upload into. When omitted the file lands at the user's Drive root. Validated server-side against the authenticated user's tree before any session is reserved.
- publicListingboolean Optional, only honoured together with autoShare=true. When true the minted share is PUBLIC (listed) instead of link-only SHARED: the share page may be indexed by search engines and the file is returned by FileMv's own cross-owner search. Ignored when autoShare is not set (the upload stays PRIVATE). Defaults to false.
Responses
- 201Session created — body carries sessionId and uploadUrl, plus the share fields when autoShare was set
- 400Validation failed (blank fileName/contentType, non-positive size)
- 401Missing or invalid Bearer token
- 404parentFolderId is unknown, owned by another user, or refers to a FILE
- 409Upload would exceed the user's storage quota
- 413Declared fileSizeBytes exceeds the per-file limit of the caller's plan (freeUserMaxBytes / paidUserMaxBytes in GET /api/public/config) — code=payload_too_large
const res = await fetch("https://filemv.com/api/uploads", {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.FILEMV_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
"contentType": "image/jpeg",
"fileName": "vacation.jpg",
"fileSizeBytes": 1048576
}),
});
const data = await res.json();{
"directUrl": null,
"fileId": "2b3c4d5e-6f70-4812-9a3b-4c5d6e7f8091",
"sessionId": "2b3c4d5e-6f70-4812-9a3b-4c5d6e7f8091",
"shareToken": null,
"shortUrl": null,
"uploadUrl": "https://filemv.com/files/2b3c4d5e-6f70-4812-9a3b-4c5d6e7f8091"
}This endpoint changes data — run it from your own environment with the sample above.
Look up the current status of an upload session
Returns the session id, its state (PENDING / UPLOADING / COMPLETE / FAILED), and — once complete — the resulting fileId + fileName. Ownership is enforced: a session may only be read by the user who created it.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
| sessionId required | path | string | Upload session UUID returned by POST /api/uploads |
Responses
- 200Current session status
- 400Malformed sessionId
- 401Missing or invalid Bearer token
- 403Session is owned by a different user
- 404Unknown sessionId
- 502Storage worker rejected this service's own credential, or answered an unresolvable status
const res = await fetch("https://filemv.com/api/uploads/3f9c1e2a-7b4d-4c1e-9a2f-0d8e6b1c5a77/status", {
method: "GET",
headers: {
"Authorization": `Bearer ${process.env.FILEMV_API_KEY}`,
},
});
const data = await res.json();{
"fileId": "2b3c4d5e-6f70-4812-9a3b-4c5d6e7f8091",
"fileName": "vacation.jpg",
"sessionId": "2b3c4d5e-6f70-4812-9a3b-4c5d6e7f8091",
"status": "COMPLETE"
}This call needs parameters — run it from your own environment with the sample above.
Create an anonymous upload session
Reserves an upload session for an unauthenticated uploader, mints a share token, and applies the configured retention. A missing/rejected captcha surfaces as 400 before any session is reserved. publicListing=true creates the share PUBLIC (listed, indexable by search engines); absent or false keeps the link-only SHARED default.
Request bodyapplication/json
- captchaTokenstring Cloudflare Turnstile token. Required when filemv.captcha.enabled=true; tolerated blank in dev/test.
- contentTypestringrequired MIME content type as detected by the client. Required.
- fileNamestringrequired Display name of the file being uploaded. Required.
- publicListingboolean Optional. When true the share is created PUBLIC (listed): the share page may be indexed by search engines and the file is returned by FileMv's own search. Absent or false keeps the link-only SHARED default (unlisted, noindex).
- sizeBytesinteger Declared file size in bytes. Must be > 0.
Responses
- 201Session created — body carries the upload URL and the share token
- 400Validation failed (code validation_failed: blank fileName/contentType, non-positive size) or captcha missing/rejected (code captcha_failed, message "Captcha verification failed")
- 429Per-IP anonymous-upload rate-limit exceeded
const res = await fetch("https://filemv.com/api/public/uploads", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
"contentType": "image/png",
"fileName": "screenshot.png",
"sizeBytes": 524288
}),
});
const data = await res.json();{
"directUrl": null,
"fileId": "3f9c1e2a-7b4d-4c1e-9a2f-0d8e6b1c5a77",
"sessionId": "3f9c1e2a-7b4d-4c1e-9a2f-0d8e6b1c5a77",
"shareToken": "Qm4t7Xz2Lp9Kd3Wf8Rb1Nc",
"shortUrl": "https://filemv.com/…",
"uploadUrl": "https://filemv.com/…"
}This endpoint changes data — run it from your own environment with the sample above.
Create anonymous upload sessions for a batch of files
Multi-file twin of POST /api/public/uploads. Verifies the captcha token ONCE, consumes ONE per-IP rate-limit token for the whole batch, validates every file's size cap before reserving anything, then reserves one upload session per file (own share token and short link each) and returns them in request order. Batch size is capped by anonymousMaxFilesPerBatch (/api/public/config). publicListing=true creates every share PUBLIC (listed, indexable by search engines); absent or false keeps the link-only SHARED default.
Request bodyapplication/json
- captchaTokenstring Cloudflare Turnstile token, verified ONCE for the whole batch. Required when filemv.captcha.enabled=true; tolerated blank in dev/test.
- object[]required Files to reserve sessions for, in display order. At least one; at most anonymousMaxFilesPerBatch.
- publicListingboolean Optional. When true every file's share is created PUBLIC (listed): the share page may be indexed by search engines and the file is returned by FileMv's own search. Absent or false keeps the link-only SHARED default (unlisted, noindex).
Responses
- 201Sessions created — body carries one single-file response per file, in request order
- 400Validation failed (empty list, blank fileName/contentType, non-positive size, more files than anonymousMaxFilesPerBatch) or captcha missing/rejected (code captcha_failed)
- 413At least one file exceeds the anonymous per-file size cap — nothing was reserved
- 429Per-IP anonymous-upload rate-limit exceeded
const res = await fetch("https://filemv.com/api/public/uploads/batch", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
"files": [
{
"contentType": "image/jpeg",
"fileName": "nyaralas-2026.jpg",
"sizeBytes": 524288
}
]
}),
});
const data = await res.json();{
"files": [
{
"directUrl": null,
"fileId": "3f9c1e2a-7b4d-4c1e-9a2f-0d8e6b1c5a77",
"sessionId": "3f9c1e2a-7b4d-4c1e-9a2f-0d8e6b1c5a77",
"shareToken": "Qm4t7Xz2Lp9Kd3Wf8Rb1Nc",
"shortUrl": "https://filemv.com/…",
"uploadUrl": "https://filemv.com/…"
}
]
}This endpoint changes data — run it from your own environment with the sample above.
Files
List direct children of a folder (or root items), or search files across all folders
Returns the direct children of the requested folder, or the user's root items when parentId is omitted. Folder rows carry a recursive size rollup; file rows carry their own size + metadata. The optional ?sort= parameter selects the result ordering — see the parameter doc for the accepted vocabulary. Unknown values raise 400. The optional ?q= parameter switches to a folder-independent file search: when non-blank, parentId is ignored and the response contains only the caller's own non-deleted files (across every folder) whose name contains q case-insensitively.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
| parentId | query | string | UUID of the parent folder. Omit (or null) to list root items. Ignored when `q` is provided. |
| limit | query | integer | Page size. Server-side maximum applies. |
| page | query | integer | 1-based page number. |
| sort | query | string | Result ordering. Accepted values: `type,name` (default — folder-first-then-alpha, preserves historical behaviour); `name` / `-name` (ascending / descending by name, mixed folders + files); `createdAt` / `-createdAt` (by creation timestamp, mixed); `size` / `-size` (FILES only — folders are surfaced first by name, files after by size). Unknown values raise 400. |
| q | query | string | Optional folder-independent file-name search term. When non-blank, `parentId` is ignored and the response lists only the caller's own non-deleted files (across all folders) whose name contains this value case-insensitively. Blank / absent keeps the parentId-scoped folder listing. |
Responses
- 200Paginated list of file-system items
- 400Unsupported `sort` value
- 401Missing or invalid Bearer token
- 404parentId is unknown or owned by a different user
const res = await fetch("https://filemv.com/api/files", {
method: "GET",
headers: {
"Authorization": `Bearer ${process.env.FILEMV_API_KEY}`,
},
});
const data = await res.json();{
"files": [
{
"commentsCount": 1,
"contentKind": "FILE",
"deletedAt": "2026-10-04T09:30:00Z",
"downloadCount": 1,
"id": "3f9c1e2a-7b4d-4c1e-9a2f-0d8e6b1c5a77",
"likesCount": 1,
"mimeType": "string",
"name": "vacation.jpg",
"parentId": "3f9c1e2a-7b4d-4c1e-9a2f-0d8e6b1c5a77",
"previewUrl": "https://filemv.com/…",
"shareMode": "string",
"shareToken": "Qm4t7Xz2Lp9Kd3Wf8Rb1Nc",
"size": 1048576,
"type": "file",
"uploadedAt": "2026-10-04T09:30:00Z"
}
],
"pagination": {
"currentPage": 1,
"totalItems": 1,
"totalPages": 1
}
}Create a new folder under the caller's tree
Creates a folder at the root (parentId omitted) or under an existing folder owned by the caller.
Request bodyapplication/json
- namestringrequired Display name for the new folder. Required. Max 255 chars; must not contain path separators.
- parentIdstring Optional parent folder UUID. Omit to create the folder at the user's Drive root.
Responses
- 201Folder created
- 400Validation failed (blank name)
- 401Missing or invalid Bearer token
- 404parentId is unknown, owned by a different user, or refers to a FILE
- 409A sibling with the same name already exists in the destination
const res = await fetch("https://filemv.com/api/files/folders", {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.FILEMV_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
"name": "Holiday 2025",
"parentId": "2b3c4d5e-6f70-4812-9a3b-4c5d6e7f8091"
}),
});
const data = await res.json();{
"commentsCount": 1,
"contentKind": "FILE",
"deletedAt": "2026-10-04T09:30:00Z",
"downloadCount": 1,
"id": "3f9c1e2a-7b4d-4c1e-9a2f-0d8e6b1c5a77",
"likesCount": 1,
"mimeType": "string",
"name": "vacation.jpg",
"parentId": "3f9c1e2a-7b4d-4c1e-9a2f-0d8e6b1c5a77",
"previewUrl": "https://filemv.com/…",
"shareMode": "string",
"shareToken": "Qm4t7Xz2Lp9Kd3Wf8Rb1Nc",
"size": 1048576,
"type": "file",
"uploadedAt": "2026-10-04T09:30:00Z"
}This endpoint changes data — run it from your own environment with the sample above.
Move several items to the same destination in one request
Always returns 200 on a well-formed request; per-item outcomes are partitioned into 'moved' and 'failed'. Malformed input (empty ids, no destination) still returns 400.
Request bodyapplication/json
- idsstring[]required UUIDs of the file-system items to move. Must contain at least one entry.
- moveToRootboolean Move every item to the user's Drive root. Mutually exclusive with parentId.
- parentIdstring Destination parent folder UUID. Mutually exclusive with moveToRoot.
Responses
- 200Per-item success / failure breakdown
- 400Validation failed — empty ids, or neither parentId nor moveToRoot supplied
- 401Missing or invalid Bearer token
const res = await fetch("https://filemv.com/api/files/move", {
method: "PATCH",
headers: {
"Authorization": `Bearer ${process.env.FILEMV_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
"ids": [
"6f1e2d3c-4b5a-4987-8a6b-5c4d3e2f1a0b",
"9a8b7c6d-5e4f-4321-9876-0fedcba98765"
],
"parentId": "2b3c4d5e-6f70-4812-9a3b-4c5d6e7f8091"
}),
});
const data = await res.json();{
"failed": [
{
"id": "3f9c1e2a-7b4d-4c1e-9a2f-0d8e6b1c5a77",
"message": "string",
"status": 1
}
],
"moved": [
{
"commentsCount": 1,
"contentKind": "FILE",
"deletedAt": "2026-10-04T09:30:00Z",
"downloadCount": 1,
"id": "3f9c1e2a-7b4d-4c1e-9a2f-0d8e6b1c5a77",
"likesCount": 1,
"mimeType": "string",
"name": "vacation.jpg",
"parentId": "3f9c1e2a-7b4d-4c1e-9a2f-0d8e6b1c5a77",
"previewUrl": "https://filemv.com/…",
"shareMode": "string",
"shareToken": "Qm4t7Xz2Lp9Kd3Wf8Rb1Nc",
"size": 1048576,
"type": "file",
"uploadedAt": "2026-10-04T09:30:00Z"
}
]
}This endpoint changes data — run it from your own environment with the sample above.
List soft-deleted files owned by the caller
Newest-trashed-first. When a folder is trashed each descendant file lands here as its own row. Folder rows are listed only with includeFolders=true , and then only the folders trashed directly (a sub-folder trashed together with its parent comes back with the parent's restore).
Parameters
| Name | In | Type | Description |
|---|---|---|---|
| includeFolders | query | boolean | Also list the folders the caller trashed directly (type `folder`, `size` null). Default `false`: files only. |
Responses
- 200Trashed rows, newest-trashed-first
- 401Missing or invalid Bearer token
const res = await fetch("https://filemv.com/api/files/trash", {
method: "GET",
headers: {
"Authorization": `Bearer ${process.env.FILEMV_API_KEY}`,
},
});
const data = await res.json();[
{
"commentsCount": 1,
"contentKind": "FILE",
"deletedAt": "2026-10-04T09:30:00Z",
"downloadCount": 1,
"id": "3f9c1e2a-7b4d-4c1e-9a2f-0d8e6b1c5a77",
"likesCount": 1,
"mimeType": "string",
"name": "vacation.jpg",
"parentId": "3f9c1e2a-7b4d-4c1e-9a2f-0d8e6b1c5a77",
"previewUrl": "https://filemv.com/…",
"shareMode": "string",
"shareToken": "Qm4t7Xz2Lp9Kd3Wf8Rb1Nc",
"size": 1048576,
"type": "file",
"uploadedAt": "2026-10-04T09:30:00Z"
}
]Move and/or rename a file-system item
Body must supply at least one mutation — a move target (parentId or moveToRoot=true) or a non-null name. Move and rename can be combined in a single PATCH and are applied atomically.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
| id required | path | string | UUID of the item to update |
Request bodyapplication/json
- moveToRootboolean Move the item to the user's Drive root. Mutually exclusive with parentId.
- namestring New display name for the item. Max 255 chars; must not contain path separators. Server-side sanitisation additionally trims whitespace and strips ASCII control characters.
- parentIdstring Destination parent folder UUID. Mutually exclusive with moveToRoot.
Responses
- 200The updated item
- 400Validation failed (no mutation supplied, name too long)
- 401Missing or invalid Bearer token
- 404Item or destination parent is unknown / owned by another user
- 409Name collision in the destination, or move would create a folder cycle
const res = await fetch("https://filemv.com/api/files/6f1e2d3c-4b5a-4987-8a6b-5c4d3e2f1a0b", {
method: "PATCH",
headers: {
"Authorization": `Bearer ${process.env.FILEMV_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
"name": "report-final.pdf",
"parentId": "2b3c4d5e-6f70-4812-9a3b-4c5d6e7f8091"
}),
});
const data = await res.json();{
"commentsCount": 1,
"contentKind": "FILE",
"deletedAt": "2026-10-04T09:30:00Z",
"downloadCount": 1,
"id": "3f9c1e2a-7b4d-4c1e-9a2f-0d8e6b1c5a77",
"likesCount": 1,
"mimeType": "string",
"name": "vacation.jpg",
"parentId": "3f9c1e2a-7b4d-4c1e-9a2f-0d8e6b1c5a77",
"previewUrl": "https://filemv.com/…",
"shareMode": "string",
"shareToken": "Qm4t7Xz2Lp9Kd3Wf8Rb1Nc",
"size": 1048576,
"type": "file",
"uploadedAt": "2026-10-04T09:30:00Z"
}This endpoint changes data — run it from your own environment with the sample above.
Soft-delete an item (move it to Trash)
Folders are soft-deleted along with every descendant. Items can be restored via POST /{id}/restore until the TrashCleanupService sweeps them after the retention window.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
| id required | path | string | UUID of the item to soft-delete |
Responses
- 204Item soft-deleted
- 401Missing or invalid Bearer token
- 404Item is unknown or owned by a different user
const res = await fetch("https://filemv.com/api/files/6f1e2d3c-4b5a-4987-8a6b-5c4d3e2f1a0b", {
method: "DELETE",
headers: {
"Authorization": `Bearer ${process.env.FILEMV_API_KEY}`,
},
});
console.log(res.status); // 204No response body.
This endpoint changes data — run it from your own environment with the sample above.
Return the ancestor chain of an item
Used by the Drive UI to render breadcrumbs when a user deep-links into a folder. The returned list is ordered root-first and ends with the item itself.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
| id required | path | string | UUID of the item whose ancestors should be returned |
Responses
- 200Ordered ancestor list, ending with the item itself
- 401Missing or invalid Bearer token
- 404Item is unknown or owned by a different user
const res = await fetch("https://filemv.com/api/files/6f1e2d3c-4b5a-4987-8a6b-5c4d3e2f1a0b/path", {
method: "GET",
headers: {
"Authorization": `Bearer ${process.env.FILEMV_API_KEY}`,
},
});
const data = await res.json();[
{
"commentsCount": 1,
"contentKind": "FILE",
"deletedAt": "2026-10-04T09:30:00Z",
"downloadCount": 1,
"id": "3f9c1e2a-7b4d-4c1e-9a2f-0d8e6b1c5a77",
"likesCount": 1,
"mimeType": "string",
"name": "vacation.jpg",
"parentId": "3f9c1e2a-7b4d-4c1e-9a2f-0d8e6b1c5a77",
"previewUrl": "https://filemv.com/…",
"shareMode": "string",
"shareToken": "Qm4t7Xz2Lp9Kd3Wf8Rb1Nc",
"size": 1048576,
"type": "file",
"uploadedAt": "2026-10-04T09:30:00Z"
}
]This call needs parameters — run it from your own environment with the sample above.
Permanently delete a soft-deleted item
Deletes the stored file and its metadata for good. Idempotent — calling on an already-purged id still returns 204. The item must be in Trash first; calling on an alive row returns 409.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
| id required | path | string | UUID of the soft-deleted item to permanently delete |
Responses
- 204Item permanently deleted (or already purged)
- 401Missing or invalid Bearer token
- 404Item is unknown or owned by a different user
- 409Item exists but is not soft-deleted — caller must soft-delete first
const res = await fetch("https://filemv.com/api/files/6f1e2d3c-4b5a-4987-8a6b-5c4d3e2f1a0b/permanent", {
method: "DELETE",
headers: {
"Authorization": `Bearer ${process.env.FILEMV_API_KEY}`,
},
});
console.log(res.status); // 204No response body.
This endpoint changes data — run it from your own environment with the sample above.
Restore a soft-deleted item from Trash
If the original parent folder is itself in Trash the item is restored to the Drive root. Restoring a folder also restores every descendant that was trashed together with it (same delete); descendants trashed separately before the folder stay in Trash.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
| id required | path | string | UUID of the soft-deleted item to restore |
Responses
- 200The restored item
- 401Missing or invalid Bearer token
- 404Item is unknown, owned by a different user, or not in Trash
- 409A name collision in the restore destination blocks the restore
const res = await fetch("https://filemv.com/api/files/6f1e2d3c-4b5a-4987-8a6b-5c4d3e2f1a0b/restore", {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.FILEMV_API_KEY}`,
},
});
const data = await res.json();{
"commentsCount": 1,
"contentKind": "FILE",
"deletedAt": "2026-10-04T09:30:00Z",
"downloadCount": 1,
"id": "3f9c1e2a-7b4d-4c1e-9a2f-0d8e6b1c5a77",
"likesCount": 1,
"mimeType": "string",
"name": "vacation.jpg",
"parentId": "3f9c1e2a-7b4d-4c1e-9a2f-0d8e6b1c5a77",
"previewUrl": "https://filemv.com/…",
"shareMode": "string",
"shareToken": "Qm4t7Xz2Lp9Kd3Wf8Rb1Nc",
"size": 1048576,
"type": "file",
"uploadedAt": "2026-10-04T09:30:00Z"
}This endpoint changes data — run it from your own environment with the sample above.
Get a signed download URL for a caller-owned file
Inline=true returns a URL with Content-Disposition: inline (browser preview); inline=false returns an attachment URL. A file the caller owns that is quarantined (under takedown) is still served to its owner, from the quarantine store.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
| fileId required | path | string | |
| inline | query | boolean | true to request an inline-disposition URL (preview); false (default) yields an attachment URL. |
Responses
- 200Body carries the signed download URL
- 401Missing or invalid Bearer token
- 403Caller does not own the file
- 404File not found
- 410file_purged — the file's bytes were purged (the row is kept); no URL is minted
const res = await fetch("https://filemv.com/api/files/6f1e2d3c-4b5a-4987-8a6b-5c4d3e2f1a0b/download-url", {
method: "GET",
headers: {
"Authorization": `Bearer ${process.env.FILEMV_API_KEY}`,
},
});
const data = await res.json();{
"downloadUrl": "https://filemv.com/…"
}This call needs parameters — run it from your own environment with the sample above.
Create a one-shot ZIP for a selection of caller-owned files
Verifies ownership server-side for every id in the request and builds the archive. Returns a one-shot download URL for the ZIP on success.
Request bodyapplication/json
- fileIdsstring[]required Caller-owned file UUIDs to bundle into the ZIP. Bounded [1, 200] — both limits surface as 400.
Responses
- 200Manifest accepted — body carries the archive download URL
- 400Validation failed (empty / oversize fileIds list)
- 401Missing or invalid Bearer token
- 403One or more ids are unknown, purged, or owned by a different user
- 413Selection exceeds the per-archive size cap (storage.archive.max-bytes) — the uniform error envelope with code=archive_size_exceeded, plus actual_bytes / cap_bytes
- 502Storage worker rejected the archive request for a reason other than the size cap
const res = await fetch("https://filemv.com/api/files/archive", {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.FILEMV_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
"fileIds": [
"6f1e2d3c-4b5a-4987-8a6b-5c4d3e2f1a0b",
"9a8b7c6d-5e4f-4321-9876-0fedcba98765"
]
}),
});
const data = await res.json();{}This endpoint changes data — run it from your own environment with the sample above.
Sharing
Toggle PUBLIC / PRIVATE visibility for a batch of items
Folder ids are accepted and expand to their direct file children (depth-1). Per-item failures are surfaced inline so a partially-failed batch never aborts the whole call. Returns 200 when every item succeeds and 207 Multi-Status when at least one item fails. Inline per-id codes: 404 unknown, 409 trashed-or-anonymous, 410 purged.
Request bodyapplication/json
- idsstring[]required Mix of file ids and folder ids to retarget. Folder ids cascade to direct file children only (depth-1). Bounded [1, 200] — caps the per-request work (one SELECT per id in FileVisibilityService).
- modestringrequired Target visibility. Restricted to PUBLIC (mint/keep share token + list site-wide) or PRIVATE (clear token + unlist). SHARED goes through the per-file PATCH. PRIVATE · PUBLIC · SHARED
Responses
- 200Every item succeeded — `updated` populated, `failed` empty
- 207Partial failure — `failed` is non-empty (per-id 404 / 409 / 410); `updated` holds the items that did succeed
- 400Validation failed (empty ids, invalid mode)
- 401Missing or invalid Bearer token
const res = await fetch("https://filemv.com/api/files/visibility", {
method: "PATCH",
headers: {
"Authorization": `Bearer ${process.env.FILEMV_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
"ids": [
"6f1e2d3c-4b5a-4987-8a6b-5c4d3e2f1a0b",
"9a8b7c6d-5e4f-4321-9876-0fedcba98765"
],
"mode": "PUBLIC"
}),
});
const data = await res.json();{
"failed": [
{
"id": "3f9c1e2a-7b4d-4c1e-9a2f-0d8e6b1c5a77",
"message": "string",
"status": 1
}
],
"updated": [
{
"commentsCount": 1,
"contentKind": "FILE",
"deletedAt": "2026-10-04T09:30:00Z",
"downloadCount": 1,
"id": "3f9c1e2a-7b4d-4c1e-9a2f-0d8e6b1c5a77",
"likesCount": 1,
"mimeType": "string",
"name": "vacation.jpg",
"parentId": "3f9c1e2a-7b4d-4c1e-9a2f-0d8e6b1c5a77",
"previewUrl": "https://filemv.com/…",
"shareMode": "string",
"shareToken": "Qm4t7Xz2Lp9Kd3Wf8Rb1Nc",
"size": 1048576,
"type": "file",
"uploadedAt": "2026-10-04T09:30:00Z"
}
]
}This endpoint changes data — run it from your own environment with the sample above.
Public
API keys
List API keys
Lists the caller's API keys, newest first, revoked ones included. Rows carry the display prefix and lifecycle metadata, never the raw key. lastUsedAt is updated at most once a minute.
Responses
- 200The caller's keys
- 401Not authenticated (`authentication_required`)
const res = await fetch("https://filemv.com/api/me/api-keys", {
method: "GET",
headers: {
"Authorization": `Bearer ${process.env.FILEMV_API_KEY}`,
},
});
const data = await res.json();No response body.
Create an API key
Mints a new personal API key. The raw key is returned in rawKey exactly once: store it immediately, it can never be retrieved again (only a keyed hash is kept). The body and its label are optional.
Request bodyapplication/json
- labelstring Optional free-text label (≤ 80 chars) to tell keys apart. Blank means none.
Responses
- 201Key created — `rawKey` is shown only in this response
- 400`label` longer than 80 characters (`validation_failed`)
- 401Not authenticated (`authentication_required`)
- 409The user already has 10 active keys (`api_key_limit_reached`)
const res = await fetch("https://filemv.com/api/me/api-keys", {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.FILEMV_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
"label": "CI server"
}),
});
const data = await res.json();No response body.
This endpoint changes data — run it from your own environment with the sample above.
Revoke an API key
Revokes one of the caller's keys. Requests carrying it are rejected from then on. Revoking an already revoked key is a no-op.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
| id required | path | string |
Responses
- 204Revoked
- 401Not authenticated (`authentication_required`)
- 404No such key for the caller (`api_key_not_found`)
const res = await fetch("https://filemv.com/api/me/api-keys/6f1e2d3c-4b5a-4987-8a6b-5c4d3e2f1a0b", {
method: "DELETE",
headers: {
"Authorization": `Bearer ${process.env.FILEMV_API_KEY}`,
},
});
console.log(res.status); // 204No response body.
This endpoint changes data — run it from your own environment with the sample above.
Webhooks
List all registered webhooks (secret masked)
Returns all enabled and disabled webhook endpoints for the calling user. The signingSecretMasked field exposes only the last 4 characters of the 64-char secret.
Responses
- 200Webhook list (may be empty)
- 401Missing or invalid authentication
const res = await fetch("https://filemv.com/api/users/me/webhooks", {
method: "GET",
headers: {
"Authorization": `Bearer ${process.env.FILEMV_API_KEY}`,
},
});
const data = await res.json();[
{
"consecutiveFailures": 1,
"createdAt": "2026-10-04T09:30:00Z",
"disabledReason": "string",
"enabled": true,
"id": "3f9c1e2a-7b4d-4c1e-9a2f-0d8e6b1c5a77",
"signingSecretMasked": "string",
"url": "https://filemv.com/…"
}
]Register a new webhook endpoint
Creates a new webhook endpoint and returns the full HMAC signing secret once. Available on every plan. The URL must pass SSRF validation (400 webhook_url_blocked if not). The per-user limit is enforced (409 webhook_limit_reached if exceeded).
Request bodyapplication/json
- urlstringrequired HTTPS delivery endpoint URL. Must pass SSRF validation.
Responses
- 201Webhook registered — response body contains the one-time full signing secret
- 400Validation failed (blank URL, URL too long, or URL blocked by SSRF filter)
- 401Missing or invalid authentication
- 409Per-user webhook limit reached (webhook_limit_reached)
- 429Too many webhook mutations from this user — Retry-After header indicates the backoff window
const res = await fetch("https://filemv.com/api/users/me/webhooks", {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.FILEMV_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
"url": "https://example.com/webhook"
}),
});
const data = await res.json();{
"createdAt": "2026-10-04T09:30:00Z",
"enabled": true,
"id": "1c2d3e4f-5a6b-4c7d-8e9f-0a1b2c3d4e5f",
"signingSecret": "4f9c2b7e1a6d3c8f0b5e2a9d7c4f1e8b3a6d9c2f5e8b1a4d7c0f3e6b9a2d5c8f",
"url": "https://example.com/webhook"
}This endpoint changes data — run it from your own environment with the sample above.
Partially update a webhook (url and/or enabled state)
Both fields are optional — omitting a field is a no-op for that attribute. A URL change re-runs SSRF validation. Returns 404 if the webhook does not exist or belongs to another user.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
| id required | path | string |
Request bodyapplication/json
- enabledboolean | null New enabled state. True re-enables. False disables. Null leaves the state unchanged.
- urlstring | null New delivery endpoint URL. Must pass SSRF validation. Null leaves the URL unchanged.
Responses
- 200Updated webhook (secret masked)
- 400Validation failed (URL too long or URL blocked by SSRF filter)
- 401Missing or invalid authentication
- 404Webhook not found or belongs to another user
const res = await fetch("https://filemv.com/api/users/me/webhooks/6f1e2d3c-4b5a-4987-8a6b-5c4d3e2f1a0b", {
method: "PATCH",
headers: {
"Authorization": `Bearer ${process.env.FILEMV_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
"url": "https://example.com/webhook"
}),
});
const data = await res.json();{
"consecutiveFailures": 1,
"createdAt": "2026-10-04T09:30:00Z",
"disabledReason": "string",
"enabled": true,
"id": "3f9c1e2a-7b4d-4c1e-9a2f-0d8e6b1c5a77",
"signingSecretMasked": "string",
"url": "https://filemv.com/…"
}This endpoint changes data — run it from your own environment with the sample above.
Delete a webhook and its delivery history
Purges all webhook_delivery rows first, then removes the webhook row. Returns 404 if the webhook does not exist or belongs to another user.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
| id required | path | string |
Responses
- 204Webhook deleted
- 401Missing or invalid authentication
- 404Webhook not found or belongs to another user
const res = await fetch("https://filemv.com/api/users/me/webhooks/6f1e2d3c-4b5a-4987-8a6b-5c4d3e2f1a0b", {
method: "DELETE",
headers: {
"Authorization": `Bearer ${process.env.FILEMV_API_KEY}`,
},
});
console.log(res.status); // 204No response body.
This endpoint changes data — run it from your own environment with the sample above.
Rotate the HMAC signing secret for a webhook
Generates a new 64-character HMAC signing secret. The old secret is immediately invalidated — any in-flight delivery signed with the old secret will fail verification. The full new secret is returned once; store it securely. Returns 404 if the webhook does not exist or belongs to another user.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
| id required | path | string |
Responses
- 200New signing secret (full, one-time)
- 401Missing or invalid authentication
- 404Webhook not found or belongs to another user
- 429Too many webhook mutations from this user — Retry-After header indicates the backoff window
const res = await fetch("https://filemv.com/api/users/me/webhooks/6f1e2d3c-4b5a-4987-8a6b-5c4d3e2f1a0b/rotate-secret", {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.FILEMV_API_KEY}`,
},
});
const data = await res.json();{
"createdAt": "2026-10-04T09:30:00Z",
"enabled": true,
"id": "1c2d3e4f-5a6b-4c7d-8e9f-0a1b2c3d4e5f",
"signingSecret": "4f9c2b7e1a6d3c8f0b5e2a9d7c4f1e8b3a6d9c2f5e8b1a4d7c0f3e6b9a2d5c8f",
"url": "https://example.com/webhook"
}This endpoint changes data — run it from your own environment with the sample above.
Send a one-shot test webhook delivery
Synchronous test call — no outbox row is persisted and no retry is scheduled. Returns the HTTP outcome immediately. Returns 404 if the webhook does not exist or belongs to another user.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
| id required | path | string |
Responses
- 200Test delivery outcome (check delivered field)
- 401Missing or invalid authentication
- 404Webhook not found or belongs to another user
- 429Too many webhook mutations from this user — Retry-After header indicates the backoff window
const res = await fetch("https://filemv.com/api/users/me/webhooks/6f1e2d3c-4b5a-4987-8a6b-5c4d3e2f1a0b/test", {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.FILEMV_API_KEY}`,
},
});
const data = await res.json();{
"delivered": true,
"error": "string",
"statusCode": 1
}This endpoint changes data — run it from your own environment with the sample above.
Webhooks — delivery and signatures
Register an HTTPS endpoint with POST /api/users/me/webhooks. Events arrive as signed JSON POST requests. Webhooks are available on every plan. Each user can have at most 5 webhooks. A URL pointing to a private, loopback or internal address is rejected with 400 webhook_url_blocked.
Verifying the signature
Every delivery carries an X-FileMV-Signature: t=<unix>,v1=<hex> header. v1 = HMAC-SHA256(key, "<t>." + raw body), in lowercase hex. The key is the 64-character signingSecret you got at registration, used as text (its UTF-8 bytes). Do not hex-decode it. Compute over the raw body, before any JSON parsing. Reject a request whose t is more than 5 minutes old. The server sends a fresh t on every delivery attempt.
import crypto from "node:crypto";
// rawBody: the request body exactly as received (Buffer), before JSON.parse
export function verifyFileMvSignature(rawBody, header, secret, toleranceSec = 300) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const t = Number(parts.t);
if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
const expected = crypto
.createHmac("sha256", Buffer.from(secret, "utf8"))
.update(`${t}.`)
.update(rawBody)
.digest("hex");
const a = Buffer.from(expected, "utf8");
const b = Buffer.from(parts.v1 ?? "", "utf8");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}Events
share.download— a recipient notified by email downloadedshare.download.anonymous— an anonymous download from a sharecollect.uploaded— someone uploaded to your collect linkproofing.submitted— the client submitted their selectionwebhook.test— the test delivery
Delivery
Only a 2xx response counts as success; redirects are not followed. Timeouts: 5 s to connect, 10 s to respond. On failure there are at most 6 attempts: immediately, then after 1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours. If 10 deliveries in a row fail on every attempt, the webhook is disabled (disabledReason: auto_disabled_failures) and you get an email. Deduplicate on the X-FileMV-Delivery header.
Sample body
{
"id": "evt_5b0f2c1e-8d7a-4e3b-9c6f-1a2b3c4d5e6f",
"type": "share.download",
"created": 1791105000,
"data": {
"share_token": "Qm4t7Xz2Lp9Kd3Wf8Rb1Nc",
"share_name": "Holiday 2025",
"recipient_email_domain": "example.com",
"occurred_at": "2026-10-04T09:30:00Z"
}
}Changelog
Passwords: at most 72 bytes
Share passwords (POST /api/files/{fileId}/share, POST /api/folders/{folderId}/share, PATCH …/share/password, POST /api/files/share-multi) and paste passwords (POST /api/pastes) are now limited to 72 bytes of UTF-8, down from 128 characters. That is 36 accented letters or 18 emoji. A longer password gets 400 password_too_long; some of these used to return a 500.
Webhooks on every plan
The /api/users/me/webhooks endpoints (create, enable, test delivery, secret rotation) now work on the free plan too, and delivery no longer depends on the plan: the 403 webhook_paid_required response is gone.
Custom links on every plan
PATCH /api/files/{fileId}/share/slug, PATCH /api/folders/{folderId}/share/slug and GET /api/share/slug-available now work on the free plan too: the 403 vanity_slug_paid_required response is gone. On these endpoints a 403 now only means the caller does not own the file or folder.
publicListing on the single-file anonymous upload too
POST /api/public/uploads now accepts the optional publicListing field too, with the same meaning as on the batch endpoint: when true the share is PUBLIC (listed) and may be indexed by search engines; absent or false keeps it SHARED.
Listed shares at upload time: publicListing
New optional publicListing field on POST /api/public/uploads/batch and (together with autoShare) on POST /api/uploads. When true the share is PUBLIC (listed) and may be indexed by search engines; absent or false keeps the previous behaviour (SHARED). The GET /api/public/share/{token} response has a new indexable field telling whether the share page may be indexed.
Public API reference published
Documentation for the upload, file, sharing, API-key and webhook endpoints, with JavaScript, PHP and C# samples. Every /api response now carries X-RateLimit-Limit, -Remaining and -Reset headers.