Errors
An error from this API is a request you can send. Ask for a five-second window at a level capped to three and the refusal carries a suggestion object holding the three-second call — ready to spread straight back into the retry. That is the difference between an agent that recovers and one that apologises.
Last updated
The shape of an error
Two fields are always present. The third is what makes an agent able to recover on its own.
{
"error": {
"type": "window_too_long_for_detail",
"message": "motion is limited to 3s windows; you asked for 5s.",
"suggestion": { "start": 0, "end": 3, "detail": "motion" },
"max_window_seconds": 3
}
}- type
- The machine-readable discriminator. Branch on this, never on the message — messages are written for a reader and will be improved.
- message
- What went wrong, written to be acted on. It names the limit and what was asked for, not just that a limit exists.
- suggestion
- Present when one concrete call would have worked. It is an arguments object, so the recovery is a spread rather than a parse.
- alternative
- Present when no single call is the answer — a different approach, in prose, for a reader to choose from.
import { WindowTooLong } from "@playhead/sdk";
try {
await playhead.watch({ url, detail: "motion", start: 0, end: 5 });
} catch (error) {
if (error instanceof WindowTooLong && error.suggestion) {
await playhead.watch({ url, ...error.suggestion });
} else {
throw error;
}
}Every type
Thirteen of them. The recover column is the point of the table — an error type without a fix is just a label.
| Type | Status | When | What to do |
|---|---|---|---|
window_too_long_for_detail | 400 | The window asked for is longer than that level allows. | The response carries a suggestion object holding the call that would work. Spread it back into the request. |
invalid_window | 400 | end is before start, or the window falls outside the video. | Clamp against video.duration, which any earlier response reports. |
unknown_detail | 400 | A detail level that does not exist. | GET /v1/detail-levels returns the current list. |
invalid_request | 400 | A malformed timestamp, or neither url nor upload_id. | The message names the field. Timestamps take seconds or MM:SS. |
unauthorized | 401 | No credential, a malformed one, or an expired token. | Send Authorization: Bearer sk_live_…. Keys are created in the dashboard. |
insufficient_credits | 402 | The wallet this credential spends is empty. | Buy credit or wait for the monthly renewal. Not a retry — the balance has to change first. |
insufficient_scope | 403 | An OAuth token that was never granted video:read. | Reconnect the account and approve the scope. |
tier_limit | 403 | The plan does not allow this — motion on free, or a video past the length cap. | Use a lower level, a shorter window, or upgrade. |
video_not_found | 404 | A video, job or upload id that names nothing you own. | Ids are scoped to an organization. Check which key you are sending. |
source_unavailable | 422 | The video could not be fetched: private, geo-blocked, deleted, or behind a login. | Upload the file instead. The message distinguishes a login wall from a hard block, because they need different fixes. |
no_video_stream | 422 | ffprobe cannot read the file as media, or it holds neither video nor audio. | Camera raw is refused by name with the export to make instead. Everything else means a broken or truncated file. |
rate_limited | 429 | Too many requests, or more jobs at once than the plan allows. | Back off and retry. Honour Retry-After when it is sent. |
processing_failed | 500 | A fault on our side, not something to fix in the request. | Retrying is safe and is the right move. |
Retrying
Two of the thirteen. The SDK does this for you; anything else building its own queue needs the same rule.
| Retry | Do not retry |
|---|---|
rate_limited (429) — back off, honouring Retry-After | insufficient_credits (402) — the balance has to change |
processing_failed (500) — a fault on our side, safe to repeat | window_too_long_for_detail (400) — send the suggestion instead |
| A connection that never landed | source_unavailable (422) — upload the file |
Silence that is not an error
Two responses come back with a 200 and still mean “do not conclude anything from this”.
- technical.skipped === true
- The cut, freeze, black-frame and silence detectors never ran — they are skipped on windows over fifteen minutes. The empty lists beside this flag mean nothing at all, and reporting “no cuts” from them is the mistake this field exists to prevent.
- speech.is_speech === false
- The audio was music or noise rather than speech. A transcript is still returned, and it is fluent and entirely invented. Treat it as absent.
- coverage.gaps.length > 0
- Not an error either, but the seconds nobody looked at.
coverage.truncated.next_startnames where to resume when a level's window cap cut the request short.
The SDK turns each wire type into a class, so these become instanceof checks rather than string comparisons.
Questions
- Which errors should I retry?
- Only 429 and 5xx, plus a connection that never landed. Everything else needs something to change first — a shorter window, a different level, a topped-up balance — and retrying it burns the rate limit to arrive at the same refusal.
- What is the suggestion field?
- Arguments for a call that would work, not prose. When a window is too long for a detail level, suggestion holds the window that fits, ready to spread straight back into the request.
- How do I tell a geo-block from a login wall?
- The message distinguishes them, because they need different fixes: a login wall means upload the file, while a block means the video may be reachable from elsewhere. Both arrive as source_unavailable with a 422.
- Why did an empty cut list come back for a long video?
- Check technical.skipped. The detectors are skipped automatically on windows longer than fifteen minutes because a full decode costs more than the flags are worth on a survey. Without that flag, a video nobody measured looks identical to a video with no cuts.