Workflow recipes
Three jobs from start to finish, each as one script in four forms: curl, the ff command line, the TypeScript SDK and the Python SDK. They run against the Sandbox at https://qa.app.final-frame.com/api with the key in FF_API_KEY. Choosing a form shows it in every recipe.
Every write carries an Idempotency-Key made from a run id, RUN. A script that stops half way, run again with the same RUN within a day, gets the first answers back for the steps already done and carries on; nothing is created twice. What each status and refusal means is on Troubleshooting.
| Recipe | Needs | On a Sandbox key |
|---|---|---|
| Deal to delivered | project.create, project.configure, asset.upload; integration.configure for the webhook | Runs; the webhook step answers 403 and the script polls instead |
| Rights to avails | org.view; org.settings.edit to record | The reads and the export run; recording answers 403 naming org.settings.edit |
| Configuration as code | org.view; org.settings.edit to apply | Export runs; apply answers 403 naming org.settings.edit |
Run the steps the Sandbox refuses with a key from your own organisation that carries the permission.
Deal to delivered
Create a deal, add the title and a placeholder for its master, upload the master in parts, hear the QC verdict by webhook, and mark the master delivered. The routes: POST /v1/projects, POST /v1/projects/{id}/titles, POST /v1/projects/{id}/deliverables, POST /v1/webhooks, POST .../uploads then .../complete, and PATCH .../deliverables/{id}.
Completing the upload files the master on its placeholder as received and queues the automatic QC checks. The verdict moves the row to qc_passed or qc_failed and raises qc.passed or qc.failed, whose data carries the deliverableId, the verdict and up to twenty failed checks. ready is the last status in the default set; an organisation with its own statuses lists them in GET /v1/config under taxonomies.deliverableStatuses.
#!/usr/bin/env bash
# Needs curl and jq. FILE is the master to upload.
set -euo pipefail
FF=https://qa.app.final-frame.com/api/v1
FILE=${FILE:-master.mov}
RUN=${RUN:-$(uuidgen)} # run again with the same RUN to resume
AUTH=(-H "Authorization: Bearer $FF_API_KEY")
JSON=(-H "Content-Type: application/json")
post() { curl -fsS --fail-with-body -X "$1" "$FF$2" "${AUTH[@]}" "${JSON[@]}" -H "Idempotency-Key: $RUN-$3" -d "$4"; }
# 1. The deal, its title and a placeholder for the master
PROJECT=$(post POST /projects deal '{"name":"Northern Lights: Nordics SVOD","client":"Nordic Stream","territories":["NORDICS"],"rightsGranted":["svod"],"licenceStarts":"2027-01-01","licenceEnds":"2028-12-31"}' | jq -r .id)
TITLE=$(post POST /projects/$PROJECT/titles title '{"name":"Northern Lights","kind":"feature","originalLanguage":"en"}' | jq -r .id)
MASTER=$(post POST /projects/$PROJECT/deliverables master "{\"kind\":\"picture_master\",\"label\":\"Feature master\",\"titleId\":\"$TITLE\"}" | jq -r .id)
# 2. Hear about QC (needs integration.configure; store the secret, it is shown once)
post POST /webhooks hook '{"url":"https://hooks.example.com/final-frame","events":["qc.passed","qc.failed"]}' | jq -r .secret > webhook-secret.txt || echo "no webhook: polling instead"
# 3. Upload in parts: the bytes go straight to storage
SIZE=$(wc -c < "$FILE" | tr -d ' ')
SESSION=$(post POST /projects/$PROJECT/deliverables/$MASTER/uploads upload "{\"filename\":\"$(basename "$FILE")\",\"contentType\":\"video/quicktime\",\"byteSize\":$SIZE}")
SESSION_ID=$(jq -r .sessionId <<<"$SESSION"); PART_SIZE=$(jq -r .partSize <<<"$SESSION")
split -b "$PART_SIZE" -a 4 "$FILE" "part-$RUN-" # more than 50 parts: POST .../uploads/$SESSION_ID/parts for the rest
n=0; : > etags.jsonl
for part in part-$RUN-*; do
n=$((n + 1)); URL=$(jq -r ".parts[$((n - 1))].url" <<<"$SESSION")
ETAG=$(curl -fsS -X PUT --upload-file "$part" "$URL" -D - -o /dev/null | awk 'tolower($1) == "etag:" { gsub(/["\r]/, "", $2); print $2 }')
echo "{\"partNumber\":$n,\"etag\":\"$ETAG\"}" >> etags.jsonl
done
post POST /projects/$PROJECT/deliverables/$MASTER/uploads/$SESSION_ID/complete complete "$(jq -s '{parts: .}' etags.jsonl)" > /dev/null
rm part-$RUN-* etags.jsonl
# 4. QC runs by itself. The webhook says when; without one, poll the row
until STATUS=$(curl -fsS "$FF/projects/$PROJECT/deliverables/$MASTER" "${AUTH[@]}" | jq -r .status); [[ $STATUS == qc_passed || $STATUS == qc_failed ]]; do sleep 20; done
echo "QC: $STATUS"
# 5. Mark it delivered: ready is the last status in the default set
if [[ $STATUS == qc_passed ]]; then post PATCH /projects/$PROJECT/deliverables/$MASTER ready '{"status":"ready"}' | jq '{label, status}'; fi#!/usr/bin/env bash
# ff for every API call; curl only for the part PUTs, which go to storage, not the API.
set -euo pipefail
export FF_API_BASE=https://qa.app.final-frame.com/api # FF_API_KEY holds the key
FILE=${FILE:-master.mov}
RUN=${RUN:-$(uuidgen)}
call() { ff call "$1" "$2" --data "$3" --idempotency-key "$RUN-$4" --json; }
PROJECT=$(call POST /v1/projects '{"name":"Northern Lights: Nordics SVOD","client":"Nordic Stream","territories":["NORDICS"],"rightsGranted":["svod"]}' deal | jq -r .id)
TITLE=$(call POST /v1/projects/$PROJECT/titles '{"name":"Northern Lights","kind":"feature","originalLanguage":"en"}' title | jq -r .id)
MASTER=$(call POST /v1/projects/$PROJECT/deliverables "{\"kind\":\"picture_master\",\"label\":\"Feature master\",\"titleId\":\"$TITLE\"}" master | jq -r .id)
call POST /v1/webhooks '{"url":"https://hooks.example.com/final-frame","events":["qc.passed","qc.failed"]}' hook | jq -r .secret > webhook-secret.txt || true
SIZE=$(wc -c < "$FILE" | tr -d ' ')
SESSION=$(call POST /v1/projects/$PROJECT/deliverables/$MASTER/uploads "{\"filename\":\"$(basename "$FILE")\",\"contentType\":\"video/quicktime\",\"byteSize\":$SIZE}" upload)
split -b "$(jq -r .partSize <<<"$SESSION")" -a 4 "$FILE" "part-$RUN-"
n=0; : > etags.jsonl
for part in part-$RUN-*; do
n=$((n + 1))
ETAG=$(curl -fsS -X PUT --upload-file "$part" "$(jq -r ".parts[$((n - 1))].url" <<<"$SESSION")" -D - -o /dev/null | awk 'tolower($1) == "etag:" { gsub(/["\r]/, "", $2); print $2 }')
echo "{\"partNumber\":$n,\"etag\":\"$ETAG\"}" >> etags.jsonl
done
call POST /v1/projects/$PROJECT/deliverables/$MASTER/uploads/$(jq -r .sessionId <<<"$SESSION")/complete "$(jq -s '{parts: .}' etags.jsonl)" complete > /dev/null
rm part-$RUN-* etags.jsonl
ff deliverables $PROJECT # status and QC per row; run again until qc_passed
call PATCH /v1/projects/$PROJECT/deliverables/$MASTER '{"status":"ready"}' ready | jq '{label, status}'// Node 20+, ESM. npm: @finalframe/sdk
import { randomUUID } from 'node:crypto';
import { open, stat } from 'node:fs/promises';
import { createClient } from '@finalframe/sdk';
const ff = createClient({ apiKey: process.env.FF_API_KEY!, baseUrl: 'https://qa.app.final-frame.com/api' });
const run = process.env.RUN ?? randomUUID(); // the same RUN resumes a stopped run
const write = <T>(method: string, path: string, body: unknown, step: string) =>
ff.raw<T>(method, path, body, { 'idempotency-key': `${run}-${step}` });
const project = await write<{ id: string }>('POST', '/v1/projects', {
name: 'Northern Lights: Nordics SVOD', client: 'Nordic Stream', territories: ['NORDICS'], rightsGranted: ['svod'],
}, 'deal');
const title = await write<{ id: string }>('POST', `/v1/projects/${project.id}/titles`, { name: 'Northern Lights', kind: 'feature', originalLanguage: 'en' }, 'title');
const master = await write<{ id: string }>('POST', `/v1/projects/${project.id}/deliverables`, { kind: 'picture_master', label: 'Feature master', titleId: title.id }, 'master');
// Needs integration.configure. Keep the secret: it is shown once.
const hook = await write<{ secret: string }>('POST', '/v1/webhooks', { url: 'https://hooks.example.com/final-frame', events: ['qc.passed', 'qc.failed'] }, 'hook').catch(() => null);
// Upload in parts, straight to storage
const file = process.env.FILE ?? 'master.mov';
const byteSize = (await stat(file)).size;
const uploads = `/v1/projects/${project.id}/deliverables/${master.id}/uploads`;
type Part = { partNumber: number; url: string };
const session = await write<{ sessionId: string; partSize: number; partCount: number; parts: Part[] }>('POST', uploads, { filename: file, contentType: 'video/quicktime', byteSize }, 'upload');
const urls = [...session.parts];
while (urls.length < session.partCount) {
const more = await ff.raw<{ parts: Part[] }>('POST', `${uploads}/${session.sessionId}/parts`, { from: urls.length + 1, to: Math.min(session.partCount, urls.length + 200) });
urls.push(...more.parts);
}
const handle = await open(file);
const parts = [];
for (const { partNumber, url } of urls) {
const bytes = Buffer.alloc(Math.min(session.partSize, byteSize - (partNumber - 1) * session.partSize));
await handle.read(bytes, 0, bytes.length, (partNumber - 1) * session.partSize);
const res = await fetch(url, { method: 'PUT', body: bytes });
if (!res.ok) throw new Error(`part ${partNumber}: ${res.status}`);
parts.push({ partNumber, etag: res.headers.get('etag')! });
}
await handle.close();
await write('POST', `${uploads}/${session.sessionId}/complete`, { parts }, 'complete');
// QC runs by itself. With a webhook, the receiver below acts on qc.passed; without one, poll.
if (!hook) {
let status = '';
while (status !== 'qc_passed' && status !== 'qc_failed') {
await new Promise((r) => setTimeout(r, 20_000));
status = (await ff.raw<{ status: string }>('GET', `/v1/projects/${project.id}/deliverables/${master.id}`)).status;
}
if (status === 'qc_passed') await write('PATCH', `/v1/projects/${project.id}/deliverables/${master.id}`, { status: 'ready' }, 'ready');
console.log('QC:', status);
}# Python 3.9+. pip: finalframe
import os, time, urllib.request, uuid
from finalframe import Client, FinalFrameError
ff = Client(api_key=os.environ["FF_API_KEY"], base_url="https://qa.app.final-frame.com/api")
run = os.environ.get("RUN") or str(uuid.uuid4()) # the same RUN resumes a stopped run
def write(method, path, body, step):
return ff.raw(method, path, body, {"idempotency-key": f"{run}-{step}"})
project = write("POST", "/v1/projects", {"name": "Northern Lights: Nordics SVOD", "client": "Nordic Stream",
"territories": ["NORDICS"], "rightsGranted": ["svod"]}, "deal")
title = write("POST", f"/v1/projects/{project['id']}/titles", {"name": "Northern Lights", "kind": "feature", "originalLanguage": "en"}, "title")
master = write("POST", f"/v1/projects/{project['id']}/deliverables",
{"kind": "picture_master", "label": "Feature master", "titleId": title["id"]}, "master")
try: # needs integration.configure; keep the secret, it is shown once
hook = write("POST", "/v1/webhooks", {"url": "https://hooks.example.com/final-frame", "events": ["qc.passed", "qc.failed"]}, "hook")
except FinalFrameError as err:
hook = None
print("no webhook:", err.status, err.scope)
path = os.environ.get("FILE", "master.mov")
size = os.path.getsize(path)
uploads = f"/v1/projects/{project['id']}/deliverables/{master['id']}/uploads"
session = write("POST", uploads, {"filename": os.path.basename(path), "contentType": "video/quicktime", "byteSize": size}, "upload")
urls = list(session["parts"])
while len(urls) < session["partCount"]:
more = ff.raw("POST", f"{uploads}/{session['sessionId']}/parts",
{"from": len(urls) + 1, "to": min(session["partCount"], len(urls) + 200)})
urls.extend(more["parts"])
parts = []
with open(path, "rb") as f:
for part in urls:
f.seek((part["partNumber"] - 1) * session["partSize"])
put = urllib.request.Request(part["url"], data=f.read(session["partSize"]), method="PUT")
with urllib.request.urlopen(put, timeout=600) as res:
parts.append({"partNumber": part["partNumber"], "etag": res.headers["ETag"]})
write("POST", f"{uploads}/{session['sessionId']}/complete", {"parts": parts}, "complete")
# QC runs by itself. With a webhook, the receiver acts on qc.passed; without one, poll.
row = f"/v1/projects/{project['id']}/deliverables/{master['id']}"
status = ""
while hook is None and status not in ("qc_passed", "qc_failed"):
time.sleep(20)
status = ff.raw("GET", row)["status"]
if status == "qc_passed":
write("PATCH", row, {"status": "ready"}, "ready")
print("QC:", status or "the webhook will say")The receiver
Where the script registered a webhook, this receiver closes the loop: it checks the signature, answers within ten seconds, ignores a delivery it has already handled, and marks the master ready on qc.passed. FF_WEBHOOK_SECRET is the whsec_ secret the registration returned.
// The receiver: verify the signature, answer at once, then mark the master delivered on qc.passed.
import { createHmac, timingSafeEqual } from 'node:crypto';
import { createServer } from 'node:http';
import { createClient } from '@finalframe/sdk';
const ff = createClient({ apiKey: process.env.FF_API_KEY!, baseUrl: 'https://qa.app.final-frame.com/api' });
const key = Buffer.from(process.env.FF_WEBHOOK_SECRET!.replace(/^whsec_/, ''), 'base64');
const handled = new Set<string>(); // keep these in your database: a redelivery repeats the webhook-id
createServer(async (req, res) => {
let body = '';
for await (const chunk of req) body += chunk;
const id = String(req.headers['webhook-id']);
const timestamp = String(req.headers['webhook-timestamp']);
const expected = Buffer.from(createHmac('sha256', key).update(`${id}.${timestamp}.${body}`).digest('base64'));
const signed = String(req.headers['webhook-signature'] ?? '').split(' ').some((s) => {
const given = Buffer.from(s.replace(/^v1,/, ''));
return given.length === expected.length && timingSafeEqual(given, expected);
});
if (!signed || Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return res.writeHead(401).end();
res.writeHead(204).end(); // a 2xx within ten seconds, then the work
if (handled.has(id)) return;
handled.add(id);
const event = JSON.parse(body);
if (event.type === 'qc.passed') {
await ff.raw('PATCH', `/v1/projects/${event.projectId}/deliverables/${event.data.deliverableId}`, { status: 'ready' }, { 'idempotency-key': `ready-${event.id}` });
} else if (event.type === 'qc.failed') {
console.log('QC failed:', event.data.label, event.data.failed);
}
}).listen(8080);# The receiver: verify the signature, answer at once, then mark the master delivered on qc.passed.
import base64, hashlib, hmac, json, os, time
from http.server import BaseHTTPRequestHandler, HTTPServer
from finalframe import Client
ff = Client(api_key=os.environ["FF_API_KEY"], base_url="https://qa.app.final-frame.com/api")
key = base64.b64decode(os.environ["FF_WEBHOOK_SECRET"].removeprefix("whsec_"))
handled = set() # keep these in your database: a redelivery repeats the webhook-id
class Hook(BaseHTTPRequestHandler):
def do_POST(self):
body = self.rfile.read(int(self.headers["content-length"])).decode()
wid, ts = self.headers["webhook-id"], self.headers["webhook-timestamp"]
expected = base64.b64encode(hmac.new(key, f"{wid}.{ts}.{body}".encode(), hashlib.sha256).digest()).decode()
signed = any(hmac.compare_digest(s.removeprefix("v1,"), expected) for s in self.headers.get("webhook-signature", "").split())
if not signed or abs(time.time() - int(ts)) > 300:
self.send_response(401); self.end_headers(); return
self.send_response(204); self.end_headers() # a 2xx within ten seconds, then the work
if wid in handled:
return
handled.add(wid)
event = json.loads(body)
if event["type"] == "qc.passed":
row = f"/v1/projects/{event['projectId']}/deliverables/{event['data']['deliverableId']}"
ff.raw("PATCH", row, {"status": "ready"}, {"idempotency-key": f"ready-{event['id']}"})
elif event["type"] == "qc.failed":
print("QC failed:", event["data"]["label"], event["data"]["failed"])
HTTPServer(("", 8080), Hook).serve_forever()Rights to avails
Record what you hold in a work and what narrows it, ask whether a right is free, and send the result to a buyer as EMA Avails. The routes: GET /v1/rights/works, POST /v1/rights/holdings, POST /v1/rights/restrictions, GET /v1/rights/availability, and GET /v1/rights/avails/ema or, for a large catalogue, POST /v1/rights/avails/ema/exports.
With the holding and the holdback in place, and nothing else recorded on the work, AVOD in the UK over 2027 answers partial: free from 1 July, with the holdback named as the reason. A mistaken record is revoked with POST /v1/rights/holdings/{id}/revoke or /restrictions/{id}/revoke; the record stays on file. More on Rights.
#!/usr/bin/env bash
set -euo pipefail
FF=https://qa.app.final-frame.com/api/v1
RUN=${RUN:-$(uuidgen)}
AUTH=(-H "Authorization: Bearer $FF_API_KEY")
post() { curl -fsS --fail-with-body -X POST "$FF$1" "${AUTH[@]}" -H "Content-Type: application/json" -H "Idempotency-Key: $RUN-$2" -d "$3"; }
# 1. A work, by its identity
WORK=$(curl -fsS -G "$FF/rights/works" "${AUTH[@]}" --data-urlencode "limit=1" | jq -r '.data[0].identity')
# 2. What we hold: SVOD, AVOD, TVOD and EST in the UK and Ireland, 2027 to 2031, exclusive (needs org.settings.edit)
post /rights/holdings holding "{\"identity\":\"$WORK\",\"source\":\"licensed_in\",\"counterparty\":\"Aurora Pictures\",\"rights\":[\"svod\",\"avod\",\"tvod\",\"est\"],\"territories\":[\"GB\",\"IE\"],\"starts\":\"2027-01-01\",\"ends\":\"2031-12-31\",\"endKind\":\"date\",\"exclusive\":true}" | jq '{id, rights, territories}'
# 3. What narrows it: AVOD held back in the UK for the first half of 2027
post /rights/restrictions holdback "{\"identity\":\"$WORK\",\"kind\":\"holdback\",\"rights\":[\"avod\"],\"territories\":[\"GB\"],\"starts\":\"2027-01-01\",\"ends\":\"2027-06-30\",\"note\":\"AVOD after six months of SVOD\"}" | jq '{id, kind}'
# 4. The answer, with reasons and the free intervals
curl -fsS -G "$FF/rights/availability" "${AUTH[@]}" --data-urlencode "identity=$WORK" \
--data-urlencode "right=avod" --data-urlencode "territory=GB" --data-urlencode "from=2027-01-01" --data-urlencode "to=2027-12-31" \
| jq '.answer | {state, intervals, reasons}'
# 5. EMA Avails 1.7.3 for the UK, what is available and what is licensed out
curl -fsS -G "$FF/rights/avails/ema" "${AUTH[@]}" --data-urlencode "territory=GB" --data-urlencode "from=2027-01-01" \
--data-urlencode "rights=svod,avod,tvod,est" --data-urlencode "include=both" -D headers.txt -o avails-gb.xlsx
grep -i '^x-ff-avails-summary' headers.txt#!/usr/bin/env bash
set -euo pipefail
export FF_API_BASE=https://qa.app.final-frame.com/api
RUN=${RUN:-$(uuidgen)}
WORK=$(ff works --limit 1 --json | jq -r '.[0].identity')
# Needs org.settings.edit
ff call POST /v1/rights/holdings --idempotency-key "$RUN-holding" --json --data "{\"identity\":\"$WORK\",\"source\":\"licensed_in\",\"counterparty\":\"Aurora Pictures\",\"rights\":[\"svod\",\"avod\",\"tvod\",\"est\"],\"territories\":[\"GB\",\"IE\"],\"starts\":\"2027-01-01\",\"ends\":\"2031-12-31\",\"endKind\":\"date\",\"exclusive\":true}"
ff call POST /v1/rights/restrictions --idempotency-key "$RUN-holdback" --json --data "{\"identity\":\"$WORK\",\"kind\":\"holdback\",\"rights\":[\"avod\"],\"territories\":[\"GB\"],\"starts\":\"2027-01-01\",\"ends\":\"2027-06-30\"}"
ff check --identity "$WORK" --right avod --territory GB --from 2027-01-01 --to 2027-12-31
ff works --territory GB --from 2027-01-01 --to 2027-12-31 --rights svod,avod,tvod,est
# EMA Avails in the background: start, poll, then fetch the signed link
EXPORT=$(ff call POST /v1/rights/avails/ema/exports --idempotency-key "$RUN-avails" --json \
--data '{"territory":"GB","from":"2027-01-01","rights":"svod,avod,tvod,est","include":"both"}' | jq -r .id)
until STATE=$(ff call GET /v1/rights/avails/ema/exports/$EXPORT --json); [[ $(jq -r .status <<<"$STATE") != running ]]; do
jq -r '"\(.percent)% \(.phrase)"' <<<"$STATE"; sleep 5
done
curl -fsS -o avails-gb.xlsx "$(jq -r .downloadUrl <<<"$STATE")"
jq .summary <<<"$STATE"import { randomUUID } from 'node:crypto';
import { writeFile } from 'node:fs/promises';
import { createClient } from '@finalframe/sdk';
const base = 'https://qa.app.final-frame.com/api';
const ff = createClient({ apiKey: process.env.FF_API_KEY!, baseUrl: base });
const run = process.env.RUN ?? randomUUID();
const post = <T>(path: string, body: unknown, step: string) => ff.raw<T>('POST', path, body, { 'idempotency-key': `${run}-${step}` });
const [work] = (await ff.rights.works({ limit: 1 })).data;
const identity = work!.identity;
// Needs org.settings.edit
await post('/v1/rights/holdings', {
identity, source: 'licensed_in', counterparty: 'Aurora Pictures', rights: ['svod', 'avod', 'tvod', 'est'],
territories: ['GB', 'IE'], starts: '2027-01-01', ends: '2031-12-31', endKind: 'date', exclusive: true,
}, 'holding');
await post('/v1/rights/restrictions', {
identity, kind: 'holdback', rights: ['avod'], territories: ['GB'], starts: '2027-01-01', ends: '2027-06-30',
}, 'holdback');
const { answer } = await ff.rights.availability({ identity, right: 'avod', territory: 'GB', from: '2027-01-01', to: '2027-12-31' });
console.log(answer.state, answer.intervals, answer.reasons);
// EMA Avails 1.7.3: a workbook, so read it with fetch rather than the JSON client
const query = new URLSearchParams({ territory: 'GB', from: '2027-01-01', rights: 'svod,avod,tvod,est', include: 'both' });
const res = await fetch(`${base}/v1/rights/avails/ema?${query}`, { headers: { authorization: `Bearer ${process.env.FF_API_KEY}` } });
if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`);
await writeFile('avails-gb.xlsx', Buffer.from(await res.arrayBuffer()));
console.log(JSON.parse(res.headers.get('x-ff-avails-summary')!));import json, os, urllib.parse, urllib.request, uuid
from finalframe import Client
base = "https://qa.app.final-frame.com/api"
ff = Client(api_key=os.environ["FF_API_KEY"], base_url=base)
run = os.environ.get("RUN") or str(uuid.uuid4())
def post(path, body, step):
return ff.raw("POST", path, body, {"idempotency-key": f"{run}-{step}"})
identity = ff.rights.works(limit=1)["data"][0]["identity"]
# Needs org.settings.edit
post("/v1/rights/holdings", {"identity": identity, "source": "licensed_in", "counterparty": "Aurora Pictures",
"rights": ["svod", "avod", "tvod", "est"], "territories": ["GB", "IE"],
"starts": "2027-01-01", "ends": "2031-12-31", "endKind": "date", "exclusive": True}, "holding")
post("/v1/rights/restrictions", {"identity": identity, "kind": "holdback", "rights": ["avod"], "territories": ["GB"],
"starts": "2027-01-01", "ends": "2027-06-30"}, "holdback")
answer = ff.rights.availability(identity=identity, right="avod", territory="GB", from_="2027-01-01", to="2027-12-31")["answer"]
print(answer["state"], answer["intervals"], answer["reasons"])
# EMA Avails 1.7.3: a workbook, so read the bytes directly
query = urllib.parse.urlencode({"territory": "GB", "from": "2027-01-01", "rights": "svod,avod,tvod,est", "include": "both"})
req = urllib.request.Request(f"{base}/v1/rights/avails/ema?{query}", headers={"authorization": f"Bearer {os.environ['FF_API_KEY']}"})
with urllib.request.urlopen(req, timeout=300) as res, open("avails-gb.xlsx", "wb") as out:
out.write(res.read())
print(json.loads(res.headers["x-ff-avails-summary"]))Configuration as code
Export the organisation's set-up, change a delivery spec in the file, and apply it. GET /v1/config exports delivery specs, house formats, the metadata schema and every taxonomy. POST /v1/config/plan lists what a document would change without writing; PUT /v1/config applies it: specs are matched by name, taxonomy terms by key, formats and the schema are replaced when given, and nothing is deleted unless prune names the section. The same document applied twice changes nothing the second time. To run the apply from CI, see Configuration as code.
#!/usr/bin/env bash
set -euo pipefail
FF=https://qa.app.final-frame.com/api/v1
AUTH=(-H "Authorization: Bearer $FF_API_KEY")
# 1. Export the set-up (needs org.view)
curl -fsS "$FF/config" "${AUTH[@]}" > final-frame.json
jq -r '.deliverySpecs[].name' final-frame.json # the specs, by name
# 2. Change one spec: its HD picture at 25 fps and EBU R128 loudness
SPEC=${SPEC:-$(jq -r '.deliverySpecs[0].name' final-frame.json)}
jq --arg name "$SPEC" '(.deliverySpecs[] | select(.name == $name) | .doc.formats) |=
(.video_hd.frameRate = "25" | .audio.loudness = "EBU R128 (-23 LUFS)")' final-frame.json > next.json
diff <(jq -S . final-frame.json) <(jq -S . next.json) || true
# 3. Apply it (needs org.settings.edit). Upserts by name and key; deletes nothing
curl -fsS --fail-with-body -X PUT "$FF/config" "${AUTH[@]}" -H "Content-Type: application/json" \
-H "Idempotency-Key: config-$(shasum -a 256 next.json | cut -c1-32)" --data-binary @next.json
# answers with counts: specs created and updated, formats and schema applied, taxonomy terms added and updated
mv next.json final-frame.json#!/usr/bin/env bash
set -euo pipefail
export FF_API_BASE=https://qa.app.final-frame.com/api
ff config export > final-frame.json
SPEC=${SPEC:-$(jq -r '.deliverySpecs[0].name' final-frame.json)}
jq --arg name "$SPEC" '(.deliverySpecs[] | select(.name == $name) | .doc.formats) |=
(.video_hd.frameRate = "25" | .audio.loudness = "EBU R128 (-23 LUFS)")' final-frame.json > next.json && mv next.json final-frame.json
# keep final-frame.json in your repository; see exactly what would change, then apply (needs org.settings.edit)
ff config plan final-frame.json
ff config apply final-frame.json --idempotency-key "config-$(shasum -a 256 final-frame.json | cut -c1-32)"import { createHash } from 'node:crypto';
import { writeFile } from 'node:fs/promises';
import { createClient } from '@finalframe/sdk';
type Spec = { name: string; destination: string | null; description: string | null; doc: { formats?: Record<string, Record<string, string>> } };
type Config = { version: number; deliverySpecs: Spec[]; formats: unknown; metadataSchema: unknown; taxonomies: unknown };
const ff = createClient({ apiKey: process.env.FF_API_KEY!, baseUrl: 'https://qa.app.final-frame.com/api' });
// 1. Export and keep it
const config = await ff.raw<Config>('GET', '/v1/config');
await writeFile('final-frame.json', JSON.stringify(config, null, 2));
// 2. Change one spec
const spec = config.deliverySpecs.find((s) => s.name === (process.env.SPEC ?? config.deliverySpecs[0]!.name))!;
spec.doc.formats = {
...spec.doc.formats,
video_hd: { ...spec.doc.formats?.video_hd, frameRate: '25' },
audio: { ...spec.doc.formats?.audio, loudness: 'EBU R128 (-23 LUFS)' },
};
// 3. Apply only what changed: a document without the other parts leaves them alone (needs org.settings.edit)
const change = { version: 1, deliverySpecs: [spec] };
const key = `config-${createHash('sha256').update(JSON.stringify(change)).digest('hex').slice(0, 32)}`;
console.log(await ff.raw('PUT', '/v1/config', change, { 'idempotency-key': key }));import hashlib, json, os
from finalframe import Client
ff = Client(api_key=os.environ["FF_API_KEY"], base_url="https://qa.app.final-frame.com/api")
# 1. Export and keep it
config = ff.raw("GET", "/v1/config")
with open("final-frame.json", "w") as f:
json.dump(config, f, indent=2, ensure_ascii=False)
# 2. Change one spec
name = os.environ.get("SPEC") or config["deliverySpecs"][0]["name"]
spec = next(s for s in config["deliverySpecs"] if s["name"] == name)
formats = spec["doc"].setdefault("formats", {})
formats.setdefault("video_hd", {})["frameRate"] = "25"
formats.setdefault("audio", {})["loudness"] = "EBU R128 (-23 LUFS)"
# 3. Apply only what changed (needs org.settings.edit)
change = {"version": 1, "deliverySpecs": [spec]}
key = "config-" + hashlib.sha256(json.dumps(change, sort_keys=True).encode()).hexdigest()[:32]
print(ff.raw("PUT", "/v1/config", change, {"idempotency-key": key}))