Opening Final Frame
Verifying your credentialsEstablishing an encrypted sessionPreparing your workspace
Secured by WorkOS
This is taking longer than usual. Your connection may be slow.

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:

FieldMeaning
presence.fileA file is filed on the row. filename, byteSize, contentType and receivedAt say what
presence.dataA data row whose data has been written, in part or in full
Both falseAn 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":"..."}]}' --json

With 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_doubt and the reason. Send the open call again with "confirmed": true to 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 read status and qc. The machine's verdict lands as qc_passed or qc_failed; a person's review follows.
  • Subscribe. Register a webhook for asset.received, qc.passed, qc.failed and deliverable.status_changed. Every event names the deliverable in subject.

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:

ToolDoesNeeds
list_deliverablesThe deal's rows with presence; the filters and sort aboveproject.view
read_deliverableOne row in fullproject.view
start_uploadOpens a session and returns signed part URLsasset.upload
upload_part_urlsMore signed part URLs for an open sessionasset.upload
complete_uploadJoins the parts and files the result on the rowasset.upload
set_deliverable_statusMoves a row to a status from your listproject.configure
write_title_metadataWrites one language's editorial recordproject.configure
read_deliverable_dataThe data a data row holdsproject.view
get_download_linkA fifteen-minute download linkasset.download

Every write lands on the action log with the credential named.