# Extract document text

Read an uploaded document as text with extraction status and source coverage.

Documentation index: https://webcite.co/llms.txt
Canonical page: https://webcite.co/api-docs/extract
API origin: https://api.webcite.co
Authentication: x-api-key header. Keep keys on your server.

## When to use it

Use the returned asset ID from [upload](/api-docs/upload). Text-layer extraction is attempted before OCR when supported. Scanned documents can require OCR.

Check `state` before using `markdown`. Branch on the stable `code`; display `reason` as diagnostic text. See [document workflows](/api-docs/document-workflows).

## Request

POST /api/v1/extract

1 credit for a billable extraction outcome.

### curl

```curl
curl --fail-with-body -X POST 'https://api.webcite.co/api/v1/extract' \
  -H "x-api-key: $WEBCITE_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{
  "asset_id": "YOUR_ASSET_ID"
}'
```

### Node.js

```javascript
const response = await fetch("https://api.webcite.co/api/v1/extract", {
  method: "POST",
  headers: {
    "x-api-key": process.env.WEBCITE_API_KEY,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
  "asset_id": "YOUR_ASSET_ID"
}),
});
if (!response.ok) throw new Error(`HTTP ${response.status}: ${await response.text()}`);
console.log(await response.json());
```

### python

```python
import os
import json
import requests

payload = json.loads("{\n  \"asset_id\": \"YOUR_ASSET_ID\"\n}")
response = requests.post(
    "https://api.webcite.co/api/v1/extract",
    headers={"x-api-key": os.environ["WEBCITE_API_KEY"]},
    json=payload,
    timeout=(10, 300),
)
response.raise_for_status()
print(response.json())
```

## Response

Read state, code, reason, extraction_method, markdown, and any lost parts. complete: false means the whole source was not recovered. Do not convert an error or partial read into an empty successful document.

## Errors

The contract lists 400, 401, 404, 413, 429, 500, 502, 503, and 504 outcomes. Inspect the response: missing assets, oversized files, storage failures, and timeouts need different remedies. Never treat a failed download as an empty document.

## OpenAPI operation

```json
{
  "path": "/api/v1/extract",
  "method": "POST",
  "operation": {
    "description": "All-formats extraction (PDF, spreadsheets, docx/pptx/html/txt, …) into a normalized document: whole-doc `markdown`, per-page/sheet `units` with page/sheet provenance, and (for spreadsheets) sheet names. Deterministic-first; scanned PDFs fall back to vision OCR. Extraction never hard-fails — an unreadable asset returns empty text.\n\n**A partial read says so.** When the file opened but a part of it could not be read (a workbook sheet whose part the package is missing, a page that failed), the response carries `complete: false` and `lost[]` naming what was not recovered — sheet or page, and why. Both keys are absent from a complete read, so seeing either is a finding rather than a default, and `state` (`complete` | `partial` | `error` | `unsupported`) is the same determination in one word. `unsupported` was served but not listed here: it is the answer for a format no reader is registered for, and a consumer switching on the documented three fell through on it.\n\n**Why a read did not complete, in two fields.** `reason` is free text straight from whatever refused or failed, so it carries detail no enum can — `spreadsheet_too_large:bytes:33554433>33554432` has the observed value and the limit in it. It is not stable: it includes dependency error text such as `extraction_failed:Cannot read properties of undefined (reading 'Pages')`. `code` is the stable classification of the same refusal, and it is the one to switch on.\n\n| `code` | Meaning | Caller's move |\n|--------|---------|---------------|\n| `source_too_large` | a size or expansion ceiling refused the read | send a smaller file; `reason` has the numbers |\n| `source_encrypted` | the container is password-protected | send an unlocked copy |\n| `source_corrupt` | the container could not be opened as the format it declares | re-export the file |\n| `unsupported_format` | no reader is registered for this format | convert it |\n| `ocr_unavailable` | no text layer, and no vision provider is configured | configure a vision key, or send a text-layer file |\n| `partial_extraction` | some of the source came back and some did not | use what came back; `lost[]` names the rest |\n| `empty_source` | the source was read and held nothing | nothing to retry — this is a fact about the file |\n| `extraction_error` | our failure | retry |\n\nBoth are `null` on a complete read. Each `lost[]` entry carries its own `reason` and `code` for the part it names, which can differ from the document's: a workbook that lost one sheet of two is `partial_extraction` while the lost sheet is `source_corrupt`.\n\n**`extraction_method`** says which path produced the text, so a consumer storing this as evidence can tell a deterministic read from a model transcription:\n\n| Value | Meaning |\n|-------|---------|\n| `text_layer` | the file's own machine-readable text, read deterministically |\n| `ocr` | no text layer, so a vision model transcribed the page image |\n| `spreadsheet_cells` | cells read out of the workbook, no model and no rendering |\n| `none` | nothing readable came back (empty, unparseable, or a scan with no OCR configured) |\n\nA document can mix paths — a text-layer PDF with two scanned pages in it. The top-level `extraction_method` is then the **dominant** path (the one behind the most readable units, ties going to `ocr`); `units[].extraction_method` is the exact answer for that page or sheet.\n\n**Spreadsheet units** also carry `sheet`: the tab's native structure straight from the workbook. `sheet.merges` lists the merged ranges (`[\"A1:A2\",\"B1:C1\"]`) and `sheet.cells[].merged_range` says which range each cell belongs to, so a header spanning two columns can be rebuilt: the `text` CSV flattens it to a value plus a blank.\n\n**Cost: 1 credit.** Deterministic for text-layer documents; downloads and parses a file.",
    "operationId": "ApiV1Controller_extract",
    "parameters": [],
    "requestBody": {
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/ExtractRequestDto"
          }
        }
      },
      "required": true
    },
    "responses": {
      "200": {
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ExtractResponseDto"
            }
          }
        },
        "description": "{ format, markdown, extraction_method, state, reason, code, units[{ …, extraction_method, sheet? }], sheets?[], complete?: false, lost?[{ kind, index, sheet?, state, reason, code }] }"
      },
      "400": {
        "description": "asset_url is not an allowed public web address, or neither asset_id nor asset_url was given"
      },
      "401": {
        "description": "Unauthorized - API key required"
      },
      "404": {
        "description": "The asset does not exist, or the store has no bytes for it"
      },
      "413": {
        "description": "The asset is over the 50 MB download ceiling. The body carries `bytes` (null when the reader refused mid-stream) and `max_bytes`"
      },
      "429": {
        "description": "Rate limit exceeded"
      },
      "502": {
        "description": "The asset store returned a failure. Ours, not the request — retry"
      },
      "503": {
        "description": "Private evidence storage is not configured, so an evidence:// asset cannot be read"
      },
      "504": {
        "description": "The asset download timed out. Retry"
      }
    },
    "security": [
      {
        "x-api-key": []
      },
      {
        "bearer": []
      }
    ],
    "summary": "Extract any document into normalized text + units with provenance",
    "tags": [
      "Public API"
    ]
  },
  "schemas": {
    "ExtractRequestDto": {
      "properties": {
        "asset_id": {
          "description": "An uploaded asset id (from POST /upload). One of asset_id / asset_url is required.",
          "type": "string"
        },
        "asset_url": {
          "description": "A direct URL to the file. One of asset_id / asset_url is required.",
          "type": "string"
        }
      },
      "type": "object"
    },
    "ExtractResponseDto": {
      "properties": {
        "code": {
          "description": "The stable classification of `reason`, safe to switch on. Null on a complete read.\n\n| Code | What it means | What to do |\n|------|---------------|------------|\n| `source_too_large` | a size or expansion ceiling refused the read | `reason` carries the observed value and the limit; send a smaller file |\n| `source_encrypted` | the container is password-protected | send an unlocked copy |\n| `source_corrupt` | the container could not be opened as the format it declares | re-export the file |\n| `unsupported_format` | no reader is registered for this format | convert it |\n| `ocr_unavailable` | the page had no text layer and no vision provider is configured | configure a vision key, or send a text-layer file |\n| `partial_extraction` | some of the source was recovered and some was not | use what came back; `lost[]` names the rest |\n| `empty_source` | the source was read and held nothing | a fact about the file, not a failure — nothing to retry |\n| `extraction_error` | our failure | retry |",
          "enum": [
            "source_too_large",
            "source_encrypted",
            "source_corrupt",
            "unsupported_format",
            "ocr_unavailable",
            "partial_extraction",
            "empty_source",
            "extraction_error"
          ],
          "nullable": true,
          "type": "string"
        },
        "complete": {
          "description": "Present, and only ever `false`, when the read did not recover the whole source. Absent on a complete read, so seeing it is a determination rather than a default.",
          "type": "boolean"
        },
        "extraction_method": {
          "description": "The path that produced most of this document's text.",
          "enum": [
            "text_layer",
            "ocr",
            "spreadsheet_cells",
            "none"
          ],
          "type": "string"
        },
        "format": {
          "description": "The format the bytes were read as.",
          "type": "string"
        },
        "lost": {
          "description": "The parts that were not recovered. Absent on a complete read.",
          "items": {
            "$ref": "#/components/schemas/LostPartDto"
          },
          "type": "array"
        },
        "markdown": {
          "description": "The whole document as markdown. Empty when nothing was read.",
          "type": "string"
        },
        "reason": {
          "description": "Free text naming the cause, straight from whatever refused or failed — `extraction_failed:File is password-protected`, `spreadsheet_too_large:bytes:33554433>33554432`. Carries detail no enum can (the observed value and the limit are both in it) and is NOT stable: it includes dependency error text. Display it; do not branch on it. Null on a complete read.",
          "nullable": true,
          "type": "string"
        },
        "state": {
          "description": "How much of the source the read recovered, in one word.",
          "enum": [
            "complete",
            "partial",
            "unsupported",
            "error"
          ],
          "type": "string"
        }
      },
      "required": [
        "format",
        "markdown",
        "extraction_method",
        "state",
        "reason",
        "code"
      ],
      "type": "object"
    },
    "LostPartDto": {
      "properties": {
        "code": {
          "description": "The stable classification of this part's `reason`. Same vocabulary as the document-level `code`; the two can differ, because a document that recovered most of itself is `partial_extraction` while the one part it lost names its own cause.",
          "enum": [
            "source_too_large",
            "source_encrypted",
            "source_corrupt",
            "unsupported_format",
            "ocr_unavailable",
            "partial_extraction",
            "empty_source",
            "extraction_error"
          ],
          "nullable": true,
          "type": "string"
        },
        "index": {
          "description": "1-based page / sheet index, as on the unit that failed.",
          "type": "number"
        },
        "kind": {
          "description": "Which kind of unit was lost.",
          "enum": [
            "page",
            "sheet"
          ],
          "type": "string"
        },
        "reason": {
          "description": "Free text: what the reader said about this part.",
          "nullable": true,
          "type": "string"
        },
        "sheet": {
          "description": "Sheet units only: the tab the workbook declared.",
          "type": "string"
        },
        "state": {
          "description": "What happened to this part specifically.",
          "enum": [
            "partial",
            "unreadable",
            "error"
          ],
          "type": "string"
        }
      },
      "required": [
        "kind",
        "index",
        "state",
        "reason",
        "code"
      ],
      "type": "object"
    }
  }
}
```
