Inbound and outbound
Final Frame is where the files and data for a delivery are gathered, checked and handed on. This guide is for the people and agents that move them: how to find what a deal still needs, how to put a file or data into its place, how to know when it has passed QC, and how to take finished assets and data out. Everything here runs over the API, the ff command line, the TypeScript SDK and the MCP server, with the same permissions and the same action log as the product.
1. What a placeholder is
When a deal is set up, Final Frame cuts its deliverables: one row per thing the deal owes, for every title, season and episode. A UHD master, a 5.1 mix, Japanese subtitles, key art, a synopsis in French, a BBFC certificate. Each row is a placeholder until something fills it.
A row fills one of two ways:
- A file lands in your organisation's storage and is filed on the row. Video, audio, subtitles, artwork and documents work this way.
- Data is written on the title the row belongs to. Metadata, credits and classification rows summarise that data and move on their own as it arrives.
Every row says which it is through presence:
| Field | Meaning |
|---|---|
presence.file | A file is filed on the row. filename, byteSize, contentType and receivedAt say what |
presence.data | A data row whose data has been written, in part or in full |
| Both false | An empty placeholder, still waiting |
The row's status comes from your organisation's own status list: awaited, received, QC passed, ready, and whatever you have added. GET /v1/config lists it under taxonomies.deliverableStatuses.
2. Finding the empty placeholders for a title
List a deal's deliverables and filter. presence=empty keeps the rows still waiting; titleId narrows to one title, season or episode and everything under it.
GET /v1/projects/{projectId}/deliverables?presence=empty&titleId={titleId}Other filters: family (video, audio, subtitles, artwork, documents, metadata), kind, status, language, territory. Sort with sort=family, status, dueDate or title. Pages carry next_cursor.
ff call GET "/v1/projects/$PROJECT/deliverables?presence=empty&titleId=$TITLE" --json
const page = await ff.raw('GET', `/v1/projects/${projectId}/deliverables?presence=empty&titleId=${titleId}`);One row in full, with the delivered file's own facts and the requirement as the spec records it: GET /v1/projects/{projectId}/deliverables/{deliverableId}.
3. Putting a file in
Three calls. The bytes go straight to your organisation's storage over signed URLs and never through the API. The key needs asset.upload.
1. Open the upload. Say what is coming: the filename, the content type and the exact size.
curl -X POST "$API/v1/projects/$PROJECT/deliverables/$ROW/uploads" \
-H "Authorization: Bearer $FF_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{"filename":"ep101_ja.srt","contentType":"application/x-subrip","byteSize":48213}'The answer is a session: sessionId, partSize, partCount, and signed parts[] for the first fifty parts. A file of 16 MB or less is one part.
2. Send the parts. PUT the bytes of each part to its URL. Part n starts at offset (n-1) * partSize and is partSize long; the last part is shorter. Keep the ETag header each PUT returns.
curl -X PUT "$PART_URL" --data-binary @ep101_ja.srt -D - -o /dev/null | grep -i etag
For files with more than fifty parts, ask for the next run of URLs as you go, up to two hundred at a time: POST .../uploads/{sessionId}/parts with {"from":51,"to":250}. Parts can go in parallel; eight at a time is a good number.
3. Complete. List every part with its ETag.
curl -X POST "$API/v1/projects/$PROJECT/deliverables/$ROW/uploads/$SESSION/complete" \
-H "Authorization: Bearer $FF_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{"parts":[{"partNumber":1,"etag":"\"9b2cf535f27731c974343645a3985328\""}]}'Final Frame joins the parts, checks the size against what you declared, files the object on the row, sets the status to received, makes a streaming proxy for picture and sound, and starts the automatic QC checks. The row comes back with presence.file true.
With ff:
ff call POST /v1/projects/$PROJECT/deliverables/$ROW/uploads --idempotency-key $(uuidgen) \
--data '{"filename":"ep101_ja.srt","contentType":"application/x-subrip","byteSize":48213}' --json
ff call POST /v1/projects/$PROJECT/deliverables/$ROW/uploads/$SESSION/complete --idempotency-key $(uuidgen) \
--data '{"parts":[{"partNumber":1,"etag":"..."}]}' --jsonWith the SDK:
const session = await ff.raw('POST', `/v1/projects/${projectId}/deliverables/${rowId}/uploads`,
{ filename, contentType, byteSize }, { 'idempotency-key': crypto.randomUUID() });
// PUT each part to session.parts[i].url and keep the ETag headers.
await ff.raw('POST', `/v1/projects/${projectId}/deliverables/${rowId}/uploads/${session.sessionId}/complete`,
{ parts }, { 'idempotency-key': crypto.randomUUID() });Two things to know:
- Idempotency. Open and complete take an
Idempotency-Key. A retry with the same key returns the first answer instead of opening a second session or filing twice. - Doubt. A picture or sound file that looks wrong for its row (the same file already delivered to another row, or a size far from its siblings) is refused with
delivery_doubtand the reason. Send the open call again with"confirmed": trueto go ahead.
4. Putting data in
Metadata placeholders fill from the title's editorial record. Write one language at a time. The key needs project.configure.
PUT /v1/projects/{projectId}/titles/{titleId}/metadata
{"language":"fr-FR","name":"...","tagline":"...","synopsis120":"...","synopsis240":"...",
"synopsis500":"...","synopsis1000":"...","genres":["Drama"],"keywords":["..."]}The call replaces that language's record, so send every field you want kept. synopsis1000 is the exception: left out, it stays. The matching metadata row moves to partial as fields arrive and to in_review when all five prose fields are written. Read it back with GET on the same address, or ?lang=fr-FR for one language.
Statuses move through PATCH /v1/projects/{projectId}/deliverables/{deliverableId} with any of status, notes and dueDate. The status must be a key from your list. Each change is on the action log with the key that made it.
5. Knowing when QC has passed
Automatic QC runs when a file lands, unless the project has switched it off. Two ways to follow it:
- Poll.
GET .../deliverables/{deliverableId}and readstatusandqc. The machine's verdict lands asqc_passedorqc_failed; a person's review follows. - Subscribe. Register a webhook for
asset.received,qc.passed,qc.failedanddeliverable.status_changed. Every event names the deliverable insubject.
6. Taking finished assets and data out
Files: ask for a download link. It is a signed URL, valid for fifteen minutes, and the bytes come straight from storage. The key needs asset.download.
GET /v1/projects/{projectId}/deliverables/{deliverableId}/download
ff download $PROJECT $ROW
const { url } = await ff.deliverables.downloadUrl(projectId, rowId);Data: GET /v1/projects/{projectId}/deliverables/{deliverableId}/data gives the structured data a metadata, credits or classification row summarises. A title's full editorial record is at GET /v1/projects/{projectId}/titles/{titleId}/metadata.
Every link issued is on the action log.
7. The MCP tools
An assistant connected to the MCP server has the same surface, under the same key:
| Tool | Does | Needs |
|---|---|---|
list_deliverables | The deal's rows with presence; the filters and sort above | project.view |
read_deliverable | One row in full | project.view |
start_upload | Opens a session and returns signed part URLs | asset.upload |
upload_part_urls | More signed part URLs for an open session | asset.upload |
complete_upload | Joins the parts and files the result on the row | asset.upload |
set_deliverable_status | Moves a row to a status from your list | project.configure |
write_title_metadata | Writes one language's editorial record | project.configure |
read_deliverable_data | The data a data row holds | project.view |
get_download_link | A fifteen-minute download link | asset.download |
Every write lands on the action log with the credential named.