Host: https://api.pixeldrive.dev. Shared public projects need no auth to list. A Bearer API key lists a public project even when sharing is off, and is required for private projects (tokened URLs). Write keys can POST /v1/upload. IDs in this API are publicIds, not dashboard ids.
| Method | Path | Returns |
|---|---|---|
| GET | /v1/projects/{publicId} | Project root: files in the root, plus child folders |
| GET | /v1/folders/{publicId} | One folder: files in that folder, plus its child folders |
| GET | /v1/files/{publicId} | One file. Requires Authorization: Bearer. Private files return a tokened url + expiresAt. |
| GET | /v1/files?path= | One file by path (selects/hero.jpg). Optional folder publicId and ttl. Read key. |
| DELETE | /v1/files/{publicId} | Delete a file and its retained versions. Write key. 204. |
| POST | /v1/upload | Start an upload, or replace with replace. Write key. Server picks put vs multipart. |
| POST | /v1/upload/{publicId}/complete | Verify the object and return the file JSON. |
| POST | /v1/upload/{publicId}/abort | Cancel an in-flight upload or replace. 204. |
| GET | /v1/project | The key’s project listing (same shape as GET /v1/projects/{id}). |
Legacy /api/v1/… paths still work. Prefer /v1/….
| Param | Default | Effect |
|---|---|---|
| limit | 100 | Files to return in this page, 1–500, newest first. Omitted uses the default. Child folders are always included (up to 100) and do not count against limit. |
| cursor | omitted | Opaque token from the previous page’s nextCursor. Omit on the first request. |
| tag | omitted | Tag as shown in the app (slug). Spaces become hyphens: la jolla is la-jolla. On a project listing this is the whole project, not just the root. Unknown slug → empty files (200). Invalid slug → 400. |
| recursive | omitted | Set to true to flatten a small subtree in one response. Nested files include folderPath (names joined with /). Child folders stay the immediate children of the requested container. Cannot be combined with cursor. |
| ttl | 900 | Private files only, on GET /v1/files/{id}. Link lifetime in seconds, 1–86400 (default 15 minutes). |
page through a containerlet cursor = null;
const files = [];
do {
const url = new URL(
"https://api.pixeldrive.dev/v1/projects/YOUR_PUBLIC_ID",
);
url.searchParams.set("limit", "200");
if (cursor) url.searchParams.set("cursor", cursor);
const page = await fetch(url).then((res) => res.json());
files.push(...page.files);
cursor = page.nextCursor;
} while (cursor);recursiveconst res = await fetch(
"https://api.pixeldrive.dev/v1/projects/YOUR_PUBLIC_ID?recursive=true",
);
const project = await res.json();
// Nested files include folderPath, e.g. "selects/outtakes"200 application/json{
"name": "summer-lookbook",
"files": [
{
"publicId": "6dec2e93a1b2c3d4e5f6a7b8c9d0e1f2",
"name": "eng-448.jpg",
"url": "https://cdn.pixeldrive.dev/{projectId}/{publicId}/v1",
"stableUrl": "https://cdn.pixeldrive.dev/f/{publicId}",
"optimizable": true,
"src": "https://cdn.pixeldrive.dev/f/{publicId}?w=1280",
"srcset": "https://cdn.pixeldrive.dev/f/{publicId}?w=320 320w, …",
"contentType": "image/jpeg",
"kind": "image",
"size": 4821933,
"width": 5695,
"height": 3797,
"blurhash": "LGF5]+Yk^6#M@-5c,1J5",
"tags": ["favorites"],
"uploadedAt": 1734567890123
}
],
"folders": [
{ "publicId": "b86a4c21d0e1f2a3b4c5d6e7f8a9b0c1", "name": "selects" }
],
"nextCursor": null
}| Field | Type | Notes |
|---|---|---|
| name | string | Project or folder name |
| files[].publicId | string | Unguessable file id |
| files[].name | string | Original filename |
| files[].url | string | Public: immutable versioned CDN object (cache ~1 year). Private: tokened /f/ URL. |
| files[].stableUrl | string | Public: 302 to the current version; follows replaces. Add ?w= for optimization (Pro). Private: same as url (already tokened). |
| files[].optimizable | boolean | Source is within Cloudflare resize caps (100 MB / 100 MP / 12k px) |
| files[].src | string? | Pro + optimizable: stableUrl?w=1280 |
| files[].srcset | string? | Pro + optimizable: ladder 320–1920 against stableUrl |
| files[].contentType | string | From storage, e.g. image/jpeg |
| files[].kind | image | video | pdf | doc | text | Derived from contentType. See Allowed types. |
| files[].size | number | Bytes |
| files[].width | number? | Client-computed at upload |
| files[].height | number? | Client-computed at upload |
| files[].blurhash | string? | Optional placeholder hash |
| files[].tags | string[] | Slugs on this file, sorted. Same string as ?tag= |
| files[].folderPath | string? | Only when recursive=true and the file is nested |
| files[].uploadedAt | number | Unix ms (_creationTime) |
| files[].expiresAt | number? | Private listings only: Unix ms when the tokened url dies |
| folders[].publicId | string | Use with GET /v1/folders/{id} |
| folders[].name | string | Folder name |
| nextCursor | string | null | Pass as cursor for the next page. null means this was the last page. Always null when recursive=true. |
Create a key in project settings. Shown once. Send it as Authorization: Bearer pd_live_…. One key is scoped to one project. Write can upload; Read can list and mint URLs. Read keys 404 on upload routes. Keys work on public projects even when the public API toggle is off. Private-project keys mint expiring /f/ URLs. Keys keep working after a plan change, under the current file-size and storage caps.
Same allowlist on the dashboard, POST /v1/upload, the SDK, and MCP. kind is derived from MIME — callers never send it. HTML, SVG, and JavaScript are rejected, including on private projects.
| kind | What | Accepts |
|---|---|---|
| image | Photos | image/* except SVG |
| video | Video | video/* |
| application/pdf | ||
| doc | Word | .doc, .docx |
| text | Markdown | text/markdown |
A project can tighten this list and the max file size in Settings → Uploads. It cannot raise the account plan cap. Keys inherit the project’s rules.
Write key required. The key’s project is implicit — do not send projectId. size is required so we choose put (≤ 32 MB) vs multipart (16 MB parts). PUT the bytes to the returned URLs; never POST the file body to this API. Caps and rate limits are in Limits. The project’s Settings → Uploads rules apply here too.
POST /v1/upload{
"name": "selects/hero.jpg",
"contentType": "image/jpeg",
"size": 4821933,
"folder": "optional-folder-publicId",
"tags": ["favorites"],
"replace": "optional-file-publicId",
"upsert": true
}Path segments in name find-or-create folders (max 8). Optional folder is a folder publicId; the path is relative to it. Tags are stored on start and applied only after complete. Optional replace is a file publicId in this project — same complete/abort URLs, folder and tags stay, name becomes the new basename, kind may change. upsert: true replaces the ready file at that path instead of minting a second one (do not send it with replace). Paths are unique per folder; a colliding upload without upsert is 409. GET /v1/files?path= looks up by that path. Optional Idempotency-Key header (24 hours). DELETE /v1/files/{publicId} is 204. Cached public URLs may still serve until TTL.
200 put{
"id": "6dec2e93…",
"name": "hero.jpg",
"folderPath": "selects",
"contentType": "image/jpeg",
"size": 4821933,
"upload": { "mode": "put", "url": "https://…" }
}Multipart: { "mode": "multipart", "partSize": 16777216, "parts": [{ "partNumber": 1, "url": "…" }] }. Complete with part ETags. Complete returns the same JSON as GET /v1/files/{id}. Abort is 204 while the file is still uploading or replacing.
TypeScript: @pixeldrive-dev/sdk (npm i @pixeldrive-dev/sdk). Agents: @pixeldrive-dev/mcp with PIXELDRIVE_API_KEY.
sdkimport { readFile } from "node:fs/promises";
import { Pixeldrive } from "@pixeldrive-dev/sdk";
const pd = new Pixeldrive({ apiKey: process.env.PIXELDRIVE_API_KEY });
const file = await pd.upload({
file: await readFile("./brief.pdf"),
name: "contracts/brief.pdf",
contentType: "application/pdf",
});| Header | Value |
|---|---|
| Content-Type | application/json |
| Cache-Control | public, max-age=60 — or private, no-store when a key is used |
| Access-Control-Allow-Origin | * |
| Status | Body | When |
|---|---|---|
| 400 | { "error": "Invalid project id" } | Empty or nested path |
| 400 | { "error": "Invalid limit" } | limit is not an integer from 1 to 500 |
| 400 | { "error": "Invalid tag" } | tag is empty after normalize (needs a letter or number) |
| 400 | { "error": "recursive cannot be used with cursor" } | Both recursive=true and cursor were sent |
| 400 | { "error": "Invalid cursor" } | cursor is malformed or expired |
| 400 | { "error": "Unknown file type", "code": "invalid" } | MIME is not on the allowlist, or this project disallows that kind |
| 404 | { "error": "Not found", "code": "not_found" } | Unknown id, sharing is off, missing/invalid/read key on a write route, or a second DELETE |
| 401 | { "error": "Unauthorized", "code": "unauthorized" } | Missing or malformed Bearer on upload or DELETE |
| 409 | { "error": "…", "code": "conflict" } | Complete/abort when nothing is in flight, replace while uploading or already replacing, or Idempotency-Key reused with a different body |
| 413 | { "error": "…", "code": "too_large or plan_limit" } | Over the plan file-size or storage cap, or this project's size limit |
| 429 | { "error": "Too many requests" } | API key read limit (300/min) or write starts (60/min) |
| 500 | { "error": "Server misconfigured" } | Missing CDN config |
Private containers look like missing ones. Don’t treat 404 as “this id never existed.”
Upload uses the same account plan as the dashboard. A project can tighten allowed types and max file size below that plan (Settings → Uploads); keys cannot raise it. Rate limits are per key, not per account.
| What | Cap |
|---|---|
| Write starts | 60 / minute per key |
| Reads (list + mint URL) | 300 / minute per key |
| Keys per project | 10 |
| File size | 100 GB (Pro) |
| Account storage | 1 TB (Pro) |
| Files and projects | Unlimited (Pro) |
| Single PUT | ≤ 32 MB; larger files use 16 MB multipart parts |
| Allowed types | Photos (image/* except SVG), video, PDF, Word, markdown |
| Folder path in name | 8 segments |
| Idempotency-Key | 24 hours |
| Private link TTL | 15 minutes default, 24 hours max |
| Files per listing request | 100 default, 500 max (newest first) |
| Total files in a container | No cap — follow nextCursor |
| Child folders per container | 100 |
| Files per folder when recursive=true | 500 |
| Folders walked when recursive=true | 100 |
| Listing cache | 60 seconds |
A folder with thousands of files is listed by looping nextCursor. For a large tree, paginate the container, then GET /v1/folders/{id} for each child. recursive=true is for small lookbooks, not a full-library dump.