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
| Header | Meaning |
|---|---|
X-Request-Id | Unique id for this request. Quote it in bug reports. |
X-RateLimit-Limit-Minute | Your key's per-minute cap. |
X-RateLimit-Remaining-Minute | Requests left in the current minute. |
X-RateLimit-Reset-Seconds | Seconds until the minute window resets. |
X-RateLimit-Limit-Day | Your key's cap over a rolling 24 hours. |
X-RateLimit-Remaining-Day | Requests 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.
| Endpoint | Per key | Per user |
|---|---|---|
GET /search | 60 / minute | 120 / minute |
POST /files/{id}/copy, /compress, /unzip (combined) | 30 / 10 minutes | 60 / 10 minutes |
POST /uploads (open a session) | 300 / 10 minutes | 600 / 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
Scope files:read. Lists the direct contents of a folder, folders first.
| Query | Description |
|---|---|
parent_id | Folder to list. Omit for the workspace root (or the scope folder, for a folder-scoped key). |
q | Case-insensitive name filter within this folder. For a workspace-wide search use /search. |
sort | name_asc (default) or name_desc. Folders always come first. |
limit / offset | Pagination (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
Scope files:read. Returns one file object.
Works for folders too. Errors: 404 not_found.
Download 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
Scope files:write. One-shot multipart/form-data upload, up to
5 GB. For large files or unreliable networks use
chunked uploads instead.
| Field | Description |
|---|---|
file | Required. The file. |
parent_id | Destination folder. Defaults to the root (or the scope folder). |
name | Optional 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
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
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
Scope files:write. Duplicates a file, or a folder with everything inside
it. Copies start out private (share settings are not copied).
| Body | Description |
|---|---|
parent_id | Destination folder, or null for the root. Omit to copy next to the original. |
name | Name 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
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
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
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
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
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
Scope files:read. Same query parameters, response and paging as
GET /v1/files/files?parent_id={id}.
Rename or move a folder
Scope files:write. Same body and behavior as
PATCH /files/{id}.
Delete a folder
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
| Body | Description |
|---|---|
name | Required. File name. |
size | Required. Total size in bytes. |
parent_id | Destination 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
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)
{
"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
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
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.
Search
Scope files:read. Searches the whole workspace (or the key's scope folder)
by name, with the same ranking as the Bodek Files search box: names starting with the
query, then names containing it, then related words, then photo locations.
| Query | Description |
|---|---|
q | Search text (max 200 characters). Required unless types or location is given. |
types | Comma-separated filter: image, video, audio, document, archive, other, folder. |
location | Match against photo location metadata (e.g. Paris). |
include_folders | true (default) or false. |
sort | relevance (default), name_asc, name_desc, date_new, date_old, size_big, size_small. |
limit / offset | Pagination. |
curl "https://dev.bodek.us/v1/files/search?q=invoice&types=document&limit=10" \
-H "Authorization: Bearer bf_live_XXXX"
{
"data": [ { "id": "AbCd1234", "type": "file", "name": "invoice-0042.pdf", …, "match_rank": 0 } ],
"meta": { "total": 1, "offset": 0, "limit": 10, "next_offset": null, "capped": false }
}
Each result is a file object plus match_rank (0 = name starts with the
query, 1 = name contains it, 2 = related word, 3 = location, 9 = filter-only). A search
considers at most 500 matches; meta.capped is true when that
limit was hit — narrow the query. Errors: 400 missing_query,
400 invalid_types.
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
The workspace this key is bound to, with human-readable storage figures
(used_human, limit_human).
List workspaces
Every workspace the key's user belongs to, with their role.
Create an organization
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
Any workspace the user is a member of; otherwise 404 workspace_not_found.
Rename a workspace
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 route | Same 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
Create a webhook
| Body | Description |
|---|---|
name | Required, max 120 characters. |
url | Required. 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. |
events | Required. Event names from the catalog, or ["*"] for everything. |
active | Default 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
Update a webhook
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
Returns 204.
List event types
Scope webhooks:read. The event names you can subscribe to.
{
"data": [ { "event": "file.created", "description": "A file was uploaded" }, … ],
"meta": { "wildcard": "*" }
}
Recent 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 *.
| Event | Fired by | data |
|---|---|---|
file.created | Upload, chunked-upload complete, copy of a file, compress | File object |
folder.created | Create folder, copy of a folder, unzip | File object (folder) |
file.renamed | PATCH with name | File object |
file.moved | PATCH with parent_id, POST …/move | File object |
file.deleted | Delete a file or folder | File object (snapshot before deletion) |
share.changed | Create / change a share | Share object |
member.role_changed | Change a member's role | { "user_id", "new_role" } |
member.removed | Remove a member | { "user_id" } |
member.added | Reserved — 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
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
}
}
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.
| Scope | Grants |
|---|---|
files:read | List, get, download, search; get and list folders. |
files:write | Upload (simple and chunked), rename, move, copy, compress, unzip. |
files:delete | Delete files and folders. |
folders:write | Create folders. |
share:read | Read share-link status. |
share:write | Create, change and revoke share links. |
workspace:read | Read workspaces and storage usage. |
workspace:write | Create organizations and rename workspaces. |
webhooks:read | List webhooks, event types and deliveries. |
webhooks:write | Create, update and delete webhooks. |
iam:read / iam:write | Member and invitation aliases under /workspaces/{id}. |
Error codes
| Status | Code | Meaning |
|---|---|---|
| 400 | invalid_json | The body isn't valid JSON. |
| 400 | various | Validation failures, listed with each endpoint above. |
| 401 | missing_authorization | No Authorization: Bearer … header. |
| 401 | invalid_token_format / invalid_token | Malformed or unknown key. |
| 401 | token_revoked / token_expired | The key was revoked or has expired. |
| 401 | token_orphaned | The key's creator no longer belongs to its workspace. |
| 401 | account_disabled | The key's creator has been disabled. |
| 402 | subscription_required / plan_upgrade_required | The key's creator has no active subscription for Files. |
| 403 | wrong_service | The key is for another service (e.g. an iam key on /v1/files). |
| 403 | insufficient_scope | Missing scope; see details.required. |
| 403 | origin_blocked / origin_required / ip_blocked | The key's origin restrictions rejected the request. |
| 403 | out_of_scope | The write would land outside a folder-scoped key's folder, or the endpoint (webhooks, workspace settings) isn't available to folder-scoped keys. |
| 403 | not_admin / cross_workspace | Admin rights needed, or a different workspace than the key's. |
| 404 | not_found / folder_not_found | No such item in this workspace (or outside the key's folder). |
| 405 | method_not_allowed | The path exists but not for this method. |
| 409 | incomplete_upload / webhook_limit_reached | The resource isn't in the right state yet. |
| 410 | upload_gone | Upload session unknown, finished, aborted or expired. |
| 413 | quota_exceeded | Not enough storage left in the workspace. |
| 413 | body_too_large / too_large / too_many_items | Request body over 1 MB, or a compress / unzip / copy over the API limits. |
| 429 | rate_limited / upload_queue_full / invite_rate_limited | Slow down; retry later (see Retry-After when present). |
| 500 | internal_error | Unexpected failure; include details.request_id when reporting it. |
| 502 | — | The gateway couldn't reach the Files backend; retry. |