Files API

Store and organize files and folders, run searches, create share links, manage workspaces and invitations, and receive signed webhooks. Uses a files key.

Base   https://dev.bodek.us/v1/files
Auth   Authorization: Bearer bf_live_…

Every path on this page is shown with the full gateway prefix, e.g. GET /v1/files/files/{id} is https://dev.bodek.us/v1/files/files/{id}.

A key is bound to one workspace (your personal workspace, or an organization) and acts as the person who created it. Everything the key reads or writes lives in that workspace. If that person later leaves the workspace, the key stops working (401 token_orphaned).

A key can also be folder-scoped: it then only sees that folder and everything beneath it. Ids outside the folder answer 404 not_found (never 403, so the key can't probe for them), calls that would default to the workspace root use the scope folder instead, and anything that would write outside the folder answers 403 out_of_scope.

Switching from the old endpoint? Replace file.bodeksolutions.com/api/v1 with dev.bodek.us/v1/files — resource paths are identical. The same key also works directly against the files origin, https://files.bodek.us/api/v1, which is the better choice for very large transfers (see Chunked uploads).

Conventions

Requests

Request bodies are JSON (Content-Type: application/json) except for the simple upload (multipart) and upload chunks (raw bytes or multipart). File and folder ids are 8-character strings such as AbCd1234.

Responses

Successful responses wrap the payload in data; list endpoints add meta. DELETE endpoints return 204 with no body.

{
  "data": [ { "id": "AbCd1234", "type": "file", "name": "report.pdf", … } ],
  "meta": { "total": 134, "offset": 0, "limit": 20, "next_offset": 20 }
}

Pagination

List endpoints use offset pagination: limit (1–100, default 20) and offset (default 0). meta.next_offset is the offset of the next page, or null on the last page. A single folder listing returns at most 5,000 items in total.

Errors

Errors carry a stable machine code, a human message, and sometimes details:

{
  "error": {
    "code": "insufficient_scope",
    "message": "This key needs the scope: files:write",
    "details": { "required": ["files:write"] }
  }
}

Unexpected failures return 500 internal_error with details.request_id. See Error codes for the full list.

Response headers

HeaderMeaning
X-Request-IdUnique id for this request. Quote it in bug reports.
X-RateLimit-Limit-MinuteYour key's per-minute cap.
X-RateLimit-Remaining-MinuteRequests left in the current minute.
X-RateLimit-Reset-SecondsSeconds until the minute window resets.
X-RateLimit-Limit-DayYour key's cap over a rolling 24 hours.
X-RateLimit-Remaining-DayRequests left in the rolling 24 hours.

Rate limits

Every authenticated request counts once against both caps — including each upload chunk. Going over either cap returns 429 rate_limited with details.limit; wait for the window to reset.

Expensive endpoints have additional limits on top of your key's caps, counted per key and per user (across all of the user's keys). Going over returns 429 rate_limited with a Retry-After header.

EndpointPer keyPer user
GET /search60 / minute120 / minute
POST /files/{id}/copy, /compress, /unzip (combined)30 / 10 minutes60 / 10 minutes
POST /uploads (open a session)300 / 10 minutes600 / 10 minutes
POST /workspaces/{id}/members, POST /iam/members (invitation emails)20 / hour per user; 100 / day per workspace; 3 / day per recipient

Request bodies

JSON bodies are limited to 1 MB (413 body_too_large). String fields must be JSON strings; ids in the path must be well-formed or the call returns 404.

Keys, accounts and browsers

A key acts as the account that created it, with that account's current role. It stops working (401) when the key is revoked or expires, when the account is removed from the workspace (token_orphaned) or disabled (account_disabled), and returns 402 subscription_required / plan_upgrade_required while the account's subscription is inactive. test and live keys currently have identical access to the same data — the prefix is a label only.

Browser calls are allowed from any origin via CORS (Access-Control-Allow-Origin: *); credentials (cookies) are never accepted — only the Authorization header authenticates. A key's domains restriction checks the Origin (or Referer) header, matching a listed host exactly or, for *.example.com, that host and its subdomains; those headers are set by browsers but can be forged by other clients, so for server-side keys prefer the IPs restriction (IPv4 and IPv6 addresses and CIDR ranges).

The file object

Files and folders share one shape. Folders have "type": "folder".

{
  "id": "AbCd1234",
  "type": "file",                     // "file" | "folder"
  "name": "report.pdf",
  "parent_id": "FoLd5678",            // null = workspace root
  "size": 48213,                      // bytes; always 0 for folders
  "mime_type": "application/pdf",
  "extension": "pdf",
  "has_thumbnail": false,
  "share_status": "private",          // "private" | "public" | "limited"
  "share_url": "https://files.bodek.us/sl/AbCd1234",
  "share_active": false,              // true when the link currently opens the file
  "shared_with": null,                // comma-separated emails for "limited" shares
  "share_password_protected": false,
  "share_expires_at": null,           // ISO 8601 or null
  "created_at": "2026-09-01 12:00:00",
  "updated_at": "2026-09-01 12:00:00"
}

share_url is always present — it's derived from the id — but only opens the file while share_active is true.

Files

List files

GET /v1/files/files

Scope files:read. Lists the direct contents of a folder, folders first.

QueryDescription
parent_idFolder to list. Omit for the workspace root (or the scope folder, for a folder-scoped key).
qCase-insensitive name filter within this folder. For a workspace-wide search use /search.
sortname_asc (default) or name_desc. Folders always come first.
limit / offsetPagination (see Conventions).
curl "https://dev.bodek.us/v1/files/files?parent_id=FoLd5678&limit=50" \
  -H "Authorization: Bearer bf_live_XXXX"
{
  "data": [ { "id": "AbCd1234", "type": "file", "name": "report.pdf", … } ],
  "meta": { "total": 1, "offset": 0, "limit": 50, "next_offset": null }
}

Errors: 404 folder_not_found, 400 not_a_folder.

Get a file

GET /v1/files/files/{id}

Scope files:read. Returns one file object. Works for folders too. Errors: 404 not_found.

Download content

GET /v1/files/files/{id}/content

Scope files:read. Streams the raw bytes with Content-Disposition: attachment. Requesting a folder returns 400 not_a_file. Byte-range requests (Range) are honoured; the gateway forwards Range and relays 206/Content-Range. For very large downloads call the files origin directly (https://files.bodek.us/api/v1) to avoid the gateway's 60-second limit.

curl -L -o report.pdf https://dev.bodek.us/v1/files/files/AbCd1234/content \
  -H "Authorization: Bearer bf_live_XXXX"

Upload a file

POST /v1/files/files

Scope files:write. One-shot multipart/form-data upload, up to 5 GB. For large files or unreliable networks use chunked uploads instead.

FieldDescription
fileRequired. The file.
parent_idDestination folder. Defaults to the root (or the scope folder).
nameOptional name override.
curl -X POST https://dev.bodek.us/v1/files/files \
  -H "Authorization: Bearer bf_live_XXXX" \
  -F "file=@report.pdf" -F "parent_id=FoLd5678"

Returns 201 with the new file object and fires file.created. Errors: 400 missing_file, 400 upload_failed, 400 file_type_blocked, 404 folder_not_found, 413 quota_exceeded. File and folder names can't contain /, \ or control characters (400 invalid_name).

Rename or move

PATCH /v1/files/files/{id}

Scope files:write. Send name, parent_id (null = workspace root), or both. Works for files and folders.

curl -X PATCH https://dev.bodek.us/v1/files/files/AbCd1234 \
  -H "Authorization: Bearer bf_live_XXXX" -H "Content-Type: application/json" \
  -d '{ "name": "report-final.pdf" }'

Returns the updated file object; fires file.renamed and/or file.moved. Errors: 400 no_changes, 400 invalid_name, 400 file_type_blocked, 400 not_a_folder, 400 invalid_move, 403 out_of_scope, 404 folder_not_found.

Move

POST /v1/files/files/{id}/move

Scope files:write. Like PATCH with parent_id, but reports refusals from the storage layer (e.g. not enough space when moving between workspaces) instead of ignoring them.

curl -X POST https://dev.bodek.us/v1/files/files/AbCd1234/move \
  -H "Authorization: Bearer bf_live_XXXX" -H "Content-Type: application/json" \
  -d '{ "parent_id": "FoLd5678" }'

Returns the moved file object; fires file.moved. Errors: 400 missing_parent_id, 400 not_a_folder, 400 invalid_move, 400 move_failed, 403 out_of_scope, 404 folder_not_found, 413 quota_exceeded.

Copy

POST /v1/files/files/{id}/copy

Scope files:write. Duplicates a file, or a folder with everything inside it. Copies start out private (share settings are not copied).

BodyDescription
parent_idDestination folder, or null for the root. Omit to copy next to the original.
nameName for the copy. Defaults to the original name.
curl -X POST https://dev.bodek.us/v1/files/files/AbCd1234/copy \
  -H "Authorization: Bearer bf_live_XXXX" -H "Content-Type: application/json" \
  -d '{ "parent_id": "Arch1ve0", "name": "report-2026.pdf" }'

Returns 201 with the new file object; fires file.created (or folder.created). Errors: 400 invalid_copy, 400 invalid_name, 400 not_a_folder, 400 copy_failed, 403 out_of_scope, 404 folder_not_found, 413 quota_exceeded, 413 too_many_items (a folder with more than 10,000 items inside), 429 rate_limited.

Compress to .zip

POST /v1/files/files/{id}/compress

Scope files:write. No body. Zips a file or folder into <name>.zip next to the original. Returns 201 with the archive's file object; fires file.created. A folder-scoped key cannot compress its scope folder itself (403 out_of_scope, the archive would land outside it). Limits: at most 10,000 items and 10 GB of input, and no more than the workspace's free space. Other errors: 400 compress_failed, 413 quota_exceeded, 413 too_many_items, 413 too_large, 429 rate_limited.

curl -X POST https://dev.bodek.us/v1/files/files/FoLd5678/compress \
  -H "Authorization: Bearer bf_live_XXXX"

Extract a .zip

POST /v1/files/files/{id}/unzip

Scope files:write. No body. Extracts a .zip into a new folder named after the archive, next to it. Blocked file types inside the archive are skipped; extraction stops early if the workspace runs out of space. Entry paths are always kept inside the new folder (../ and absolute paths are stripped; symlink entries are stored as plain files). Before extracting, the archive is checked: at most 10,000 entries and 10 GB uncompressed, no more than the workspace's free space, and no control characters in entry names.

{
  "data": { "id": "NeWf0ldr", "type": "folder", "name": "photos", … },
  "meta": { "message": "Extracted 42 item(s)." }
}

Returns 201; fires folder.created. Errors: 400 not_a_file, 400 not_a_zip, 400 unzip_failed, 400 invalid_archive, 413 quota_exceeded, 413 too_many_items, 413 too_large, 429 rate_limited.

Compress, extract and folder downloads run synchronously. The gateway waits up to 60 seconds; for very large folders call the files origin directly.

Delete

DELETE /v1/files/files/{id}

Scope files:delete. Deletes a file, or a folder and everything inside it. Deletion is permanent — there is no trash to restore from. Returns 204; fires file.deleted with a snapshot of the item.

Folders

Folders are files with type: "folder", so every /files endpoint above also works on them. The /folders/{id} routes additionally reject file ids with 400 not_a_folder.

Create a folder

POST /v1/files/folders

Scope folders:write.

curl -X POST https://dev.bodek.us/v1/files/folders \
  -H "Authorization: Bearer bf_live_XXXX" -H "Content-Type: application/json" \
  -d '{ "name": "Invoices", "parent_id": null }'

Returns 201 with the folder; fires folder.created. Errors: 400 invalid_name, 400 not_a_folder, 404 folder_not_found.

Get a folder

GET /v1/files/folders/{id}

Scope files:read. The file object plus total_size (bytes in the whole subtree), item_count (direct children) and path (breadcrumbs from the top — for a folder-scoped key, from the scope folder).

{
  "data": {
    "id": "FoLd5678", "type": "folder", "name": "Invoices", "parent_id": "Acc0unts", …,
    "total_size": 1843200,
    "item_count": 12,
    "path": [ { "id": "Acc0unts", "name": "Accounts" }, { "id": "FoLd5678", "name": "Invoices" } ]
  }
}

List a folder

GET /v1/files/folders/{id}/children

Scope files:read. Same query parameters, response and paging as GET /v1/files/files?parent_id={id}.

Rename or move a folder

PATCH /v1/files/folders/{id}

Scope files:write. Same body and behavior as PATCH /files/{id}.

Delete a folder

DELETE /v1/files/folders/{id}

Scope files:delete. Permanently deletes the folder and its contents. Returns 204.

Chunked uploads

Resumable uploads for large files (up to 100 GB). Open a session, send the file in fixed-size chunks (in any order, in parallel if you like), then complete it. If the connection drops, ask which chunks are missing and resend only those. All routes need scope files:write. Sessions belong to the key's user, workspace and folder scope; a session with no activity for 7 days is deleted.

1. Open a session

POST /v1/files/uploads
BodyDescription
nameRequired. File name.
sizeRequired. Total size in bytes.
parent_idDestination folder. Defaults to the root (or scope folder).
curl -X POST https://dev.bodek.us/v1/files/uploads \
  -H "Authorization: Bearer bf_live_XXXX" -H "Content-Type: application/json" \
  -d '{ "name": "video.mp4", "size": 734003200, "parent_id": "FoLd5678" }'
{
  "data": {
    "upload_key": "9f2c4e1a0b7d4c3e8a6f5b2d1c0e9f8a",
    "name": "video.mp4", "size": 734003200, "parent_id": "FoLd5678",
    "chunk_size": 5242880, "total_chunks": 140,
    "missing_chunks": [0, 1, 2, …, 139],
    "resumed": false
  }
}

Returns 201. Opening a session with the same name, size and folder as an unfinished one resumes it instead: 200, "resumed": true, and missing_chunks lists only what is still needed. Always use the returned chunk_size (5 MB, 16 MB above 1 GB, 32 MB above 20 GB).

Errors: 400 invalid_name, 400 invalid_size, 400 invalid_upload, 400 file_type_blocked, 400 not_a_folder, 404 folder_not_found, 413 quota_exceeded, and 429 upload_queue_full when too many bytes are already in flight for this account — retry after the Retry-After header (also in details.retry_after_ms).

2. Send chunks

POST /v1/files/uploads/{upload_key}/chunk?index={n}

Send chunk n (zero-based) as the raw request body, or as a multipart field named chunk (with index as a form field). Every chunk must be exactly chunk_size bytes except the last one.

# chunk 0 of a file split with: split -b 5242880 -d -a 4 video.mp4 part.
curl -X POST "https://dev.bodek.us/v1/files/uploads/9f2c…9f8a/chunk?index=0" \
  -H "Authorization: Bearer bf_live_XXXX" \
  -H "Content-Type: application/octet-stream" \
  --data-binary @part.0000
{ "data": { "upload_key": "9f2c…9f8a", "index": 0, "received": true, "duplicate": false, "complete": false } }

complete turns true once every chunk has arrived. Resending a chunk that was already stored is harmless ("duplicate": true). Errors: 400 invalid_index, 400 empty_chunk, 400 chunk_rejected (wrong size, index out of range, session no longer open), 410 upload_gone.

3. Check progress (optional)

GET /v1/files/uploads/{upload_key}
{
  "data": {
    "upload_key": "9f2c…9f8a", "status": "open", "name": "video.mp4", "size": 734003200,
    "parent_id": "FoLd5678", "chunk_size": 5242880, "total_chunks": 140,
    "received_chunks": 138, "missing_chunks": [17, 93],
    "created_at": "2026-09-23 10:00:00"
  }
}

Errors: 410 upload_gone (unknown, finished, aborted or expired session).

4. Complete

POST /v1/files/uploads/{upload_key}/complete

Assembles the file and returns 201 with the new file object; fires file.created. Thumbnails and metadata are generated in the background right after. Errors: 409 incomplete_upload (with details.missing_chunks), 413 quota_exceeded, 400 complete_failed (safe to retry), 410 upload_gone.

Abort

DELETE /v1/files/uploads/{upload_key}

Discards the session and any chunks received. Returns 204.

Each chunk is one request against your rate limit. For multi-gigabyte files, calling the files origin directly (https://files.bodek.us/api/v1/uploads…, same key) avoids the gateway's 60-second per-request limit on slow links.

Share links

A share link (https://files.bodek.us/sl/{file_id}) is either private (off), public (anyone with the link) or limited (only listed emails, after they verify their address). Links can carry a password and an expiry, after which they revert to private.

The share object

{
  "file_id": "AbCd1234",
  "status": "public",
  "url": "https://files.bodek.us/sl/AbCd1234",
  "share_active": true,
  "shared_with": null,
  "share_password_protected": true,
  "share_expires_at": "2026-12-31T23:59:59+00:00"
}

Create or change a share

POST /v1/files/shares

Scope share:write. Returns 201 with the share object; fires share.changed.

BodyDescription
file_idRequired.
statuspublic (default), limited or private.
emailsComma-separated addresses allowed to open a limited share (max 100). No email is sent to them — share the link yourself.
passwordOptional. Set a password; "" clears it; omit to keep the current one.
expires_atOptional. ISO 8601 or Y-m-d H:i:s; "" clears it; omit to keep.
curl -X POST https://dev.bodek.us/v1/files/shares \
  -H "Authorization: Bearer bf_live_XXXX" -H "Content-Type: application/json" \
  -d '{ "file_id": "AbCd1234", "status": "public", "password": "s3cret-pass", "expires_at": "2026-12-31T23:59:59Z" }'

Errors: 400 invalid_file_id, 400 invalid_status, 400 share_failed (e.g. password too short, expiry in the past, bad emails), 404 not_found.

Change share status

PATCH /v1/files/shares/{file_id}

Scope share:write. Same body as POST without file_id; status is required. Returns 200 with the share object.

Read share status

GET /v1/files/shares/{file_id}

Scope share:read. Returns the share object.

Revoke a share

DELETE /v1/files/shares/{file_id}

Scope share:write. Sets the share to private and clears its password and expiry. Returns 204.

Storage

GET /v1/files/storage

Scope workspace:read. Usage and limits for the key's workspace.

{
  "data": {
    "workspace_id": 12,
    "used": 5368709120, "limit": 107374182400, "unlimited": false,
    "remaining": 102005473280, "percent_used": 5,
    "used_human": "5 GB", "limit_human": "100 GB",
    "file_count": 1834, "folder_count": 97,
    "scope_folder_id": null, "scope_used": null,
    "max_upload_size": { "simple": 5368709120, "chunked": 107374182400 }
  }
}

used/limit are workspace-wide (limit is null when unlimited). file_count and folder_count cover what the key can see; for a folder-scoped key they, and scope_used, describe the scope folder's contents. max_upload_size is the per-file limit for the simple and chunked uploads.

Workspaces

File, share, webhook and member calls always act on the workspace the key is bound to. These endpoints read and manage workspaces. Scopes workspace:read / workspace:write.

The workspace object

{
  "id": 12,
  "name": "Acme Inc.",
  "type": "org",                       // "personal" | "org"
  "owner_id": 3,
  "storage": { "used": 5368709120, "limit": 107374182400, "unlimited": false },
  "role": "admin",                     // your role; only in GET /workspaces
  "created_at": "2026-01-15 09:30:00"
}

Current workspace

GET /v1/files/workspaces/current

The workspace this key is bound to, with human-readable storage figures (used_human, limit_human).

List workspaces

GET /v1/files/workspaces

Every workspace the key's user belongs to, with their role.

Create an organization

POST /v1/files/workspaces
curl -X POST https://dev.bodek.us/v1/files/workspaces \
  -H "Authorization: Bearer bf_live_XXXX" -H "Content-Type: application/json" \
  -d '{ "name": "Acme Inc.", "ein": "12-3456789" }'

Scope workspace:write. The caller becomes the owner; the key stays bound to its original workspace. Returns 201. Errors: 400 invalid_name, 400 create_failed, 403 org_limit_reached.

Get a workspace

GET /v1/files/workspaces/{id}

Any workspace the user is a member of; otherwise 404 workspace_not_found.

Rename a workspace

PATCH /v1/files/workspaces/{id}

Scope workspace:write; owner or admin only, and only for the workspace the key is bound to (403 cross_workspace otherwise). Folder-scoped keys can't rename workspaces (403 out_of_scope). Body { "name": "…" } (max 100 characters). Errors: 400 no_changes, 400 rename_failed, 403 not_admin.

Deleting a workspace is intentionally not available through the API; use the web app.

Members & invites

Organization-workspace members and invitations can be managed with a files key through these workspace-scoped aliases, or with an iam key through the IAM API. They take the same bodies and return the same shapes as the IAM endpoints. {id} must be the key's own workspace (403 cross_workspace otherwise), and the key needs the iam:read / iam:write scopes. Member writes and invitations need an admin or the owner; changing a role (and inviting someone as admin) is owner-only, as in the web app.

Files-key routeSame as
GET /v1/files/workspaces/{id}/members
GET /v1/iam/members
POST /v1/files/workspaces/{id}/members
POST /v1/iam/members
PATCH /v1/files/workspaces/{id}/members/{user_id}
PATCH /v1/iam/members/{user_id}
DELETE /v1/files/workspaces/{id}/members/{user_id}
DELETE /v1/iam/members/{user_id}
GET /v1/files/workspaces/{id}/invites
GET /v1/iam/invites
DELETE /v1/files/workspaces/{id}/invites/{invite_id}
DELETE /v1/iam/invites/{invite_id}
GET /v1/files/workspaces/{id}/invite-codes
GET /v1/iam/invite-codes
POST /v1/files/workspaces/{id}/invite-codes
POST /v1/iam/invite-codes
DELETE /v1/files/workspaces/{id}/invite-codes/{code}
DELETE /v1/iam/invite-codes/{code}

Webhooks

Get an HTTPS POST when something changes in the workspace. Scopes webhooks:read / webhooks:write. Creating, updating and deleting webhooks requires the key's owner to be a workspace admin or the owner (403 not_admin). Folder-scoped keys can't use the webhook endpoints at all (403 out_of_scope), because webhooks report on the whole workspace. A workspace can have up to 20 webhooks (409 webhook_limit_reached).

The webhook object

{
  "id": 7,
  "workspace_id": 12,
  "name": "Sync to CRM",
  "url": "https://example.com/hooks/bodek",
  "events": ["file.created", "file.deleted"],   // or ["*"]
  "active": true,
  "last_status": 200,
  "last_delivered_at": "2026-09-23 10:01:00",
  "last_error": null,
  "created_at": "2026-09-01 08:00:00"
}

List webhooks

GET /v1/files/webhooks

Create a webhook

POST /v1/files/webhooks
BodyDescription
nameRequired, max 120 characters.
urlRequired. An https:// URL (max 2,048 characters) on port 443 or 1024–65535, without a username/password, whose host resolves only to public internet addresses. http://, localhost, private / loopback / link-local ranges (including 169.254.169.254) and other internal addresses are rejected. The address is checked again on every delivery.
eventsRequired. Event names from the catalog, or ["*"] for everything.
activeDefault true.
curl -X POST https://dev.bodek.us/v1/files/webhooks \
  -H "Authorization: Bearer bf_live_XXXX" -H "Content-Type: application/json" \
  -d '{ "name": "Sync to CRM", "url": "https://example.com/hooks/bodek", "events": ["file.created", "file.deleted"] }'
{
  "data": {
    "webhook": { "id": 7, "name": "Sync to CRM", … },
    "secret": "3f9a…64 hex characters…",
    "secret_warning": "Save this secret. It will not be shown again."
  }
}

Returns 201. The secret is shown only once. Errors: 400 invalid_webhook.

Get a webhook

GET /v1/files/webhooks/{id}

Update a webhook

PATCH /v1/files/webhooks/{id}

Send any of name, url, events, active (other fields are ignored; the same URL rules apply). Returns the updated webhook. The secret cannot be rotated — delete and recreate the webhook instead.

Delete a webhook

DELETE /v1/files/webhooks/{id}

Returns 204.

List event types

GET /v1/files/webhooks/events

Scope webhooks:read. The event names you can subscribe to.

{
  "data": [ { "event": "file.created", "description": "A file was uploaded" }, … ],
  "meta": { "wildcard": "*" }
}

Recent deliveries

GET /v1/files/webhooks/{id}/deliveries

Scope webhooks:read. The most recent delivery attempts, newest first. limit 1–200 (default 30).

{
  "data": [
    {
      "id": 912, "event": "file.created",
      "status": "delivered",          // "pending" | "delivered" | "failed"
      "attempts": 1, "response_status": 200, "last_error": null,
      "created_at": "2026-09-23 10:00:02", "delivered_at": "2026-09-23 10:01:00",
      "next_attempt_at": null         // set while a retry is scheduled
    }
  ],
  "meta": { "webhook_id": 7, "limit": 30 }
}

Webhook events

Events are produced by changes made through this API (the share-expiry sweep is the exception). Subscribe to a name below, or to *.

EventFired bydata
file.createdUpload, chunked-upload complete, copy of a file, compressFile object
folder.createdCreate folder, copy of a folder, unzipFile object (folder)
file.renamedPATCH with nameFile object
file.movedPATCH with parent_id, POST …/moveFile object
file.deletedDelete a file or folderFile object (snapshot before deletion)
share.changedCreate / change a shareShare object
member.role_changedChange a member's role{ "user_id", "new_role" }
member.removedRemove a member{ "user_id" }
member.addedReserved — accepted in subscriptions but not currently emitted—

Only * subscribers also receive workspace.created, workspace.renamed (workspace object) and share.expired (sent when an expired share link is switched back to private).

Delivery

POST /hooks/bodek HTTP/1.1
Content-Type: application/json
User-Agent: Bodek-Files-Webhook/1.0
X-Bodek-Event: file.created
X-Bodek-Signature: 5d41402abc4b2a76b9719d911017c592…
X-Bodek-Delivery: 912

{
  "event": "file.created",
  "workspace_id": 12,
  "timestamp": "2026-09-23T10:00:02+00:00",
  "data": { "id": "AbCd1234", "type": "file", "name": "report.pdf", … }
}

Deliveries are sent by a background worker, usually within a minute. Respond with any 2xx within 5 seconds (3 seconds to connect); redirects are not followed and only the first 64 KB of your response is read. The destination is resolved and checked on every attempt; if it no longer points to a public address the attempt fails. Deliveries stop if the member who created the webhook leaves the workspace. Anything else is retried after 1 min, 5 min, 15 min, 1 h and 6 h, then marked failed. Deliveries can arrive more than once or out of order — de-duplicate on X-Bodek-Delivery (the same id on every retry of a delivery) if it matters.

Verifying deliveries

X-Bodek-Signature is the lowercase hex HMAC-SHA256 of the raw request body, keyed with the webhook's secret. Compute it over the exact bytes you received (before JSON parsing) and compare in constant time. To reject replayed requests, also check that the signed body's timestamp is recent (e.g. within 10 minutes, allowing for retries by comparing against X-Bodek-Delivery ids you've already processed).

// PHP
$body     = file_get_contents('php://input');
$expected = hash_hmac('sha256', $body, $secret);
if (!hash_equals($expected, $_SERVER['HTTP_X_BODEK_SIGNATURE'] ?? '')) {
    http_response_code(401); exit;
}
$event = json_decode($body, true);
// Node.js (Express)
const crypto = require('crypto');
app.post('/hooks/bodek', express.raw({ type: 'application/json' }), (req, res) => {
  const expected = crypto.createHmac('sha256', SECRET).update(req.body).digest('hex');
  const given = Buffer.from(req.get('X-Bodek-Signature') || '');
  if (given.length !== expected.length ||
      !crypto.timingSafeEqual(Buffer.from(expected), given)) return res.sendStatus(401);
  const event = JSON.parse(req.body);
  res.sendStatus(204);
});
# Python (Flask)
import hmac, hashlib
body = request.get_data()
expected = hmac.new(SECRET.encode(), body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, request.headers.get('X-Bodek-Signature', '')):
    abort(401)

Key info & health

GET /v1/files/me

Any scope. Describes the calling key.

{
  "data": {
    "key_id": 28, "key_name": "Backup script", "key_prefix": "bf_live_ABCDEF…",
    "environment": "live", "workspace_id": 12,
    "scopes": ["files:read", "files:write"],
    "scope_folder_id": null,
    "origin_mode": "server", "allowed_origins": [],
    "rate_limit_per_min": 100, "rate_limit_per_day": 10000,
    "created_at": "…", "last_used_at": "…", "expires_at": null
  }
}
GET /v1/files/health

Liveness check — no authentication required.

{ "data": { "ok": true, "service": "Bodek Files API", "version": "v1", "timestamp": "2026-09-23T10:00:00+00:00" } }

Scopes

A key with * has every scope.

ScopeGrants
files:readList, get, download, search; get and list folders.
files:writeUpload (simple and chunked), rename, move, copy, compress, unzip.
files:deleteDelete files and folders.
folders:writeCreate folders.
share:readRead share-link status.
share:writeCreate, change and revoke share links.
workspace:readRead workspaces and storage usage.
workspace:writeCreate organizations and rename workspaces.
webhooks:readList webhooks, event types and deliveries.
webhooks:writeCreate, update and delete webhooks.
iam:read / iam:writeMember and invitation aliases under /workspaces/{id}.

Error codes

StatusCodeMeaning
400invalid_jsonThe body isn't valid JSON.
400variousValidation failures, listed with each endpoint above.
401missing_authorizationNo Authorization: Bearer … header.
401invalid_token_format / invalid_tokenMalformed or unknown key.
401token_revoked / token_expiredThe key was revoked or has expired.
401token_orphanedThe key's creator no longer belongs to its workspace.
401account_disabledThe key's creator has been disabled.
402subscription_required / plan_upgrade_requiredThe key's creator has no active subscription for Files.
403wrong_serviceThe key is for another service (e.g. an iam key on /v1/files).
403insufficient_scopeMissing scope; see details.required.
403origin_blocked / origin_required / ip_blockedThe key's origin restrictions rejected the request.
403out_of_scopeThe write would land outside a folder-scoped key's folder, or the endpoint (webhooks, workspace settings) isn't available to folder-scoped keys.
403not_admin / cross_workspaceAdmin rights needed, or a different workspace than the key's.
404not_found / folder_not_foundNo such item in this workspace (or outside the key's folder).
405method_not_allowedThe path exists but not for this method.
409incomplete_upload / webhook_limit_reachedThe resource isn't in the right state yet.
410upload_goneUpload session unknown, finished, aborted or expired.
413quota_exceededNot enough storage left in the workspace.
413body_too_large / too_large / too_many_itemsRequest body over 1 MB, or a compress / unzip / copy over the API limits.
429rate_limited / upload_queue_full / invite_rate_limitedSlow down; retry later (see Retry-After when present).
500internal_errorUnexpected failure; include details.request_id when reporting it.
502—The gateway couldn't reach the Files backend; retry.