# API reference

Every v1 endpoint, generated from the [OpenAPI 3.1 spec](/v1/openapi.json). Base URL `https://postreef.com`; authenticate with `x-api-key` or `Authorization: Bearer`.

## Endpoints

All endpoints return the standard error envelope on failure and carry `X-RateLimit-*` headers. See [errors & limits](/docs/api/errors).

### `POST /v1/extractions`

**Submit an extraction**

Starts an extraction for a public video URL. Credits are debited up-front (a flat base fee plus download rates for the requested artifacts, plus per-second AI rates when a schema is set; use `POST /v1/probe` for the exact quote first) and refunded in full if the extraction fails. When the duration can't be determined up-front, a worst-case duration of 300s is debited and the difference is refunded on completion.

If an identical extraction (same URL, schema and inputs) completed recently, the result is reused: the response has `cached: true` semantics on subsequent reads and `creditsDebited: 0`. Pass `force: true` to skip the cache and re-run.

Rate limit: 6 requests per minute. At most 2 extractions run per account at once. Submissions past that limit are accepted with status `queued` (credits debited up-front) and dispatched automatically when a slot frees.

**Parameters**

| Field | Type | Description |
| --- | --- | --- |
| `Idempotency-Key (header)` | `string` | Any unique string (e.g. a UUID). Retrying a submit with the same key and the same request body returns the original extraction with status 200 instead of creating a new one. Reusing a key with a *different* body returns 409 conflict. |

**Request body**

| Field | Type | Description |
| --- | --- | --- |
| `url` *(required)* | `string` | Public video URL (YouTube, Shorts, TikTok, Instagram Reels, …). |
| `inputs` | `"transcript" \| "comments" \| "audio" \| "video"[]` | Which modalities feed the AI extraction. Defaults to ["transcript","comments"]. Determines the price. |
| `schemaId` | `string` | Id of a predefined schema (e.g. `Recipe`) or one of your saved schemas. Mutually exclusive with `schema`. |
| `schema` | `object` | Inline JSON Schema (max 100KB) describing the structured object you want back. Omit both `schema` and `schemaId` for a download-only run (no AI, no per-second AI charge). |
| `prompt` | `string` | Extraction guidance sent to the model alongside the schema. It steers how the video is read into the schema (source priority, what to omit, common pitfalls). For a predefined `schemaId` this overrides that schema's built-in prompt; for an inline `schema` it's the prompt that pairs with it. Max 20KB. Part of the cache key, so an edited prompt re-runs instead of reusing a cached result. Ignored on download-only runs. |
| `auto` | `boolean` | Auto mode: start with the cheapest inputs (text), judge the result, and only climb to audio/video if quality is below threshold. `inputs` becomes the ceiling the climb may reach. Requires `maxSpendCredits`. Charged the ceiling at submit, refunded down to the rung actually used. Auto runs reuse a recent result on the same URL, schema and model when its inputs fit inside your `inputs` ceiling (free, `creditsDebited: 0`); pass `force: true` to re-run instead. |
| `force` | `boolean` | Skip the result cache and run the full pipeline even when a recent identical extraction exists. The run is billed as fresh. Default false. |
| `maxSpendCredits` | `number` | Spend ceiling for an auto run, in credits. Required when `auto` is true. Must be at least the cost of the cheapest rung. |
| `policy` | `"strict" \| "fallback" \| "best-effort"` | What to do when a requested input isn't available for this video (e.g. no comments): `strict` fails the run, `fallback` (default) proceeds with reduced inputs, `best-effort` silently drops the missing input. |
| `parts` | `"transcript" \| "comments" \| "audio" \| "video"[]` | Download-only runs (no schema): which artifacts to fetch. Omit for everything. Metadata, thumbnail and description are always included. Invalid alongside `schema`/`schemaId`. |
| `webhookUrl` | `string` | Per-run webhook override: an https URL (public hosts only) that receives `extraction.completed` / `extraction.failed` for this run in addition to your registered endpoints. Signed with your first registered endpoint's secret when one exists; unsigned otherwise. |

```json
{
  "url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
  "inputs": [
    "transcript",
    "comments"
  ],
  "schemaId": "Recipe",
  "webhookUrl": "https://example.com/hooks/postreef"
}
```

**Response fields**

| Field | Type | Description |
| --- | --- | --- |
| `id` *(required)* | `string` | Extraction id, used for polling, results and files. |
| `status` *(required)* | `"queued" \| "pending" \| "running" \| "complete" \| "failed"` |  |
| `url` *(required)* | `string` |  |
| `creditsDebited` *(required)* | `integer` | Credits debited up-front. 0 when the result was served from a recent identical extraction. |
| `createdAt` *(required)* | `string` |  |

**Responses**

- `200`: Idempotent replay: the Idempotency-Key matched a previous submit with the same body, so the original extraction is returned.

- `201`: Extraction created and started.

```json
{
  "id": "run_8f3a2b1c",
  "status": "pending",
  "url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
  "creditsDebited": 127,
  "createdAt": "2026-06-11T10:15:00.000Z"
}
```

- `400`: Invalid request: missing/invalid url (`invalid_url`), unsupported platform (`unsupported_url`), invalid options or webhookUrl (`invalid_request`).

- `401`: Missing or invalid API key.

- `402`: Insufficient credits (`insufficient_credits`). `details.balance` and `details.cost` carry your current balance and the quoted cost in credits.

- `404`: Referenced schemaId not found.

- `409`: Idempotency-Key was already used with a different request body (`conflict`).

- `429`: `rate_limited` (per-minute window exceeded, includes Retry-After).

### `GET /v1/extractions`

**List extractions**

Lists your extractions, newest first, with cursor pagination. Pass `next_cursor` from a previous page as `cursor` to fetch the next page.

**Parameters**

| Field | Type | Description |
| --- | --- | --- |
| `limit (query)` | `integer` | Page size, 1–100. |
| `cursor (query)` | `string` | Opaque cursor from the previous page's `next_cursor`. Do not construct it yourself. |

**Response fields**

| Field | Type | Description |
| --- | --- | --- |
| `data` *(required)* | `Extraction[]` |  |
| `has_more` *(required)* | `boolean` |  |
| `next_cursor` *(required)* | `string \| null` | Pass as `cursor` to fetch the next page. Null on the last page. |

**Responses**

- `200`: One page of extractions.

```json
{
  "data": [
    {
      "id": "run_8f3a2b1c",
      "url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
      "status": "complete",
      "createdAt": "2026-06-11T10:15:00.000Z",
      "completedAt": "2026-06-11T10:16:42.000Z",
      "creditsDebited": 127,
      "cached": false,
      "outcome": "ok"
    }
  ],
  "has_more": false,
  "next_cursor": null
}
```

- `400`: Invalid limit or cursor.

- `401`: Missing or invalid API key.

- `429`: Rate limit exceeded.

### `POST /v1/extract`

**Submit an extraction (alias)**

Exact alias of `POST /v1/extractions`, with the same request, responses, rate limit and Idempotency-Key support.

**Request body**

| Field | Type | Description |
| --- | --- | --- |
| `url` *(required)* | `string` | Public video URL (YouTube, Shorts, TikTok, Instagram Reels, …). |
| `inputs` | `"transcript" \| "comments" \| "audio" \| "video"[]` | Which modalities feed the AI extraction. Defaults to ["transcript","comments"]. Determines the price. |
| `schemaId` | `string` | Id of a predefined schema (e.g. `Recipe`) or one of your saved schemas. Mutually exclusive with `schema`. |
| `schema` | `object` | Inline JSON Schema (max 100KB) describing the structured object you want back. Omit both `schema` and `schemaId` for a download-only run (no AI, no per-second AI charge). |
| `prompt` | `string` | Extraction guidance sent to the model alongside the schema. It steers how the video is read into the schema (source priority, what to omit, common pitfalls). For a predefined `schemaId` this overrides that schema's built-in prompt; for an inline `schema` it's the prompt that pairs with it. Max 20KB. Part of the cache key, so an edited prompt re-runs instead of reusing a cached result. Ignored on download-only runs. |
| `auto` | `boolean` | Auto mode: start with the cheapest inputs (text), judge the result, and only climb to audio/video if quality is below threshold. `inputs` becomes the ceiling the climb may reach. Requires `maxSpendCredits`. Charged the ceiling at submit, refunded down to the rung actually used. Auto runs reuse a recent result on the same URL, schema and model when its inputs fit inside your `inputs` ceiling (free, `creditsDebited: 0`); pass `force: true` to re-run instead. |
| `force` | `boolean` | Skip the result cache and run the full pipeline even when a recent identical extraction exists. The run is billed as fresh. Default false. |
| `maxSpendCredits` | `number` | Spend ceiling for an auto run, in credits. Required when `auto` is true. Must be at least the cost of the cheapest rung. |
| `policy` | `"strict" \| "fallback" \| "best-effort"` | What to do when a requested input isn't available for this video (e.g. no comments): `strict` fails the run, `fallback` (default) proceeds with reduced inputs, `best-effort` silently drops the missing input. |
| `parts` | `"transcript" \| "comments" \| "audio" \| "video"[]` | Download-only runs (no schema): which artifacts to fetch. Omit for everything. Metadata, thumbnail and description are always included. Invalid alongside `schema`/`schemaId`. |
| `webhookUrl` | `string` | Per-run webhook override: an https URL (public hosts only) that receives `extraction.completed` / `extraction.failed` for this run in addition to your registered endpoints. Signed with your first registered endpoint's secret when one exists; unsigned otherwise. |

```json
{
  "url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
  "inputs": [
    "transcript",
    "comments"
  ],
  "schemaId": "Recipe",
  "webhookUrl": "https://example.com/hooks/postreef"
}
```

**Response fields**

| Field | Type | Description |
| --- | --- | --- |
| `id` *(required)* | `string` | Extraction id, used for polling, results and files. |
| `status` *(required)* | `"queued" \| "pending" \| "running" \| "complete" \| "failed"` |  |
| `url` *(required)* | `string` |  |
| `creditsDebited` *(required)* | `integer` | Credits debited up-front. 0 when the result was served from a recent identical extraction. |
| `createdAt` *(required)* | `string` |  |

**Responses**

- `201`: Extraction created and started.

### `GET /v1/extractions/{id}`

**Get an extraction**

Returns the current state of one extraction, including a coarse `phase` (`queued`, `processing`, `done`, `failed`). Poll this (or `/result`) until the status is terminal, or skip polling entirely with a webhook.

**Parameters**

| Field | Type | Description |
| --- | --- | --- |
| `id (path)` *(required)* | `string` | Extraction id returned at submit time. |

**Response fields**

| Field | Type | Description |
| --- | --- | --- |
| `id` *(required)* | `string` |  |
| `url` *(required)* | `string` |  |
| `status` *(required)* | `"queued" \| "pending" \| "running" \| "complete" \| "failed"` |  |
| `createdAt` *(required)* | `string` |  |
| `completedAt` *(required)* | `string \| null` |  |
| `creditsDebited` *(required)* | `integer` |  |
| `cached` *(required)* | `boolean` | True when the result was reused from a recent identical extraction (no credits were debited). |
| `outcome` | `"ok" \| "no_match" \| "uncertain" \| "null"` | Content-match verdict for an AI run. `ok` means the content matched the schema and `extraction` holds the data. `no_match` means the content is about something else, with a null `extraction`. `uncertain` means the inputs were too sparse to decide, also with a null `extraction`. Null on download-only runs and runs from before this field existed. |
| `verdictReason` | `string` | One-sentence explanation of the content-match verdict, present only when `outcome` is `no_match` or `uncertain`. |
| `error` | `string` | Present only when status is `failed`. |
| `phase` *(required)* | `"queued" \| "processing" \| "done" \| "failed"` | Coarse progress phase derived from status. |

**Responses**

- `200`: The extraction.

```json
{
  "id": "run_8f3a2b1c",
  "url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
  "status": "complete",
  "createdAt": "2026-06-11T10:15:00.000Z",
  "completedAt": "2026-06-11T10:16:42.000Z",
  "creditsDebited": 127,
  "cached": false,
  "outcome": "ok",
  "phase": "done"
}
```

- `401`: Missing or invalid API key.

- `404`: No extraction with this id belongs to you.

- `429`: Rate limit exceeded.

### `GET /v1/extractions/{id}/result`

**Get an extraction's result**

Returns the final result. While the extraction is still queued or processing, responds **202** with `{ "status": "queued" | "processing" }` and keep polling. Once complete, responds 200 with the summary (metadata + available file names) and, when an AI schema was supplied, the structured `extraction` object conforming to it. A failed extraction responds 200 with `status: "failed"` and a human-readable `error`.

**Parameters**

| Field | Type | Description |
| --- | --- | --- |
| `id (path)` *(required)* | `string` |  |

**Responses**

- `200`: Terminal result (complete or failed).

```json
{
  "id": "run_8f3a2b1c",
  "status": "complete",
  "summary": {
    "title": "Weeknight pasta in 15 minutes",
    "uploader": "@kitchen",
    "durationSeconds": 212,
    "files": [
      "video.mp4",
      "transcript.txt",
      "comments.json"
    ]
  },
  "extraction": {
    "name": "Weeknight pasta",
    "servings": 2
  }
}
```

- `202`: Still running. The body is `{ "status": "queued" | "processing" }`.

```json
{
  "status": "processing"
}
```

- `401`: Missing or invalid API key.

- `404`: No extraction with this id belongs to you.

- `429`: Rate limit exceeded.

### `GET /v1/extractions/{id}/files/{name}`

**Download an extraction artifact**

Streams one artifact produced by a completed extraction: `video.mp4`, `audio.m4a`, `transcript.txt`, `comments.json`, `thumbnail.jpg`, `description.txt` or subtitle files. The available names are listed in the result's `summary.files`.

**Parameters**

| Field | Type | Description |
| --- | --- | --- |
| `id (path)` *(required)* | `string` |  |
| `name (path)` *(required)* | `string` | File name exactly as listed in `summary.files`. |

**Responses**

- `200`: The file bytes; `Content-Type` matches the artifact (video/mp4, text/plain, application/json, …).

- `400`: Invalid file name.

- `401`: Missing or invalid API key.

- `404`: Extraction or file not found.

- `429`: Rate limit exceeded.

### `POST /v1/probe`

**Probe a URL and quote the price**

Inspects a video URL without starting an extraction: returns the title, duration, whether subtitles/comments exist, and the exact credit price for the inputs you intend to request. This is the canonical quote: submitting the same URL with the same inputs debits exactly this amount (or the worst-case ceiling when duration is unknown). Rate limit: 10 requests per minute. Probing is free.

**Request body**

| Field | Type | Description |
| --- | --- | --- |
| `url` *(required)* | `string` |  |
| `inputs` | `"transcript" \| "comments" \| "audio" \| "video"[]` | Inputs you intend to submit with (AI extraction quote). The quote depends on them. Defaults to ["transcript","comments"]. Mutually exclusive with `parts`. |
| `parts` | `"transcript" \| "comments" \| "audio" \| "video"[]` | Quote a download-only run instead: base fee + download rates, no AI component. Mutually exclusive with `inputs`. |

```json
{
  "url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
  "inputs": [
    "transcript",
    "comments"
  ]
}
```

**Response fields**

| Field | Type | Description |
| --- | --- | --- |
| `url` *(required)* | `string` |  |
| `durationSec` *(required)* | `number \| null` | Null when the platform hides the duration. Submitting then debits the 300s worst case, refunded down to actual on completion. |
| `title` *(required)* | `string \| null` |  |
| `hasSubtitles` *(required)* | `boolean` |  |
| `hasComments` *(required)* | `boolean` |  |
| `price` *(required)* | `object` |  |

**Responses**

- `200`: Probe result with price quote.

```json
{
  "url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
  "durationSec": 212,
  "title": "Weeknight pasta in 15 minutes",
  "hasSubtitles": true,
  "hasComments": true,
  "price": {
    "credits": 636,
    "usd": 0.0636,
    "breakdown": {
      "text": 0.0636,
      "audio": 0,
      "video": 0
    }
  }
}
```

- `400`: Invalid url, unsupported platform, or bad inputs.

- `401`: Missing or invalid API key.

- `429`: Rate limit exceeded.

- `502`: The video could not be probed right now.

### `GET /v1/openapi.json`

**This document**

Returns this OpenAPI 3.1 document. Public, no authentication required. Import it into Postman, Insomnia, or your codegen of choice.

**Responses**

- `200`: The OpenAPI 3.1 document.