The whole API fits on this page.

One endpoint, two ways to call it, one response shape. If you can curl, you've already integrated.

Prefer to see it first? Try the playground →

Authenticate

Every request carries your API key in the Authorization header. Create a key in your dashboard.

export TXTFETCH_KEY="tf_live_..."

A missing key returns 401. An invalid or revoked key returns 403. Both come back as API Gateway's platform {"message": "…"} body, not the typed error shape below. See the error reference for the full auth-failure semantics.

Extract a file

POST the bytes as multipart form data. Any of the supported formats works. 615 of the 1,683 Tika detects have a real parser, checked against the exact build we run. txtfetch reads the real type from the bytes, not the extension.

curl
curl -X POST https://api.txtfetch.com/v1/extract \
  -H "Authorization: Bearer $TXTFETCH_KEY" \
  -F file=@quarterly-report.pdf
Python
import os
import requests

with open("quarterly-report.pdf", "rb") as f:
    r = requests.post(
        "https://api.txtfetch.com/v1/extract",
        headers={"Authorization": f"Bearer {os.environ['TXTFETCH_KEY']}"},
        files={"file": f},
    )

print(r.json()["extracted_text"])
JavaScript
import { readFile } from "node:fs/promises";

const file = new Blob([await readFile("quarterly-report.pdf")]);
const form = new FormData();
form.append("file", file, "quarterly-report.pdf");

const res = await fetch("https://api.txtfetch.com/v1/extract", {
  method: "POST",
  headers: { Authorization: `Bearer ${process.env.TXTFETCH_KEY}` },
  body: form,
});

const { extracted_text } = await res.json();
console.log(extracted_text);
Go
package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"io"
	"mime/multipart"
	"net/http"
	"os"
)

type extractResponse struct {
	Status        string `json:"status"`
	ExtractedText string `json:"extracted_text"`
}

func main() {
	f, err := os.Open("quarterly-report.pdf")
	if err != nil {
		panic(err)
	}
	defer f.Close()

	var body bytes.Buffer
	writer := multipart.NewWriter(&body)
	part, err := writer.CreateFormFile("file", "quarterly-report.pdf")
	if err != nil {
		panic(err)
	}
	if _, err := io.Copy(part, f); err != nil {
		panic(err)
	}
	writer.Close()

	req, err := http.NewRequest("POST", "https://api.txtfetch.com/v1/extract", &body)
	if err != nil {
		panic(err)
	}
	req.Header.Set("Authorization", "Bearer "+os.Getenv("TXTFETCH_KEY"))
	req.Header.Set("Content-Type", writer.FormDataContentType())

	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		panic(err)
	}
	defer resp.Body.Close()

	var result extractResponse
	if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
		panic(err)
	}
	fmt.Println(result.ExtractedText)
}

Extract from a URL

Or skip the download: pass a url parameter and txtfetch fetches the document server-side.

curl
curl -X POST "https://api.txtfetch.com/v1/extract?url=https://example.com/whitepaper.docx" \
  -H "Authorization: Bearer $TXTFETCH_KEY"
Python
import os
import requests

r = requests.post(
    "https://api.txtfetch.com/v1/extract",
    headers={"Authorization": f"Bearer {os.environ['TXTFETCH_KEY']}"},
    params={"url": "https://example.com/whitepaper.docx"},
)

print(r.json()["extracted_text"])
JavaScript
const endpoint = new URL("https://api.txtfetch.com/v1/extract");
endpoint.searchParams.set("url", "https://example.com/whitepaper.docx");

const res = await fetch(endpoint, {
  method: "POST",
  headers: { Authorization: `Bearer ${process.env.TXTFETCH_KEY}` },
});

const { extracted_text } = await res.json();
console.log(extracted_text);
Go
package main

import (
	"encoding/json"
	"fmt"
	"net/http"
	"net/url"
	"os"
)

type extractResponse struct {
	Status        string `json:"status"`
	ExtractedText string `json:"extracted_text"`
}

func main() {
	endpoint, err := url.Parse("https://api.txtfetch.com/v1/extract")
	if err != nil {
		panic(err)
	}
	q := endpoint.Query()
	q.Set("url", "https://example.com/whitepaper.docx")
	endpoint.RawQuery = q.Encode()

	req, err := http.NewRequest("POST", endpoint.String(), nil)
	if err != nil {
		panic(err)
	}
	req.Header.Set("Authorization", "Bearer "+os.Getenv("TXTFETCH_KEY"))

	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		panic(err)
	}
	defer resp.Body.Close()

	var result extractResponse
	if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
		panic(err)
	}
	fmt.Println(result.ExtractedText)
}

Response

Success is always the same shape. Scanned documents and images go through OCR automatically. It's the same request and the same response.

{
  "status": "success",
  "extracted_text": "Q3 revenue grew 34% year over year, driven by...",
  "metadata": {
    "content_type": "application/pdf",
    "bytes": 482913,
    "chars": 812,
    "ocr": false
  }
}

Response formats

Need structure instead of flat text? Pass ?format=markdown for GFM output, tables included, or ?format=json for a typed element tree. The endpoint, auth, and error shape stay the same. format defaults to text (the shape above) when omitted.

curl -s -X POST "https://api.txtfetch.com/v1/extract?format=markdown" \
  -H "Authorization: Bearer $TXTFETCH_KEY" \
  -F file=@quarterly-report.pdf
{
  "status": "success",
  "markdown": "# Quarterly Report\n\n...\n\n| Region | Revenue |\n| --- | --- |\n| EMEA | 34% |\n",
  "metadata": {
    "content_type": "application/pdf",
    "bytes": 482913,
    "chars": 812,
    "format": "markdown",
    "tier": "standard",
    "pages": 4,
    "vlm": false
  }
}
curl -s -X POST "https://api.txtfetch.com/v1/extract?format=json" \
  -H "Authorization: Bearer $TXTFETCH_KEY" \
  -F file=@quarterly-report.pdf
{
  "status": "success",
  "elements": [
    { "type": "heading", "text": "Quarterly Report", "level": 1, "page": 1, "offset": 0, "bbox": null },
    {
      "type": "table",
      "text": "Region\tRevenue\nEMEA\t34%",
      "markdown": "| Region | Revenue |\n| --- | --- |\n| EMEA | 34% |",
      "html": "<table>...</table>",
      "cells": "[[…]] — one row per array entry, each cell carries text/colspan/rowspan/header",
      "rows": 2,
      "cols": 2,
      "page": 1,
      "offset": 1,
      "bbox": null
    }
  ],
  "metadata": {
    "content_type": "application/pdf",
    "bytes": 482913,
    "chars": 812,
    "format": "json",
    "tier": "standard",
    "pages": 4,
    "vlm": false
  }
}

The full element schema is part of the published contract. It covers every type, required field, and the offset/page monotonicity guarantee. See the OpenAPI spec →

Deciding which of the three to request? See the same document rendered all three ways →

Premium quality

Pass ?quality=premium to run a vision-language model instead of Tika, for documents where layout matters more than raw text. Premium 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, same as above.

curl -s -X POST "https://api.txtfetch.com/v1/extract?quality=premium" \
  -H "Authorization: Bearer $TXTFETCH_KEY" \
  -F file=@quarterly-report.pdf
{
  "status": "success",
  "extracted_text": "Q3 revenue grew 34% year over year, driven by...",
  "metadata": {
    "content_type": "application/pdf",
    "bytes": 482913,
    "chars": 812,
    "format": "text",
    "tier": "premium",
    "pages": 4,
    "vlm": true,
    "usage": { "model": "claude-sonnet-5", "input_tokens": 2140, "output_tokens": 612 }
  }
}

Premium never fails your request outright. If it hits a cost/timeout/size guardrail, it falls back safely to the same Tika baseline as the standard tier. The response is still a success, with metadata.tier_downgraded: true and a metadata.downgrade_reason explaining why.

{
  "status": "success",
  "extracted_text": "Q3 revenue grew 34% year over year, driven by...",
  "metadata": {
    "content_type": "application/pdf",
    "bytes": 482913,
    "chars": 812,
    "format": "text",
    "tier": "standard",
    "pages": 4,
    "vlm": false,
    "tier_downgraded": true,
    "downgrade_reason": "vlm_disabled"
  }
}

There's no plan-level gate on quality=premium. Every plan can request it. Full contract, guardrails, and every downgrade_reason: OpenAPI spec →

Conceptual guide to both tiers, plus the async routing and downgrade caveat: what comes back →

Wondering why you'd extract at all instead of sending the file straight to a model? see the documented token cost →

Errors

Failures are explicit and machine-readable. They're almost never a silent empty string. One case we found: a ZIP with an encrypted member returns success with the member names only. See Password-protected files. Failed extractions don't count against your quota. See the complete error reference for every code.

{
  "status": "error",
  "error": {
    "code": "extraction_failed",
    "message": "extraction produced no text"
  }
}

Limits and quotas

Every metered response carries X-RateLimit-*(per-minute) and X-Quota-* (monthly) headers. Going over either returns a typed 429. rate_limited vs quota_exceeded is disambiguated by error.code. A Retry-After header tells you exactly when to retry. Failed extractions don't count against your monthly quota, only successful ones.

{
  "status": "error",
  "error": {
    "code": "quota_exceeded",
    "message": "monthly quota exceeded for this API key"
  }
}

Full breakdown of both limits, the header contract, and the auth-failure statuses: Error reference →

Every one of these limits, with its exact current value: See every number →

Works with everything

The response is plain JSON, so txtfetch drops into any stack. Pipe it straight into your chunker, embedder, or index:

curl -s -X POST https://api.txtfetch.com/v1/extract \
  -H "Authorization: Bearer $TXTFETCH_KEY" \
  -F file=@contract.pdf | jq -r .extracted_text

Async jobs

Large uploads or documents are automatically routed to an async job path instead of blocking the request. Pass ?async=true to force it for any request. Either way you get a 202 with a job_id. Poll GET /v1/extract/{job_id} until it returns the same success shape as above.

{
  "status": "processing",
  "job_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}

Full walkthrough, SDK helpers, and webhook delivery instead of polling: Async & webhooks →

Idempotency

Pass an Idempotency-Key header on POST /v1/extract so a retried request is never double-run. A repeat under the same key replays the original response instead of re-extracting. Details, TTL, and the SDKs' automatic retry behavior: Idempotency →

Machine-readable spec

The full contract covers every field, error code, and status. It's published as OpenAPI 3.1. Generate a typed client for whatever language you're in.

docs-map

Everything in the docs.

Get started

Build on the API

What comes back

Quality checks

The automatic checks that flag likely-bad extracted text, how to sample the rest, and how to build a golden set.

200 ≠ correct

Handwriting

Not on the standard tier. What comes back for each kind of input, and where to send handwritten pages instead.

printed text, not handwriting

Equations and math

Word equations vanish. PDF math loses its layout. What comes back for each input, and what to do instead.

text out, not LaTeX

Fillable forms

Do the values you typed into a form come back? Yes, after the last page. What PDF, XFA and Word forms return, and how to find the values.

values yes, labels no

Barcodes and QR codes

Does txtfetch decode them? No. What comes back for each kind of code, and how to decode them yourself.

printed text, not decoded

Text in pictures and scans

Does the text in a picture come back? Most of it does. What PDF, Word, PowerPoint, Excel, HTML and email return, and where it lands.

comes back, not in place

Links and URLs

Do hyperlink targets come back? Not for HTML or Word. What each format returns, and how to read the URLs yourself.

link text yes, targets mostly no

Attachments and archives

Do files inside a ZIP or an email come back? Yes. File names come back for a ZIP and an Outlook .msg. An .eml drops its headers.

files yes, .eml headers no

Headers, footers, footnotes and comments

Where does the text outside the body go? It comes back inline. A Word comment carries the reviewer's name.

all come back, none stripped

Tracked changes

Do deleted words come back? Yes, with no marker. Here is how to get the accepted text.

deletions come back

Password-protected files

Does a locked file come back? No for a PDF, Word or Excel file. A locked ZIP says success and drops the content. How to unlock a file first.

locked files do not come back

Tables

Do tables come back as tables? Yes for HTML, Word, Excel, PowerPoint and ODT in Markdown and JSON. No for PDF and CSV. A merged cell moves in HTML and Word.

rows yes, merged cells no

Page numbers and citations

Can your RAG app cite page 12? Yes, for PDFs. Which formats return a page, and how to keep it through chunking.

pages for PDFs, no coordinates

Your data

Also useful

Watch it run, then take a key.

The playground replays a real recorded response for every sample document.

Open the playground →

Get an API key →