/formats is what goes in. This is what comes back.

One endpoint gives you three response shapes and two quality tiers. The same document renders as plain text, structured markdown, or typed JSON. Pick the shape your pipeline needs.

one-document-three-shapes

The same report, extracted three ways. format defaults to text when omitted. The default shape doesn't change.

format=text (default)
{
  "status": "success",
  "extracted_text": "Quarterly Report\n\nQ3 revenue grew 34% year over year, driven by expansion in EMEA.\n\nRegion\tRevenue\nNorth America\t$4.2M\nEMEA\t$3.1M\n",
  "metadata": { "content_type": "application/pdf", "bytes": 482913, "chars": 812, "ocr": false }
}
format=markdown
{
  "status": "success",
  "markdown": "# Quarterly Report\n\nQ3 revenue grew 34% year over year, driven by expansion in EMEA.\n\n| Region | Revenue |\n| --- | --- |\n| North America | $4.2M |\n| EMEA | $3.1M |\n",
  "metadata": {
    "content_type": "application/pdf",
    "bytes": 482913,
    "chars": 812,
    "ocr": false,
    "format": "markdown",
    "tier": "standard",
    "pages": 4,
    "vlm": false
  }
}
format=json
{
  "status": "success",
  "elements": [
    { "type": "heading", "text": "Quarterly Report", "level": 1, "page": 1, "offset": 0, "bbox": null },
    { "type": "paragraph", "text": "Q3 revenue grew 34% year over year, driven by expansion in EMEA.", "page": 1, "offset": 18, "bbox": null },
    {
      "type": "table",
      "text": "Region\tRevenue\nNorth America\t$4.2M\nEMEA\t$3.1M",
      "markdown": "| Region | Revenue |\n| --- | --- |\n| North America | $4.2M |\n| EMEA | $3.1M |",
      "html": "<table><thead><tr><th>Region</th><th>Revenue</th></tr></thead><tbody><tr><td>North America</td><td>$4.2M</td></tr><tr><td>EMEA</td><td>$3.1M</td></tr></tbody></table>",
      "cells": [
        [{ "text": "Region", "colspan": 1, "rowspan": 1, "header": true }, { "text": "Revenue", "colspan": 1, "rowspan": 1, "header": true }],
        [{ "text": "North America", "colspan": 1, "rowspan": 1, "header": false }, { "text": "$4.2M", "colspan": 1, "rowspan": 1, "header": false }],
        [{ "text": "EMEA", "colspan": 1, "rowspan": 1, "header": false }, { "text": "$3.1M", "colspan": 1, "rowspan": 1, "header": false }]
      ],
      "rows": 3,
      "cols": 2,
      "page": 1,
      "offset": 84,
      "bbox": null
    }
  ],
  "metadata": {
    "content_type": "application/pdf",
    "bytes": 482913,
    "chars": 812,
    "ocr": false,
    "format": "json",
    "tier": "standard",
    "pages": 4,
    "vlm": false
  }
}

which-shape-do-i-want

Response mode vs what it's for
format=Best forLearn more
textSearch indexing, classification, and plain prompting all just want the words. A flat extracted_text string is the smallest, fastest response to parse. It's also the one every existing integration already handles. This is the default: omit format entirely and you get this shape.Response shape in the docs
markdownRAG chunking needs headings and tables to survive the splitter. A structure-aware or recursive chunker keys off Markdown's headings and blank-line paragraph breaks. A flat text dump doesn't carry those breaks. Tables render as real GFM pipe tables when every cell is a simple 1×1 cell.See it chunk in the previewer, Chunking strategies for RAG
jsonLayout-aware pipelines need per-element page, offset, and type data. A typed elements array lets you filter to just tables, walk headings for a table of contents, or chunk per element. You don't need to re-derive structure from a Markdown string.Full element schema in the docs, OpenAPI spec

tables-survive

A flattened text dump loses column alignment once it's copied out of a table. Reconstructing it downstream means guessing where one column ends and the next begins. format=markdown keeps the grid:

Before: format=text

Region Revenue
North America $4.2M
EMEA $3.1M

After: format=markdown

| Region | Revenue |
| --- | --- |
| North America | $4.2M |
| EMEA | $3.1M |

Merged cells are the honest exception. GFM has no way to express a colspan/rowspan greater than 1. A table with a merged cell renders instead as a sanitized inline <table>, with only table/thead/tbody/tr/th/td tags, numeric spans, and escaped text. It's still valid GFM, since GFM allows raw HTML blocks. A table with no merged cells always renders as a plain pipe table.

quality-modes

Quality tier vs what it's for
quality=Best for
standardThe default for every request. Apache Tika parses the document's real structure; Tesseract OCR runs automatically on pages with no text layer. Fast enough to stay synchronous for the vast majority of documents.
premiumDocuments where layout matters more than raw text, like dense multi-column tables or scanned pages, need this tier. Tika's structural recovery is weakest here. This tier always routes async, even for a one-page PDF, since VLM latency can exceed the sync budget. Expect a 202 and a job_id to poll.

quality is independent of format. Pick any response shape with either tier. standard (Tika) is the default and stays synchronous. premium (a vision-language model) always routes async, even for a one-page PDF. VLM latency can exceed the sync request budget. Expect a 202 and a job_id to poll. Premium degrades rather than failing. On a cost, timeout, or size guardrail breach, or a VLM error, it falls back to the standard Tika baseline. It still returns a success, with metadata.tier_downgraded: true and a metadata.downgrade_reason explaining why. The one exception is a document the Tika baseline can't extract either. With no successful result to fall back to, you get the normal extraction error instead of a downgraded success.

Submit (premium)
curl -X POST "https://api.txtfetch.com/v1/extract?quality=premium" \
  -H "Authorization: Bearer $TXTFETCH_KEY" \
  -F file=@quarterly-report.pdf

# {"status": "processing", "job_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"}
# quality=premium always routes async, even for a one-page PDF.
Premium result
{
  "status": "success",
  "extracted_text": "Quarterly Report\n\nQ3 revenue grew 34% year over year, driven by expansion in EMEA.\n\nRegion\tRevenue\nNorth America\t$4.2M\nEMEA\t$3.1M\n",
  "metadata": {
    "content_type": "application/pdf",
    "bytes": 482913,
    "chars": 812,
    "ocr": false,
    "format": "text",
    "tier": "premium",
    "pages": 4,
    "vlm": true,
    "usage": { "model": "claude-sonnet-5", "input_tokens": 2140, "output_tokens": 612 }
  }
}
Guardrail downgrade
{
  "status": "success",
  "extracted_text": "Quarterly Report\n\nQ3 revenue grew 34% year over year, driven by expansion in EMEA.\n\nRegion\tRevenue\nNorth America\t$4.2M\nEMEA\t$3.1M\n",
  "metadata": {
    "content_type": "application/pdf",
    "bytes": 482913,
    "chars": 812,
    "ocr": false,
    "format": "text",
    "tier": "standard",
    "pages": 4,
    "vlm": false,
    "tier_downgraded": true,
    "downgrade_reason": "max_cost"
  }
}
Every documented downgrade_reason
downgrade_reasonMeaning
vlm_disabledthe VLM_ENABLED ops flag is off
max_bytesa pre-flight size guardrail breach, so the VLM was never called
max_pagesa pre-flight page-count guardrail breach, so the VLM was never called
max_costa pre-flight cost guardrail breach, so the VLM was never called
vlm_erroran HTTP/timeout/parse failure calling the VLM
truncatedthe VLM hit its own max_tokens before finishing

There's no plan-level gate on quality=premium. Every plan can request it, and pricing is unchanged either way (see pricing). Full guardrails and the measured accuracy climb between tiers: benchmarks → and the docs →.

honest-limits

  • No schema-defined field extraction. Structured Markdown and element JSON ship today; pulling typed fields per your own schema (invoice number, total, dates) is on the roadmap, not shipped.
  • Structured text, not a visual layout reconstruction. Element JSON and Markdown preserve reading order and block structure. They don't reproduce the document's visual page layout.
  • OCR accuracy tracks scan quality. A clean scan OCRs well; a low-resolution or skewed scan degrades like any OCR pipeline's would.
  • bbox is always null on the standard path. The field is reserved for a future VLM-populated version. It isn't coordinates today, on either tier.
  • Premium is async-only. There's no synchronous VLM path, regardless of document size.

faq

Does txtfetch return Markdown, not just plain text?
Yes. Pass ?format=markdown and the response carries a markdown field instead of extracted_text. Headings, nested lists, and paragraphs keep reading order, joined by blank lines. Tables render as GFM pipe tables. It ships today, using the same endpoint and auth as the default text shape.
Can I get structured JSON with per-element position info?
Yes. ?format=json returns an elements array of typed heading/paragraph/list/table/image/code nodes. Each one carries a page, offset, and bbox value. bbox is always null on the standard Tika path. It's reserved for the premium VLM path, which doesn't populate it yet either. See the element JSON schema in the docs for the full field list.
Do merged table cells survive in Markdown?
GFM has no way to express a merged cell. A table with any colspan or rowspan greater than 1 renders as a sanitized inline <table> instead of a pipe table. The sanitized table keeps only table/thead/tbody/tr/th/td tags, numeric colspan/rowspan values, and HTML-escaped text. It's still valid GFM, since GFM allows raw HTML blocks. A table with no merged cells always renders as a plain pipe table.
Is quality=premium synchronous, like the standard tier?
No. Premium always routes async, even for a one-page PDF. Vision-language model latency can exceed the synchronous request budget. Expect a 202 and a job_id to poll, the same job lifecycle as any other async request.
What happens if the premium tier fails or costs too much?
A VLM failure never fails your request. It degrades instead. On a cost, timeout, or size guardrail breach, or a VLM error, premium falls back to the standard Tika baseline. It still returns status: success, with metadata.tier_downgraded: true and a metadata.downgrade_reason explaining why. The one exception is a document the Tika baseline couldn't extract either. Then there's no successful result to fall back to, so you get the normal extraction error instead.
Can I request typed fields — invoice number, total, dates — out of a document?
Not yet. Structured Markdown and element-JSON document output ship today. Schema-defined field extraction, pulling typed fields per your own schema, is on the roadmap. It hasn't shipped yet.

Stop parsing. Start shipping.

Create an account and get an API key in minutes. The free Hobby plan needs no card.

Get started →