Files
The files API covers the full lifecycle: upload, retrieve, list, update, copy, replace, download, restore and delete.
The file object
{
"id": "file_8f2b…",
"filename": "avatar.jpg",
"path": "users/avatars",
"contentType": "image/jpeg",
"size": 245123,
"status": "uploaded",
"version": 1,
"metadata": { "userId": "u_123" },
"createdAt": "2026-01-02T10:00:00.000Z",
"updatedAt": "2026-01-02T10:00:00.000Z"
}
path is the logical folder path (null for root-level files). metadata
is custom key/value data, or null. version increments each time content
is replaced. uploadedAt and checksum appear once the file is uploaded.
deletedAt appears on deleted files.
Upload a file
Uploads are a three-step handshake so file bytes never flow through APULODI's application servers.
1 — Request an upload URL
POST /v1/files/upload
{
"filename": "avatar.jpg",
"contentType": "image/jpeg",
"size": 245123,
"path": "users/avatars",
"metadata": { "userId": "u_123" }
}
curl -X POST https://app.apulodi.dev/v1/files/upload \
-H "Authorization: Bearer $APULODI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"filename": "avatar.jpg",
"contentType": "image/jpeg",
"size": 245123,
"path": "users/avatars",
"metadata": { "userId": "u_123" }
}'
Returns 201 Created with the PENDING file and a short-lived presigned URL:
{
"file": {
"id": "file_8f2b…",
"status": "pending",
"filename": "avatar.jpg",
"contentType": "image/jpeg",
"size": 245123,
"path": "users/avatars",
"version": 1,
"metadata": { "userId": "u_123" },
"createdAt": "2026-01-02T10:00:00.000Z",
"updatedAt": "2026-01-02T10:00:00.000Z"
},
"upload": {
"url": "https://…",
"method": "PUT",
"headers": { "content-type": "image/jpeg" },
"expiresAt": "2026-01-02T10:15:00.000Z"
}
}
2 — PUT the bytes to storage, directly
curl -X PUT "$UPLOAD_URL" \
-H "Content-Type: image/jpeg" \
--data-binary @avatar.jpg
The presigned URL expires quickly — upload soon, or request a fresh one from
a new upload call. Reuse filename + size with the same Idempotency-Key
to retry safely.
3 — Complete the upload
POST /v1/files/:id/complete
curl -X POST https://app.apulodi.dev/v1/files/file_8f2b…/complete \
-H "Authorization: Bearer $APULODI_API_KEY"
APULODI HEADs the object, verifies its real size and content type, then
transitions the file to uploaded:
{
"data": {
"id": "file_8f2b…",
"status": "uploaded",
"uploadedAt": "2026-01-02T10:00:02.000Z",
"checksum": "8a3f…",
"version": 1
}
}
Retrieve a file
GET /v1/files/:id
curl https://app.apulodi.dev/v1/files/file_8f2b… \
-H "Authorization: Bearer $APULODI_API_KEY"
Returns the file object under data.
List files
GET /v1/files?limit=20&cursor=…&search=…&path=…&status=…&contentType=…&sortBy=…&order=…
All parameters are optional:
| Param | Type | Default | Notes |
|---|---|---|---|
limit | int | 20 | 1–100 |
cursor | string | — | opaque pagination cursor |
search | string | — | substring match on filename |
path | string | — | exact logical folder path |
status | string | — | pending / uploaded / failed / deleted |
contentType | string | — | exact MIME type |
sortBy | string | createdAt | createdAt / updatedAt / filename / size |
order | string | desc | asc / desc |
curl "https://app.apulodi.dev/v1/files?path=users/avatars&sortBy=size&order=desc&limit=5" \
-H "Authorization: Bearer $APULODI_API_KEY"
{
"data": [ { "id": "file_8f2b…", "filename": "avatar.jpg", "status": "uploaded" } ],
"pagination": { "hasMore": true, "nextCursor": "…" }
}
See Pagination for the full walkthrough.
Update a file
Rename, move to another logical path, and/or replace custom metadata:
PATCH /v1/files/:id
{
"filename": "new-name.jpg",
"path": "products/images",
"metadata": { "sku": "ABC-123" }
}
curl -X PATCH https://app.apulodi.dev/v1/files/file_8f2b… \
-H "Authorization: Bearer $APULODI_API_KEY" \
-H "Content-Type: application/json" \
-d '{"filename":"new-name.jpg","path":"products/images","metadata":{"sku":"ABC-123"}}'
Renames and moves are logical only — the storage key generated by APULODI never changes, so no bytes are moved. Any field is optional, but at least one must be present. Returns the updated file object.
CDN delivery (visibility)
Every file is private by default: downloads use short-lived,
authenticated presigned URLs. To serve a file publicly — embedded in
apps, <img> tags, CSS, or shared links — flip its visibility:
PATCH /v1/files/:id
{ "visibility": "PUBLIC" }
curl -X PATCH https://app.apulodi.dev/v1/files/file_8f2b… \
-H "Authorization: Bearer $APULODI_API_KEY" \
-H "Content-Type: application/json" \
-d '{"visibility":"PUBLIC"}'
A public file (once uploaded) gains a publicUrl:
{
"visibility": "public",
"publicUrl": "https://cdn.apulodi.dev/organizations/…/files/file_8f2b…/v1"
}
The URL is:
- Unguessable — the key is generated server-side from identities; it never contains user-controlled segments
- Immutable per version — replacing content writes a new versioned key and therefore a new URL, so old URLs keep serving the old version
- Edge-cached for a year — safe to embed anywhere for that version
Revert with {"visibility": "PRIVATE"}: the public URL stops being
served to API consumers, cached copies expire with the TTL, and
downloads fall back to authenticated presigned URLs.
Transformed variants inherit their file's visibility: a variant of a public file gets the same treatment on its variant download URL.
Copy a file
Server-side copy to the same or another folder — no bytes flow through the API:
POST /v1/files/:id/copy
{ "path": "backups", "filename": "avatar-copy.jpg" }
curl -X POST https://app.apulodi.dev/v1/files/file_8f2b…/copy \
-H "Authorization: Bearer $APULODI_API_KEY" \
-H "Content-Type: application/json" \
-d '{"path":"backups","filename":"avatar-copy.jpg"}'
Returns 201 Created with a new file object sharing the same bytes.
Download URL
POST /v1/files/:id/download-url
{ "expiresInSeconds": 300 }
expiresInSeconds is optional (60–86400). Returns a presigned GET URL your
users fetch directly from storage:
{
"url": "https://…",
"expiresAt": "2026-01-02T10:05:00.000Z"
}
curl -X POST https://app.apulodi.dev/v1/files/file_8f2b…/download-url \
-H "Authorization: Bearer $APULODI_API_KEY" \
-H "Content-Type: application/json" \
-d '{"expiresInSeconds":300}'
Replace content (versioning)
Replacing a file's content keeps the same file id, bumps version, and
preserves the previous object in storage.
Step 1 — initiate:
POST /v1/files/:id/replace
{ "contentType": "image/jpeg", "size": 212000 }
curl -X POST https://app.apulodi.dev/v1/files/file_8f2b…/replace \
-H "Authorization: Bearer $APULODI_API_KEY" \
-H "Content-Type: application/json" \
-d '{"contentType":"image/jpeg","size":212000}'
Returns { "file": …, "upload": { "url": …, "method": "PUT", "headers": … } }.
Step 2 — PUT the new bytes to upload.url.
Step 3 — finalize:
POST /v1/files/:id/replace/complete
curl -X POST https://app.apulodi.dev/v1/files/file_8f2b…/replace/complete \
-H "Authorization: Bearer $APULODI_API_KEY"
Returns the file with version: 2.
Delete a file
Deleting soft-deletes the file: it disappears from listings immediately and
all reads return 404, but the underlying storage object is kept for a
grace window (APULODI_DELETE_GRACE_DAYS, default 7 days) so the file can
be restored. The object is purged by a periodic sweep after the window
elapses (a file.purged event is emitted).
DELETE /v1/files/:id
curl -X DELETE https://app.apulodi.dev/v1/files/file_8f2b… \
-H "Authorization: Bearer $APULODI_API_KEY"
{ "data": { "id": "file_8f2b…", "deleted": true } }
Restore a file
Restores a soft-deleted file while it is still within the delete grace
window (its object still exists). Once the purge sweep has removed the
object, restore fails with 409 OBJECT_NOT_FOUND.
POST /v1/files/:id/restore
curl -X POST https://app.apulodi.dev/v1/files/file_8f2b…/restore \
-H "Authorization: Bearer $APULODI_API_KEY"
Returns the file with status: "uploaded".
Errors
| Status | Code | Why |
|---|---|---|
400 | VALIDATION_ERROR | Invalid body or query parameters |
404 | FILE_NOT_FOUND | No such file in your project (or it was deleted) |
409 | FILE_NOT_PENDING | complete on a file that isn't pending |
409 | FILE_NOT_UPLOADED | Action requires an uploaded file |
409 | REPLACE_IN_PROGRESS | A replacement is already pending for this file |
409 | NO_REPLACE_IN_PROGRESS | replace/complete without an initiated replacement |
413 | FILE_TOO_LARGE | Exceeds the multipart size limit |
422 | NOT_PROCESSABLE | Transform on a non-image (or SVG) file |
Image processing
Image files (jpeg, png, webp, avif, gif, tiff — not SVG) support variants: derived copies produced asynchronously from the original, which is never modified. Identical transformation requests are idempotent — the same params map to the same variant, so retries never duplicate work. Every eligible upload automatically gets a 256px webp thumbnail.
Request a transform
curl -X POST https://app.apulodi.dev/v1/files/file_8f2b…/transform \
-H "Authorization: Bearer $APULODI_API_KEY" \
-H "Content-Type: application/json" \
-d '{"width": 256, "height": 256, "fit": "cover", "format": "webp", "quality": 80}'
Only width or height is required; defaults are fit: "cover",
format: "webp", quality: 80. Dimensions are capped at 4096px. Processing
is asynchronous: the call returns 202 with the variant in pending, and the
file.processed (or file.processing_failed) event fires when it finishes.
List variants
curl https://app.apulodi.dev/v1/files/file_8f2b…/variants \
-H "Authorization: Bearer $APULODI_API_KEY"
Returns each variant with its label (e.g. w256_cover_webp_q80), status
(pending → processing → ready/failed), output dimensions, and size.
Download a variant
curl -X POST https://app.apulodi.dev/v1/files/file_8f2b…/variants/var_c1d9…/download-url \
-H "Authorization: Bearer $APULODI_API_KEY"
Returns a presigned GET URL — only ready variants qualify (otherwise
409 VARIANT_NOT_READY). Variant downloads are metered as bandwidth.
Next: Folders — the logical folder tree.