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.

Troubleshooting

Every error has one shape: the HTTP status, and a body with a stable error code, a message that says what to do next, docs (the code's row below) and retryable (whether the same request can succeed after a wait). Branch on the code; the message is for a person and its wording may change. Internal detail never reaches the body. A 429 or 503 always carries Retry-After in seconds.

HTTP/1.1 403 Forbidden
WWW-Authenticate: Bearer error="insufficient_scope", scope="audit.view"

{
  "error": "forbidden",
  "message": "This credential does not include audit.view.",
  "docs": "https://www.final-frame.com/docs/troubleshooting#forbidden",
  "retryable": false
}

Error codes

Generated from the platform's own error catalogue. Retry says whether the same request can succeed: after a wait, after you change something, or not at all.

CodeStatusWhat it meansWhat to do nextRetry
unauthenticated401No credential, or one that is not valid: revoked, expired or mistyped.Default message: “Sign in to continue.”Send Authorization: Bearer <key> with the whole key, prefix included. Check the key against GET /v1/me. A revoked or expired key needs a new one from an administrator.After a change
forbidden403The credential is valid but lacks the permission. The WWW-Authenticate header names it: scope="audit.view". Also returned for a paused app and for a key used in the wrong environment, with a message saying so.Default message: “You do not have permission to do that.”Ask an administrator for a key that carries the named permission, or for the app to be resumed. Do not retry with the same key.No
not_found404Nothing with that id is visible to this credential. A record in another organisation answers the same way, so a 404 never confirms that something exists.Default message: “Not found.”Check the id and that the key belongs to the organisation that holds the record (GET /v1/me, the X-FF-Org header). The title library id is not a deal and always answers 404 on deal routes.No
invalid_input400The request failed validation: a missing or malformed field, an unknown taxonomy key, an oversized body, or a write with no Idempotency-Key.Default message: “The request was not valid.”Read the message, which names the problem, fix the request, and send it again. The OpenAPI document gives every field.After a change
conflict409The request clashes with the current state: the record changed, the Idempotency-Key was already used for a different body, a limit was reached, or a held write was declined or expired.Default message: “That conflicts with the current state. Refresh and try again.”Read the record again and decide from what is there. For a key clash, use a new Idempotency-Key for the new request.After a change
delivery_doubt409A picture or sound file looks wrong for the placeholder it was sent to: already delivered elsewhere, or far from its siblings in size.Default message: “This file does not look like it belongs here. Check it, then send it anyway if it does.”Check the file and the placeholder. If it does belong there, send the same request again with "confirmed": true.After a change
qc_failed409The deliverable's latest QC verdict is Fail and nobody has waived it, so it cannot be approved, marked ready or sent. The answer names the rows and the waive endpoint for each.Default message: “This deliverable failed QC. Fix it and run QC again, or waive the failure with a reason.”Fix the file and run QC again, or waive the failure with a reason: POST /v1/projects/{id}/deliverables/{id}/qc-waiver (or the waive_qc tool), which needs project.configure and qc.execute. Then repeat the change.After a change
invalid_credentials401A sign-in with an email and password that do not match an account. Keys never get this answer.Default message: “No account with these details.”Check the details. The message is the same for every cause, by design.After a change
access_ended410A transfer grant or a job’s access to a shared file has ended: revoked, finished, past the sender’s ceiling, or the handoff is over.Default message: “This access has ended. Open the file again to start a new download.”Stop asking for links under that grant. Open a new grant to start a new download, and resume from the bytes already written.No
content_refused422An image service declined the request on content grounds.Default message: “The image service refused this on content grounds. Change the brief or try a different model.”Change the brief or the reference images, or choose another model. The same request gets the same answer.No
rate_limited429Too many requests: 600 a minute per key, 3,000 a minute per organisation, and lower ceilings on a few heavy routes.Default message: “Too many attempts. Wait a moment and try again.”Wait for the seconds in Retry-After, then send the same request. RateLimit-Remaining and RateLimit-Reset show the budget on every answer.Yes, after a wait
internal500Something failed on our side. It has been logged with the request.Default message: “Something went wrong on our side. It has been logged.”Retry with backoff and the same Idempotency-Key. A write that failed was not committed, so the retry runs it once. If it persists, write to support@final-frame.com with the time and the route.Yes, after a wait
unavailable503A service Final Frame depends on is not answering: a model, storage, or another provider.Default message: “That service is not responding right now. Try again in a moment.”Retry with exponential backoff and jitter, keeping the same Idempotency-Key on a write.Yes, after a wait

401 and 403: the key

A 401 means the key was not accepted at all: missing, mistyped, revoked or expired. Call GET /v1/me with it to check. A 403 means the key is good and lacks a permission; the permission is named in WWW-Authenticate as scope, and the SDKs expose it as error.scope. An administrator mints a key that carries it under Organisation settings, Integrations. Two refusals carry no scope, because no permission would help: an app its organisation has paused (“This app is paused by the organisation.”) and a key presented in the wrong environment, such as a Sandbox key on another organisation.

curl -s -D - https://qa.app.final-frame.com/api/v1/projects/<id>/audit -H "Authorization: Bearer $FF_API_KEY" -o /dev/null | grep -i www-authenticate
# www-authenticate: Bearer error="insufficient_scope", scope="audit.view"

404, or another organisation

A key sees one organisation. A record in any other organisation answers 404, exactly as a record that does not exist, so a 404 never confirms that something is there. Check the id, then check the key belongs to the organisation that holds it: GET /v1/me names the organisation, and every answer carries X-FF-Org with its slug. The title library is not a deal, and its id answers 404 on every deal route.

409: the state moved

conflict means the request no longer fits what is there: read the record again and decide from what you find. The same code answers an Idempotency-Key reused for a different request, a limit reached (fifty webhook endpoints, for example), and a held write that was declined or expired. delivery_doubt is a 409 on an upload that looks wrong for its placeholder: check the file, and if it belongs there, open the upload again with "confirmed": true.

400 and 422: the request itself

invalid_input (400) is a request that failed validation. The message names the problem: a missing field, a date not in YYYY-MM-DD, a status key the organisation does not use, a write without an Idempotency-Key. Fix it and send it again. content_refused (422) is an image service declining a brief on content grounds; the same brief gets the same answer, so change it rather than retry.

429: rate limits

Six hundred requests a minute per key and three thousand per organisation, across the API and MCP, with lower ceilings on a few heavy routes (twenty EMA Avails exports a minute, sixty config exports). Every limited answer carries RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset; a refusal is 429 rate_limited with Retry-After in seconds. Wait that long and send the same request.

// Retry 429 after Retry-After, and 500 or 503 with backoff and jitter. Writes keep their Idempotency-Key.
async function withRetry(send: () => Promise<Response>, attempts = 5): Promise<Response> {
  for (let i = 1; ; i++) {
    const res = await send();
    const retryable = res.status === 429 || res.status === 500 || res.status === 503;
    if (!retryable || i === attempts) return res;
    const after = Number(res.headers.get('retry-after'));
    const wait = after > 0 ? after * 1000 : Math.min(30_000, 500 * 2 ** i) * (0.5 + Math.random() / 2);
    await new Promise((r) => setTimeout(r, wait));
  }
}

Retrying a write safely

Every write takes an Idempotency-Key header. The first answer to a key is kept for a day: sending the same request with the same key returns that answer again, with Idempotent-Replayed: true, and writes nothing. So after a timeout, a dropped connection or a 500, send the same request with the same key. Use a new key for a new request; the same key with a different body is refused with 409. The SDKs make a key from the request's content when you pass none, so a retried call is one record.

KEY=$(uuidgen)
curl -X POST https://qa.app.final-frame.com/api/v1/projects/<id>/work-orders \
  -H "Authorization: Bearer $FF_API_KEY" -H "Idempotency-Key: $KEY" \
  -H "Content-Type: application/json" -d '{"name":"Subtitle the trailer","kind":"subtitling"}'
# The connection drops. Send it again with the same $KEY: one work order, the first answer.

202: a write held for approval

An organisation can switch on “Agent writes need approval”. Then a write from a key or an assistant answers 202 with a proposal and a Location to check, and nothing is written until a person decides. Approving runs the write as the key, checked again at that moment; declining writes nothing; seven days undecided and it expires. A key the organisation marks trusted writes straight through.

HTTP/1.1 202 Accepted
Location: /v1/proposals/5b0e...

{ "awaitingApproval": true, "message": "...", "proposal": { "id": "5b0e...", "status": "pending", ... } }

curl https://qa.app.final-frame.com/api/v1/proposals/5b0e... -H "Authorization: Bearer $FF_API_KEY"
# status: pending | running | approved | failed | declined | expired
# approved and failed carry result: { status, body }, the write's own answer

Poll the proposal, or subscribe to agent.proposal_decided. Sending the held request again with the same Idempotency-Key returns the same proposal while it waits, and the write's own answer once it has run. Only the key that made the proposal can read it.

Resuming an upload

An upload is a session of parts. POST .../uploads answers with a sessionId, the partSize and signed URLs for the first fifty parts, each good for an hour. The bytes go straight to storage. If the transfer stops, nothing is lost: ask for fresh URLs for the parts still missing with POST .../uploads/{sessionId}/parts and {"from": 12, "to": 40}, PUT those, then POST .../complete with every part's ETag. Opening the upload again with the same Idempotency-Key returns the same session rather than a second one. A complete with a part missing is refused, never joined.

Resuming a download

GET .../deliverables/{id}/download gives a signed link for fifteen minutes; ask again for a fresh one and continue with a Range request from the bytes you hold. A long download of a file the other party shares on a handed-off deal is a transfer grant (ADR 0017): POST .../transfer-grants opens it for up to the sender's ceiling, GET /v1/transfer-grants/{id}/url gives a ten-minute link as often as needed, and DELETE /v1/transfer-grants/{id}?finished=1 closes it when the last byte lands. When a grant has ended, the next link answers 410 access_ended: stop asking under it, open a new grant and resume from the bytes already written.

ff download <projectId> <deliverableId> --out master.mov     # run again after an interruption to pick up where it stopped

// TypeScript SDK: re-signs a minute before each link expires, and finishes the grant at the end
await ff.transfers.download(grant.id, { from: bytesOnDisk, write: (bytes) => appendFile('master.mov', bytes) });

Missed webhooks

A delivery not answered with a 2xx within ten seconds is tried again after 1 minute, 5 minutes, 30 minutes, 2 hours and 12 hours, then marked dead. Fifty failures in a row switch the endpoint off with a reason. To recover after an outage on your side:

  1. Switch the endpoint back on if it was switched off: PATCH /v1/webhooks/{id} with {"active": true}, which also clears its failure count.
  2. List what did not arrive: GET /v1/webhooks/{id}/deliveries?status=dead (or failed).
  3. Send each again: POST /v1/webhooks/{id}/deliveries/{deliveryId}/redeliver with an Idempotency-Key. A redelivery carries the original webhook-id, so a receiver that records ids it has handled ignores a second copy.

Webhooks tell you something changed; the API says what is true now. After a gap, read the records you care about (GET /v1/projects/{id}/deliverables, /audit) rather than replaying events in order. Webhook endpoints need integration.configure; more on Webhooks.

Still stuck

Write to support@final-frame.com with the time, the method and route, the status and code, and the X-FF-Org and X-FF-Environment headers from the answer. Never send the key.