Reference
The machine API
How a script, a render farm, a watch folder or a Node-RED flow hands files to Kadrenta on its own, no browser, no person watching. One key, three steps, and a link or a folder to show for it.
Quick start
- 1. Get a key. In the product: Studio settings → Delivery keys → Make a key. It comes with a Pro plan. A fresh key starts on "Bin only", so tick "Bin and deliveries" if you want the example below to work: it opens a delivery, and a bin-only key answers that not_permitted. The key is shown once, as
kad_up_…, copy it into wherever your pipeline keeps secrets, never into a script. - 2. Open something. Open a delivery to hand a client a link, or open a drop to put a file straight into a job's own file dock. Both are covered below.
- 3. Upload, then close. PUT the bytes to the address you were given, or send them in parts for a large file. Closing hands back the link, or the landed files.
curl -X POST https://app.kadrenta.com/v1/transfers \
-H "Authorization: Bearer kad_up_…" \
-H "Content-Type: application/json" \
-d '{
"name": "Shot 020, version 12",
"expiresInDays": 7,
"idempotencyKey": "one-per-attempt",
"files": [{ "filename": "020_v012.mov", "bytes": 812000000 }]
}'That single call is the whole first step for either road below: it answers with where the bytes may go.
The shape of it
Every call carries the key as a bearer token, and never anywhere else, no query parameter, no field in the body. The key names the studio; nothing else in the request may.
Authorization: Bearer kad_up_…A key may do exactly one thing that touches somebody else's screen: hand a delivery to a client. Everything else it does, putting a file in a job's dock, asking a delivery's own status, reads and writes what it made itself, and nothing of a colleague's.
There are two roads, and they share the same three steps (open, upload, close). Pick a delivery when the files are going to a client; pick a drop when the files belong on a job and nothing is going out today.
Deliveries: files to a client
A delivery is a link. Open one, upload every file it names, close it, and get back the address to hand or forward, nothing goes to a client on its own; sending the link stays a human decision made wherever you paste it.
POST /v1/transfers (open)
Books a row per file and hands back an upload address for each. Nothing is visible to anybody until close: there is no half-sent delivery a client could stumble onto.
POST /v1/transfers
{
"name": "Shot 020, version 12",
"message": "First pass, notes welcome", // optional
"expiresInDays": 7, // required: 3, 7, 14 or 30
"passphrase": null, // optional
"idempotencyKey": "one-per-attempt", // required
"files": [
{ "filename": "020_v012.mov", "bytes": 812000000, "folder": "renders/sh020" }
]
}
→ 201 (or 200 if idempotencyKey matches an earlier open)
{
"id": "…",
"expiresAt": "2026-10-01T12:00:00.000Z",
"files": [{
"id": "…", "filename": "020_v012.mov", "bytes": 812000000, "status": "uploading",
"upload": { "url": "…", "method": "PUT", "expiresInSeconds": 3600 },
"chunked": { "uploadId": "…", "partSize": 33554432, "partCount": 25 }
}],
"partsUrl": "/v1/transfers/…/parts",
"completeUrl": "/v1/transfers/…/complete"
}expiresInDays has no default on purpose, an expiry nobody chose could leave a client's files reachable longer than the sender meant, and that is the one direction a deadline must never drift. idempotencyKey is required too: a retried open returns the same delivery instead of mailing a client two links to the same files.
folder is optional, per file, and it is where a delivery differs from a drop: the folder is a string here because it only describes a zip, and it may be set at the open because nothing in the product has to look at it before the delivery is closed.
PUT <upload address>
The bytes, straight to storage, this app never sees them. Small files go up in one PUT to the address the open call gave you. A file with a chunked block goes up in parts instead (below); the same open answer already told you which.
POST /v1/transfers/:id/parts (a large file, in pieces)
For any file with a chunked block. Ask this before every stretch of sending, including the first, there is no separate resume call, resuming is asking this question again with the same uploadId after a connection drops. Storage's own record of what arrived is the answer, never what a caller claims.
POST /v1/transfers/:id/parts
{ "fileId": "…", "uploadId": "…", "bytes": 812000000 }
→ 200
{
"fileId": "…", "partSize": 33554432, "partCount": 25,
"done": [1, 2, 3],
"storedBytes": 100663296,
"parts": [{ "partNumber": 4, "url": "…" }, /* up to 100 */],
"more": true
}Send up to a handful of parts at once, four in parallel is about three times the throughput of one at a time on an ordinary line, because each part opens its own connection and the wait to open it, not the bandwidth, is what dominates.
POST /v1/transfers/:id/complete (close it)
An empty body is enough: this call asks storage which multipart upload is standing on each file's key and joins it itself. There is no separate finish step to look for. Safe to call twice, a file that is already landed is skipped, and the same link comes back.
POST /v1/transfers/:id/complete
{}
→ 200
{
"id": "…",
"link": "https://app.kadrenta.com/s/…",
"expiresAt": "2026-10-01T12:00:00.000Z",
"passphrase": false,
"bytes": 812000000,
"files": [{ "id": "…", "filename": "020_v012.mov", "bytes": 812000000, "folder": "renders/sh020" }]
}GET /v1/transfers/:id (read back)
The one thing a key may read about itself: whether the delivery is still good, when it ends, and whether, and when, it has been picked up. Never who picked it up, and never a list of a studio's other deliveries.
GET /v1/transfers/:id
→ 200
{
"id": "…", "status": "ready", "expiresAt": "…",
"files": [ /* … */ ],
"downloads": { "count": 1, "lastAt": "2026-09-24T16:40:00.000Z" }
}DELETE /v1/transfers/:id (take it back)
Withdraws the delivery. Asking twice is a success, not a refusal: a script that lost the answer and asks again gets told the same thing, changed or not.
A job's own file dock: renders that stay in the studio
The other half a pipeline usually wants: a render belongs to the job whether or not anything ships to a client today. Same key, same three steps, one floor further in, the job is named in the address, not the body, so it cannot be got wrong halfway through the three calls.
GET /v1/projects (which jobs you may reach)
GET /v1/projects
→ 200
{ "projects": [{ "id": "…", "name": "Winter campaign", "client": "Northline", "archived": false }] }archived is true once the job itself is archived. Its dock still holds every file that was ever put there; it just no longer counts as an active job. Added 30-09-2026, additive: a caller that does not read this field sees no other change.
GET /v1/projects/:projectId/folders (the folders already there)
GET /v1/projects/:projectId/folders
→ 200
{ "folders": ["renders", "renders/sh020", "plates"] }Every folder already in this job's dock, as a full path (renders/sh020), so a pipeline can offer a folder to drop into rather than guessing the exact spelling of one that already exists. Read-only: it never creates a folder, that happens on close below.
POST /v1/projects/:projectId/files (open a drop)
POST /v1/projects/:projectId/files
{
"files": [{ "filename": "sh020_v012.exr", "bytes": 44000000 }],
"openOperationKey": "a-uuid-per-open" // optional
}
→ 201 (or 200 if openOperationKey matches an earlier open: the same files come back)
{
"projectId": "…",
"files": [{
"id": "…", "filename": "sh020_v012.exr", "bytes": 44000000, "status": "uploading",
"upload": { "url": "…", "method": "PUT", "expiresInSeconds": 3600 }
}],
"partsUrl": "/v1/projects/…/files/parts",
"completeUrl": "/v1/projects/…/files/complete"
}There is no drop id. A drop is not a thing with a life the way a delivery is, it is three calls about a job, and the only thing that carries between them is the file ids this answer hands you. Keep those, and there is nothing else to lose.
POST /v1/projects/:projectId/files/parts (a large file, in pieces)
The same question as the delivery road's /parts, on this job's files.
POST /v1/projects/:projectId/files/complete (land them in the dock)
POST /v1/projects/:projectId/files/complete
{ "files": [{ "fileId": "…", "folder": "renders/sh020" }] }
→ 200
{
"projectId": "…", "landed": 1, "bytes": 44000000,
"files": [{ "id": "…", "filename": "sh020_v012.exr", "bytes": 44000000, "folder": "renders/sh020" }]
}The body is required here, unlike a delivery's close: a drop remembers nothing between calls, so you name what you sent. folder is a real folder in the job's dock, made if it does not exist yet, up to ten levels deep, checked before anything lands so a bad path never leaves ready files hanging with nowhere to show.
Review drafts and shot versions (early access)
Early access: ask us to switch it on for your studio.
Once a render has landed in a job's dock, the same key can put it up for your team: as the next round of an internal review, or as the next version under a shot. It is a draft, always. A key can never publish a round, make a share link, move a review to the client, or mail anybody; those stay a click in the studio, by a person.
It needs a key made with the reach "Bin, review drafts and shot versions", which only shows on Studio settings → Delivery keys once early access is on. Keys you already have keep exactly what they do. Every address below answers not_permitted for any other key.
The file has to be one this key uploaded itself, into the dock of the same job, with /v1/projects/:projectId/files and its /complete. Use the file id that call handed back. Shots and review tracks are named by id, never by name: which render belongs to which shot is your pipeline's lookup.
GET /v1/projects/:projectId/shots (turn a shot number into an id)
A pipeline usually knows a shot by its NUMBER, out of a render's own filename (BICPEN_010_v003.mp4), never by its uuid. This lists every shot on a job, grouped by breakdown, so a pipeline that wants to see the whole thing first can. Most pipelines never need to call it: the by-number address below does the same lookup itself.
GET /v1/projects/:projectId/shots
→ 200
{
"films": [{
"id": "…", "name": "Main film",
"shots": [
{ "id": "…", "number": "010", "kind": "shot", "name": "Opening drone" },
{ "id": "…", "number": "020", "kind": "shot", "name": null },
{ "id": "…", "number": "EDIT", "kind": "edit", "name": null }
]
}]
}POST /v1/projects/:projectId/shots/by-number/:number/versions (the next version, by shot number)
The same address as POST /v1/shots/:shotId/versions below, with the shot named by its number on this job instead of its id. Only ordinary numbered shots resolve here, not an R&D breakdown's own numbers.
POST /v1/projects/:projectId/shots/by-number/010/versions
{ "media_file_id": "…" }
→ 201
{ "version": { "id": "…", "shot_id": "…", "project_id": "…", "media_kind": "video", "version": 3 } }A number unique to one breakdown on this job resolves on its own. A job with more than one breakdown can have the same number twice (a main cut and an R&D pass, say): that answers ambiguous_shot_number (409) with every breakdown that has one, and the retry carries film_id in the body.
→ 409
{ "error": "ambiguous_shot_number",
"candidates": [{ "film_id": "…", "film_name": "Main film" }, { "film_id": "…", "film_name": "R&D" }],
"message": "More than one breakdown on this job has a shot with that number…" }PATCH /v1/projects/:projectId/shots/:shotId (a shot's code, status and columns)
Changes only what the body names: code (null takes it off), status (one of the breakdown's own statuses, by id or by label in any case) and fields (the breakdown's own columns, by field id, as GET .../shots lists them under field_definitions; null empties one). The values are judged by the same rules as the shot page: a choice takes one of its option ids, a person somebody on the studio's team by id, frames a whole number or a timecode, a date YYYY-MM-DD. The answer is the shot as GET hands it out, plus changed. GET on the same address reads one shot.
PATCH /v1/projects/:projectId/shots/:shotId
{ "status": "in progress", "fields": { "<field id>": 112 } }
→ 200
{ "film_id": "…",
"shot": { "id": "…", "number": "010", "code": null, "label": "010", "kind": "shot",
"name": "Opening drone",
"status": { "id": "…", "label": "In progress", "category": "active" },
"fields": { "<field id>": 112 } },
"changed": ["status", "Frames"] }A status the breakdown does not have is never made: it answers invalid_value (422) with valid_statuses, the labels there are. A field id that is not one of the breakdown's columns answers the same with valid_fields. One refused part refuses the whole call, so nothing is half written. Each change lands on the project's activity like a change on the shot page.
→ 422
{ "error": "invalid_value", "field": "status",
"valid_statuses": ["Waiting", "In progress", "Approved"],
"message": "That value is not one this shot's breakdown takes…" }PATCH /v1/projects/:projectId/shots/by-code/:code does the same with the shot named by its code (or its number label when it has none), with film_id in the body when two breakdowns on the job use that code; GET there takes ?film_id=. A key or a person may make 500 shot changes a day; a call that changed nothing does not count.
POST /v1/review/rounds (a draft round on an internal review)
POST /v1/review/rounds
{ "media_file_id": "…", "project_id": "…", "shot_id": "…", "title": "Shot 020" }
→ 201
{
"review_item": { "id": "…", "project_id": "…", "shot_id": "…",
"audience": "internal", "started_here": true },
"round": { "id": "…", "version": 1, "published": false }
}Give review_item_id to add a round to an internal track that already exists, or project_id and title to start one (shot_id is optional and hangs the track on that shot; shot_number + optional film_id names the same shot by its number instead, the same resolution POST .../shots/by-number/:number/versions uses). A shot has one internal track: naming a shot that already has one puts the round there, and started_here says false. A track that is already the client's answers not_found; a key only ever works on internal tracks. Any field not listed here is refused with invalid_body, so there is no field that publishes or shares by accident.
POST /v1/shots/:shotId/versions (the next version under a shot)
POST /v1/shots/:shotId/versions
{ "media_file_id": "…" }
→ 201
{ "version": { "id": "…", "shot_id": "…", "project_id": "…", "media_kind": "video", "version": 3 } }The number follows the shot's own series for that kind of file, and a number once taken is never reused. No caption and no note: those are a click on the shot page.
GET /v1/review/rounds/:roundId (how your round stands)
GET /v1/review/rounds/:roundId
→ 200
{ "round": { "id": "…", "version": 1, "published": false, "verdict": "none" } }Only a round this key put up itself; any other id answers not_found. verdict is one of none, requested, needs_work or approved. Nothing else comes back: not the remarks, not who decided, not the other rounds.
Beside the 240 calls a minute, each key may put up 12 review rounds and 12 shot versions a day; the thirteenth answers daily_ceiling (429) and the count starts again tomorrow. A refused call does not count.
Errors: a code to program on, a sentence to read
Every refusal carries error (a fixed code, safe to branch on) and message (a sentence for whoever reads the log). The code list does not grow casually, an unknown code should be treated as "unknown, do not retry".
{ "error": "quota_exceeded", "limit_bytes": 107374182400, "used_bytes": 106000000000,
"message": "This studio has used all the room its plan covers…" }| Code | HTTP | Means |
|---|---|---|
| quota_exceeded | 507 | The studio is at the end of the room its plan covers. |
| file_too_large | 413 | One file is over the ceiling for a single delivery (50 GB). |
| plan_required | 402 | This studio's plan does not carry the machine API. |
| too_many_files | 422 | More files in one call than the limit (500). |
| transfer_expired | 410 | The delivery has passed its end date. |
| revoked | 403 | The key, or the delivery, has been taken back (or the key expired). |
| not_permitted | 403 | This key's reach does not cover this call (for example, files-only trying to open a delivery, or a key without the review reach on the early-access addresses). |
| rate_limited | 429 | More than 240 calls from this key in the last minute; wait a moment and ask again. |
| daily_ceiling | 429 | As much as this connection may do today; the count resets tomorrow. |
| unauthorized | 401 | No key, or one this deployment never issued. |
| not_found | 404 | That id names nothing this key made. |
| invalid_body | 400 | The request could not be read as JSON in the shape this address takes. |
| invalid_value | 422 | The body was read, and a value in it is not one the shot's breakdown takes (an unknown status, a column that is not there, a value its column refuses). Comes with what it does take. |
| incomplete | 409 | Closing while a file's bytes are not all in yet, the one code that means try again. |
| ambiguous_shot_number | 409 | More than one breakdown on this job has a shot with that number; retry with film_id, named in candidates. |
revoked covers both a taken-back key and an expired one, same code, different sentence, same action: ask the studio for a new one. incomplete (409) is the one answer on this list that means try again: it shows up on a close call while a file's bytes are not all in yet.
Limits
- • The machine API comes with a Pro plan. A key already open keeps working, the plan is only asked when opening something new, never when finishing or withdrawing what is already running.
- • Up to 500 files in one delivery, and up to 500 in one drop. Split a bigger run into more than one call.
- • Storage counts against the studio's own plan room, checked at the open, before a byte is sent.
- • A key's reach is set per key: some may only put files in a job's dock and can never open a delivery to a client (the not_permitted error above is that fence). Which one a given key is set to shows on Studio settings → Delivery keys.
- • Kadrenta Drop, the desktop app, never uses a key. It signs in as a person, over the same door as the MCP connector, and reaches whatever that person may reach in the browser: a job's dock and the studio's own shared folders, plus browsing and downloading both, none of which a key may do. Those addresses take a person's token only and are not part of this reference. Drop is for paid studios: a free studio's token is answered with plan_required (402) everywhere except the calls that only finish or withdraw what was already open.
Every
/v1/call counts towards a limit of 240 calls a minute per key. One call asking for many part URLs still counts as one call. Going over it answers with a 429 and the error coderate_limited, the same shape as the other refusals above; wait a moment and ask again, the count starts over every minute.- • No delete except taking back your own delivery. A key may put files down; it may never remove a job's own files, and it never reads anything it did not deliver itself.
- • This API sends no callbacks. Wait for the answer to each call.
Node-RED example
An importable flow with two tabs, built from core nodes only (inject, function, http request, file in, debug) so it runs without installing anything extra: one that takes a local file path and a key, opens a delivery, uploads the file in parts, closes it, and shows the link; and one, added 28-09-2026, that lands a render on a job's file dock and hangs it under its shot as the next version, reading the shot number straight out of the render's own filename.
Download the flow (JSON)Import it
- Download the file above.
- In Node-RED, open the menu (top right) → Import.
- Choose the downloaded file, or paste its contents, and press Import.
- Open the new "Kadrenta delivery" tab, and set KADRENTA_API_KEY on it as an environment variable (see the node's info tab): the second tab reads the same key.
- For a delivery: set the file path on that tab's inject node, deploy, and press it. The debug panel shows the link once the flow closes the delivery.
- For a shot version: open the "Kadrenta: render to shot version" tab, set KADRENTA_PROJECT_ID on it (GET /v1/projects lists a job's id by name), set the file path on its inject node (the filename has to carry the shot number, e.g. BICPEN_010_v003.mp4), deploy, and press it. Needs a key with the review reach; the early-access section above says which.
Both tabs upload in one PUT for small files. For anything past a few dozen megabytes, adapt the parts loop the way the curl examples above show, the flow's own comment nodes point at the calls to change.
Something not working the way this page says?
Ask, and include the message field from the refusal, that sentence is written for exactly this.