diff --git a/docs.json b/docs.json
index af04ebb..f94cc07 100644
--- a/docs.json
+++ b/docs.json
@@ -41,6 +41,7 @@
"group": "Jobs",
"pages": [
"jobs/ffmpeg",
+ "jobs/ffprobe",
"jobs/compose",
{
"group": "Captions",
diff --git a/jobs/ffprobe.mdx b/jobs/ffprobe.mdx
new file mode 100644
index 0000000..f916498
--- /dev/null
+++ b/jobs/ffprobe.mdx
@@ -0,0 +1,351 @@
+---
+title: "Probe media metadata"
+sidebarTitle: "ffprobe"
+description: "Run ffprobe on a video, audio, or image URL and get a normalized summary plus the full raw report: codec, resolution, duration, fps, rotation, HDR, audio."
+icon: "magnifying-glass"
+keywords: ["ffprobe api", "media metadata api", "video metadata api", "probe video url", "ffprobe json api", "get video resolution api", "detect video rotation api", "hdr detection api", "raw ffprobe command"]
+canonical: "https://rendobar.com/docs/jobs/ffprobe"
+tag: "Live"
+---
+
+
+
+You send a `command`: the input media URL, optionally with extra ffprobe flags on top. Rendobar runs `ffprobe` and returns a normalized `summary` you branch on directly, plus the full raw `format`, `streams`, and `chapters` for anything the summary leaves out.
+
+
+**Live.** Accepts video, audio, and image URLs.
+
+
+## Probe a file
+
+
+
+```ts SDK
+import { createClient } from "@rendobar/sdk";
+
+const client = createClient({ apiKey: "rb_YOUR_KEY" });
+
+const job = await client.jobs.create({
+ type: "ffprobe",
+ params: { command: "https://cdn.rendobar.com/assets/examples/sample.mp4" },
+});
+
+const done = await client.jobs.wait(job.id);
+console.log(done.output.data.summary.kind); // "video"
+```
+
+```python Python
+import requests, time
+
+base = "https://api.rendobar.com"
+headers = {"Authorization": "Bearer rb_YOUR_KEY"}
+
+job = requests.post(
+ f"{base}/jobs",
+ headers=headers,
+ json={
+ "type": "ffprobe",
+ "params": {"command": "https://cdn.rendobar.com/assets/examples/sample.mp4"},
+ },
+).json()["data"]
+
+while job["status"] not in ("complete", "failed", "cancelled"):
+ time.sleep(1)
+ job = requests.get(f"{base}/jobs/{job['id']}", headers=headers).json()["data"]
+
+print(job["output"]["data"]["summary"]["kind"])
+```
+
+```bash cURL
+curl -X POST "https://api.rendobar.com/jobs" \
+ -H "Authorization: Bearer rb_YOUR_KEY" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "type": "ffprobe",
+ "params": { "command": "https://cdn.rendobar.com/assets/examples/sample.mp4" }
+ }'
+# Returns { "data": { "id": "job_...", "status": "waiting" } }.
+# Poll GET /jobs/{id} until "status": "complete", then read output.data.
+```
+
+
+
+The job returns an id, and the answer lands in its `output.data` once it completes. The `summary` is the part you branch on. The raw `format`, `streams`, and `chapters` sit next to it for the fields the summary does not lift out.
+
+
+An MCP client reaches the same job through the generic `submit_job` tool with `type: "ffprobe"`. See the [MCP server](/mcp-server).
+
+
+## The command
+
+`command` is additive, not a full ffprobe invocation. The runner always injects the core sections and JSON output for you (`-show_format -show_streams -show_chapters -of json`), so the minimal `command` is just the URL:
+
+```
+https://cdn.rendobar.com/assets/examples/sample.mp4
+```
+
+Anything you add stacks on top of that guaranteed core. Add `-show_frames` to get per-frame data for a bounded range:
+
+```
+-show_frames -read_intervals %+#5 https://cdn.rendobar.com/assets/examples/sample.mp4
+```
+
+This adds a `frames` array (the first 5 frames) to `output.data`, next to the `summary`, `format`, `streams`, and `chapters` you always get. A leading `ffprobe` token is fine either way.
+
+Two categories of flag get normalized instead of rejected, because they would fight the additive model:
+
+- **Writer flags** (`-of`, `-print_format`, `-output_format`) are stripped. The result is always JSON.
+- **Restricting flags** (`-select_streams`, `-show_entries`) are stripped. You already get the full structured result back, so filter it client-side instead of asking ffprobe to narrow it.
+
+Each strip adds a `WRITER_NORMALIZED` or `QUERY_NORMALIZED` entry to `output.data.warnings` rather than failing the job.
+
+`-protocol_whitelist`, `-protocol_blacklist`, and shell control characters (`>`, `|`, `;`) are rejected outright with `VALIDATION_ERROR` before the job is created. ffprobe's output is always returned to you, never written to a file.
+
+## The summary
+
+`summary` normalizes the parts of ffprobe that are awkward to read raw: rotation buried in side data, fractional frame rates, HDR spread across three color fields, cover art masquerading as a video stream.
+
+```json
+{
+ "kind": "video",
+ "container": "mp4",
+ "formatLongName": "QuickTime / MOV",
+ "durationSec": 12.48,
+ "sizeBytes": 4271822,
+ "bitrateBps": 4213000,
+ "startTimeSec": 0,
+ "streamCounts": { "video": 1, "audio": 1, "subtitle": 0, "data": 0, "attachment": 0 },
+ "tags": { "encoder": "Lavf60.16.100" },
+ "video": {
+ "codec": "h264",
+ "profile": "High",
+ "width": 1920,
+ "height": 1080,
+ "displayAspectRatio": "16:9",
+ "pixelFormat": "yuv420p",
+ "bitDepth": 8,
+ "fps": 29.97,
+ "isVariableFrameRate": false,
+ "rotation": 0,
+ "isHdr": false,
+ "bitrateBps": 4085000,
+ "language": null
+ },
+ "audio": {
+ "codec": "aac",
+ "profile": "LC",
+ "channels": 2,
+ "channelLayout": "stereo",
+ "sampleRate": 48000,
+ "bitrateBps": 128000,
+ "language": "eng"
+ }
+}
+```
+
+`kind` is the field to switch on first. It tells you what block to read next:
+
+| `kind` | Extra field | What it means |
+|---|---|---|
+| `"video"` | `video` (never `null`), `audio` (primary audio stream, or `null`) | A real, non-cover-art video stream is present |
+| `"audio"` | `audio` (never `null`) | No video stream, at least one audio stream |
+| `"image"` | `image` (never `null`) | A still image: an image container, or a zero-duration file with no audio |
+| `"other"` | none | Subtitle-only, data-only, or an attached-pic-only file with no audio track |
+
+A few things the summary gets right that raw ffprobe does not:
+
+- **Rotation is normalized.** `video.rotation` is `0`, `90`, `180`, or `270`, read from the display matrix side data (with a fallback to the legacy `rotate` tag). `width` and `height` are the stored dimensions before rotation, so a portrait phone clip reads `1920×1080` with `rotation: 90`. Apply the rotation to get display dimensions.
+- **Cover art is not a video stream.** An MP3 with embedded art reads `kind: "audio"`. The `attached_pic` stream is excluded from `streamCounts.video` and from `summary.video`.
+- **HDR is one boolean.** `video.isHdr` is `true` for PQ (`smpte2084`), HLG (`arib-std-b67`), or BT.2020 primaries. The exact color fields stay in the raw stream.
+- **Variable frame rate is flagged.** `video.fps` is the average frame rate as a float. `video.isVariableFrameRate` is `true` when the source is variable, so you know the single number is an average.
+
+Fields are `null`, never absent, when a value does not apply or ffprobe could not read it. `sizeBytes` is `null` when a remote server does not report length. `tags` is `{}`, never absent, when the container carries none.
+
+
+Every `summary` field name is stable. Write your checks against `summary.video.width` and `summary.kind` directly.
+
+
+## Parameters
+
+
+ The input media URL, optionally prefixed with additive ffprobe flags. See [The command](#the-command).
+
+
+
+ Max execution time in seconds. Plan caps apply, same as [FFmpeg](/jobs/ffmpeg#parameters). A probe rarely approaches it.
+
+
+## Get the result
+
+`POST /jobs` returns `201` right away with a job id and `status: "waiting"`. The probe runs, and the answer appears in `output.data` once `status` reaches `complete`.
+
+The SDK's `jobs.wait` polls for you and resolves with the finished job:
+
+```ts
+const job = await client.jobs.create({
+ type: "ffprobe",
+ params: { command: "https://example.com/video.mp4" },
+});
+
+const done = await client.jobs.wait(job.id);
+console.log(done.output.data.summary);
+```
+
+Over raw HTTP, poll `GET /jobs/{id}` until `status` is `complete`, or subscribe with [webhooks](/guides/webhooks) to skip polling. Most probes finish in one to three seconds.
+
+## Probe many files
+
+One job probes one file. To probe a set, submit one job per file and let them run together. Probe jobs do not count against your concurrency limit, so a batch of them dispatches at once instead of queueing behind your other work.
+
+```ts
+const urls = ["a.mp4", "b.mov", "c.webm"];
+
+const summaries = await Promise.all(
+ urls.map(async (url) => {
+ const job = await client.jobs.create({ type: "ffprobe", params: { command: url } });
+ const done = await client.jobs.wait(job.id);
+ return done.output.data.summary;
+ }),
+);
+```
+
+## How it runs
+
+1. **Probe remote.** `ffprobe` reads the URL directly over HTTPS. A faststart MP4 needs only its header, so nothing downloads, and a file larger than your plan's input limit still probes.
+2. **Fall back to download.** When a server does not support range requests, or the `moov` atom sits at the end of a non-faststart MP4, the runner downloads the file, then probes it. Your plan's input-size limit applies to this path.
+3. **Normalize.** The raw ffprobe JSON becomes the `summary`, and the raw `format`, `streams`, and `chapters` pass through untouched with ffprobe's own field names.
+
+## The output shape
+
+Every job returns the same `output` object. [Job output](/concepts/job#the-output) documents it in full. A probe is a data job: it computes an answer and writes no file.
+
+- `output.data`: the probe result, documented above.
+- `output.file`: `null`. A probe produces no file.
+- `output.files`: `[]`.
+- `output.expiresAt`: `null`.
+
+`output.data` guarantees `summary`, `format`, `streams`, `chapters`, and `warnings` on every response, consume them with no optional chaining. `programs`, `stream_groups`, `frames`, `packets`, `reportUrl`, and `stderr` are honest optionals: present only when your `command` asked for that section, or when there's something worth surfacing.
+
+```json
+{
+ "data": {
+ "id": "job_abc123",
+ "type": "ffprobe",
+ "status": "complete",
+ "output": {
+ "data": {
+ "summary": { "kind": "video", "container": "mp4", "durationSec": 12.48, "...": "..." },
+ "format": {
+ "filename": "https://cdn.rendobar.com/assets/examples/sample.mp4",
+ "format_name": "mov,mp4,m4a,3gp,3g2,mj2",
+ "format_long_name": "QuickTime / MOV",
+ "start_time": "0.000000",
+ "duration": "12.480000",
+ "size": "4271822",
+ "bit_rate": "4213000",
+ "tags": { "encoder": "Lavf60.16.100" }
+ },
+ "streams": [
+ {
+ "index": 0,
+ "codec_type": "video",
+ "codec_name": "h264",
+ "profile": "High",
+ "width": 1920,
+ "height": 1080,
+ "pix_fmt": "yuv420p",
+ "avg_frame_rate": "30000/1001",
+ "bit_rate": "4085000"
+ },
+ {
+ "index": 1,
+ "codec_type": "audio",
+ "codec_name": "aac",
+ "sample_rate": "48000",
+ "channels": 2,
+ "bit_rate": "128000",
+ "tags": { "language": "eng" }
+ }
+ ],
+ "chapters": [],
+ "warnings": []
+ },
+ "file": null,
+ "files": [],
+ "expiresAt": null
+ }
+ }
+}
+```
+
+### Warnings
+
+`output.data.warnings` carries machine-readable notes about a probe that succeeded with a caveat. Each entry is `{ code, message }`. Branch on `code`, not the message text.
+
+| `code` | Meaning |
+|---|---|
+| `DURATION_UNKNOWN` | The container has no duration, a common sign of truncation |
+| `WRITER_NORMALIZED` | A writer flag (`-of`, `-print_format`) was stripped from your command |
+| `QUERY_NORMALIZED` | A restricting flag (`-select_streams`, `-show_entries`) was stripped from your command |
+| `REPORT_SPILLED` | `frames`/`packets` were too large to return inline and were dropped; re-run with a narrower `-read_intervals` |
+
+## Error handling
+
+A malformed `command` (no URL, a blocked flag, a shell redirect) is rejected with `VALIDATION_ERROR` before the job is even created. Once the job runs, a failed probe carries an `error` object with `code`, `message`, `detail`, and `retryable`. The two you handle:
+
+| `code` | `retryable` | Meaning |
+|---|---|---|
+| `INPUT_UNREADABLE` | `false` | The file is corrupt or a format ffprobe cannot read. Do not retry. |
+| `INPUT_FETCH_FAILED` | `true` | The URL could not be fetched (expired link, DNS failure, transient 5xx). Refresh the URL and retry. |
+
+```json
+{
+ "data": {
+ "id": "job_abc123",
+ "status": "failed",
+ "error": {
+ "code": "INPUT_UNREADABLE",
+ "message": "ffprobe could not read the input",
+ "detail": "Invalid data found when processing input",
+ "retryable": false
+ }
+ }
+}
+```
+
+A file that probes but looks truncated (recognized container, no duration) succeeds with a `DURATION_UNKNOWN` warning rather than failing. Treat a `summary` with `durationSec: null` on a video as suspect.
+
+Full error catalogue: [Error codes](/support/errors).
+
+## Common uses
+
+- **Validate uploads before you transcode.** Reject a zero-duration file or an unexpected codec before you spend compute on it.
+- **Pick a rendition ladder.** Read `summary.video.width` and emit a 1080p output only when the source is at least that wide, so you never upscale.
+- **Route portrait and landscape.** Branch on `summary.video.rotation` and the display dimensions.
+- **Skip silent renditions.** `summary.audio === null` tells you there is no audio track.
+- **Meter by duration.** `summary.durationSec` for billing or limits, without downloading the file.
+- **Inspect a bounded frame range.** Add `-show_frames -read_intervals %+#5` to check keyframe placement on the first 5 seconds without pulling the whole file.
+
+## See also
+
+- [Job output](/concepts/job#the-output): the output shape every job returns
+- [FFmpeg](/jobs/ffmpeg): run a command against the file you probed
+- [Webhooks](/guides/webhooks): receive `job.completed` instead of polling
+- [Credits and billing](/concepts/credits): per-compute-second pricing detail
+- [Error codes](/support/errors): the full error catalogue