# Reducto API Reference for Coding Agents Source: https://docs.reducto.ai/agent-guide Complete, structured reference for AI coding agents integrating Reducto This page is a dense, structured reference designed for AI coding agents. It contains everything needed to integrate Reducto without navigating multiple pages. ## Fastest Successful Path Use this decision table before writing code: | Situation | Use | First command or call | | - | - | - | | Local file or folder in a coding-agent workspace | [CLI](/cli) | `reducto parse ./document.pdf` | | Agent should call Reducto as tools | [MCP server](/mcp-server) | `parse_document(document_url="https://cdn.reducto.ai/samples/fidelity-example.pdf")` | | App code in Python | Python SDK | `client.parse.run(input="https://cdn.reducto.ai/samples/fidelity-example.pdf")` | | App code in Node.js | Node.js SDK | `await client.parse.run({ input: "https://cdn.reducto.ai/samples/fidelity-example.pdf" })` | | No SDK allowed | REST API | `POST https://platform.reducto.ai/parse` | For a benchmark or smoke test, parse this public sample first: ```text theme={null} https://cdn.reducto.ai/samples/fidelity-example.pdf ``` Then replace it with the user's document URL or upload a local file and pass the returned `reducto://` file ID. ## Product Summary Reducto is the agentic document platform. It provides a complete toolkit for classification, parsing, extraction, splitting, editing, and workflow orchestration across documents (PDFs, images, spreadsheets, DOCX, and 30+ other formats) via a REST API. * **Base URL:** `https://platform.reducto.ai` * **Auth:** `Authorization: Bearer $REDUCTO_API_KEY` * **SDKs:** Python (`pip install reductoai`), Node.js (`npm install reductoai`), Go (`go get github.com/reductoai/reducto-go-sdk`) * **Input:** Upload a file via `/upload` to get a `file_id`, then pass it to any endpoint. You can also pass public URLs or presigned S3/GCS/Azure URLs directly. *** ## Authentication 1. Create a free account at [studio.reducto.ai](https://studio.reducto.ai/) 2. In the Studio sidebar, click **API Keys**, then **Create API Key** 3. Set the key as an environment variable: ```bash theme={null} # macOS / Linux export REDUCTO_API_KEY="your_api_key_here" # Windows (PowerShell) $env:REDUCTO_API_KEY="your_api_key_here" ``` The Python and Node.js SDKs automatically read `REDUCTO_API_KEY` from the environment. For the Go SDK, pass it explicitly: ```go theme={null} client := reducto.NewClient(option.WithAPIKey(os.Getenv("REDUCTO_API_KEY"))) ``` For direct REST calls, pass it as a Bearer token: ```bash theme={null} curl -X POST https://platform.reducto.ai/parse \ -H "Authorization: Bearer $REDUCTO_API_KEY" \ -H "Content-Type: application/json" \ -d '{"input": "https://example.com/doc.pdf"}' ``` *** ## Supported File Types | Category | Formats | | - | - | | PDF | `.pdf` | | Documents | `.docx`, `.doc`, `.dotx`, `.rtf`, `.txt`, `.wpd` | | Spreadsheets | `.xlsx`, `.xlsm`, `.xls`, `.xltx`, `.xltm`, `.csv`, `.qpw` | | Presentations | `.pptx`, `.ppt` | | Images | `.png`, `.jpg`/`.jpeg`, `.gif`, `.bmp`, `.tiff`, `.heic`, `.psd`, `.pcx`, `.ppm`, `.apng`, `.cur`, `.dcx`, `.ftex`, `.pixar` | Upload limit: 100MB direct, 5GB via [presigned URL](/upload/large-files). Multi-page TIFFs are processed as multi-page documents. *** ## Which Endpoint Should I Use? | I want to... | Endpoint | Method | Key config | | - | - | - | - | | Get all text, tables, and figures from a document separated into chunks with bounding box coordinates | `/parse` | POST | `enhance.agentic` for custom prompts or specialized augmentation | | Extract specific fields into JSON using a specific schema | `/extract` | POST | `instructions.schema` (JSON Schema) | | Divide a document into named sections by page range | `/split` | POST | `split_description` (section definitions) | | Fill PDF or DOCX forms | `/edit` | POST | `edit_instructions` (natural language) | | Classify a document's type before processing | `/classify` | POST | `classification_schema` (categories + criteria) | | Upload a local file for processing | `/upload` | POST (multipart) | `file` field | | Process asynchronously with webhooks | `/parse_async`, `/extract_async`, `/split_async`, `/classify_async`, `/edit_async` | POST | `webhook` URL | | Check job status or retrieve results | `/job/{job_id}` | GET | - | *** ## Quick Start (Python) ```python theme={null} from pathlib import Path from reducto import Reducto client = Reducto() # reads REDUCTO_API_KEY from env # --- Option A: Pass a URL directly (no upload needed) --- parse_result = client.parse.run(input="https://example.com/document.pdf") # --- Option B: Upload a local file first --- upload = client.upload(file=Path("document.pdf")) parse_result = client.parse.run(input=upload.file_id) # --- Handle the response (important: check result.type for large docs) --- import requests if parse_result.result.type == "url": # Large documents return a URL instead of inline content chunks = requests.get(parse_result.result.url).json() else: chunks = parse_result.result.chunks for chunk in chunks: # Use dict access for URL results, attribute access for inline results content = chunk["content"] if isinstance(chunk, dict) else chunk.content print(content) # --- Extract: pull specific fields --- extract_result = client.extract.run( input=upload.file_id, instructions={ "schema": { "type": "object", "properties": { "invoice_number": {"type": "string", "description": "The invoice number"}, "total": {"type": "number", "description": "Total amount due"} } } } ) # result is a list, access first item for single-document extraction data = extract_result.result[0] print(data["invoice_number"], data["total"]) # --- Split: find section boundaries --- split_result = client.split.run( input=upload.file_id, split_description=[ {"name": "Summary", "description": "Executive summary section"}, {"name": "Financials", "description": "Financial statements and tables"} ] ) for split in split_result.result.splits: print(f"{split.name}: pages {split.pages}") # --- Classify: identify document type --- classify_result = client.classify.run( input=upload.file_id, classification_schema=[ {"category": "invoice", "criteria": ["billing info", "itemized charges"]}, {"category": "contract", "criteria": ["legal terms", "signatures"]} ] ) print(classify_result.result) # --- Edit: fill a form --- # NOTE: Edit uses "document_url" instead of "input" (unlike other endpoints) edit_result = client.edit.run( document_url=upload.file_id, edit_instructions="Fill Name: John Doe, Date: 2024-01-15, Check 'Yes' for US Citizen" ) print(edit_result.document_url) # URL to download filled document ``` *** ## SDK Naming Conventions | SDK | Property names | Install | Notes | | - | - | - | - | | Python | snake\_case (`array_extract`, `file_id`) | `pip install reductoai` | Client auto-reads `REDUCTO_API_KEY` env var | | Node.js | snake\_case (`array_extract`, `file_id`) | `npm install reductoai` | All methods return promises, use `await` | | Go | PascalCase (`ArrayExtract`, `FileID`) | `go get github.com/reductoai/reducto-go-sdk` | Wrap values with `reducto.F()`, use `shared.UnionString()` for document URLs | | REST | snake\_case in JSON body | - | `Authorization: Bearer $REDUCTO_API_KEY` header | *** ## Parse Parameters `POST /parse`. Turn full documents into LLM-ready content: every text block, table, and figure with layout and bounding boxes. Starting December 7, 2026, Parse defaults to r-1. To keep legacy Parse, set `settings.model` to `"legacy"`. See [r-1 configuration compatibility](/configs/parse/r-1-configuration-compatibility) before changing an existing configuration. ### Core Parameters | Parameter | Type | Default | Description | | - | - | - | - | | `input` | string | **required** | File ID (`reducto://...`), public URL, presigned URL, or `jobid://...` to reprocess | ### enhance group | Parameter | Type | Default | Description | | - | - | - | - | | `enhance.agentic` | array | `[]` | Customized agentic processing entries. Use custom prompts or specialized augmentation. | | `enhance.agentic[].scope` | `"text"` \| `"table"` \| `"figure"` | - | On legacy Parse, a promptless scope provides general accuracy improvements. With r-1, use a custom prompt or advanced chart extraction. Adds latency. | | `enhance.agentic[].prompt` | string \| null | `null` | Custom prompt for agentic processing | | `enhance.agentic[].advanced_chart_agent` | bool | `false` | Advanced chart extraction for figure scope. Returns structured series data and a reconstruction image. Adds latency. | | `enhance.summarize_figures` | bool | `true` | Legacy Parse only. Generate figure descriptions. r-1 handles figures natively and ignores this setting. | | `enhance.intelligent_ordering` | bool | `false` | Legacy Parse only. Improve reading order with a vision model. r-1 handles reading order natively and ignores this setting. | ### retrieval group | Parameter | Type | Default | Description | | - | - | - | - | | `retrieval.chunking.chunk_mode` | `"disabled"` \| `"variable"` \| `"section"` \| `"page"` \| `"block"` \| `"page_sections"` | `"disabled"` | `disabled`: one chunk for entire doc. `variable`: semantic boundaries (best for RAG). `section`: split at headers. `page`: one chunk per page. `page_sections`: sections within each page. | | `retrieval.chunking.chunk_size` | int \| null | `null` | Target chunk size in characters. Defaults to 250-1500 range in variable mode. | | `retrieval.chunking.chunk_overlap` | int | `0` | Characters of overlap between adjacent chunks | | `retrieval.filter_blocks` | string\[] | `[]` | Block types to exclude from `content`/`embed`. Options: `"Header"`, `"Footer"`, `"Title"`, `"Section Header"`, `"Page Number"`, `"List Item"`, `"Figure"`, `"Table"`, `"Key Value"`, `"Text"`, `"Comment"`, `"Signature"` | | `retrieval.embedding_optimized` | bool | `false` | Optimize output for embedding models | ### formatting group | Parameter | Type | Default | Description | | - | - | - | - | | `formatting.table_output_format` | `"dynamic"` \| `"html"` \| `"md"` \| `"json"` \| `"csv"` \| `"jsonbbox"` | `"dynamic"` | `dynamic`: auto-selects md or html based on complexity. `html`: best for complex/merged cells. `md`: simple tables. `json`: programmatic cell access. | | `formatting.add_page_markers` | bool | `false` | Add page markers to output | | `formatting.merge_tables` | bool | `false` | Merge consecutive tables with same column count | | `formatting.include` | string\[] | `[]` | Include: `"change_tracking"`, `"highlight"`, `"comments"`, `"hyperlinks"`, `"signatures"`, `"ignore_watermarks"` | ### spreadsheet group | Parameter | Type | Default | Description | | - | - | - | - | | `spreadsheet.split_large_tables.enabled` | bool | `true` | Split large tables into smaller tables | | `spreadsheet.split_large_tables.size` | int | `50` | Rows per chunk for split tables | | `spreadsheet.clustering` | `"accurate"` \| `"fast"` \| `"disabled"` | `"accurate"` | Algorithm for splitting sheets into tables. Accurate uses more powerful models (5x cost). | | `spreadsheet.include` | string\[] | `[]` | Include: `"cell_colors"`, `"formula"`, `"dropdowns"` | ### settings group | Parameter | Type | Default | Description | | - | - | - | - | | `settings.model` | `"r-1"` \| `"r-1-mini"` \| `"legacy"` \| null | `null` | Parse model. `r-1`: full-page model with the highest accuracy and lower latency, for synchronous and live use. `r-1-mini`: high accuracy at low cost. Both models include agentic capabilities for complex documents. It does not accept spreadsheets (400). `legacy`: previous pipeline. Unset runs legacy Parse until December 7, 2026, then r-1. V3 only. | | `settings.page_range` | object \| null | `null` | `{"start": 1, "end": 10}` (1-indexed). Process specific pages only. | | `settings.return_images` | string\[] | `[]` | Return image URLs for block types: `"figure"`, `"table"`, `"page"` | | `settings.ocr_system` | `"standard"` \| `"legacy"` | `"standard"` | Legacy Parse only. `standard`: best multilingual OCR. `legacy`: Germanic languages only. r-1 uses native full-page processing and ignores this setting. | | `settings.extraction_mode` | `"ocr"` \| `"hybrid"` | `"hybrid"` | Legacy Parse only. `hybrid`: combines OCR with embedded PDF text. `ocr`: OCR only. r-1 uses native full-page processing and ignores this setting. | | `settings.persist_results` | bool | `false` | Keep results indefinitely (default: expire after 24h) | | `settings.force_url_result` | bool | `false` | Always return results as a URL | | `settings.timeout` | float \| null | `null` | Custom timeout in seconds | | `settings.document_password` | string \| null | `null` | Password for encrypted documents | | `settings.embed_pdf_metadata` | bool | `false` | Embed OCR metadata into returned PDF | | `settings.embed_pdf_metadata_dpi` | int | `200` | Render DPI for the rasterized pages of the embedded-OCR PDF. Range 50-250. Default 200 is suitable for on-screen viewing; raise toward the source scan DPI for crisper output when zoomed past \~200%. | ### Parse Response Shape ```json theme={null} { "job_id": "uuid", "duration": 3.89, "result": { "type": "full", // "full" (inline) or "url" (fetch from URL) "chunks": [ { "content": "# Heading\n\nText content...", // Markdown-formatted "embed": "Heading. Text content...", // Embedding-optimized "blocks": [ { "type": "Title", // Title, Section Header, Text, Table, Figure, Key Value, etc. "content": "Heading", "bbox": {"left": 0.1, "top": 0.05, "width": 0.3, "height": 0.04, "page": 1}, "confidence": "high" // "high" or "low" } ] } ] }, "usage": {"num_pages": 3, "credits": 4.0}, "studio_link": "https://studio.reducto.ai/job/..." } ``` When `result.type` is `"url"`, chunks are not inline. Fetch them from the URL: ```python theme={null} import requests if parse_result.result.type == "url": chunks = requests.get(parse_result.result.url).json() # chunks are plain dicts when fetched via URL else: chunks = parse_result.result.chunks # chunks are SDK objects with attribute access ``` *** ## Extract Parameters `POST /extract`. Pull specific fields from documents into structured JSON using a schema. Extract runs Parse internally. If a value doesn't appear in the Parse output, Extract cannot extract it. | Parameter | Type | Default | Description | | - | - | - | - | | `input` | string \| string\[] | **required** | File ID, URL, `jobid://...`, or array of job IDs to combine | | `instructions.schema` | object | `{}` | JSON Schema defining fields to extract. Field names and descriptions directly influence accuracy. | | `instructions.system_prompt` | string | `"Be precise and thorough."` | Document-level context for the LLM | | `settings.array_extract` | bool | `false` | **Deprecated.** Use `settings.deep_extract` instead. | | `settings.deep_extract` | bool | `false` | Agentic mode that iteratively refines output for near-perfect accuracy. Preferred for complex or long (array-heavy) extractions. Higher cost/latency. | | `settings.citations.enabled` | bool | `false` | Return source page, bbox, and text for each value. **Mutually exclusive with chunking.** | | `settings.citations.numerical_confidence` | bool | `true` | Include 0-1 confidence scores (vs "high"/"low") | | `settings.include_images` | bool | `false` | Include page images in extraction context | | `settings.optimize_for_latency` | bool | `false` | Higher priority processing at 2x cost | | `parsing` | object | `{}` | All Parse parameters (see above). Ignored if input is `jobid://`. | ### Extract Response Shape ```json theme={null} { "result": [ { "invoice_number": "INV-2024-001", "total": 1250.00, "line_items": [{"description": "Widget", "amount": 500.00}] } ], "job_id": "uuid", "usage": {"num_fields": 4, "num_pages": 2, "credits": 10.0}, "studio_link": "https://studio.reducto.ai/job/..." } ``` With `citations.enabled: true`, each value is wrapped: ```json theme={null} { "result": { "total": { "value": 1250.00, "citations": [ { "type": "Table", "content": "Total: $1,250.00", "bbox": {"left": 0.04, "top": 0.26, "width": 0.45, "height": 0.50, "page": 2}, "confidence": "high" } ] } } } ``` *** ## Split Parameters `POST /split`. Divide documents into named sections by page number. Split runs Parse internally, then uses an LLM to classify pages against your section descriptions. | Parameter | Type | Default | Description | | - | - | - | - | | `input` | string | **required** | File ID, URL, or `jobid://...` | | `split_description` | array | **required** | List of `{"name": "...", "description": "..."}` section definitions | | `split_rules` | string | `"Split the document into the applicable sections..."` | Natural language rules for splitting behavior | | `settings.table_cutoff` | `"truncate"` \| `"preserve"` | `"truncate"` | `truncate`: first rows only (faster). `preserve`: all content. | | `parsing` | object | `{}` | All Parse parameters. Ignored if input is `jobid://`. | ### Split Response Shape ```json theme={null} { "result": { "splits": [ {"name": "Executive Summary", "pages": [1, 2]}, {"name": "Financial Statements", "pages": [3, 4, 5, 6]}, {"name": "Risk Factors", "pages": [7, 8, 9]} ] }, "job_id": "uuid", "usage": {"num_pages": 9, "credits": 6.0} } ``` *** ## Edit Parameters `POST /edit`. Fill PDF forms and modify DOCX documents. **Note:** Edit uses `document_url` as its input parameter, not `input` like other endpoints. | Parameter | Type | Default | Description | | - | - | - | - | | `document_url` | string | **required** | File ID or URL of the document to edit (this is `document_url`, not `input`) | | `edit_instructions` | string | **required** | Natural language instructions. Be explicit: `"Fill Name: John Doe, Date: 2024-01-15"` | | `edit_options.color` | string | `"#FF0000"` | Highlight color for edits (DOCX only) | | `edit_options.enable_overflow_pages` | bool | `false` | Create appendix pages for text exceeding field capacity (PDF only) | | `form_schema` | array \| null | `null` | Pre-defined field locations for repeatable form filling. Skips detection. | ### Edit Response Shape ```json theme={null} { "document_url": "https://storage.reducto.ai/filled-form.pdf?...", "form_schema": [ { "bbox": {"left": 0.1, "top": 0.2, "width": 0.4, "height": 0.03, "page": 1}, "description": "Name field", "type": "text" } ], "usage": {"num_pages": 2, "credits": 8} } ``` The `document_url` is a presigned URL valid for 24 hours. Save the returned `form_schema` to reuse for the same form type (skips field detection). *** ## Classify Parameters `POST /classify`. Categorize a document before processing. | Parameter | Type | Default | Description | | - | - | - | - | | `input` | string | **required** | File ID or URL | | `classification_schema` | array | **required** | List of `{"category": "...", "criteria": ["...", "..."]}` | | `page_range` | object \| null | `null` | Pages to use for classification context. Defaults to first 5 pages. Max 10 pages. | | `document_metadata` | string \| null | `null` | Optional metadata to include in classification prompt | ### Classify Response Shape ```json theme={null} { "result": { "category": "invoice" }, "job_id": "uuid", "duration": 1.23 } ``` *** ## Async Processing Parse, Extract, Split, Classify, and Edit have async variants (`/parse_async`, `/extract_async`, `/split_async`, `/classify_async`, `/edit_async`). Async endpoints return a `job_id` immediately and process in the background. Poll `GET /job/{job_id}` for the result. ```python theme={null} # Submit async job job = client.parse.run_job(input=upload.file_id) print(job.job_id) # Poll for results import time while True: result = client.job.get(job.job_id) if result.status in ("Completed", "Failed"): break time.sleep(2) ``` Configure webhooks for push-based delivery instead of polling. *** ## Error Codes | HTTP Status | Meaning | Common cause | | - | - | - | | 401 | Unauthorized | Missing or invalid `REDUCTO_API_KEY` | | 422 | Validation error | Invalid parameters, schema too large, or constraint violation | | 429 | Rate limited | Too many concurrent requests. Retry with backoff. | | 500 | Server error | Transient issue. Retry with backoff. | *** ## Useful Links * [API Reference](/api-reference/parse): Full OpenAPI spec with request/response details * [Parse Configuration](/configs/overview): All configuration options * [Cookbooks](/cookbooks/overview): End-to-end tutorials (invoice extraction, form filling, RAG) * [Error Codes](/reference/error-codes): Complete error catalog * [Checking API Health & Usage](/reference/checking-api-health): Hosted API availability, usage data, and throttling signals * [Rate Limits](/reference/rate-limits): Request limits and quotas * [Credit Usage](/reference/credit-usage): How credits are calculated # Parse Async Source: https://docs.reducto.ai/api-reference/async-parse openapi.json post /parse_async Asynchronously turn full documents into LLM-ready content with r-1 or a legacy Parse pipeline. > [!NOTE] > > Starting December 7, 2026, Parse defaults to r-1. To keep legacy Parse, set `settings.model` to `"legacy"`. Parameter descriptions below call out behavior differences between the pipelines. See [Parse r-1](/parse/r-1) and [r-1 configuration compatibility](/configs/parse/r-1-configuration-compatibility). # Classify Source: https://docs.reducto.ai/api-reference/classify openapi.json post /classify # Classify Async Source: https://docs.reducto.ai/api-reference/classify-async openapi.json post /classify_async Async classify endpoint. Persists a pollable job row, then runs the same classify CPU-worker pipeline without awaiting the result. Clients poll ``GET /job/{job_id}``. # Edit Source: https://docs.reducto.ai/api-reference/edit openapi.json post /edit # Edit Async Source: https://docs.reducto.ai/api-reference/edit-async openapi.json post /edit_async # Extract Source: https://docs.reducto.ai/api-reference/extract openapi.json post /extract Reducto Extract API pulls structured fields from any document using a schema you define or schemaless configuration. # Extract Async Source: https://docs.reducto.ai/api-reference/extract-async openapi.json post /extract_async # Parse Source: https://docs.reducto.ai/api-reference/parse openapi.json post /parse Turn full documents into LLM-ready content with r-1 or a legacy Parse pipeline. > [!NOTE] > > Starting December 7, 2026, Parse defaults to r-1. To keep legacy Parse, set `settings.model` to `"legacy"`. Parameter descriptions below call out behavior differences between the pipelines. See [Parse r-1](/parse/r-1) and [r-1 configuration compatibility](/configs/parse/r-1-configuration-compatibility). # Pipeline Source: https://docs.reducto.ai/api-reference/pipeline openapi.json post /pipeline # Pipeline Async Source: https://docs.reducto.ai/api-reference/pipeline-async openapi.json post /pipeline_async # Split Source: https://docs.reducto.ai/api-reference/split openapi.json post /split # Split Async Source: https://docs.reducto.ai/api-reference/split-async openapi.json post /split_async # Upload Source: https://docs.reducto.ai/api-reference/upload openapi.json post /upload # Classify Best Practices Source: https://docs.reducto.ai/classify/best-practices Write better classification schemas for more accurate results The quality of your classification depends heavily on how you define your categories and criteria. Here are guidelines for getting the best results. *** ## Be Specific with Criteria Criteria should describe concrete, observable characteristics of the document, things that would be visible on the page. > [!TIP] > > Think of criteria as instructions you'd give a human reviewer: "Look for X, Y, and Z to identify this type of document." **Good criteria** describe what you'd actually see in the document: ```json theme={null} { "category": "invoice", "criteria": [ "contains an invoice number or reference number", "has line items with quantities and unit prices", "shows a total amount due or balance", "includes vendor/supplier contact information" ] } ``` **Weak criteria** are too vague or abstract: ```json theme={null} { "category": "invoice", "criteria": [ "is an invoice", "looks like an invoice", ] } ``` *** ## Make Categories Mutually Exclusive Design your categories so that a given document clearly belongs to one category over others. If categories overlap significantly, classification accuracy will suffer. ```json theme={null} [ { "category": "declaration_page", "criteria": [ "summarizes active coverage for a specific policy term", "includes policy number, named insured, and insurer details", "shows effective and expiration dates", "lists coverage types with limits and deductibles", "states total premium and billing or installment info" ] }, { "category": "explanation_of_benefits", "criteria": [ "explains how a submitted medical claim was processed", "includes member ID, claim number, and service dates", "breaks down billed amount, allowed amount, and insurer payment", "shows patient responsibility (copay, deductible, coinsurance)", "clearly indicates this is not a bill" ] } ] ``` *** ## Text vs. Image-Based Classification Classify works with both text-heavy and visually distinct documents. Your criteria can reference either textual content or visual characteristics: * **Text-based criteria**: `"contains the words 'Terms and Conditions'"`, `"includes a table of financial figures"` * **Visual/structural criteria**: `"has a photo ID section"`, `"contains handwritten notes"`, `"includes a signature block"` For documents that are primarily distinguished by layout rather than text (e.g., a passport vs. a driver's license), include structural criteria like `"contains a machine-readable zone at the bottom"` or `"has a photo in the upper-left corner"`. *** ## Use Enough Categories You must provide at least two categories. Classify returns the **best match** from your schema, so even if none of the categories are a perfect fit, it will return the closest one. If you need an escape hatch, add a catch-all category: ```json theme={null} { "category": "other", "criteria": [ "does not match any of the other document types (list all the other document types)", "unrecognized format or content" ] } ``` *** ## Use Confidence Scores to Refine Your Schema The [response confidence breakdown](/classify/response-format#structured-confidence-reasoning-you-can-act-on) tells you exactly which criteria matched or didn't for every category. Use this to iterate on your schema: 1. Run Classify on a batch of sample documents. 2. Check documents where the winning category had low confidence (e.g., below `0.7`). 3. Inspect the `criteria_confidence` to see which criteria are too broad, too narrow, or overlapping with other categories. 4. Adjust your criteria and re-run until confidence scores improve. *** ## Group Categories for Coarse Routing Use groups when many detailed categories share one downstream pipeline. Keep the fine-grained categories so Classify can identify the document accurately, then route on `extra_metadata.grouping` instead of `result.category`. See [Grouping Metadata](/classify/response-format#grouping-metadata) for the response format. *** ## Start with the Default Model, Escalate to Deep Classify Start with the default model for normal documents. Check the winning category's confidence score. If documents or schemas stay low-confidence, retry with `model: "accurate"` to use Deep Classify. Deep Classify costs more per page than the default model. See [Credit Usage](/reference/credit-usage). See [Deep Classify configuration](/configs/classify/configuration#deep-classify) for an example. *** ## Related > [card] [Classify Overview](/classify/overview) > > Quick start, request parameters, and pipeline integration. > [card] [Response Format](/classify/response-format) > > Confidence scores, per-criterion reasoning, and all response fields. # Classify Source: https://docs.reducto.ai/classify/overview Categorize documents before you parse Classify determines what kind of document you are looking at before any downstream processing begins. You specify categories with natural language criteria, and Reducto returns the best match. Classify is typically the first step in a document workflow. It routes documents to the right pipeline with the right configurations, so that [Parse](/parse/overview), [Extract](/extract/overview), and [Split](/split) each run with settings tuned for the specific document type. For a higher-level view of routing use cases and how Classify fits with the rest of the platform, see the [Classify product overview](https://reducto.ai/classify?utm_source=docs\&utm_medium=referral\&utm_campaign=docs_product_bridge\&utm_content=classify_overview_top). *** ## When to Use Classify Classify routes your documents to the right pipeline upstream of further processing. Instead of parsing all of your documents with the same settings, you can contextualize your documents before you parse them. **Common use cases:** * **Document triage during onboarding.** Quickly classify documents into categories when users upload files on your platform. For example, sort into different types of legal documents. * **Conditional parsing configurations.** Classify handwritten doctor's notes versus other forms, then enable agentic text in Parse with specific prompts downstream for the doctor's notes. * **Schema routing for extraction.** Classify your documents upfront to apply different extraction schemas downstream. Passports get one schema, immigration forms get another. * **Pipeline branching.** Use classification results to decide whether a document needs Split, Extract, or both, and with which Parse configurations. For example, some financial documents may need specific splits, while others may not. Use Classify on its own to label documents as part of a larger user flow, or apply Classify before your existing Reducto pipeline. *** ## Quick Start Play around at [the Classify demo](https://classify.reducto.ai). ```python Python theme={null} from reducto import Reducto client = Reducto() response = client.classify.run( input="https://example.com/document.pdf", classification_schema=[ { "category": "invoice", "criteria": [ "contains billing information", "has itemized charges", "includes payment details or amounts due", ], }, { "category": "contract", "criteria": [ "contains legal terms and conditions", "includes signature lines or parties involved", "references obligations or agreements", ], }, { "category": "receipt", "criteria": [ "shows a completed transaction", "includes items purchased with prices", "has a total amount paid", ], }, ], ) print(response) ``` ```javascript Node.js theme={null} import Reducto from 'reductoai'; const client = new Reducto(); const response = await client.classify.run({ input: 'https://example.com/document.pdf', classification_schema: [ { category: 'invoice', criteria: [ 'contains billing information', 'has itemized charges', 'includes payment details or amounts due', ], }, { category: 'contract', criteria: [ 'contains legal terms and conditions', 'includes signature lines or parties involved', 'references obligations or agreements', ], }, { category: 'receipt', criteria: [ 'shows a completed transaction', 'includes items purchased with prices', 'has a total amount paid', ], }, ], }); console.log(response); ``` ```bash cURL theme={null} curl -X POST https://platform.reducto.ai/classify \ -H "Authorization: Bearer $REDUCTO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "input": "https://example.com/document.pdf", "classification_schema": [ { "category": "invoice", "criteria": [ "contains billing information", "has itemized charges", "includes payment details or amounts due" ] }, { "category": "contract", "criteria": [ "contains legal terms and conditions", "includes signature lines or parties involved", "references obligations or agreements" ] }, { "category": "receipt", "criteria": [ "shows a completed transaction", "includes items purchased with prices", "has a total amount paid" ] } ] }' ``` **What this does:** 1. **Upload** the document to get a `file_id` 2. **Call `/classify`** with the file reference and a list of categories, each with criteria describing what makes a document belong to that category 3. **Get back** the best-matching category ```json theme={null} { "job_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "result": { "category": "invoice" }, "usage": { "num_pages": 5, "num_categories": 3, "credits": 2.5 } } ``` Visualize your classification with [the Classify demo](https://classify.reducto.ai). Classify works on all file types we can Parse. > [card] [Response Format Details](/classify/response-format) > > Full breakdown of confidence scores, per-criterion reasoning, and all response fields. *** ## Request Parameters ```python Python theme={null} from reducto import Reducto client = Reducto() response = client.classify.run( input="...", # Required: upload response or URL classification_schema=[...], # Required: categories with criteria model="default", # Optional: "default" or "accurate" (Deep Classify) category_groups={"financial": ["invoice"]}, # Optional: group categories page_range={"start": 1, "end": 10}, # Optional: pages to use as context document_metadata="...", # Optional: additional context ) ``` ```javascript Node.js theme={null} import Reducto from 'reductoai'; const client = new Reducto(); const response = await client.classify.run({ input: '...', // Required: upload response or URL classification_schema: [...], // Required: categories with criteria model: 'default', // Optional: "default" or "accurate" (Deep Classify) category_groups: { financial: ['invoice'] }, // Optional: group categories page_range: { start: 1, end: 10 }, // Optional: pages to use as context document_metadata: '...', // Optional: additional context }); ``` ```bash cURL theme={null} curl -X POST https://platform.reducto.ai/classify \ -H "Authorization: Bearer $REDUCTO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "input": "YOUR_FILE_ID_OR_URL", "classification_schema": [{"category": "...", "criteria": ["..."]}], "model": "default", "category_groups": {"financial": ["invoice"]}, "page_range": {"start": 1, "end": 10}, "document_metadata": "optional context string" }' ``` ### input (required) The document to classify. Accepts the same formats as other Reducto endpoints: | Format | Example | When to use | | - | - | - | | Upload response | `reducto://abc123` | Local files uploaded via `/upload` | | Public URL | `https://example.com/doc.pdf` | Publicly accessible documents | | Presigned URL | `https://bucket.s3.../doc.pdf?X-Amz-...` | Files in your cloud storage | ### classification\_schema (required) A list of categories you want to classify the document into. Each category has a name and a list of criteria describing what makes a document belong to that category. ```json theme={null} { "classification_schema": [ { "category": "invoice", "criteria": [ "contains billing information", "has itemized charges", "includes payment details" ] }, { "category": "contract", "criteria": [ "contains legal terms and conditions", "includes signature lines", "references parties and obligations" ] } ] } ``` | Field | Type | Description | | - | - | - | | `category` | `string` | The category name/label that documents will be classified into (e.g., `"invoice"`, `"contract"`, `"receipt"`) | | `criteria` | `list[string]` | A list of criteria, keywords, or descriptions that define what characteristics a document must have to be classified into this category | ### model The `model` parameter defaults to `"default"`. Set it to `"accurate"` to use Deep Classify, which improves accuracy on hard documents. Deep Classify costs more per page than the default model. See [Credit Usage](/reference/credit-usage). See [Deep Classify configuration](/configs/classify/configuration#deep-classify). ### category\_groups The `category_groups` parameter defaults to `{}`. It maps group names to category labels, such as `{"financial": ["invoice", "receipt"], "legal": ["contract"]}`. When you provide groups, the response includes `extra_metadata.grouping` with the winning category's group, or `"ungrouped"` when the winning category is in no group. See [Grouping Metadata](/classify/response-format#grouping-metadata). ### page\_range `page_range` (optional, defaults to first 5 pages): The page range to use as context for classification. Accepts an object with `start` and `end` fields (1-indexed, inclusive). If more than 10 pages are selected, the request returns an error. Only applies to PDFs. See [Classify Configuration](/configs/classify/configuration) for examples and cost implications. ### document\_metadata `document_metadata` (optional, defaults to `null`): A metadata string to include in classification prompts. Use this to provide additional context about the document that may help with classification. *** ## How It Works 1. **Document ingestion.** Classify accepts your document and processes it to understand its content. 2. **Category evaluation.** Each category in your `classification_schema` is evaluated against the document. The criteria you provide guide what the model looks for. 3. **Best match selection.** Classify returns the single best-matching category. It compares all categories and picks the one whose criteria best describe the document. Classify is optimized for latency. It's a lightweight operation compared to full parsing or extraction, designed to return results fast enough to use as an inline routing step without adding meaningful overhead to your pipeline. Classify has both synchronous (`/classify`) and asynchronous (`/classify_async`) endpoints. Use async when you need webhook delivery or want to poll for the result later. *** ## Using Classify in a Pipeline Classify is most useful when combined with other Reducto endpoints. Here's a common pattern: classify first, then route to different Parse/Extract configurations based on the result. ```python Python theme={null} from pathlib import Path from reducto import Reducto client = Reducto() # Step 1: Upload upload = client.upload(file=Path("document.pdf")) # Step 2: Classify classification = client.classify.run( input=upload.file_id, classification_schema=[ { "category": "handwritten_notes", "criteria": ["contains handwritten text", "informal layout", "pen or pencil marks"], }, { "category": "printed_form", "criteria": ["structured layout with fields", "typed text", "checkboxes or form widgets"], }, ], ) category = classification.result.category # Step 3: Route to appropriate pipeline if category == "handwritten_notes": result = client.parse.run( input=upload.file_id, enhance={"agentic": [{"scope": "text", "prompt": "This is a handwritten medical note. Pay close attention to medication names and dosages."}]}, ) else: # Standard extraction for printed forms result = client.extract.run( input=upload.file_id, instructions={ "schema": { "type": "object", "properties": { "patient_name": {"type": "string"}, "date_of_birth": {"type": "string"}, "insurance_id": {"type": "string"}, }, } }, ) print(result) ``` ```javascript Node.js theme={null} import fs from 'fs'; import Reducto from 'reductoai'; const client = new Reducto(); // Step 1: Upload const upload = await client.upload({ file: fs.createReadStream('document.pdf') }); // Step 2: Classify const classification = await client.classify.run({ input: upload.file_id, classification_schema: [ { category: 'handwritten_notes', criteria: ['contains handwritten text', 'informal layout', 'pen or pencil marks'], }, { category: 'printed_form', criteria: ['structured layout with fields', 'typed text', 'checkboxes or form widgets'], }, ], }); const category = classification.result.category; // Step 3: Route to appropriate pipeline if (category === 'handwritten_notes') { const result = await client.parse.run({ input: upload.file_id, enhance: { agentic: [{ scope: 'text', prompt: 'This is a handwritten medical note. Pay close attention to medication names and dosages.' }] }, }); console.log(result); } else { // Standard extraction for printed forms const result = await client.extract.run({ input: upload.file_id, instructions: { schema: { type: 'object', properties: { patient_name: { type: 'string' }, date_of_birth: { type: 'string' }, insurance_id: { type: 'string' }, }, }, }, }); console.log(result); } ``` ```bash cURL theme={null} # Step 1: Upload FILE_ID=$(curl -s -X POST https://platform.reducto.ai/upload \ -H "Authorization: Bearer $REDUCTO_API_KEY" \ -F "file=@document.pdf" | jq -r '.file_id') # Step 2: Classify CATEGORY=$(curl -s -X POST https://platform.reducto.ai/classify \ -H "Authorization: Bearer $REDUCTO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "input": "'$FILE_ID'", "classification_schema": [ { "category": "handwritten_notes", "criteria": ["contains handwritten text", "informal layout", "pen or pencil marks"] }, { "category": "printed_form", "criteria": ["structured layout with fields", "typed text", "checkboxes or form widgets"] } ] }' | jq -r '.result.category') # Step 3: Route to appropriate pipeline if [ "$CATEGORY" = "handwritten_notes" ]; then curl -X POST https://platform.reducto.ai/parse \ -H "Authorization: Bearer $REDUCTO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "input": "'$FILE_ID'", "enhance": { "agentic": [{"scope": "text", "prompt": "This is a handwritten medical note. Pay close attention to medication names and dosages. "}] } }' else curl -X POST https://platform.reducto.ai/extract \ -H "Authorization: Bearer $REDUCTO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "input": "'$FILE_ID'", "instructions": { "schema": { "type": "object", "properties": { "patient_name": {"type": "string"}, "date_of_birth": {"type": "string"}, "insurance_id": {"type": "string"} } } } }' fi ``` *** ## FAQs > [accordion] When should I use Deep Classify? > > Use Deep Classify for hard or visually similar documents, or when the default model returns low confidence. Deep Classify costs more per page than the default model. See [Credit Usage](/reference/credit-usage). > [accordion] How does Classify differ from Extract or Split? > > Reducto can already Classify documents after parsing using Extract or Split. > > Extract pulls **specific data** out of a document alongside an enum-based classification. Split works best when you have multi-document packets. However, both of these endpoints need Parse. > > Classify is utilized **before** Parse, and as such is optimized for latency and cost. Classify also returns structured reasoning and confidence. > [accordion] What are the latency and accuracy trade-offs? > > Classify is faster than Parse or Extract because it doesn't need to do full document parsing. It focuses on high-level document understanding to match against your categories. > > Accuracy depends on how well your criteria describe each category. Well-defined, mutually exclusive categories with specific criteria will yield the best results. > [accordion] What types of classifications can I specify? > > Any categories you want. You define both the category names and the criteria. Common examples include: > > * **Document type**: invoice, contract, receipt, tax form > * **Content characteristics**: handwritten vs. typed, single-page vs. multi-page > * **Domain-specific**: ACORD forms vs. declarations pages, W-2 vs. 1099 > * **Processing needs**: needs OCR enhancement vs. standard processing > [accordion] Do I need to specify categories, or can Reducto infer them? > > You must provide your own categories via `classification_schema`. Classify matches your document against the categories you define. It doesn't auto-discover document types. > > This is by design: your categories should reflect your specific pipeline needs. A healthcare company and a law firm would classify the same document differently based on their downstream processing requirements. > [accordion] Can I use Classify with Parse and Extract? > > Yes. This is the primary use case. Classify sits at the top of your pipeline to route documents, then you call Parse, Extract, or Split with configurations tailored to each document type. > > See [Using Classify in a Pipeline](#using-classify-in-a-pipeline) above for a complete example. > [accordion] What file types does Classify support? > > Classify is optimized for PDFs and images (PNG, JPEG, etc.), but also supports the remaining formats supported by Reducto's Parse endpoint. > [accordion] What happens if my document doesn't match any category well? > > Classify always returns the best match from your schema, even if the fit isn't perfect. If you need to handle unrecognized documents, add an `"other"` category with criteria like `"does not match any of the other document types"`. Be sure to enumerate the types it should not match *** ## Related > [card] [Response Format](/classify/response-format) > > Confidence scores, per-criterion reasoning, and all response fields. > [card] [Best Practices](/classify/best-practices) > > Write better classification schemas for more accurate results. > [card] [Classify Configuration](/configs/classify/configuration) > > Page ranges and classification schema options. > [card] [Credit Usage](/reference/credit-usage) > > How Classify credits are calculated. > [card] [Comparing classification software](https://reducto.ai/guides/best-document-classification-software-2026) > > How classification platforms differ on routing, confidence, and human review. > [card] [Document AI platforms compared](https://reducto.ai/guides/best-document-ai-platforms-apis) > > Platform scope across parsing, extraction, and document operations. # Classify Response Format Source: https://docs.reducto.ai/classify/response-format Confidence scores, per-criterion reasoning, and all response fields Classify returns a category label, a per-criterion confidence breakdown for every category in your schema, and timing information. This page explains every field in the response. *** ## Response Structure ```json theme={null} { "job_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "result": { "category": "invoice" }, "response_confidence": { "categories": [ { "category": "invoice", "confidence": 1.0, "criteria_confidence": [ {"criterion": "contains billing information", "confidence": "high"}, {"criterion": "has itemized charges", "confidence": "high"}, {"criterion": "includes payment details or amounts due", "confidence": "high"} ] }, { "category": "contract", "confidence": 0.33, "criteria_confidence": [ {"criterion": "contains legal terms and conditions", "confidence": "low"}, {"criterion": "includes signature lines or parties involved", "confidence": "low"}, {"criterion": "references obligations or agreements", "confidence": "high"} ] } ] }, "usage": { "num_pages": 5, "num_categories": 2, "credits": 2.5 }, "duration": 1.23 } ``` ### Top-Level Fields | Field | Type | Description | | - | - | - | | `job_id` | string | Unique identifier for the classification job | | `result.category` | string | The best-matching category from your `classification_schema`. Always one of the category names you provided. | | `extra_metadata` | object | `{"grouping": ""}` when the request set `category_groups`. The value is `"ungrouped"` if the winning category is in no group. Omitted when the request did not set `category_groups`. | | `response_confidence` | object | Per-category and per-criterion confidence breakdown. `null` when no criteria are provided. | | `usage` | object | Page count, category count, and credit consumption for this classification. See [Usage Fields](#usage-fields) below. May be `null`. | | `duration` | number | Time in seconds the classify request took. May be `null`. | ### Usage Fields The `usage` object contains: | Field | Type | Description | | - | - | - | | `num_pages` | integer | Number of pages used as context for classification. Defaults to 5, or fewer if the document is shorter. | | `num_categories` | integer | Number of categories in the `classification_schema` that were evaluated. | | `credits` | number | Total credits consumed. Each page costs 0.5 credits. May be `null`. | | `usage_breakdown` | object | Raw usage quantities. Present only for accounts on per-product pricing, where the credit fields are null. See [Pricing Migration Guide](/reference/pricing-migration#what-your-api-responses-look-like). | ### Category Confidence Fields Each entry in `response_confidence.categories` contains: | Field | Type | Description | | - | - | - | | `category` | string | The category name from your schema | | `confidence` | number | Float between 0 and 1 representing the fraction of criteria that matched for this category | | `criteria_confidence` | array | Per-criterion evaluation results | ### Criteria Confidence Fields Each entry in `criteria_confidence` contains: | Field | Type | Description | | - | - | - | | `criterion` | string | The criterion text from your schema | | `confidence` | string | `"high"` (criterion matched the document) or `"low"` (criterion did not match) | ## Grouping Metadata Use `category_groups` to map detailed categories to broader groups for downstream routing. ```python Python theme={null} from reducto import Reducto client = Reducto() response = client.classify.run( input="https://example.com/invoice.pdf", classification_schema=[ { "category": "invoice", "criteria": ["itemized charges", "amount due", "vendor details"], }, { "category": "receipt", "criteria": ["completed transaction", "items purchased", "total paid"], }, { "category": "contract", "criteria": ["legal terms", "parties and obligations", "signature lines"], }, ], category_groups={ "financial": ["invoice", "receipt"], "legal": ["contract"], }, ) print(response) ``` ```javascript Node.js theme={null} import Reducto from 'reductoai'; const client = new Reducto(); const response = await client.classify.run({ input: 'https://example.com/invoice.pdf', classification_schema: [ { category: 'invoice', criteria: ['itemized charges', 'amount due', 'vendor details'], }, { category: 'receipt', criteria: ['completed transaction', 'items purchased', 'total paid'], }, { category: 'contract', criteria: ['legal terms', 'parties and obligations', 'signature lines'], }, ], category_groups: { financial: ['invoice', 'receipt'], legal: ['contract'], }, }); console.log(response); ``` ```bash cURL theme={null} curl -X POST https://platform.reducto.ai/classify \ -H "Authorization: Bearer $REDUCTO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "input": "https://example.com/invoice.pdf", "classification_schema": [ { "category": "invoice", "criteria": ["itemized charges", "amount due", "vendor details"] }, { "category": "receipt", "criteria": ["completed transaction", "items purchased", "total paid"] }, { "category": "contract", "criteria": ["legal terms", "parties and obligations", "signature lines"] } ], "category_groups": { "financial": ["invoice", "receipt"], "legal": ["contract"] } }' ``` The response includes the matched group: ```json theme={null} { "result": { "category": "invoice" }, "extra_metadata": { "grouping": "financial" } } ``` Route on `extra_metadata.grouping` when your downstream pipeline is shared by several detailed categories. *** ## Structured Confidence: Reasoning You Can Act On Classify doesn't just return a label. It returns a per-criterion confidence breakdown for **every** category you defined, not just the winner. This gives you structured, interpretable reasoning for why a document was classified the way it was. Each criterion you define becomes a yes/no evaluation. The `confidence` score for a category is the fraction of its criteria that matched (`high`). In the example above, `"invoice"` scored `1.0` because all 3 criteria matched, while `"contract"` scored `0.33` because only 1 of 3 criteria matched. This structured output is useful in several ways: * **Auditability.** You can trace exactly which criteria drove a classification decision. If an invoice was misclassified, inspect the `criteria_confidence` to see which criteria matched or didn't. * **Threshold-based routing.** Instead of blindly trusting `result.category`, check the confidence score. If the top category scores below a threshold (e.g., `0.6`), flag it for human review rather than routing it automatically. * **Ambiguity detection.** If two categories score similarly (e.g., `0.67` and `0.55`), the document may be ambiguous. Use this signal to trigger a different workflow or request additional information. * **Schema refinement.** Low-confidence classifications across your pipeline tell you which criteria need to be more specific. The per-criterion breakdown pinpoints exactly which criteria are too broad or overlapping. > [!TIP] > > Think of criteria as a structured checklist. This makes the classification decision transparent and programmatically accessible, not just a black-box label. *** ## Example: Confidence-Based Routing Use the per-category confidence scores to build routing logic that handles uncertain classifications gracefully. ```python Python theme={null} import requests API_KEY = "YOUR_REDUCTO_API_KEY" HEADERS = {"Authorization": f"Bearer {API_KEY}", "Content-Type": "application/json"} # Classify the document classify_resp = requests.post( "https://platform.reducto.ai/classify", headers=HEADERS, json={ "input": "YOUR_FILE_ID", "classification_schema": [ {"category": "invoice", "criteria": ["contains billing information", "has itemized charges"]}, {"category": "contract", "criteria": ["contains legal terms", "includes signature lines"]}, ], }, ) result = classify_resp.json() # Extract the winning category and its confidence category = result["result"]["category"] confidence = next( c for c in result["response_confidence"]["categories"] if c["category"] == category ) if confidence["confidence"] >= 0.7: # High confidence: route automatically print(f"Routing {category} automatically (confidence: {confidence['confidence']})") else: # Low confidence: flag for human review print(f"Flagging for review: {category} (confidence: {confidence['confidence']})") print("Criteria breakdown:") for c in confidence["criteria_confidence"]: print(f" {c['criterion']}: {c['confidence']}") ``` ```bash cURL theme={null} # Classify and inspect confidence RESPONSE=$(curl -s -X POST https://platform.reducto.ai/classify \ -H "Authorization: Bearer $REDUCTO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "input": "YOUR_FILE_ID", "classification_schema": [ {"category": "invoice", "criteria": ["contains billing information", "has itemized charges"]}, {"category": "contract", "criteria": ["contains legal terms", "includes signature lines"]} ] }') # Get the category and confidence CATEGORY=$(echo $RESPONSE | jq -r '.result.category') CONFIDENCE=$(echo $RESPONSE | jq -r ".response_confidence.categories[] | select(.category == \"$CATEGORY\") | .confidence") echo "Category: $CATEGORY (confidence: $CONFIDENCE)" # Show per-criterion breakdown echo "Criteria breakdown:" echo $RESPONSE | jq -r ".response_confidence.categories[] | select(.category == \"$CATEGORY\") | .criteria_confidence[] | \" \(.criterion): \(.confidence)\"" ``` *** ## Related > [card] [Classify Overview](/classify/overview) > > Quick start, request parameters, and pipeline integration. > [card] [Best Practices](/classify/best-practices) > > Write better classification schemas for more accurate results. # Reducto CLI Source: https://docs.reducto.ai/cli Access Reducto from your terminal. The Reducto CLI gives you direct terminal access to Reducto's document capabilities: parse, extract, split, classify, and edit. Use it for batch processing, scripting, CI/CD pipelines, and quick operations without writing application code. ## Fast path for coding agents Use the CLI when the document is already on disk or when an agent needs a terminal-first workflow. It avoids writing upload code and produces Markdown files that agents can read directly. ```bash theme={null} pip install reducto-cli reducto login curl -L -o fidelity-example.pdf https://cdn.reducto.ai/samples/fidelity-example.pdf reducto parse ./fidelity-example.pdf ``` For a local file: ```bash theme={null} reducto parse ./document.pdf ``` For a directory: ```bash theme={null} reducto parse ./documents ``` The CLI writes `.parse.md` next to each input file. For agent tool calling instead of terminal commands, use the [MCP server](/mcp-server). ## Installation Install the Reducto CLI using pip: ```bash theme={null} pip install reducto-cli ``` ## Authentication Before using the CLI, authenticate by running: ```bash theme={null} reducto login ``` This command opens [Reducto Studio](https://studio.reducto.ai/) in your browser, where you can securely authenticate your CLI session. ## Quick Examples ```bash theme={null} # Parse a single file reducto parse path/to/document.pdf # Parse an entire folder reducto parse ./docs # Extract with a schema (path or inline JSON) reducto extract ./docs/invoice.pdf -s schemas/invoice.json # Edit a single file reducto edit path/to/document.pdf --instructions "Your editing instructions here" ``` Parsed outputs are written as `.parse.md`. Extraction reuses existing parses when possible and saves `.extract.json` containing only the payload. ## Supported File Types The CLI supports the same file types as the Reducto API: | Format | Extensions | | - | - | | PDF | `.pdf` | | Images | `.png`, `.jpg`, `.jpeg` | | Office documents | `.doc`, `.docx`, `.ppt`, `.pptx` | | Spreadsheets | `.xls`, `.xlsx` | Commands accept either a file or a directory. Directories are scanned recursively, and only supported file types are processed. ## Parse Command The `parse` command converts documents into structured markdown output. ### Flags | Flag | Description | | - | - | | `--agentic` | Enables agentic processing for tables, text, and figures. With r-1, use it for custom prompts or specialized augmentation. Legacy Parse uses it as a general accuracy enhancement. Adds latency. | | `--change-tracking` | Enables change tracking during parsing. Returns `` tags around strikethrough text, `` tags around underlined text, and `` tags around colored adjacent strikethrough and underlined text. Useful for documents with revision history. | | `--highlights` | Include highlighted text in the parsed output. | | `--hyperlinks` | Include embedded hyperlinks in the parsed output. | | `--comments` | Include document comments in the parsed output. | ### Examples ```bash theme={null} # Basic parse reducto parse document.pdf # Enable agentic processing (adds latency) reducto parse document.pdf --agentic # Parse a contract with change tracking reducto parse contract.pdf --change-tracking # Parse with all metadata reducto parse document.pdf --hyperlinks --comments --highlights # Combine flags as needed reducto parse legal_doc.pdf --agentic --change-tracking --comments ``` ## Extract Command The `extract` command pulls structured data from documents according to a JSON Schema you provide. It automates information extraction by mapping complex or unstructured documents into machine-readable JSON. ### Common Use Cases * Extracting line items, totals, vendor/customer info from invoices and receipts * Pulling key fields, tables, or sections from contracts or legal documents * Capturing form field values from scanned forms or applications * Summarizing structured results from reports, statements, or medical records ### Schema Guidelines * Schemas must be valid JSON Schema documents * The top-level schema **must** be an object (`{"type": "object", ...}`), not an inline string or array * Provide explicit property definitions so the extractor can map fields deterministically * Schemas may be supplied as file paths or inline JSON strings ### Example Schema ```json theme={null} { "type": "object", "properties": { "items": { "type": "array", "items": { "type": "object", "properties": { "article_number": {"type": "string"}, "description": {"type": "string"}, "quantity": {"type": "number"}, "unit_price": {"type": "number"}, "total_price": {"type": "number"} }, "required": [ "article_number", "description", "quantity", "unit_price", "total_price" ] } } }, "required": ["items"] } ``` > [!NOTE] > > You can reuse parses across multiple extractions: the CLI automatically detects existing `.parse.md` files, rehydrates the recorded job ID, and uses `jobid://` references to accelerate extraction jobs. ## Edit Command The `edit` command modifies documents using natural language instructions. It uploads the document, applies the specified edits, and downloads the resulting file. ### Usage ```bash theme={null} reducto edit path/to/document.pdf --instructions "Your editing instructions here" reducto edit path/to/document.pdf -i "Your editing instructions here" ``` ### Parameters | Parameter | Required | Description | | - | - | - | | `path` | Yes | Path to a file or directory. Directories are scanned recursively for supported file types. | | `--instructions`, `-i` | Yes | Natural language instructions describing the edits to apply. | ### Output Edited files are saved alongside the original with the naming pattern `.edited.`. For example: * `invoice.pdf` becomes `invoice.edited.pdf` * `report.docx` becomes `report.edited.docx` ### Examples ```bash theme={null} reducto edit contract.pdf -i "Fill in the client name as 'Acme Corporation' and set the contract date to January 15, 2024" reducto edit document.pdf -i "Fill out the form with: Name: John Doe, Email: john@example.com, Select 'Yes' for newsletter subscription" ``` ### Tips for Effective Instructions For best results with the `--instructions` flag: * Be specific about what content to modify and how * Reference specific elements (headers, footers, tables, specific text) * Describe the desired outcome clearly * For bulk operations on directories, ensure instructions apply uniformly to all file types ## Next Steps > [card] [API Quickstart](/quickstart) > > Learn to use the Reducto API directly for more advanced integrations. > [card] [Parse Options](/configs/overview) > > Explore all available parsing configurations. > [card] [Extract Overview](/extract/overview) > > Deep dive into structured data extraction. > [card] [Edit Overview](/editing/edit-overview) > > Learn more about document editing capabilities. # SpreadsheetViewer Source: https://docs.reducto.ai/components/spreadsheet-viewer A React component for viewing Excel spreadsheets with bounding box overlays for document extraction visualization > [!NOTE] > > **Enterprise Component** — The SpreadsheetViewer component is available to Reducto enterprise customers. > Contact [sales@reducto.ai](mailto:sales@reducto.ai) to get access. ## Overview The `SpreadsheetViewer` component provides a powerful, read-only Excel spreadsheet viewer with support for bounding box overlays. It's designed to visualize Reducto extraction results directly on your spreadsheet documents. > [card] Excel Support > > View `.xlsx`, `.xls`, and `.xlsm` files with full sheet navigation > [card] Bounding Boxes > > Overlay extraction results with clickable, colored regions > [card] Zero Tailwind > > CSS Variables for easy theming without Tailwind dependency > [card] TypeScript > > Full type definitions included for excellent DX ## Installation **1. Configure npm registry** Create or update your project's `.npmrc` file to authenticate with GitHub Packages: ```bash .npmrc theme={null} @reductoai-collab:registry=https://npm.pkg.github.com //npm.pkg.github.com/:_authToken=${REDUCTO_NPM_TOKEN} ``` > [!WARNING] > > Never commit your token directly to `.npmrc`. Use an environment variable as shown above. **2. Set your authentication token** Reducto will provide you with an access token. Set it as an environment variable: > [tab] macOS / Linux > > Add to your shell profile (`~/.bashrc`, `~/.zshrc`, etc.): > > ```bash theme={null} > export REDUCTO_NPM_TOKEN="your-token-from-reducto" > ``` > > Then reload your shell: > > ```bash theme={null} > source ~/.zshrc # or ~/.bashrc > ``` > [tab] Windows > > Set as a system environment variable: > > ```powershell theme={null} > [System.Environment]::SetEnvironmentVariable('REDUCTO_NPM_TOKEN', 'your-token-from-reducto', 'User') > ``` > > Restart your terminal after setting. > [tab] CI/CD > > Add `REDUCTO_NPM_TOKEN` as a secret in your CI environment: > > ```yaml GitHub Actions theme={null} > env: > REDUCTO_NPM_TOKEN: ${{ secrets.REDUCTO_NPM_TOKEN }} > ``` > > ```yaml GitLab CI theme={null} > variables: > REDUCTO_NPM_TOKEN: $REDUCTO_NPM_TOKEN > ``` **3. Install the package** > [tab] npm > > ```bash theme={null} > npm install @reductoai-collab/components > ``` > [tab] yarn > > ```bash theme={null} > yarn add @reductoai-collab/components > ``` > [tab] pnpm > > ```bash theme={null} > pnpm add @reductoai-collab/components > ``` **4. Import the styles** Import the CSS file in your application's entry point: ```tsx App.tsx or main.tsx theme={null} import '@reductoai-collab/components/styles/spreadsheet-viewer.css'; ``` ## Quick Start Here's a minimal example to get you started: ```tsx theme={null} import { SpreadsheetViewer } from '@reductoai-collab/components'; import '@reductoai-collab/components/styles/spreadsheet-viewer.css'; function App() { return (
{ console.log(`Loaded ${metadata.sheetCount} sheets`); }} onError={(error) => { console.error(`Error: ${error.message}`); }} />
); } ``` > [!NOTE] > > The `SpreadsheetViewer` requires a container with defined dimensions. Always wrap it in a parent element with explicit `height` and `width`. ## Usage Examples ### Basic Viewer Display a spreadsheet without any overlays: ```tsx theme={null} import { SpreadsheetViewer } from '@reductoai-collab/components'; function BasicViewer() { return (
{ console.log('Sheet names:', metadata.sheetNames); }} />
); } ``` ### With Bounding Boxes Highlight specific regions of the spreadsheet with colored bounding boxes: ```tsx theme={null} import { SpreadsheetViewer, type BoundingBox } from '@reductoai-collab/components'; function ViewerWithBboxes() { const bboxes: BoundingBox[] = [ { id: 'header-row', page: 1, // Sheet number (1-indexed) top: 1, // Starting row left: 1, // Starting column (A = 1) width: 5, // Number of columns height: 1, // Number of rows color: 'blue', label: 'Header', }, { id: 'data-table', page: 1, top: 2, left: 1, width: 5, height: 10, color: 'green', label: 'Sales Data', metadata: { tableId: 'tbl-001', confidence: 0.95, }, }, ]; return (
{ console.log('Clicked:', bbox.id); console.log('Custom data:', bbox.metadata); }} />
); } ``` ### Integrating with Reducto API Results Convert Reducto extraction results to bounding boxes: ```tsx theme={null} import { useState } from 'react'; import { SpreadsheetViewer, type BoundingBox } from '@reductoai-collab/components'; interface ReductoBlock { id: string; type: 'table' | 'text' | 'key_value'; bbox: { page: number; top: number; left: number; width: number; height: number; }; content: string; } interface ExtractionResult { blocks: ReductoBlock[]; } function ReductoViewer({ documentUrl, extractionResult, }: { documentUrl: string; extractionResult: ExtractionResult; }) { const [selectedBlock, setSelectedBlock] = useState(null); // Map block types to colors const colorMap: Record = { table: 'blue', text: 'green', key_value: 'purple', }; // Convert Reducto blocks to bounding boxes const bboxes: BoundingBox[] = extractionResult.blocks.map((block) => ({ id: block.id, page: block.bbox.page, top: block.bbox.top, left: block.bbox.left, width: block.bbox.width, height: block.bbox.height, color: colorMap[block.type] ?? 'gray', label: block.type, metadata: { blockId: block.id, type: block.type, content: block.content, }, })); return (
{/* Viewer */}
{ const block = extractionResult.blocks.find( (b) => b.id === bbox.metadata?.blockId ); setSelectedBlock(block ?? null); }} />
{/* Details Panel */}

Selected Block

{selectedBlock ? (

Type: {selectedBlock.type}

Content:

              {selectedBlock.content}
            
) : (

Click a bounding box to see details

)}
); } ``` ### Custom Styling with CSS Variables Override the default styles using CSS variables: ```css custom-theme.css theme={null} :root { /* Container */ --sv-background: #1a1a2e; --sv-border-color: #2d2d44; --sv-border-radius: 8px; /* Tabs */ --sv-tab-background: #2d2d44; --sv-tab-background-active: #3d3d5c; --sv-tab-color: #a0a0b0; --sv-tab-color-active: #ffffff; /* Grid */ --sv-header-background: #2d2d44; --sv-header-color: #ffffff; --sv-cell-background: #1a1a2e; --sv-cell-color: #e0e0e0; --sv-cell-border-color: #2d2d44; /* Bounding boxes */ --sv-bbox-border-width: 2px; --sv-bbox-opacity: 0.15; --sv-bbox-opacity-hover: 0.25; } ``` > [accordion] All available CSS variables > > ```css theme={null} > :root { > /* Container */ > --sv-font-family: system-ui, -apple-system, sans-serif; > --sv-background: #ffffff; > --sv-border-color: #e5e7eb; > --sv-border-radius: 4px; > > /* Tabs */ > --sv-tab-height: 36px; > --sv-tab-padding: 0 16px; > --sv-tab-background: #f9fafb; > --sv-tab-background-hover: #f3f4f6; > --sv-tab-background-active: #ffffff; > --sv-tab-color: #6b7280; > --sv-tab-color-active: #111827; > --sv-tab-font-size: 13px; > --sv-tab-font-weight: 500; > > /* Grid */ > --sv-header-background: #f9fafb; > --sv-header-color: #374151; > --sv-header-font-size: 12px; > --sv-header-font-weight: 600; > --sv-cell-background: #ffffff; > --sv-cell-color: #111827; > --sv-cell-font-size: 13px; > --sv-cell-padding: 4px 8px; > --sv-cell-border-color: #e5e7eb; > --sv-cell-min-width: 80px; > --sv-cell-height: 24px; > --sv-row-header-width: 50px; > > /* Selection */ > --sv-selection-background: rgba(59, 130, 246, 0.1); > --sv-selection-border-color: #3b82f6; > > /* Scrollbar */ > --sv-scrollbar-width: 8px; > --sv-scrollbar-track: #f1f1f1; > --sv-scrollbar-thumb: #c1c1c1; > --sv-scrollbar-thumb-hover: #a1a1a1; > > /* Bounding boxes */ > --sv-bbox-border-width: 2px; > --sv-bbox-opacity: 0.1; > --sv-bbox-opacity-hover: 0.2; > > /* Loading & Error states */ > --sv-loading-color: #6b7280; > --sv-error-color: #dc2626; > --sv-error-background: #fef2f2; > } > ``` ## API Reference ### SpreadsheetViewer Props **url** `string` *required* URL to the Excel file (`.xlsx`, `.xls`, or `.xlsm`). Can be a relative path, absolute URL, or blob URL. **bboxes** `BoundingBox[]` Array of bounding boxes to overlay on the spreadsheet. **onLoad** `(metadata: WorkbookMetadata) =\> void` Callback fired when the workbook is successfully loaded. **onError** `(error: SpreadsheetError) =\> void` Callback fired when an error occurs loading or parsing the file. **onBboxClick** `(bbox: BoundingBox) =\> void` Callback fired when a bounding box is clicked. **className** `string` Additional CSS class name for the container element. **style** `React.CSSProperties` Inline styles for the container element. ### BoundingBox Type ```typescript theme={null} interface BoundingBox { id: string; // Unique identifier page: number; // Sheet number (1-indexed) top: number; // Starting row (1-indexed) left: number; // Starting column (1-indexed, A=1) width: number; // Number of columns height: number; // Number of rows color?: BoundingBoxColor; // Box color (default: 'blue') label?: string; // Optional label text metadata?: Record; // Custom data } type BoundingBoxColor = | 'blue' | 'green' | 'red' | 'yellow' | 'purple' | 'orange' | 'pink' | 'gray'; ``` ### WorkbookMetadata Type ```typescript theme={null} interface WorkbookMetadata { sheetCount: number; // Total number of sheets sheetNames: string[]; // Array of sheet names activeSheet: number; // Currently active sheet index } ``` ### SpreadsheetError Type ```typescript theme={null} interface SpreadsheetError { code: 'FETCH_ERROR' | 'PARSE_ERROR' | 'INVALID_FORMAT'; message: string; originalError?: Error; } ``` ## Troubleshooting > [accordion] Package not found during installation > > Ensure your `.npmrc` is configured correctly and the `REDUCTO_NPM_TOKEN` environment variable is set: > > ```bash theme={null} > echo $REDUCTO_NPM_TOKEN # Should print your token > ``` > > If using a monorepo, place the `.npmrc` in the root directory. > [accordion] CORS errors when loading spreadsheets > > If loading files from a different domain, ensure the server sends appropriate CORS headers: > > ``` > Access-Control-Allow-Origin: * > ``` > > Alternatively, proxy the request through your own backend or use blob URLs. > [accordion] Bounding boxes not appearing > > Verify that: > > 1. The `page` property matches the sheet number (1-indexed) > 2. The `top` and `left` values are within the sheet bounds > 3. You've imported the CSS file > [accordion] Styles not applying > > Make sure you import the CSS file in your app's entry point: > > ```tsx theme={null} > import '@reductoai-collab/components/styles/spreadsheet-viewer.css'; > ``` > > If using CSS modules or scoped styles, ensure the import is global. ## Support > [card] [Get Access](mailto:sales@reducto.ai) > > Contact **[sales@reducto.ai](mailto:sales@reducto.ai)** to get access to `@reductoai-collab/components` and receive your authentication token. For technical support, reach out to your Reducto account representative or email [support@reducto.ai](mailto:support@reducto.ai). # Classify Configuration Source: https://docs.reducto.ai/configs/classify/configuration Configure page ranges and classification behavior Classify accepts a document and a list of categories, then returns the best match. This page covers the configuration options that control how classification works. ## Page Range By default, Classify uses the first 5 pages of a document as context for classification. For most documents, the first few pages contain enough information to determine document type (cover pages, headers, introductory sections). You can increase context up to 10 pages using the `page_range` parameter when distinguishing content appears deeper in the document. ```python Python theme={null} from reducto import Reducto client = Reducto() response = client.classify.run( input="https://example.com/document.pdf", page_range={"start": 1, "end": 10}, classification_schema=[ { "category": "annual_report", "criteria": ["financial statements", "shareholder letter", "auditor's report"], }, { "category": "quarterly_filing", "criteria": ["quarterly results", "interim statements"], }, ], ) ``` ```javascript Node.js theme={null} import Reducto from 'reductoai'; const client = new Reducto(); const response = await client.classify.run({ input: 'https://example.com/document.pdf', page_range: { start: 1, end: 10 }, classification_schema: [ { category: 'annual_report', criteria: ['financial statements', 'shareholder letter', "auditor's report"], }, { category: 'quarterly_filing', criteria: ['quarterly results', 'interim statements'], }, ], }); ``` ```bash cURL theme={null} curl -X POST https://platform.reducto.ai/classify \ -H "Authorization: Bearer $REDUCTO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "input": "https://example.com/document.pdf", "page_range": {"start": 1, "end": 10}, "classification_schema": [ { "category": "annual_report", "criteria": ["financial statements", "shareholder letter", "auditor'\''s report"] }, { "category": "quarterly_filing", "criteria": ["quarterly results", "interim statements"] } ] }' ``` * Page numbers are 1-indexed (first page is page 1). * Both `start` and `end` are inclusive. * If no `page_range` is specified, the first 5 pages are used. * If more than 10 pages are selected, the request returns an error. * Only applies to PDFs. Ignored for other document types. > [!NOTE] > > Each page of context costs 0.5 credits. Using the default 5 pages costs 2.5 credits per classification. Increasing to 10 pages costs 5.0 credits. Only increase when the default pages don't contain enough distinguishing content. See [Credit Usage](/reference/credit-usage) for details. ## Deep Classify Set `model` to `"accurate"` to run Deep Classify for higher accuracy on hard documents. The default is `"default"`. ```python Python theme={null} from reducto import Reducto client = Reducto() response = client.classify.run( input="https://example.com/document.pdf", model="accurate", classification_schema=[ { "category": "annual_report", "criteria": ["financial statements", "shareholder letter"], }, { "category": "quarterly_filing", "criteria": ["quarterly results", "interim statements"], }, ], ) ``` ```javascript Node.js theme={null} import Reducto from 'reductoai'; const client = new Reducto(); const response = await client.classify.run({ input: 'https://example.com/document.pdf', model: 'accurate', classification_schema: [ { category: 'annual_report', criteria: ['financial statements', 'shareholder letter'], }, { category: 'quarterly_filing', criteria: ['quarterly results', 'interim statements'], }, ], }); ``` ```bash cURL theme={null} curl -X POST https://platform.reducto.ai/classify \ -H "Authorization: Bearer $REDUCTO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "input": "https://example.com/document.pdf", "model": "accurate", "classification_schema": [ { "category": "annual_report", "criteria": ["financial statements", "shareholder letter"] }, { "category": "quarterly_filing", "criteria": ["quarterly results", "interim statements"] } ] }' ``` ## Category Groups Use `category_groups` to map detailed category labels to broader groups. The parameter defaults to `{}`. When you set it, Classify adds `extra_metadata.grouping` to the response. ```python Python theme={null} from reducto import Reducto client = Reducto() response = client.classify.run( input="https://example.com/document.pdf", classification_schema=[ {"category": "invoice", "criteria": ["itemized charges", "amount due"]}, {"category": "receipt", "criteria": ["completed transaction", "total paid"]}, {"category": "contract", "criteria": ["legal terms", "signature lines"]}, ], category_groups={ "financial": ["invoice", "receipt"], "legal": ["contract"], }, ) ``` ```javascript Node.js theme={null} import Reducto from 'reductoai'; const client = new Reducto(); const response = await client.classify.run({ input: 'https://example.com/document.pdf', classification_schema: [ { category: 'invoice', criteria: ['itemized charges', 'amount due'] }, { category: 'receipt', criteria: ['completed transaction', 'total paid'] }, { category: 'contract', criteria: ['legal terms', 'signature lines'] }, ], category_groups: { financial: ['invoice', 'receipt'], legal: ['contract'], }, }); ``` ```bash cURL theme={null} curl -X POST https://platform.reducto.ai/classify \ -H "Authorization: Bearer $REDUCTO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "input": "https://example.com/document.pdf", "classification_schema": [ {"category": "invoice", "criteria": ["itemized charges", "amount due"]}, {"category": "receipt", "criteria": ["completed transaction", "total paid"]}, {"category": "contract", "criteria": ["legal terms", "signature lines"]} ], "category_groups": { "financial": ["invoice", "receipt"], "legal": ["contract"] } }' ``` The response includes `extra_metadata.grouping` with the matched group, or `"ungrouped"` if the winning category is in no group. See [Grouping Metadata](/classify/response-format#grouping-metadata) for the response. ## Classification Schema The `classification_schema` parameter defines what categories Classify can return. Each category needs a name and a list of criteria. ### Writing effective criteria Criteria are natural language descriptions that tell the model what to look for. More specific criteria produce better results. **Good criteria** describe observable features: * "Contains a table of itemized charges with quantities and unit prices" * "Includes signature blocks for multiple parties" * "Has a header with 'INVOICE' or invoice number" **Weak criteria** are too generic: * "Business document" * "Has text" * "Contains information" ### Example: Financial document routing ```python theme={null} response = client.classify.run( input=upload.file_id, classification_schema=[ { "category": "invoice", "criteria": [ "itemized list of charges or line items", "total amount due", "billing and payment information", "vendor or supplier details", ], }, { "category": "bank_statement", "criteria": [ "account balance and transaction history", "deposits and withdrawals listed by date", "bank name and account number", ], }, { "category": "tax_form", "criteria": [ "tax identification numbers (SSN, EIN)", "income and deduction categories", "IRS form number (W-2, 1099, 1040)", ], }, { "category": "receipt", "criteria": [ "single transaction with date and amount", "store or merchant name", "payment method (cash, card, etc.)", ], }, ], ) ``` *** ## Related > [card] [Classify Overview](/classify/overview) > > Introduction to document classification. > [card] [Chaining API Calls](/workflows/chaining-endpoints) > > Route classified documents to Parse and Extract. > [card] [Credit Usage](/reference/credit-usage) > > Classification pricing details. # Form Schema Source: https://docs.reducto.ai/configs/edit/form-schema Pre-define field locations for faster, more consistent PDF form filling Form schemas let you define exactly where form fields are located in a PDF, what type they are, and how they should be filled. Instead of relying on Edit to detect fields each time, you provide the field definitions upfront. This matters for two reasons: **speed** and **consistency**. With a form schema, Edit skips field detection and description generation, processing forms significantly faster. And because the same fields are targeted every time, you get deterministic results across thousands of form fills. *** ## The Workflow 1. **Run Edit once without a form\_schema** to let Reducto detect all fields 2. **Save the returned `form_schema`** from the response 3. **Use that schema for subsequent fills** of the same form type ```python Python theme={null} from reducto import Reducto import json client = Reducto() # First run: let Edit detect fields result = client.edit.run( document_url="https://example.com/w9-blank.pdf", edit_instructions="Fill with: Name: Test Corp, EIN: 12-3456789" ) # Save the detected schema (convert Pydantic objects to dicts) schema_dicts = [field.model_dump() for field in result.form_schema] with open("w9_schema.json", "w") as f: json.dump(schema_dicts, f) # Subsequent runs: much faster with saved schema with open("w9_schema.json") as f: saved_schema = json.load(f) result = client.edit.run( document_url="https://example.com/w9-blank.pdf", edit_instructions="Fill with: Name: Acme Inc, EIN: 98-7654321", form_schema=saved_schema ) ``` ```javascript Node.js theme={null} import Reducto from 'reductoai'; import fs from 'fs'; const client = new Reducto(); // First run: let Edit detect fields const result = await client.edit.run({ document_url: 'https://example.com/w9-blank.pdf', edit_instructions: 'Fill with: Name: Test Corp, EIN: 12-3456789' }); // Save the detected schema fs.writeFileSync('w9_schema.json', JSON.stringify(result.form_schema, null, 2)); // Subsequent runs: much faster with saved schema const savedSchema = JSON.parse(fs.readFileSync('w9_schema.json', 'utf-8')); const result2 = await client.edit.run({ document_url: 'https://example.com/w9-blank.pdf', edit_instructions: 'Fill with: Name: Acme Inc, EIN: 98-7654321', form_schema: savedSchema }); ``` ```bash cURL theme={null} # First run: detect fields and save schema curl -s -X POST https://platform.reducto.ai/edit \ -H "Authorization: Bearer $REDUCTO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "document_url": "https://example.com/w9-blank.pdf", "edit_instructions": "Fill with: Name: Test Corp, EIN: 12-3456789" }' | jq '.form_schema' > w9_schema.json # Subsequent runs: much faster with saved schema SCHEMA=$(cat w9_schema.json) curl -X POST https://platform.reducto.ai/edit \ -H "Authorization: Bearer $REDUCTO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "document_url": "https://example.com/w9-blank.pdf", "edit_instructions": "Fill with: Name: Acme Inc, EIN: 98-7654321", "form_schema": '"$SCHEMA"' }' ``` The first call runs field detection and generates descriptions. Subsequent calls with the schema skip those steps entirely. | Scenario | Pipeline | | - | - | | Without form\_schema | Detection → Context → Descriptions → Fill | | With form\_schema | Fill only | *** ## Schema Structure A form schema is an array of field definitions: ```python theme={null} form_schema = [ { "bbox": { "left": 0.227, # Distance from left edge (0-1) "top": 0.144, # Distance from top edge (0-1) "width": 0.15, # Width as fraction of page "height": 0.025, # Height as fraction of page "page": 1 # Page number (1-indexed) }, "description": "Bank Routing/ABA Number", "type": "text", "fill": True, # Let LLM determine value (default) "value": None # No fixed value }, { "bbox": {"left": 0.432, "top": 0.144, "width": 0.2, "height": 0.025, "page": 1}, "description": "Bank Name", "type": "text", "value": "Wells Fargo" # Fixed value bypasses LLM }, { "bbox": {"left": 0.227, "top": 0.54, "width": 0.02, "height": 0.02, "page": 1}, "description": "Domestic wire checkbox", "type": "checkbox" } ] ``` ### Field Properties | Property | Type | Required | Description | | - | - | - | - | | `bbox` | object | Yes | Normalized coordinates (0-1 range from top-left) | | `description` | string | Yes | Used by LLM to map instructions to this field | | `type` | string | Yes | `text`, `checkbox`, `dropdown`, or `barcode` | | `fill` | boolean | No | Whether to fill this field (default: `true`) | | `value` | string | No | Fixed value that bypasses LLM | ### Bounding Box Coordinates are normalized (0-1), measured from the **top-left corner**: ``` (0,0) ─────────────────────── (1,0) │ │ │ ┌─────────┐ │ │ │ Field │ left: 0.1 │ │ └─────────┘ top: 0.2 │ │ │ (0,1) ─────────────────────── (1,1) ``` > [!NOTE] > > Page numbers are 1-indexed. The first page is `page: 1`. *** ## Fill Control The `fill` and `value` properties control how each field is handled: | fill | value | Behavior | | - | - | - | | `true` (default) | `null` | LLM determines value from instructions | | `true` | `"something"` | Uses this exact value, ignores instructions | | `false` | any | Field left empty | Use `value` for fields that should always contain the same thing (form version, tax year). Use `fill: false` for fields that should stay blank (signature boxes). *** ## Widget Types ### Text Standard text input fields. The LLM extracts the relevant value from your instructions based on the field's description. ```python theme={null} { "type": "text", "description": "Social Security Number in XXX-XX-XXXX format", "bbox": {"left": 0.55, "top": 0.72, "width": 0.4, "height": 0.03, "page": 1} } ``` Include format expectations in the description. "SSN in XXX-XX-XXXX format" produces better results than just "SSN" because the LLM knows how to format the output. ### Checkbox Boolean fields that get checked or unchecked. The LLM interprets your instructions to determine whether the box should be checked. ```python theme={null} { "type": "checkbox", "description": "US Citizen - Yes", "bbox": {"left": 0.15, "top": 0.35, "width": 0.02, "height": 0.02, "page": 1} } ``` Checkbox bounding boxes should be small and roughly square. Make descriptions explicit about what checking means: "US Citizen - Yes" is clearer than "Citizenship" when there are Yes/No checkbox pairs. ### Dropdown Selection fields with predefined options. The LLM suggests a value, and Edit selects the matching option from the PDF's dropdown. ```python theme={null} { "type": "dropdown", "description": "State of incorporation", "bbox": {"left": 0.3, "top": 0.4, "width": 0.2, "height": 0.03, "page": 1} } ``` > [!WARNING] > > The value must exactly match an available option. If your instructions say "CA" but the dropdown only contains "California", the field is skipped silently. Consider listing options in the description: "State (CA, NY, TX, ...)" to help the LLM match correctly. ### Barcode Special fields for barcode data. These are typically detected automatically in forms that have barcode regions and filled with encoded data. ```python theme={null} { "type": "barcode", "description": "Document tracking code", "bbox": {"left": 0.7, "top": 0.9, "width": 0.25, "height": 0.05, "page": 1} } ``` *** ## Troubleshooting > [accordion] Text appears in wrong location > > Coordinates are from the top-left (0,0), with Y increasing downward. If you measured from bottom-left, flip the Y values: `correct_top = 1 - your_top - height` > > Start with a single field, verify it works, then add more incrementally. > [accordion] Schema doesn't match existing PDF widgets > > Edit matches schema fields to existing widgets using bounding box overlap. If overlap is less than 50%, a new widget is created. > > Run Edit without a schema first to see actual widget positions, then adjust your coordinates to match. > [accordion] Checkbox not checking > > 1. Ensure `type` is `"checkbox"`, not `"text"` > 2. Bounding boxes should be small and square > 3. Be explicit in instructions: "Check the 'Yes' checkbox for US citizenship" > [accordion] Fields skipped silently > > Check for: > > * `fill: false` on the field > * Dropdown value not matching available options exactly > * Instructions don't mention data for this field > * Unsupported widget types (signatures, images) # Array Extraction Source: https://docs.reducto.ai/configs/extract/array-extraction Extract complete arrays from long documents without truncation > [!WARNING] > > Array Extraction is being deprecated. We recommend using [Deep Extract](/configs/extract/deep-extract) instead, which provides higher accuracy on complex and long extractions through an agentic loop. Array extraction is a mode specifically designed for extracting **arrays** (lists of items) from documents. It exists because LLMs have context limits that cause them to truncate long lists. The core problem: if you ask an LLM to extract 500 transactions from a bank statement, it might return only the first 50-100 before stopping. Array extraction solves this by splitting the document into segments, extracting the array items from each segment, then merging results. **This only affects array fields in your schema.** Scalar fields (strings, numbers, single objects) are still extracted from the full document context normally. ## When to Use ```python theme={null} result = client.extract.run( input=upload, instructions={"schema": schema}, settings={"array_extract": True} ) ``` Enable it when: * Your schema has array fields with many items (50+) * Extraction results look truncated or end abruptly * Tables span multiple pages If you're extracting a few scalar fields like "invoice\_number" and "total\_amount", you don't need this. ## How It Works 1. **Segment** the document into overlapping page ranges 2. **Extract** array items from each segment independently 3. **Merge** all array items together 4. **Deduplicate** items that appeared in overlapping regions Segments overlap at boundaries to catch items that span page breaks. If a table row starts on page 10 and continues to page 11, both segments will capture it, and deduplication removes the duplicate. ## Schema Requirements Your schema must have at least one top-level array property: ```python theme={null} schema = { "type": "object", "properties": { "transactions": { # This array is extracted segment-by-segment "type": "array", "items": { "type": "object", "properties": { "date": {"type": "string"}, "amount": {"type": "number"} } } }, "account_number": {"type": "string"} # This scalar is extracted normally } } ``` The schema root must be an object, not an array: ```python theme={null} # Wrong - will error {"type": "array", "items": {...}} # Correct {"type": "object", "properties": {"items": {"type": "array", ...}}} ``` ## Deduplication When segments overlap, the same item may be extracted twice. Reducto deduplicates using content similarity. **Problem:** If your document has legitimately identical items (two transactions with the same date and amount), deduplication might incorrectly merge them. **Solution:** Add distinguishing fields like line numbers or IDs: ```python theme={null} "items": { "type": "object", "properties": { "line_number": {"type": "integer", "description": "Row number if visible"}, "date": {"type": "string"}, "amount": {"type": "number"} } } ``` ## With Citations Array extraction works with citations. Each item retains its source location: ```python theme={null} result = client.extract.run( input=upload, instructions={"schema": schema}, settings={ "array_extract": True, "citations": {"enabled": True} } ) for item in result.result["transactions"]: if item["amount"].citations: page = item["amount"].citations[0].bbox.page print(f"${item['amount'].value} on page {page}") ``` ## Troubleshooting **Still missing items:** 1. Check Parse output first (`client.parse.run`). Extract can only find what Parse sees. 2. Add system prompt: "Extract every item. Do not skip any rows." **Duplicate items:** Add unique identifiers (line numbers, IDs) to help differentiation. **Schema error:** Ensure at least one `"type": "array"` property exists at the top level. # Citations Source: https://docs.reducto.ai/configs/extract/citations Link extracted values to their source locations in the document Citations tell you exactly where each extracted value came from in the document. When enabled, every field includes bounding box coordinates pointing to the source text. This matters for: * **Verification**: Confirm extractions are correct by checking source text * **Compliance**: Maintain audit trails for regulated workflows * **Debugging**: See where the model looked when values are wrong * **User experience**: Let users click from extracted data to the original location ```python theme={null} result = client.extract.run( input=upload, instructions={"schema": schema}, settings={ "citations": {"enabled": True} } ) ``` citations.enabled numerical\_confidence Request

Response


## Response Structure

With citations enabled, each value becomes an object with `value` and `citations`:

```json theme={null}
{
  "result": {
    "invoice_total": {
      "value": 1575.00,
      "citations": [
        {
          "type": "Table",
          "content": "Total Due: $1,575.00",
          "bbox": {
            "left": 0.65,
            "top": 0.82,
            "width": 0.25,
            "height": 0.03,
            "page": 1,
            "original_page": 1
          },
          "confidence": "high",
          "granular_confidence": {
            "extract_confidence": 0.95,
            "parse_confidence": 0.91
          },
          "parentBlock": {
            "type": "Table",
            "content": "Subtotal: $1,500.00\nTax: $75.00\nTotal Due: $1,575.00",
            "bbox": {"left": 0.60, "top": 0.75, "width": 0.35, "height": 0.12, "page": 1}
          }
        }
      ]
    }
  }
}
```

**Fields:**

* `type`: Block type (`Text`, `Table`, `Key Value`, etc.)
* `content`: The source text
* `bbox`: Bounding box coordinates (normalized 0-1 for PDFs/images)
* `confidence`: `"high"` or `"low"`
* `granular_confidence`: Numeric scores (`extract_confidence`, `parse_confidence`) between 0-1
* `parentBlock`: The larger Parse block containing this citation, for context

## Working with Citations

**Accessing a scalar field:**

```python theme={null}
invoice_number = result.result["invoice_number"]
print(f"Value: {invoice_number.value}")

if invoice_number.citations:
    citation = invoice_number.citations[0]
    print(f"Found on page {citation.bbox.page}")
    print(f"Source text: {citation.content}")
```

**Looping through array items:**

```python theme={null}
for item in result.result["line_items"]:
    amount = item["amount"]
    print(f"${amount.value}")
    
    if amount.citations:
        print(f"  Found on page {amount.citations[0].bbox.page}")
```

## Bounding Box Coordinates

For PDFs and images, coordinates are normalized to \[0, 1] relative to page dimensions. `left: 0.5` means halfway across the page.

`page` is the page number in the processed result. `original_page` is the page number in the original document, which differs when you use page ranges.

To convert to pixels, multiply by page dimensions:

```python theme={null}
x_px = bbox["left"] * page_width_px
y_px = bbox["top"] * page_height_px
```

## Spreadsheet Citations

Excel and CSV files use cell coordinates instead of normalized positions:

* `left`: Column number (1-indexed, so 1 = A, 2 = B, 3 = C)
* `top`: Row number (1-indexed, so 1 = first row)
* `page`: Sheet number (1-indexed position of the sheet in the workbook)

A citation with `{"left": 3, "top": 15, "page": 2}` points to cell C15 on the second sheet.

All extraction modes use this convention, including `spreadsheet_agent` and `deep_extract`.

## Confidence Scores

Each citation includes a `confidence` field with a categorical value (`"high"` or `"low"`).

By default in v3, `numerical_confidence` is **enabled**, which adds `granular_confidence` with numeric 0-1 scores:

```json theme={null}
{
  "confidence": "high",
  "granular_confidence": {
    "extract_confidence": 0.95,
    "parse_confidence": 0.91
  }
}
```

* `extract_confidence`: How confident the LLM is about the extraction
* `parse_confidence`: How confident the OCR/parsing is about the underlying text

To disable numeric scores and only get categorical confidence:

```python theme={null}
settings={
    "citations": {
        "enabled": True,
        "numerical_confidence": False  # Only return "high"/"low"
    }
}
```

Low `parse_confidence` suggests OCR errors. Low `extract_confidence` suggests the model was uncertain about interpretation.

## Constraints

**Citations disable chunking:** The document is processed as a single unit to maintain precise coordinate mapping.

**Empty citations:** A field's `citations` array can be empty in two cases. The value was inferred rather than found directly in the document, or the value was found but Reducto could not reliably localize it to a source span. The second case is rare. In both cases the `value` is still returned correctly. Always check `if field.citations:` before accessing them. For an inferred value, re-running will not add a citation, so adjust the schema or field description if you need the value tied to source text. For the rare localization failure, re-running the extraction can recover the citation.

## Studio Visualization

Every response includes a `studio_link`. In Studio, citations are interactive:

* Click an extracted field to highlight its source in the document
* Click a highlight to jump to the corresponding field


# Deep Extract
Source: https://docs.reducto.ai/configs/extract/deep-extract

Achieve near-perfect accuracy on complex and long extractions with our agentic loop

## What is Deep Extract?

Deep Extract is an agentic extraction mode that iteratively refines its output to achieve near-perfect accuracy. Unlike standard extraction which makes a single pass over the document, Deep Extract runs an agentic loop that verifies and corrects its results against the source document until a quality threshold is met.

This is especially powerful for complex documents where a single extraction pass may miss values, misalign table rows, or produce inconsistencies. Deep Extract catches these issues by checking its own work and re-extracting until the results are accurate.

***

## When to Use It

Deep Extract is designed for extractions where accuracy is critical and the cost of errors is high. Common use cases include:

* **Invoice line item extraction** — Ensuring every line item is captured and that totals reconcile with the sum of individual amounts
* **Financial statement processing** — Extracting balance sheets, income statements, or cash flow data where numbers must be internally consistent
* **Legal document extraction** — Pulling clauses, dates, and party information from contracts where missing a single field has real consequences
* **Medical and insurance forms** — Capturing patient data, procedure codes, and billing amounts that must be complete and correct
* **Multi-page tables** — Documents with hundreds of rows spanning many pages where standard extraction may truncate or skip entries

If your extraction is simple (a few scalar fields from a short document), standard extraction is sufficient. Use Deep Extract when you need high reliability on complex or lengthy documents.

***

## How to Use It

Enable Deep Extract by setting `deep_extract` to `true` in the `settings` object:

```python Python theme={null}
result = client.extract.run(
    input=upload.file_id,
    instructions={
        "schema": schema,
        "system_prompt": "Extract all line items from this invoice. Iterate until the line items sum up to the total listed in the document."
    },
    settings={
        "deep_extract": True
    }
)
```

```javascript Node.js theme={null}
const result = await client.extract.run({
  input: upload.file_id,
  instructions: {
    schema,
    system_prompt: 'Extract all line items from this invoice. Iterate until the line items sum up to the total listed in the document.'
  },
  settings: {
    deep_extract: true
  }
});
```

```bash cURL theme={null}
curl -X POST https://platform.reducto.ai/extract \
  -H "Authorization: Bearer $REDUCTO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "input": "reducto://your-file-id",
    "instructions": {
      "schema": {...},
      "system_prompt": "Extract all line items from this invoice. Iterate until the line items sum up to the total listed in the document."
    },
    "settings": {
      "deep_extract": true
    }
  }'
```

***

## Best Practices

### Add verification criteria to your system prompt

The agentic loop uses your system prompt to determine when extraction is "good enough." Including explicit verification criteria gives the agent a concrete goal to iterate toward.

```python theme={null}
instructions={
    "schema": invoice_schema,
    "system_prompt": (
        "Extract all line items from this invoice. "
        "Iterate until the line items sum up to the total listed in the document."
    )
}
```

Other examples of effective verification criteria:

* **Financial documents:** "Verify that the sum of all transaction amounts equals the statement total."
* **Multi-page tables:** "Ensure every row in the table is captured. The document states there are N entries — verify the count matches."
* **Contracts:** "Confirm that all parties listed in the signature block are captured in the parties array."

### Use with a well-defined schema

Deep Extract works best when your schema has clear field names and descriptions. The agent uses these to understand what it's looking for during each iteration. See [Extract Best Practices](/extraction/best-practices-extract) for schema design guidance.

### Pair with Parse configuration

Deep Extract can only verify and refine what Parse sees. If the underlying parse output is missing data (e.g., a table isn't detected), Deep Extract won't be able to find it either. Consider enabling [agentic mode](/configs/parse/agentic-modes) for tables or using [HTML table output](/configs/parse/table-output-formats) for complex documents.

***

## Related

> [card] [Extract Overview](/extract/overview)
>
> Endpoint basics and parameters.

> [card] [Best Practices](/extraction/best-practices-extract)
>
> Schema design and prompt writing tips.

> [card] [Citations](/configs/extract/citations)
>
> Link values to source locations.

> [card] [Credit Usage](/reference/credit-usage)
>
> Understand how credits are calculated.


# Configuration Overview
Source: https://docs.reducto.ai/configs/overview

Configure every step of Reducto document processing

Every Reducto endpoint exposes configuration options that control how documents are processed. This section covers all available configurations, from Parse formatting and retrieval settings through extraction schemas and workflow orchestration.## Configuration by Endpoint> [tab] Parse
>
> Parse converts documents into structured content. Options are grouped by purpose:
>
> | Group | Purpose | Pages |
> | - | - | - |
> | `enhance` | Customized agentic processing | [Agentic Modes](/configs/parse/agentic-modes), [Chart Extraction](/configs/parse/chart-extraction) |
> | `retrieval` | RAG optimization | [Chunking Methods](/configs/parse/chunking-methods) |
> | `formatting` | Detecting styling & output format | [Table Formats](/configs/parse/table-output-formats), [Additional Document Data](/configs/parse/additional-document-data) |
> | `spreadsheet` | Excel/CSV handling | [Spreadsheet Processing](/configs/parse/spreadsheet) |
> | `settings` | Processing controls | [Processing Settings](/configs/parse/ocr-settings), [Page Ranges](/configs/parse/page-ranges) |
>
> ```python theme={null}
> result = client.parse.run(
>     input=upload,
>     enhance={...},
>     retrieval={...},
>     formatting={...},
>     spreadsheet={...},
>     settings={...}
> )
> ```> [tab] Extract
>
> Extract pulls structured data from documents using a JSON schema.
>
> | Group | Purpose | Pages |
> | - | - | - |
> | `instructions` | Schema and system prompt | (base config) |
> | `settings` | Citations, array extraction | [Array Extraction](/configs/extract/array-extraction), [Citations](/configs/extract/citations) |
> | `parsing` | Document processing | All Parse options |
>
> ```python theme={null}
> result = client.extract.run(
>     input=upload,
>     instructions={"schema": {...}, "system_prompt": "..."},
>     settings={"deep_extract": True, "citations": {"enabled": True}},
>     parsing={...}
> )
> ```> [tab] Split
>
> Split divides documents into logical sections.
>
> | Group | Purpose | Pages |
> | - | - | - |
> | `split_description` | Section definitions | [Split Configuration](/configs/split/configuration) |
> | `split_rules` | Splitting logic prompt | [Split Configuration](/configs/split/configuration) |
> | `settings` | Table handling | [Split Configuration](/configs/split/configuration) |
> | `parsing` | Document processing | All Parse options |
>
> ```python theme={null}
> result = client.split.run(
>     input=upload,
>     split_description=[{"name": "...", "description": "..."}],
>     split_rules="...",
>     settings={"table_cutoff": "truncate"}
> )
> ```> [tab] Classify
>
> Classify determines document type based on natural language criteria.
>
> | Group | Purpose | Pages |
> | - | - | - |
> | `classification_schema` | Category definitions | [Classify Configuration](/configs/classify/configuration) |
> | `page_range` | Pages used as context | [Classify Configuration](/configs/classify/configuration) |
>
> ```python theme={null}
> response = client.classify.run(
>     input=upload,
>     classification_schema=[
>         {"category": "invoice", "criteria": ["billing info", "itemized charges"]},
>         {"category": "contract", "criteria": ["legal terms", "signatures"]},
>     ]
> )
> ```> [tab] Edit
>
> Edit fills forms and modifies documents.
>
> | Option | Purpose | Pages |
> | - | - | - |
> | `edit_instructions` | Natural language instructions | (base config) |
> | `form_schema` | Pre-defined field locations | [Form Schema](/configs/edit/form-schema) |
> | `edit_options` | Highlight color, overflow | (base config) |
>
> ```python theme={null}
> result = client.edit.run(
>     document_url=upload,
>     edit_instructions="Fill name: John Doe, date: 2024-01-15",
>     form_schema=[...],
>     edit_options={"color": "#FF0000"}
> )
> ```## Common Patterns> [accordion] RAG-optimized parsing
>
> Variable chunking with embedding optimization for vector search:
>
> ```python theme={null}
> result = client.parse.run(
>     input=upload,
>     retrieval={
>         "chunking": {"chunk_mode": "variable", "chunk_size": 1000},
>         "embedding_optimized": True
>     },
>     formatting={"table_output_format": "dynamic"}
> )
> ```> [accordion] Customized agentic processing
>
> r-1 handles general-purpose accuracy improvements natively. Add a scoped custom prompt when your workflow needs use-case-specific instructions:
>
> ```python theme={null}
> result = client.parse.run(
>     input=upload,
>     enhance={
>         "agentic": [{
>             "scope": "table",
>             "prompt": "Preserve domain-specific notation exactly as written."
>         }]
>     }
> )
> ```> [accordion] Complete extraction with citations
>
> Deep Extract with source locations for long or complex documents (higher cost/latency):
>
> ```python theme={null}
> result = client.extract.run(
>     input=upload,
>     instructions={"schema": schema},
>     settings={
>         "deep_extract": True,
>         "citations": {"enabled": True}
>     }
> )
> ```## Migrating to r-1r-1 is the main Parse model (see [Parse r-1](/parse/r-1)). Starting December 7, 2026, Parse defaults to r-1. To keep legacy Parse, set `settings.model` to `"legacy"`. Review [r-1 configuration compatibility](/configs/parse/r-1-configuration-compatibility) before moving an existing pipeline to r-1.## Migrating from v2If you're using the legacy configuration format, use this converter to transform your v2 config to v3:

V2 payload


          
          
          \
V2 payload with defaults applied \
V3 payload \

See the [Migration Guide](/v/legacy/migration-guide) for complete mapping tables and examples.


# Additional Document Data
Source: https://docs.reducto.ai/configs/parse/additional-document-data

Extract revision marks, comments, highlights, hyperlinks, and signatures

When you parse a document, Reducto extracts the main text content by default. But documents often contain additional information layered on top: revision marks from Track Changes, margin comments, highlighted passages, hyperlinks, and signatures. The `formatting.include` option lets you extract these.

```python Python theme={null}
result = client.parse.run(
    input=upload.file_id,
    formatting={
        "include": ["change_tracking", "comments", "highlight", "hyperlinks", "signatures"]
    }
)
```

```javascript Node.js theme={null}
const result = await client.parse.run({
  input: upload.file_id,
  formatting: {
    include: ['change_tracking', 'comments', 'highlight', 'hyperlinks', 'signatures']
  }
});
```

```bash cURL theme={null}
curl -X POST https://platform.reducto.ai/parse \
  -H "Authorization: Bearer $REDUCTO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "input": "reducto://your-file-id",
    "formatting": {
      "include": ["change_tracking", "comments", "highlight", "hyperlinks", "signatures"]
    }
  }'
```

By default, none of these are extracted. Enable only what you need, since each adds processing overhead.

> [!NOTE]
>
> These formatting options are available in the Python SDK, Node.js SDK, and via cURL. The Go SDK has limited support—only `enable_underlines` (for change tracking) is currently available.

## Change Tracking

Legal documents, contracts, and collaborative drafts often use underlines and strikethroughs to show what changed between versions. Reducto can detect these and wrap them in HTML tags so you can programmatically identify revisions.

```python Python theme={null}
formatting={"include": ["change_tracking"]}
```

```javascript Node.js theme={null}
formatting: { include: ['change_tracking'] }
```

```bash cURL theme={null}
"formatting": {"include": ["change_tracking"]}
```

When enabled, underlined and struck-through text appears with markup:

```html theme={null}
The agreement shall commence on January 1, 2024 February 15, 2024.
```

The `` tag marks strikethrough (typically deletions), `` marks underlines (typically insertions), and `` wraps the entire revision region.

**How it works:** For digital PDFs and Word documents, Reducto reads the embedded formatting information. For scanned documents, it uses a segmentation model to visually detect underlines and strikethroughs on the page image.

**Common uses:**

* Contract review: automatically extract what changed between versions
* Compliance: track modifications to policies and procedures
* Editorial workflows: preserve editor suggestions in parsed output

## Comments

PDF sticky notes, Word margin comments, and Excel cell notes contain reviewer feedback, questions, and instructions that are separate from the document content itself. Reducto extracts these as distinct blocks.

```python Python theme={null}
formatting={"include": ["comments"]}
```

```javascript Node.js theme={null}
formatting: { include: ['comments'] }
```

```bash cURL theme={null}
"formatting": {"include": ["comments"]}
```

Each comment becomes its own block with the comment text and its position on the page:

```json theme={null}
{
  "type": "Comment",
  "content": "Verify this figure with the finance team before publishing",
  "bbox": {"left": 0.85, "top": 0.15, "width": 0.1, "height": 0.05, "page": 1}
}
```

The bounding box tells you where the comment annotation appeared (normalized to \[0, 1] relative to page size). This lets you correlate comments with nearby content if needed.

## Highlights

Highlighted text usually signals importance. Reducto can detect highlighted passages and wrap them in `` tags, letting you identify what reviewers or authors emphasized.

```python Python theme={null}
formatting={"include": ["highlight"]}
```

```javascript Node.js theme={null}
formatting: { include: ['highlight'] }
```

```bash cURL theme={null}
"formatting": {"include": ["highlight"]}
```

Output:

```html theme={null}
The key finding was that revenue increased 47% year-over-year despite market headwinds.
```

**How it works:** For digital documents, Reducto reads highlight annotations. For scanned documents, it uses a segmentation model to detect colored highlighting (typically yellow, but other colors work too).

**Common uses:**

* Extract key passages from research documents
* Identify what reviewers marked as significant during review
* Use highlights as importance signals for summarization

## Hyperlinks

Documents contain links to external resources, internal references, and citations. Reducto extracts these and converts them to markdown link format, preserving both the display text and the URL.

```python Python theme={null}
formatting={"include": ["hyperlinks"]}
```

```javascript Node.js theme={null}
formatting: { include: ['hyperlinks'] }
```

```bash cURL theme={null}
"formatting": {"include": ["hyperlinks"]}
```

Output:

```markdown theme={null}
For more details, see [our methodology paper](https://example.com/methodology.pdf).
```

**Common uses:**

* Build reference lists from academic papers
* Audit documents for broken or outdated links
* Extract cited sources for verification

## Signatures

Forms and contracts often contain signature fields. Reducto can detect where signatures appear, which is useful for determining whether a document has been signed or for locating signature regions for downstream processing.

```python Python theme={null}
formatting={"include": ["signatures"]}
```

```javascript Node.js theme={null}
formatting: { include: ['signatures'] }
```

```bash cURL theme={null}
"formatting": {"include": ["signatures"]}
```

Detected signatures appear as blocks marking their location:

```json theme={null}
{
  "type": "Signature",
  "content": "",
  "bbox": {"left": 0.1, "top": 0.8, "width": 0.3, "height": 0.1, "page": 2}
}
```

The actual signature image is not extracted (for privacy). The block identifies where a signature was detected.

**Common uses:**

* Verify that forms have been signed before processing
* Route unsigned documents back for completion
* Classify documents as signed vs. unsigned

## Format Compatibility

Not all features work with all document types:

**Change tracking and highlights** work best with Word documents, which store this information natively. For PDFs, Reducto uses visual detection models, which work well but may miss subtle formatting. Scanned documents rely entirely on visual detection.

**Comments** work with PDF annotations (sticky notes), Word margin comments, and Excel cell notes. Scanned documents don't have extractable comments.

**Hyperlinks** work with PDFs, Word documents, HTML files, and Excel files (XLSX, XLSM, XLTX, XLTM, XLS) that contain embedded links. Scanned documents don't preserve hyperlink information. A linked spreadsheet cell returns `[cell text](url)`. Links to other places in the workbook are dropped.

**Signatures** are detected visually, so they work across all document types including scans.


# Agentic Modes
Source: https://docs.reducto.ai/configs/parse/agentic-modes

Add custom prompts and specialized agentic processing to Parse

r-1 handles general-purpose accuracy improvements for text, tables, figures, and page layouts natively. Use agentic processing with r-1 when your workflow needs a custom prompt for a specific content type or advanced chart extraction.

Agentic processing runs additional model steps and adds latency.

> [!NOTE]
>
> r-1 is the main Parse model (see [Parse r-1](/parse/r-1)). Existing legacy pipelines can continue using promptless agentic scopes. Review [r-1 configuration compatibility](/configs/parse/r-1-configuration-compatibility) before migrating an existing configuration.

## Basic Usage

Add an entry to the `agentic` array for each scope that needs customized processing. Each entry must include a custom prompt or enable advanced chart extraction.

```python Python theme={null}
result = client.parse.run(
    input=upload.file_id,
    enhance={
        "agentic": [
            {
                "scope": "table",
                "prompt": "Preserve domain-specific notation such as <0.01, ±, and N/A exactly as written."
            },
            {
                "scope": "figure",
                "advanced_chart_agent": True
            }
        ]
    }
)
```

```javascript Node.js theme={null}
const result = await client.parse.run({
  input: upload.file_id,
  enhance: {
    agentic: [
      {
        scope: 'table',
        prompt: 'Preserve domain-specific notation such as <0.01, ±, and N/A exactly as written.'
      },
      {
        scope: 'figure',
        advanced_chart_agent: true
      }
    ]
  }
});
```

```bash cURL theme={null}
curl -X POST https://platform.reducto.ai/parse \
  -H "Authorization: Bearer $REDUCTO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "input": "reducto://your-file-id",
    "enhance": {
      "agentic": [
        {
          "scope": "table",
          "prompt": "Preserve domain-specific notation such as <0.01, ±, and N/A exactly as written."
        },
        {
          "scope": "figure",
          "advanced_chart_agent": true
        }
      ]
    }
  }'
```

You can configure more than one scope in a request, but only one entry per scope.

> [!NOTE]
>
> Agentic modes are available in the Python SDK, Node.js SDK, and via cURL. The Go SDK does not yet support agentic modes.

## Custom Prompts

Custom prompts tell Reducto how to interpret or represent content for your use case. Pages that need a custom prompt run through the matching agentic pipeline. Other pages continue through r-1.

### Text Scope

Text-scoped custom prompts apply to form regions, such as key-value fields. r-1 handles general body text natively.

```python Python theme={null}
enhance={"agentic": [{"scope": "text", "prompt": "Preserve account numbers and dates exactly as written."}]}
```

```javascript Node.js theme={null}
enhance: { agentic: [{ scope: 'text', prompt: 'Preserve account numbers and dates exactly as written.' }] }
```

```bash cURL theme={null}
"enhance": {"agentic": [{"scope": "text", "prompt": "Preserve account numbers and dates exactly as written."}]}
```

### Table Scope

Table-scoped custom prompts control how Reducto encodes domain-specific table content.

```python Python theme={null}
enhance={"agentic": [{"scope": "table", "prompt": "Preserve domain-specific notation such as <0.01, ±, and N/A exactly as written."}]}
```

```javascript Node.js theme={null}
enhance: { agentic: [{ scope: 'table', prompt: 'Preserve domain-specific notation such as <0.01, ±, and N/A exactly as written.' }] }
```

```bash cURL theme={null}
"enhance": {"agentic": [{"scope": "table", "prompt": "Preserve domain-specific notation such as <0.01, ±, and N/A exactly as written."}]}
```

#### Table Mode

The `mode` option controls where Reducto applies the table custom prompt. It defaults to `"default"`.

| Mode | Behavior |
| - | - |
| `"default"` | Selectively applies customized table processing where it is likely to help. |
| `"max"` | Applies customized processing to every table in the job. |

```python Python theme={null}
enhance={
    "agentic": [{
        "scope": "table",
        "mode": "max",
        "prompt": "Preserve domain-specific notation exactly as written."
    }]
}
```

```javascript Node.js theme={null}
enhance: {
  agentic: [{
    scope: 'table',
    mode: 'max',
    prompt: 'Preserve domain-specific notation exactly as written.'
  }]
}
```

```bash cURL theme={null}
"enhance": {
  "agentic": [{
    "scope": "table",
    "mode": "max",
    "prompt": "Preserve domain-specific notation exactly as written."
  }]
}
```

### Figure Scope

r-1 generates a short description for each figure natively. Add one figure-scoped custom prompt when your workflow needs a use-case-specific interpretation.

```python Python theme={null}
enhance={"agentic": [{"scope": "figure", "prompt": "Describe the relationships between components in this diagram."}]}
```

```javascript Node.js theme={null}
enhance: { agentic: [{ scope: 'figure', prompt: 'Describe the relationships between components in this diagram.' }] }
```

```bash cURL theme={null}
"enhance": {"agentic": [{"scope": "figure", "prompt": "Describe the relationships between components in this diagram."}]}
```

## Advanced Chart Extraction

Use `advanced_chart_agent` when you need structured numerical data from charts. Reducto runs the separate chart extraction agent and augments the r-1 result.

```python Python theme={null}
enhance={"agentic": [{"scope": "figure", "advanced_chart_agent": True}]}
```

```javascript Node.js theme={null}
enhance: { agentic: [{ scope: 'figure', advanced_chart_agent: true }] }
```

```bash cURL theme={null}
"enhance": {"agentic": [{"scope": "figure", "advanced_chart_agent": true}]}
```

For supported chart types and response details, see [Chart Extraction](/configs/parse/chart-extraction).

## Legacy Agentic Processing

Existing legacy Parse pipelines can continue using promptless agentic scopes as general-purpose accuracy enhancements.

| Promptless scope | Legacy behavior |
| - | - |
| `text` | Adds a vision-language model pass for text and form regions. |
| `table` | Adds a vision-language model pass to reconstruct table structure. |
| `figure` | Adds enhanced figure processing. `summarize_figures` must also be enabled. |

Do not carry promptless scopes into an r-1 configuration. r-1 handles these general improvements natively. With r-1, text and table scopes require a custom prompt. Figure scopes require a custom prompt or `advanced_chart_agent`.

### Legacy Figure Summarization

For legacy Parse, `summarize_figures` uses a separate model to generate generic figure descriptions. It defaults to `true`.

```python Python theme={null}
enhance={"summarize_figures": True}
```

```javascript Node.js theme={null}
enhance: { summarize_figures: true }
```

```bash cURL theme={null}
"enhance": {"summarize_figures": true}
```

r-1 generates figure descriptions natively and ignores this setting.

## When to Use Agentic Processing

| Situation | Configuration |
| - | - |
| General parsing, including handwriting, complex tables, and figures | No agentic configuration with r-1 |
| Use-case-specific instructions for form regions | Text scope with a custom prompt |
| Domain-specific table representation | Table scope with a custom prompt |
| Use-case-specific figure interpretation | Figure scope with a custom prompt |
| Structured numerical data from charts | Figure scope with `advanced_chart_agent` |
| General-purpose accuracy enhancement on an existing legacy pipeline | Promptless legacy scope |

## Latency

Customized agentic processing and advanced chart extraction run additional model steps and add latency. Pages without a matching custom prompt continue through r-1 without those additional steps.

Customized agentic processing does not add cost to r-1 processing.


# Chart Extraction
Source: https://docs.reducto.ai/configs/parse/chart-extraction

Extract structured data from charts and graphs

Reducto can extract numerical data from visualizations and output it as structured tables. This page covers how to configure chart extraction and what chart types are supported.

> [!NOTE]
>
> r-1 is the main Parse model (see [Parse r-1](/parse/r-1)). It generates short figure descriptions natively. Existing legacy pipelines can continue using legacy figure summarization and promptless figure processing.

## Chart Processing with r-1

r-1 offers three ways to process figures and charts:

| Level | Configuration | What it does |
| - | - | - |
| **Native** | No additional configuration | Generates a short description for each figure in the full-page pass |
| **Customized** | `{"scope": "figure", "prompt": "..."}` | Uses your instructions to produce a use-case-specific interpretation |
| **Advanced** | `{"scope": "figure", "advanced_chart_agent": True}` | Multi-stage pipeline for precise numerical extraction |

Customized and advanced chart processing run additional model steps and add latency. They do not add cost to r-1 processing.

## Native Figure Descriptions

r-1 detects figures and generates a short description as part of the same full-page pass. You do not need to configure `summarize_figures` or add a promptless figure scope.

**Output example:** `"Bar chart showing Q1-Q4 revenue growth, with Q4 reaching approximately $2.5M"`

Native descriptions make figures searchable in RAG applications. Use customized or advanced processing when you need a more specific result.

## Customized Figure Processing

Add one figure-scoped custom prompt when your workflow needs a use-case-specific interpretation:

```python Python theme={null}
result = client.parse.run(
    input=upload.file_id,
    enhance={
        "agentic": [
            {
                "scope": "figure",
                "prompt": "Describe the relationships between components in this diagram."
            }
        ]
    }
)
```

```javascript Node.js theme={null}
const result = await client.parse.run({
  input: upload.file_id,
  enhance: {
    agentic: [
      {
        scope: 'figure',
        prompt: 'Describe the relationships between components in this diagram.'
      }
    ]
  }
});
```

```bash cURL theme={null}
curl -X POST https://platform.reducto.ai/parse \
  -H "Authorization: Bearer $REDUCTO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "input": "reducto://your-file-id",
    "enhance": {
      "agentic": [
        {
          "scope": "figure",
          "prompt": "Describe the relationships between components in this diagram."
        }
      ]
    }
  }'
```

## Advanced: Chart Agent Pipeline

For precise numerical extraction, enable `advanced_chart_agent`. Reducto runs the separate chart extraction agent and augments the r-1 result:

```python Python theme={null}
result = client.parse.run(
    input=upload.file_id,
    enhance={
        "agentic": [{"scope": "figure", "advanced_chart_agent": True}]
    }
)
```

```javascript Node.js theme={null}
const result = await client.parse.run({
  input: upload.file_id,
  enhance: {
    agentic: [{ scope: 'figure', advanced_chart_agent: true }]
  }
});
```

```bash cURL theme={null}
curl -X POST https://platform.reducto.ai/parse \
  -H "Authorization: Bearer $REDUCTO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "input": "reducto://your-file-id",
    "enhance": {
      "agentic": [{"scope": "figure", "advanced_chart_agent": true}]
    }
  }'
```

### How the Pipeline Works

The chart agent runs multiple parallel tasks, then combines results:

**Stage 1: Parallel extraction**

* **Component detection**: Identifies each data series (lines, bars, areas, scatter points) and their colors/styles
* **OCR**: Detects all text (axis labels, titles, legends, tick values)
* **Legend detection**: Maps colors to series labels
* **Coordinate extraction**: Finds axis boundaries and tick positions

**Stage 2: Processing**

* **Masking**: Isolates each component by color/style for individual processing
* **Axis functions**: Builds mathematical functions to convert pixel coordinates to actual values (handles linear, logarithmic, and time series axes)
* **Tick alignment**: Maps detected points to axis tick values

**Stage 3: Value extraction**

* Converts pixel coordinates to actual (x, y) values using the axis functions
* Falls back to a VLM for components that couldn't be processed deterministically
* Outputs a consolidated markdown table

### Output Format

Data is returned as a markdown table with the X-axis as rows and each component as a column:

```markdown theme={null}
| Date | Revenue ($M) | Expenses ($M) |
| --- | --- | --- |
| 2020-01 | 125.4 | 98.2 |
| 2020-02 | 142.8 | 105.1 |
| 2020-03 | 168.5 | 112.7 |
```

For bar charts, values show the range: `(bottom, top)`.

## Supported Chart Types

| Chart Type | Support Level | Notes |
| - | - | - |
| **Vertical bar charts** | ✅ Full | Detects bar heights and x-axis categories |
| **Line charts** | ✅ Full | Tracks points along each series |
| **Area charts** | ✅ Full | Extracts top/bottom boundaries |
| **Scatter plots** | ✅ Partial | Works for sparse plots; very dense plots may fail |
| **Combination charts** | ✅ Full | Handles mixed bar/line/area in same chart |
| **Time series** | ✅ Full | Supports YYYY, YYYY-MM, YYYY-MM-DD formats |
| **Logarithmic axes** | ✅ Full | Correctly interprets log-scale values |
| **Dual Y-axis** | ✅ Full | Maps components to primary or secondary axis |

### Not Supported

The advanced pipeline will skip these chart types (falls back to VLM description):

* **Horizontal bar charts**: Axis orientation not supported
* **Pie charts**: No coordinate-based extraction possible
* **Radar/spider charts**: Non-Cartesian coordinate system
* **Density plots**: Continuous distributions don't map to discrete points
* **Flow charts/diagrams**: Not data visualizations
* **Multiple charts in one image**: Requires a single chart per figure
* **Charts with data labels**: If values are already printed on each point, extraction is skipped (the data is already visible)

## Combining with Other Scopes

You can combine advanced chart extraction with custom prompts for other content types. For example, this request applies domain-specific instructions to tables and extracts structured numerical data from charts:

```python Python theme={null}
result = client.parse.run(
    input=upload.file_id,
    enhance={
        "agentic": [
            {
                "scope": "table",
                "prompt": "Preserve domain-specific notation such as <0.01, ±, and N/A exactly as written."
            },
            {"scope": "figure", "advanced_chart_agent": True}
        ]
    }
)
```

```javascript Node.js theme={null}
const result = await client.parse.run({
  input: upload.file_id,
  enhance: {
    agentic: [
      {
        scope: 'table',
        prompt: 'Preserve domain-specific notation such as <0.01, ±, and N/A exactly as written.'
      },
      { scope: 'figure', advanced_chart_agent: true }
    ]
  }
});
```

```bash cURL theme={null}
curl -X POST https://platform.reducto.ai/parse \
  -H "Authorization: Bearer $REDUCTO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "input": "reducto://your-file-id",
    "enhance": {
      "agentic": [
        {
          "scope": "table",
          "prompt": "Preserve domain-specific notation such as <0.01, ±, and N/A exactly as written."
        },
        {"scope": "figure", "advanced_chart_agent": true}
      ]
    }
  }'
```

## Legacy Figure Processing

Existing legacy Parse pipelines can continue using the previous figure settings:

| Configuration | Legacy behavior |
| - | - |
| `summarize_figures: true` | Uses a separate model to generate a generic figure description. This is enabled by default. |
| `{"scope": "figure"}` | Adds enhanced promptless figure processing. `summarize_figures` must also be enabled. |
| `{"scope": "figure", "advanced_chart_agent": true}` | Runs the multi-stage chart extraction pipeline. |

When migrating to r-1, remove `summarize_figures` and promptless figure scopes. r-1 generates figure descriptions natively. Keep `advanced_chart_agent` when you need structured numerical data from charts.

## Limitations

* **Resolution matters**: Higher quality source images produce more accurate extractions
* **Processing time**: The advanced pipeline is significantly slower than native figure processing. For async calls, use `priority=True` to speed up processing.
* **Dense charts**: Scatter plots with many overlapping points may have reduced accuracy
* **Same-color styles**: Charts where solid and dashed lines share the same color can confuse component detection


# Chunking Methods
Source: https://docs.reducto.ai/configs/parse/chunking-methods

Control how parsed content is grouped in API responses

When Reducto parses a document, it extracts individual **blocks**: paragraphs, headers, tables, figures, list items. Chunking controls how these blocks are **grouped together** when returned in the API response. For a complete overview of the response structure, see [Parse Response Format](/parse/response-format).

This matters for RAG pipelines: most embedding models and vector databases work best with text segments of a specific size. Too small, and you lose context. Too large, and retrieval becomes imprecise. Chunking lets you control this tradeoff without post-processing the response yourself.

## Basic Usage

```python Python theme={null}
result = client.parse.run(
    input=upload.file_id,
    retrieval={
        "chunking": {
            "chunk_mode": "variable",
            "chunk_size": 1000
        }
    }
)

# Response contains grouped blocks
for chunk in result.result.chunks:
    print(chunk.content)  # Combined content of all blocks in this chunk
    print(chunk.blocks)   # Individual blocks with metadata
```

```javascript Node.js theme={null}
const result = await client.parse.run({
  input: upload.file_id,
  retrieval: {
    chunking: {
      chunk_mode: 'variable',
      chunk_size: 1000
    }
  }
});

// Response contains grouped blocks
for (const chunk of result.result.chunks) {
  console.log(chunk.content);  // Combined content of all blocks in this chunk
  console.log(chunk.blocks);   // Individual blocks with metadata
}
```

```go Go theme={null}
result, _ := client.Parse.Run(context.Background(), reducto.ParseRunParams{
    ParseConfig: reducto.ParseConfigParam{
        DocumentURL: reducto.F[reducto.ParseConfigDocumentURLUnionParam](
            shared.UnionString(upload.FileID),
        ),
        Options: reducto.F(shared.BaseProcessingOptionsParam{
            Chunking: reducto.F(shared.BaseProcessingOptionsChunkingParam{
                ChunkMode: reducto.F(shared.BaseProcessingOptionsChunkingChunkModeVariable),
            }),
        }),
    },
})
```

```bash cURL theme={null}
curl -X POST https://platform.reducto.ai/parse \
  -H "Authorization: Bearer $REDUCTO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "input": "reducto://your-file-id",
    "retrieval": {
      "chunking": {
        "chunk_mode": "variable",
        "chunk_size": 1000
      }
    }
  }'
```

## Chunking Modes

> [tab] variable
>
> **Best for:** RAG, semantic search
>
> Groups blocks to target a specific character count while keeping semantically related content together. This is the recommended mode for most RAG applications.
>
> ```python Python theme={null}
> retrieval={"chunking": {"chunk_mode": "variable", "chunk_size": 1000}}
> ```
>
> ```javascript Node.js theme={null}
> retrieval: { chunking: { chunk_mode: 'variable', chunk_size: 1000 } }
> ```
>
> ```go Go theme={null}
> Options: reducto.F(shared.BaseProcessingOptionsParam{
>     Chunking: reducto.F(shared.BaseProcessingOptionsChunkingParam{
>         ChunkMode: reducto.F(shared.BaseProcessingOptionsChunkingChunkModeVariable),
>     }),
> })
> ```
>
> ```bash cURL theme={null}
> "retrieval": {"chunking": {"chunk_mode": "variable", "chunk_size": 1000}}
> ```
>
> The algorithm:
>
> 1. Groups blocks by document structure (new group at each title/section header)
> 2. Splits oversized groups at natural boundaries (points where blocks are physically separated on the page)
> 3. Applies adjacency rules: keeps headers with content, figures with captions, list items together
> 4. Merges undersized groups to reach the target range
>
> **Size behavior:** When you specify `chunk_size: 1000`, chunks will range from 750 to 1250 characters (±25%). Without a size specified, the default range is 750-1250.

> [tab] section
>
> **Best for:** Hierarchical documents, manuals, legal docs
>
> Each chunk starts at a title or section header and contains everything until the next header. No size limits.
>
> ```python Python theme={null}
> retrieval={"chunking": {"chunk_mode": "section"}}
> ```
>
> ```javascript Node.js theme={null}
> retrieval: { chunking: { chunk_mode: 'section' } }
> ```
>
> ```go Go theme={null}
> Options: reducto.F(shared.BaseProcessingOptionsParam{
>     Chunking: reducto.F(shared.BaseProcessingOptionsChunkingParam{
>         ChunkMode: reducto.F(shared.BaseProcessingOptionsChunkingChunkModeSection),
>     }),
> })
> ```
>
> ```bash cURL theme={null}
> "retrieval": {"chunking": {"chunk_mode": "section"}}
> ```
>
> Use when document structure is meaningful and you want to preserve it. Chunks can be large if sections are long.

> [tab] page
>
> **Best for:** Presentations, page-specific analysis, spreadsheets
>
> One chunk per page. For spreadsheets, one chunk per sheet.
>
> ```python Python theme={null}
> retrieval={"chunking": {"chunk_mode": "page"}}
> ```
>
> ```javascript Node.js theme={null}
> retrieval: { chunking: { chunk_mode: 'page' } }
> ```
>
> ```bash cURL theme={null}
> "retrieval": {"chunking": {"chunk_mode": "page"}}
> ```
>
> Use when page boundaries matter or when each page/sheet is a self-contained unit.

> [tab] page\_sections
>
> **Best for:** Documents where both page and section context matter
>
> Splits by page first, then by sections within each page.
>
> ```python Python theme={null}
> retrieval={"chunking": {"chunk_mode": "page_sections"}}
> ```
>
> ```javascript Node.js theme={null}
> retrieval: { chunking: { chunk_mode: 'page_sections' } }
> ```
>
> ```bash cURL theme={null}
> "retrieval": {"chunking": {"chunk_mode": "page_sections"}}
> ```
>
> Useful when you need to know which page content came from while also preserving section structure.

> [tab] block
>
> **Best for:** Maximum granularity, custom chunking logic
>
> Each block becomes its own chunk. Gives you the finest granularity to implement your own chunking logic downstream.
>
> ```python Python theme={null}
> retrieval={"chunking": {"chunk_mode": "block"}}
> ```
>
> ```javascript Node.js theme={null}
> retrieval: { chunking: { chunk_mode: 'block' } }
> ```
>
> ```go Go theme={null}
> Options: reducto.F(shared.BaseProcessingOptionsParam{
>     Chunking: reducto.F(shared.BaseProcessingOptionsChunkingParam{
>         ChunkMode: reducto.F(shared.BaseProcessingOptionsChunkingChunkModeBlock),
>     }),
> })
> ```
>
> ```bash cURL theme={null}
> "retrieval": {"chunking": {"chunk_mode": "block"}}
> ```

> [tab] disabled
>
> **Best for:** Small documents, no chunking needed
>
> Returns all blocks as a single chunk.
>
> ```python Python theme={null}
> retrieval={"chunking": {"chunk_mode": "disabled"}}
> ```
>
> ```javascript Node.js theme={null}
> retrieval: { chunking: { chunk_mode: 'disabled' } }
> ```
>
> ```go Go theme={null}
> Options: reducto.F(shared.BaseProcessingOptionsParam{
>     Chunking: reducto.F(shared.BaseProcessingOptionsChunkingParam{
>         ChunkMode: reducto.F(shared.BaseProcessingOptionsChunkingChunkModeDisabled),
>     }),
> })
> ```
>
> ```bash cURL theme={null}
> "retrieval": {"chunking": {"chunk_mode": "disabled"}}
> ```

## Optimizing for Embeddings

Tables often embed poorly because their structure doesn't translate well to vector representations. Enable `embedding_optimized` to generate natural language summaries of tables:

```python Python theme={null}
retrieval={
    "chunking": {"chunk_mode": "variable"},
    "embedding_optimized": True
}
```

```javascript Node.js theme={null}
retrieval: {
  chunking: { chunk_mode: 'variable' },
  embedding_optimized: true
}
```

```bash cURL theme={null}
"retrieval": {
  "chunking": {"chunk_mode": "variable"},
  "embedding_optimized": true
}
```

With this enabled, each chunk has two content fields:

* `content`: Original format (tables as HTML/markdown)
* `embed`: Optimized for embeddings (tables converted to summaries like "This table shows quarterly revenue by region...")

Use `embed` for vector search, `content` for display.

## Filtering Block Types

Some content types (headers, footers, page numbers) add noise to search results. Filter them out:

```python Python theme={null}
retrieval={"filter_blocks": ["Header", "Footer", "Page Number"]}
```

```javascript Node.js theme={null}
retrieval: { filter_blocks: ['Header', 'Footer', 'Page Number'] }
```

```go Go theme={null}
Options: reducto.F(shared.BaseProcessingOptionsParam{
    FilterBlocks: reducto.F([]shared.BaseProcessingOptionsFilterBlock{
        shared.BaseProcessingOptionsFilterBlockHeader,
        shared.BaseProcessingOptionsFilterBlockFooter,
        shared.BaseProcessingOptionsFilterBlockPageNumber,
    }),
})
```

```bash cURL theme={null}
"retrieval": {"filter_blocks": ["Header", "Footer", "Page Number"]}
```

Filtered blocks still appear in `chunks[].blocks` with full metadata, but they're excluded from the `content` and `embed` text fields.

**Available types:** `Header`, `Footer`, `Title`, `Section Header`, `Page Number`, `List Item`, `Figure`, `Table`, `Key Value`, `Text`, `Comment`, `Signature`

## Response Structure

Each chunk in the response contains:

```json theme={null}
{
  "content": "Combined markdown content of all blocks in this chunk",
  "embed": "Embedding-optimized version (tables summarized if embedding_optimized=True)",
  "blocks": [
    {
      "type": "Text",
      "content": "Individual block content",
      "bbox": {"left": 0.1, "top": 0.2, "width": 0.8, "height": 0.05, "page": 1},
      "confidence": "high"
    }
  ]
}
```

The `blocks` array gives you access to individual elements with their bounding boxes and types, useful for citations or highlighting source locations. See [Parse Response Format](/parse/response-format) for complete field documentation.

## Troubleshooting

> [accordion] Chunks are too large
>
> Reduce `chunk_size` or switch to `block` mode and implement your own chunking logic on the individual blocks.

> [accordion] Chunks lack sufficient context
>
> Increase `chunk_size` or use `section` mode if your document has well-defined sections.

> [accordion] Tables are split across chunks
>
> Increase `chunk_size` to accommodate full tables. Alternatively, enable `formatting.merge_tables` to combine consecutive tables with the same column structure before chunking.

> [accordion] Retrieval quality is poor for tables
>
> Enable `embedding_optimized: True` to generate natural language summaries of tables for the `embed` field.


# Word and Cell Citations
Source: https://docs.reducto.ai/configs/parse/citations

Locate every word and table cell of the parsed output on the page

Citations locate the parsed output on the page. With `return_citations` turned on, each text and table block lists every word of its `content` with the word's position in that content and the box around the word on the page. Table blocks also list each cell with its row and column indices and the box around the cell's words.

Use citations when you need to point from parsed text back to the exact spot in the source document.

![The top of a cash flow statement table, shown twice: once with a box around every word, once with a box around every cell](https://mintcdn.com/reducto/QCMzDMnS97v0uEac/images/citations/apple-10k-cash-flow-citations.png?fit=max\&auto=format\&n=QCMzDMnS97v0uEac\&q=85\&s=3fb71c57ad881ff41e5cc66dd2875891)

Word and cell citations on the cash flow statement in Apple's 2023 Form 10-K, parsed with r-1. Merged header cells are in amber.

## When to Use

* Highlight the exact words a value came from, not the whole block.
* Link a value in a table, such as a dollar amount, to its cell's row and column, including merged cells.
* Build review tools where a reader clicks text and sees it on the page.
* Search or redact at word level using the parsed text instead of raw OCR.

## Enabling Citations

Set `settings.return_citations` to `true`. It defaults to `false`.

```python Python theme={null}
from reducto import Reducto

client = Reducto()

result = client.parse.run(
    input="https://cdn.reducto.ai/samples/10k-apple.pdf",
    settings={
        "model": "r-1",
        "return_citations": True,
    },
    formatting={"table_output_format": "html"},
)
```

```javascript Node.js theme={null}
import Reducto from 'reductoai';

const client = new Reducto();

const result = await client.parse.run({
  input: 'https://cdn.reducto.ai/samples/10k-apple.pdf',
  // The SDK's Settings type does not list return_citations yet.
  settings: {
    model: 'r-1',
    return_citations: true,
  } as Reducto.Settings,
  formatting: { table_output_format: 'html' },
});
```

```bash cURL theme={null}
curl -X POST https://platform.reducto.ai/parse \
  -H "Authorization: Bearer $REDUCTO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "input": "https://cdn.reducto.ai/samples/10k-apple.pdf",
    "settings": {
      "model": "r-1",
      "return_citations": true
    },
    "formatting": {"table_output_format": "html"}
  }'
```

> [!NOTE]
>
> `return_citations` requires [r-1 or r-1 mini](/parse/r-1). A request that runs on legacy Parse returns a 400 error.
>
> Citations run OCR. r-1 includes OCR at no extra charge. With r-1 mini, OCR is an add-on at a list price of \$1 per 1,000 pages (see [Usage and Pricing](/reference/credit-usage#parse-endpoint)). If you also use `return_ocr_data` or `embed_pdf_metadata` with r-1 mini, OCR is billed only once per page.

> [!WARNING]
>
> `return_citations` can't be combined with `formatting.merge_tables`, which joins a table that continues across pages. A request that sets both returns a 400 error. Merged cells within a table are supported. See [Compatibility](#compatibility).

HTML tables give the best cell citations, because HTML is the only [table format](/configs/parse/table-output-formats) that keeps a merged cell as one cell. JSON and Markdown tables also get citations, with one row and one column per cell.

## Explore a Response

The boxes below come from a real `return_citations` response for page 35 of the [Apple 10-K sample](https://cdn.reducto.ai/samples/10k-apple.pdf). Hover over a box to see its entry.

Show:

. Hover or click a box; scroll the table for more rows.

The full cash flow statement table from Apple's 2023 Form 10-K

## Word Citations

Every text and table block gets an `extra.word_citations` list, one entry per word, in the order the words appear in `content`:

```json theme={null}
{
  "type": "Text",
  "content": "Total due: $1,200",
  "bbox": {
    "left": 0.10, "top": 0.20, "width": 0.30, "height": 0.02,
    "page": 3, "original_page": 3
  },
  "extra": {
    "word_citations": [
      {
        "text": "Total",
        "char_start": 0,
        "char_end": 5,
        "bbox": {
          "left": 0.10, "top": 0.20, "width": 0.05, "height": 0.02,
          "page": 3, "original_page": 3
        }
      },
      {
        "text": "due:",
        "char_start": 6,
        "char_end": 10,
        "bbox": {
          "left": 0.16, "top": 0.20, "width": 0.04, "height": 0.02,
          "page": 3, "original_page": 3
        }
      },
      {
        "text": "$1,200",
        "char_start": 14,
        "char_end": 20,
        "bbox": {
          "left": 0.21, "top": 0.20, "width": 0.07, "height": 0.02,
          "page": 3, "original_page": 3
        }
      }
    ]
  }
}
```

| Field | Type | Description |
| - | - | - |
| `text` | string | The word as it reads on the page. HTML entities and JSON escapes are decoded, so `S&P` becomes `S&P`. |
| `char_start` | integer | Where the word starts in the block's `content`. |
| `char_end` | integer | Where the word ends in the block's `content`. The range is half-open: `content[char_start:char_end]` is the word's source text. |
| `bbox` | object or null | The box around the word on the page, in the same format as the block's [`bbox`](/parse/response-format). `null` when the word cannot be located on the page. |

Positions count the markup in `content`. In the example, `$1,200` starts at 14 because `` comes before it. Markup never becomes a word: HTML tags, Markdown table pipes, and the brackets and quotes of a JSON table are skipped. In a JSON table, a word's range points at the word inside its JSON string.

The slice `content[char_start:char_end]` can differ from `text` when the source contains an entity or an escape. For example, `S&P` in HTML has the `text` `S&P`, and `Shareholders\u2019` in a JSON table has the `text` `Shareholders’`.

Positions count Unicode code points. In JavaScript, index `Array.from(content)` rather than the string itself, so a character such as an emoji earlier in the block does not shift the range.

## Cell Citations

Table blocks also get an `extra.cell_citations` list, one entry per non-empty cell, in the order the cells appear in `content`.

For this table, rendered as HTML:

```html theme={null}
RegionRevenue
North America2025$1.2M
2026$1.5M
``` the block's `extra` holds `word_citations` for its words and this `cell_citations` list: ```json theme={null} { "cell_citations": [ { "text": "Region", "row_idx": [0], "col_idx": [0], "bbox": { "left": 0.11, "top": 0.11, "width": 0.06, "height": 0.02, "page": 1, "original_page": 1 } }, { "text": "Revenue", "row_idx": [0], "col_idx": [1, 2], "bbox": { "left": 0.345, "top": 0.11, "width": 0.06, "height": 0.02, "page": 1, "original_page": 1 } }, { "text": "North America", "row_idx": [1, 2], "col_idx": [0], "bbox": { "left": 0.11, "top": 0.17, "width": 0.13, "height": 0.02, "page": 1, "original_page": 1 } }, { "text": "2025", "row_idx": [1], "col_idx": [1], "bbox": { "left": 0.26, "top": 0.15, "width": 0.04, "height": 0.02, "page": 1, "original_page": 1 } }, { "text": "$1.2M", "row_idx": [1], "col_idx": [2], "bbox": { "left": 0.45, "top": 0.15, "width": 0.05, "height": 0.02, "page": 1, "original_page": 1 } }, { "text": "2026", "row_idx": [2], "col_idx": [1], "bbox": { "left": 0.26, "top": 0.19, "width": 0.04, "height": 0.02, "page": 1, "original_page": 1 } }, { "text": "$1.5M", "row_idx": [2], "col_idx": [2], "bbox": { "left": 0.45, "top": 0.19, "width": 0.05, "height": 0.02, "page": 1, "original_page": 1 } } ] } ``` | Field | Type | Description | | - | - | - | | `text` | string | The cell's words, separated by single spaces. | | `row_idx` | array of integers | Every row the cell covers, counting from 0. A cell merged across rows 1 and 2 has `[1, 2]`. | | `col_idx` | array of integers | Every column the cell covers, counting from 0. | | `bbox` | object or null | The box around all of the cell's words. `null` when none of them can be located on the page. | JSON and Markdown tables don't record merged cells, so each of their cells covers one row and one column. ## Drawing a Citation on the Page Each `bbox` is normalized from 0 to 1 against the page width and height, measured from the top-left corner. Multiply by the size of your page image to draw it: ```python Python theme={null} for chunk in result.result.chunks: for block in chunk.blocks: for word in (block.extra or {}).get("word_citations", []): box = word["bbox"] if box is None: continue x = box["left"] * image_width y = box["top"] * image_height w = box["width"] * image_width h = box["height"] * image_height source = block.content[word["char_start"]:word["char_end"]] ``` ```javascript Node.js theme={null} for (const chunk of result.result.chunks) { for (const block of chunk.blocks) { const chars = Array.from(block.content); for (const word of block.extra?.word_citations ?? []) { const box = word.bbox; if (!box) continue; const x = box.left * imageWidth; const y = box.top * imageHeight; const w = box.width * imageWidth; const h = box.height * imageHeight; const source = chars.slice(word.char_start, word.char_end).join(''); } } } ``` Use `bbox.page` to pick the page image. `page` counts pages in this response and `original_page` counts pages in the source document. They differ when you set a [page range](/configs/parse/page-ranges). ## Compatibility `return_citations` requires r-1 or r-1 mini. A request that runs on legacy Parse returns a 400 error. `return_citations` cannot be combined with `formatting.merge_tables`. A request that sets both returns a 400 error. Figure blocks have no word or cell citations. # Processing Settings Source: https://docs.reducto.ai/configs/parse/ocr-settings Configure legacy OCR behavior and general Parse processing settings The `settings` config group controls how documents are processed, including timeouts, response options, and special cases like password-protected files. > [!NOTE] > > r-1 is the main Parse model (see [Parse r-1](/parse/r-1)). It uses native full-page processing and ignores `settings.ocr_system` and `settings.extraction_mode`. Existing legacy pipelines can continue using these controls. Other settings on this page continue to work with r-1. ```python Python theme={null} result = client.parse.run( input=upload.file_id, settings={ "timeout": 300, "page_range": {"start": 1, "end": 50} } ) ``` ```javascript Node.js theme={null} const result = await client.parse.run({ input: upload.file_id, settings: { timeout: 300, page_range: { start: 1, end: 50 } } }); ``` ```bash cURL theme={null} curl -X POST https://platform.reducto.ai/parse \ -H "Authorization: Bearer $REDUCTO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "input": "reducto://your-file-id", "settings": { "timeout": 300, "page_range": {"start": 1, "end": 50} } }' ``` ## OCR System (Legacy Parse) For legacy Parse pipelines, `ocr_system` selects how text is extracted from images and scanned documents. r-1 ignores this setting. ```python Python theme={null} settings={"ocr_system": "standard"} ``` ```javascript Node.js theme={null} settings: { ocr_system: 'standard' } ``` ```bash cURL theme={null} "settings": {"ocr_system": "standard"} ``` **`standard` (default):** Our primary OCR engine supporting 60+ languages. Handles mixed-language documents automatically. > [accordion] Supported languages (standard OCR) > > Afrikaans, Albanian, Arabic, Armenian, Belarusian, Bengali, Bulgarian, Catalan, Chinese, Croatian, Czech, Danish, Dutch, English, Estonian, Filipino, Finnish, French, German, Greek, Gujarati, Hebrew, Hindi, Hungarian, Icelandic, Indonesian, Italian, Japanese, Kannada, Khmer, Korean, Lao, Latvian, Lithuanian, Macedonian, Malay, Malayalam, Marathi, Nepali, Norwegian, Persian, Polish, Portuguese, Punjabi, Romanian, Russian, Serbian, Slovak, Slovenian, Spanish, Swedish, Tagalog, Tamil, Telugu, Thai, Turkish, Ukrainian, Vietnamese, Yiddish **`legacy`:** An older engine optimized for Germanic languages only. Available for backwards compatibility with existing integrations. Use `standard` for new projects. > [accordion] Supported languages (legacy OCR) > > English, German, Dutch, Norwegian, Swedish, Danish, Icelandic, Afrikaans > [!NOTE] > > The Go SDK uses different OCR system values (`highres`, `multilingual`, `combined`). Use Python, Node.js, or cURL for the `standard` and `legacy` options. > [!NOTE] > > r-1 handles handwriting, faded text, and poor scans natively. It is not necessary to add a promptless agentic text scope as a general accuracy upgrade. For use-case-specific instructions, see [Agentic Modes](/configs/parse/agentic-modes). ## Extraction Mode (Legacy Parse) For legacy Parse pipelines, `extraction_mode` controls how text is extracted from PDFs that have embedded text layers. r-1 ignores this setting. ```python Python theme={null} settings={"extraction_mode": "hybrid"} ``` ```javascript Node.js theme={null} settings: { extraction_mode: 'hybrid' } ``` ```bash cURL theme={null} "settings": {"extraction_mode": "hybrid"} ``` **`hybrid` (default):** Uses good quality metadata first, then OCR. Best when processing mixed document sets where some have reliable embedded text and others don't. **`ocr`:** Uses optical character recognition only, ignoring any embedded text in the PDF. Best for scanned documents, images, or when embedded text is unreliable or corrupted. **`metadata`:** Uses embedded text from PDF metadata only, without OCR. Best for native DOCX/PDFs with reliable text layers where you want faster processing. ## Page Range Process only specific pages to save time and credits. See [Page Ranges](/configs/parse/page-ranges) for complete documentation. ```python Python theme={null} # Pages 1-10 only settings={"page_range": {"start": 1, "end": 10}} # Multiple ranges settings={"page_range": [{"start": 1, "end": 5}, {"start": 20, "end": 25}]} ``` ```javascript Node.js theme={null} // Pages 1-10 only settings: { page_range: { start: 1, end: 10 } } // Multiple ranges settings: { page_range: [{ start: 1, end: 5 }, { start: 20, end: 25 }] } ``` ```go Go theme={null} // Pages 1-10 only AdvancedOptions: reducto.F(shared.AdvancedProcessingOptionsParam{ PageRange: reducto.F[shared.AdvancedProcessingOptionsPageRangeUnionParam]( shared.PageRangeParam{ Start: reducto.F(int64(1)), End: reducto.F(int64(10)), }, ), }) ``` ```bash cURL theme={null} # Pages 1-10 only "settings": {"page_range": {"start": 1, "end": 10}} # Multiple ranges "settings": {"page_range": [{"start": 1, "end": 5}, {"start": 20, "end": 25}]} ``` ## Timeout Set a maximum processing time in seconds. If processing exceeds this limit, the request fails rather than hanging indefinitely. ```python Python theme={null} settings={"timeout": 300} # 5 minutes ``` ```javascript Node.js theme={null} settings: { timeout: 300 } // 5 minutes ``` ```bash cURL theme={null} "settings": {"timeout": 300} ``` If not specified, Reducto uses internal defaults appropriate for the document size. ## Embedded Document Properties Set `extract_document_properties` to `true` to return properties embedded in the original pre-conversion file, including PDF Info/XMP and OOXML core properties. This setting defaults to `false`. ```python Python theme={null} result = client.parse.run( input=upload.file_id, settings={"extract_document_properties": True} ) ``` ```javascript Node.js theme={null} const result = await client.parse.run({ input: upload.file_id, settings: { extract_document_properties: true } }); ``` ```bash cURL theme={null} curl -X POST https://platform.reducto.ai/parse \ -H "Authorization: Bearer $REDUCTO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "input": "reducto://your-file-id", "settings": {"extract_document_properties": true} }' ``` The response includes a top-level `document_properties` object for supported files with embedded properties. It is `null` when no properties are found or the input format is unsupported. Supported formats are PDF, DOCX, XLSX, and PPTX. Legacy binary `.doc`, `.xls`, and `.ppt` files are not supported. Each field is nullable, and dates use timezone-aware ISO-8601 strings. PDF Info/XMP populates `title`, `author`, `subject`, `keywords`, `creator`, `producer`, `created_at`, and `modified_at`. `last_modified_by` is only available from OOXML core properties. DOCX, XLSX, and PPTX can populate `title`, `author`, `subject`, `keywords`, `creator`, `last_modified_by`, `created_at`, and `modified_at`; `producer` is PDF-only. ```json theme={null} { "document_properties": { "title": "Quarterly Report", "author": "Jane Smith", "subject": "Financial results", "keywords": "finance, quarterly", "creator": "Microsoft Word", "producer": null, "last_modified_by": "Alex Chen", "created_at": "2024-01-15T09:30:00+00:00", "modified_at": "2024-02-01T16:45:12+00:00" } } ``` ## Password-Protected Documents For encrypted PDFs that require a password to open: ```python Python theme={null} settings={"document_password": "secret123"} ``` ```javascript Node.js theme={null} settings: { document_password: 'secret123' } ``` ```go Go theme={null} AdvancedOptions: reducto.F(shared.AdvancedProcessingOptionsParam{ DocumentPassword: reducto.F("secret123"), }) ``` ```bash cURL theme={null} "settings": {"document_password": "secret123"} ``` The password is used to decrypt the document before processing. It's transmitted securely but not stored. ## Return Images By default, blocks contain only extracted text. Enable `return_images` to get pre-signed URLs pointing to cropped images of specific block types: ```python Python theme={null} settings={"return_images": ["figure", "table"]} ``` ```javascript Node.js theme={null} settings: { return_images: ['figure', 'table'] } ``` ```bash cURL theme={null} "settings": {"return_images": ["figure", "table"]} ``` When enabled, applicable blocks include an `image_url` field: ```json theme={null} { "type": "Figure", "bbox": {"left": 0.1, "top": 0.2, "width": 0.8, "height": 0.4, "page": 1}, "content": "Bar chart showing quarterly revenue growth from Q1 to Q4...", "image_url": "https://storage.reducto.ai/figures/abc123.png?X-Amz-Expires=3600..." } ``` The URL is a pre-signed S3 link valid for a limited time. Download or process the image before expiration. **Options:** * `figure`: Cropped images for figure blocks (charts, diagrams, photos, illustrations) * `table`: Cropped images for table blocks ## Return OCR Data Returns word-level and line-level OCR data with bounding boxes. r-1 and legacy Parse both include OCR at no extra charge. With r-1 mini, OCR is an add-on at a list price of \$1 per 1,000 pages (see [Usage and Pricing](/reference/credit-usage#parse-endpoint)). This setting is supported by r-1, r-1 mini, and legacy Parse. ```python Python theme={null} settings={"return_ocr_data": True} ``` ```javascript Node.js theme={null} settings: { return_ocr_data: true } ``` ```go Go theme={null} AdvancedOptions: reducto.F(shared.AdvancedProcessingOptionsParam{ ReturnOcrData: reducto.F(true), }) ``` ```bash cURL theme={null} "settings": {"return_ocr_data": true} ``` The response `result` object includes an `ocr` field containing `words` and `lines` arrays: ```json theme={null} { "job_id": "parse_abc123xyz", "result": { "type": "full", "chunks": [...], "ocr": { "words": [ { "text": "Revenue", "bbox": {"left": 0.12, "top": 0.08, "width": 0.15, "height": 0.02, "page": 1}, "confidence": 0.98, "rotation": 0 } ], "lines": [ { "text": "Revenue Report Q4 2024", "bbox": {"left": 0.12, "top": 0.08, "width": 0.45, "height": 0.02, "page": 1}, "confidence": 0.97, "rotation": 0 } ] } } } ``` Each word and line includes: * `text`: The recognized text * `bbox`: Normalized bounding box (coordinates as fractions of page dimensions) * `confidence`: OCR confidence score between 0 and 1 * `rotation`: Detected rotation angle in degrees (0-360, counterclockwise) ## Persist Results By default, processed results are stored temporarily and eventually deleted. Enable persistence to keep results indefinitely in long-term storage: ```python Python theme={null} settings={"persist_results": True} ``` ```javascript Node.js theme={null} settings: { persist_results: true } ``` ```bash cURL theme={null} "settings": {"persist_results": true} ``` When enabled, you can retrieve results later using the job ID without reprocessing the document. The response includes a `job_id` that serves as the retrieval key: ```json theme={null} { "job_id": "parse_abc123xyz", "duration": 2.34, "result": {...} } ``` Retrieve stored results later: ```python Python theme={null} job = client.job.get("parse_abc123xyz") result = job.result ``` ```javascript Node.js theme={null} const job = await client.job.retrieve('parse_abc123xyz'); const result = job.result; ``` ```bash cURL theme={null} curl https://platform.reducto.ai/job/parse_abc123xyz \ -H "Authorization: Bearer $REDUCTO_API_KEY" ``` > [!NOTE] > > Requires opting in to Reducto Studio. Contact support to enable this feature for your organization. ## Embed PDF Metadata Embeds the OCR-extracted text back into the PDF as a hidden text layer. The response includes a `pdf_url` pointing to the enhanced PDF: ```python Python theme={null} settings={"embed_pdf_metadata": True} ``` ```javascript Node.js theme={null} settings: { embed_pdf_metadata: true } ``` ```bash cURL theme={null} "settings": {"embed_pdf_metadata": true} ``` ```json theme={null} { "job_id": "parse_abc123xyz", "pdf_url": "https://storage.reducto.ai/pdfs/abc123.pdf?...", "result": {...} } ``` ### Render DPI By default we render the source PDF at 200 DPI when building the embedded-OCR PDF — small files, sharp at default zoom in a doc viewer, slight softness only past \~200% zoom. To control this directly, pass `embed_pdf_metadata_dpi` (range 50-250): ```python Python theme={null} settings={"embed_pdf_metadata": True, "embed_pdf_metadata_dpi": 150} ``` ```javascript Node.js theme={null} settings: { embed_pdf_metadata: true, embed_pdf_metadata_dpi: 150 } ``` ```bash cURL theme={null} "settings": {"embed_pdf_metadata": true, "embed_pdf_metadata_dpi": 150} ``` Higher values preserve more detail when zoomed in but produce larger PDFs; lower values shrink the output but soften the rendered text at high zoom. Pick close to the source scan DPI for scan-derived PDFs; the default 200 is right for most digital documents. The returned PDF looks identical to the original but now supports: * Text selection and copy/paste in PDF viewers * Full-text search within the document * Accessibility features (screen readers can read the text) ## Force URL Result By default, Reducto returns the full result inline in the response. For very large documents, this is automatically switched to a URL. You can force URL mode regardless of size: ```python Python theme={null} settings={"force_url_result": True} ``` ```javascript Node.js theme={null} settings: { force_url_result: true } ``` ```go Go theme={null} Options: reducto.F(shared.BaseProcessingOptionsParam{ ForceURLResult: reducto.F(true), }) ``` ```bash cURL theme={null} "settings": {"force_url_result": true} ``` When enabled, the response contains a URL instead of inline content: ```json theme={null} { "job_id": "parse_abc123xyz", "result": { "type": "url", "url": "https://storage.reducto.ai/results/abc123.json?...", "result_id": "abc123" } } ``` Fetch the full result by downloading from the URL. The URL is pre-signed and valid for a limited time. ## Force File Extension Reducto automatically detects file types from URLs and content. Override this detection when automatic detection fails or returns incorrect results: ```python Python theme={null} settings={"force_file_extension": ".pdf"} ``` ```javascript Node.js theme={null} settings: { force_file_extension: '.pdf' } ``` ```go Go theme={null} AdvancedOptions: reducto.F(shared.AdvancedProcessingOptionsParam{ ForceFileExtension: reducto.F(".pdf"), }) ``` ```bash cURL theme={null} "settings": {"force_file_extension": ".pdf"} ``` Common scenarios: * URLs without file extensions (e.g., `https://api.example.com/document/12345`) * URLs with misleading extensions * Pre-signed URLs with complex query parameters that confuse detection Valid extensions include `.pdf`, `.png`, `.jpg`, `.docx`, `.xlsx`, `.pptx`, and all other [supported file types](/upload/overview#supported-file-types). # Page Ranges Source: https://docs.reducto.ai/configs/parse/page-ranges Process specific pages of a document Reducto allows you to specify which pages of a document to process using the `page_range` parameter in settings. You can specify a single range or multiple ranges. ## Single Range For processing a continuous range of pages, specify `start` and `end` page numbers: ```python Python theme={null} result = client.parse.run( input=upload.file_id, settings={ "page_range": {"start": 1, "end": 10} } ) ``` ```javascript Node.js theme={null} const result = await client.parse.run({ input: upload.file_id, settings: { page_range: { start: 1, end: 10 } } }); ``` ```go Go theme={null} result, _ := client.Parse.Run(context.Background(), reducto.ParseRunParams{ ParseConfig: reducto.ParseConfigParam{ DocumentURL: reducto.F[reducto.ParseConfigDocumentURLUnionParam]( shared.UnionString(upload.FileID), ), AdvancedOptions: reducto.F(shared.AdvancedProcessingOptionsParam{ PageRange: reducto.F[shared.AdvancedProcessingOptionsPageRangeUnionParam]( shared.PageRangeParam{ Start: reducto.F(int64(1)), End: reducto.F(int64(10)), }, ), }), }, }) ``` ```bash cURL theme={null} curl -X POST https://platform.reducto.ai/parse \ -H "Authorization: Bearer $REDUCTO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "input": "reducto://your-file-id", "settings": { "page_range": {"start": 1, "end": 10} } }' ``` This processes pages 1 through 10. ## Multiple Ranges For non-contiguous pages, provide an array of range objects: ```python Python theme={null} result = client.parse.run( input=upload.file_id, settings={ "page_range": [ {"start": 1, "end": 5}, {"start": 10, "end": 15} ] } ) ``` ```javascript Node.js theme={null} const result = await client.parse.run({ input: upload.file_id, settings: { page_range: [ { start: 1, end: 5 }, { start: 10, end: 15 } ] } }); ``` ```go Go theme={null} result, _ := client.Parse.Run(context.Background(), reducto.ParseRunParams{ ParseConfig: reducto.ParseConfigParam{ DocumentURL: reducto.F[reducto.ParseConfigDocumentURLUnionParam]( shared.UnionString(upload.FileID), ), AdvancedOptions: reducto.F(shared.AdvancedProcessingOptionsParam{ PageRange: reducto.F[shared.AdvancedProcessingOptionsPageRangeUnionParam]( shared.AdvancedProcessingOptionsPageRangeArrayParam{ {Start: reducto.F(int64(1)), End: reducto.F(int64(5))}, {Start: reducto.F(int64(10)), End: reducto.F(int64(15))}, }, ), }), }, }) ``` ```bash cURL theme={null} curl -X POST https://platform.reducto.ai/parse \ -H "Authorization: Bearer $REDUCTO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "input": "reducto://your-file-id", "settings": { "page_range": [ {"start": 1, "end": 5}, {"start": 10, "end": 15} ] } }' ``` This processes pages 1-5 and 10-15. ## Notes * Page numbers are 1-indexed (first page is page 1) * Both `start` and `end` are inclusive * If no page range is specified, the entire document is processed * The `end` page must be greater than or equal to `start` * Ranges do not need to be in order * Overlapping ranges are processed only once * If `end` exceeds the document length, processing stops at the last page ## With Split Endpoint For the Split endpoint, `page_range` is nested under `parsing.settings`: ```python Python theme={null} result = client.split.run( input=upload.file_id, split_description=[...], parsing={ "settings": { "page_range": {"start": 40, "end": 80} } } ) ``` ```javascript Node.js theme={null} const result = await client.split.run({ input: upload.file_id, split_description: [...], parsing: { settings: { page_range: { start: 40, end: 80 } } } }); ``` ```bash cURL theme={null} curl -X POST https://platform.reducto.ai/split \ -H "Authorization: Bearer $REDUCTO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "document_url": "reducto://your-file-id", "split_description": [...], "parsing": { "settings": { "page_range": {"start": 40, "end": 80} } } }' ``` # r-1 configuration compatibility Source: https://docs.reducto.ai/configs/parse/r-1-configuration-compatibility Review your Parse settings before migrating an existing workflow to r-1. > [!NOTE] > > Starting December 7, 2026, Parse defaults to r-1. To keep legacy Parse, set `settings.model` to `"legacy"`. At a high level, r-1 delivers agentic-quality parsing through a unified full-page model and supports the full Parse configuration surface except for the settings listed below. Some settings are ignored because r-1 handles the behavior natively, while others are not supported yet. Custom prompts and advanced chart extraction remain available as specialized agentic augmentations. ## Choose a model Set `settings.model` to `"r-1"` or `"r-1-mini"` on a V3 Parse request. ```python Python theme={null} result = client.parse.run( input=upload.file_id, settings={"model": "r-1"} # or "r-1-mini" ) ``` ```javascript Node.js theme={null} const result = await client.parse.run({ input: upload.file_id, settings: { model: 'r-1' } // or 'r-1-mini' }); ``` ```bash cURL theme={null} # Set "model" to "r-1-mini" to use r-1 mini. curl -X POST https://platform.reducto.ai/parse \ -H "Authorization: Bearer $REDUCTO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "input": "reducto://your-file-id", "settings": {"model": "r-1"} }' ``` | Value | Behavior | | - | - | | `"r-1"` | Parse each page with the r-1 full-page model. | | `"r-1-mini"` | Parse each page with [r-1 mini](/parse/r-1#r-1-mini), a smaller r-1 model. | | `"legacy"` | Parse with the legacy pipeline. | | unset | Parse with the legacy pipeline until December 7, 2026, then with r-1. | ## V3 configuration compatibility | Configuration | r-1 | r-1 mini | | - | - | - | | `enhance.summarize_figures` and `enhance.intelligent_ordering` | Ignored. r-1 handles figure descriptions and reading order natively. | Same as r-1. | | Agentic text, table, or figure scope without a custom prompt | Remove before migrating. r-1 handles the general quality improvement natively. | Same as r-1. | | `settings.extraction_mode` and `settings.ocr_system` | Ignored. r-1 uses its native full-page processing. | Same as r-1. | | `formatting.include = "highlight"`, `"signatures"`, or `"ignore_watermarks"` | Ignored. r-1 handles these by default. | Same as r-1. | | `formatting.table_output_format = "jsonbbox"` | Not supported. | Not supported. | | `track_offsets` | Not supported. | Not supported. | | Spreadsheets | Supported. | Not supported on hosted deployments. Returns a 400 error. | | On-prem deployments | Not available. | Available. | ## V2 configurations r-1 is not available through the V2 API. V2 requests always use legacy Parse. To use r-1, convert your request to the V3 API first. The [V2 to V3 converter](/configs/overview#migrating-from-v2) transforms an existing V2 config, then review the V3 table above before enabling r-1. > [!WARNING] > > Remove unsupported settings before migrating an existing configuration to r-1. Requests containing incompatible settings can fail validation. > [card] [Parse r-1](/parse/r-1) > > Learn what r-1 handles natively and when to add specialized agentic processing. > [card] [Parse configurations](/configs/overview) > > Configure chunking, formatting, retrieval, and processing settings. # Spreadsheet Processing Source: https://docs.reducto.ai/configs/parse/spreadsheet Configure how Excel, CSV, and spreadsheet files are processed Spreadsheets present a unique challenge: a single sheet can contain multiple logical tables, empty regions, header rows, and metadata scattered across cells. The `spreadsheet` config group controls how Reducto identifies table boundaries, handles large tables, and extracts cell-level metadata like colors and formulas. ```python Python theme={null} result = client.parse.run( input=upload.file_id, spreadsheet={ "clustering": "accurate", "split_large_tables": {"enabled": True, "size": 50}, "include": ["cell_colors", "formula"], "exclude": ["hidden_sheets"] } ) ``` ```javascript Node.js theme={null} const result = await client.parse.run({ input: upload.file_id, spreadsheet: { clustering: 'accurate', split_large_tables: { enabled: true, size: 50 }, include: ['cell_colors', 'formula'], exclude: ['hidden_sheets'] } }); ``` ```bash cURL theme={null} curl -X POST https://platform.reducto.ai/parse \ -H "Authorization: Bearer $REDUCTO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "input": "reducto://your-file-id", "spreadsheet": { "clustering": "accurate", "split_large_tables": {"enabled": true, "size": 50}, "include": ["cell_colors", "formula"], "exclude": ["hidden_sheets"] } }' ``` ## Table Clustering Many spreadsheets contain multiple tables on the same sheet, separated by empty rows or columns. Clustering detects where one table ends and another begins, so each table becomes its own block in the output. ```python Python theme={null} spreadsheet={"clustering": "accurate"} ``` ```javascript Node.js theme={null} spreadsheet: { clustering: 'accurate' } ``` ```bash cURL theme={null} "spreadsheet": {"clustering": "accurate"} ``` **`accurate` (default):** Uses an LLM to analyze the sheet structure and identify table boundaries. This handles complex layouts where tables have different column structures, headers in unusual positions, or subtle separations. Costs 5x per cell compared to `fast`. **`fast`:** Uses a rule-based algorithm to find tables based on empty rows/columns. Works well for simple spreadsheets where tables are clearly separated. Standard per-cell cost. **`disabled`:** Treats the entire sheet as one table. Use this when you know each sheet contains exactly one table, or when you want raw cell data without any boundary detection. > [!NOTE] > > The Go SDK currently only supports `default` and `disabled` clustering modes. Use Python, Node.js, or cURL for `accurate` and `fast` modes. ```python Python theme={null} # Simple spreadsheet with obvious table boundaries spreadsheet={"clustering": "fast"} # Each sheet is a single table spreadsheet={"clustering": "disabled"} ``` ```javascript Node.js theme={null} // Simple spreadsheet with obvious table boundaries spreadsheet: { clustering: 'fast' } // Each sheet is a single table spreadsheet: { clustering: 'disabled' } ``` ```bash cURL theme={null} # Simple spreadsheet with obvious table boundaries "spreadsheet": {"clustering": "fast"} # Each sheet is a single table "spreadsheet": {"clustering": "disabled"} ``` ## Large Table Splitting Tables with many rows create problems downstream: they can exceed LLM context windows, make chunking difficult, and slow down processing. By default, Reducto splits tables that exceed 50 rows into smaller chunks, each retaining the header row for context. ```python Python theme={null} spreadsheet={ "split_large_tables": { "enabled": True, "size": 50 # Max rows per chunk } } ``` ```javascript Node.js theme={null} spreadsheet: { split_large_tables: { enabled: true, size: 50 // Max rows per chunk } } ``` ```go Go theme={null} AdvancedOptions: reducto.F(shared.AdvancedProcessingOptionsParam{ LargeTableChunking: reducto.F(shared.AdvancedProcessingOptionsLargeTableChunkingParam{ Enabled: reducto.F(true), Size: reducto.F(int64(50)), }), }) ``` ```bash cURL theme={null} "spreadsheet": { "split_large_tables": { "enabled": true, "size": 50 } } ``` Each chunk becomes a separate table block. If your original table has headers in row 1 and 200 data rows, you get 4 blocks: rows 1-50, 1+51-100, 1+101-150, 1+151-200 (header repeated in each). **When to disable:** If your downstream processing needs all rows together (for example, sorting or aggregation), disable splitting: ```python Python theme={null} spreadsheet={"split_large_tables": {"enabled": False}} ``` ```javascript Node.js theme={null} spreadsheet: { split_large_tables: { enabled: false } } ``` ```go Go theme={null} AdvancedOptions: reducto.F(shared.AdvancedProcessingOptionsParam{ LargeTableChunking: reducto.F(shared.AdvancedProcessingOptionsLargeTableChunkingParam{ Enabled: reducto.F(false), }), }) ``` ```bash cURL theme={null} "spreadsheet": {"split_large_tables": {"enabled": false}} ``` **Adjusting chunk size:** For tables where rows are highly interdependent, increase the size. For very wide tables that consume lots of tokens, decrease it: ```python Python theme={null} # More context per chunk spreadsheet={"split_large_tables": {"size": 100}} # Smaller chunks for wide tables spreadsheet={"split_large_tables": {"size": 25}} ``` ```javascript Node.js theme={null} // More context per chunk spreadsheet: { split_large_tables: { size: 100 } } // Smaller chunks for wide tables spreadsheet: { split_large_tables: { size: 25 } } ``` ```go Go theme={null} // More context per chunk AdvancedOptions: reducto.F(shared.AdvancedProcessingOptionsParam{ LargeTableChunking: reducto.F(shared.AdvancedProcessingOptionsLargeTableChunkingParam{ Size: reducto.F(int64(100)), }), }) // Smaller chunks for wide tables AdvancedOptions: reducto.F(shared.AdvancedProcessingOptionsParam{ LargeTableChunking: reducto.F(shared.AdvancedProcessingOptionsLargeTableChunkingParam{ Size: reducto.F(int64(25)), }), }) ``` ```bash cURL theme={null} # More context per chunk "spreadsheet": {"split_large_tables": {"size": 100}} # Smaller chunks for wide tables "spreadsheet": {"split_large_tables": {"size": 25}} ``` ## Including Cell Metadata By default, Reducto extracts cell values only. You can optionally include colors and formulas. ### Cell Colors Financial spreadsheets often use color to convey meaning: red for negative values, green for positive, yellow for warnings. Enable `cell_colors` to preserve this information: ```python Python theme={null} spreadsheet={"include": ["cell_colors"]} ``` ```javascript Node.js theme={null} spreadsheet: { include: ['cell_colors'] } ``` ```bash cURL theme={null} "spreadsheet": {"include": ["cell_colors"]} ``` Colors appear as inline styles in HTML table output: ```html theme={null} -$5,000 ``` The `color` property is text color; `background-color` is cell highlight/fill. ### Formulas Spreadsheets contain computational logic in formulas. Enable `formula` to capture the original formula alongside the computed value: ```python Python theme={null} spreadsheet={"include": ["formula"]} ``` ```javascript Node.js theme={null} spreadsheet: { include: ['formula'] } ``` ```bash cURL theme={null} "spreadsheet": {"include": ["formula"]} ``` Formulas appear as `data-formula` attributes in HTML output: ```html theme={null} $125,000 ``` This is useful when you need to understand how values were calculated, audit spreadsheet logic, or recreate the computation elsewhere. ## Excluding Content Spreadsheets often contain content you don't want to process: hidden sheets with intermediate calculations, hidden rows/columns, embedded images, or styling information. ```python Python theme={null} spreadsheet={"exclude": ["hidden_sheets", "hidden_rows", "hidden_cols"]} ``` ```javascript Node.js theme={null} spreadsheet: { exclude: ['hidden_sheets', 'hidden_rows', 'hidden_cols'] } ``` ```bash cURL theme={null} "spreadsheet": {"exclude": ["hidden_sheets", "hidden_rows", "hidden_cols"]} ``` **`hidden_sheets`:** Skips sheets marked as hidden in Excel. Many spreadsheets hide calculation sheets or raw data that isn't meant for display. **`hidden_rows` and `hidden_cols`:** Skips rows and columns that are hidden. Useful when spreadsheets hide detail rows in grouped/outlined sections. **`styling`:** Excludes all styling information (fonts, borders, colors). Use when you only need values. **`spreadsheet_images`:** Skips embedded images and charts. These are processed separately by default, but you can skip them if not needed. By default, hidden content IS processed. Explicitly exclude it if your spreadsheets contain sensitive data in hidden areas or if hidden content is irrelevant to your use case. ## Cell Count Limit Giant spreadsheets with millions of cells can generate unexpectedly large bills. The `max_cell_count` option lets you set a cap on the total non-empty cells across all sheets. If the count exceeds the limit, the request fails with a 422 error before any expensive processing begins. ```python Python theme={null} spreadsheet={"max_cell_count": 500000} ``` ```javascript Node.js theme={null} spreadsheet: { max_cell_count: 500000 } ``` ```bash cURL theme={null} "spreadsheet": {"max_cell_count": 500000} ``` Defaults to `null` (no limit). Only non-empty cells count. Empty cells and cells with no value inside an oversized used range do not contribute. When a spreadsheet exceeds the limit, the error response includes the actual cell count so you can adjust: ```json theme={null} { "error": "Spreadsheet cell count (1,234,567) exceeds max_cell_count limit (500,000). Set a higher max_cell_count in spreadsheet options or set to null to disable the limit." } ``` The response `usage` object includes `non_empty_cell_count` for all spreadsheet parse jobs, so you can monitor cell counts and set appropriate limits: ```json theme={null} { "usage": { "num_pages": 12, "credits": 45.0, "non_empty_cell_count": 45230 } } ``` ## Example: Financial Model Financial models typically have multiple tables per sheet, use colors for emphasis, and have hidden calculation sheets: ```python Python theme={null} result = client.parse.run( input=financial_model_url, spreadsheet={ "clustering": "accurate", # Multiple tables with complex layouts "include": ["cell_colors"], # Color indicates meaning "exclude": ["hidden_sheets"] # Skip calculation sheets } ) ``` ```javascript Node.js theme={null} const result = await client.parse.run({ input: financialModelUrl, spreadsheet: { clustering: 'accurate', // Multiple tables with complex layouts include: ['cell_colors'], // Color indicates meaning exclude: ['hidden_sheets'] // Skip calculation sheets } }); ``` ```bash cURL theme={null} curl -X POST https://platform.reducto.ai/parse \ -H "Authorization: Bearer $REDUCTO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "input": "https://example.com/financial_model.xlsx", "spreadsheet": { "clustering": "accurate", "include": ["cell_colors"], "exclude": ["hidden_sheets"] } }' ``` ## Example: Data Export Large data exports are typically single tables with thousands of rows: ```python Python theme={null} result = client.parse.run( input=data_export_url, spreadsheet={ "clustering": "disabled", # Single table per sheet "split_large_tables": {"enabled": True, "size": 100} # Larger chunks for context } ) ``` ```javascript Node.js theme={null} const result = await client.parse.run({ input: dataExportUrl, spreadsheet: { clustering: 'disabled', // Single table per sheet split_large_tables: { enabled: true, size: 100 } // Larger chunks for context } }); ``` ```bash cURL theme={null} curl -X POST https://platform.reducto.ai/parse \ -H "Authorization: Bearer $REDUCTO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "input": "https://example.com/data_export.csv", "spreadsheet": { "clustering": "disabled", "split_large_tables": {"enabled": true, "size": 100} } }' ``` # Table Output Formats Source: https://docs.reducto.ai/configs/parse/table-output-formats Control how tables are formatted in API responses Reducto extracts tables from documents and can return them in several formats. The format you choose affects how merged cells, headers, and structure are represented. ## Setting Table Format ```python Python theme={null} result = client.parse.run( input=upload.file_id, formatting={"table_output_format": "html"} ) ``` ```javascript Node.js theme={null} const result = await client.parse.run({ input: upload.file_id, formatting: { table_output_format: 'html' } }); ``` ```go Go theme={null} result, _ := client.Parse.Run(context.Background(), reducto.ParseRunParams{ ParseConfig: reducto.ParseConfigParam{ DocumentURL: reducto.F[reducto.ParseConfigDocumentURLUnionParam]( shared.UnionString(upload.FileID), ), AdvancedOptions: reducto.F(shared.AdvancedProcessingOptionsParam{ TableOutputFormat: reducto.F(shared.AdvancedProcessingOptionsTableOutputFormatHTML), }), }, }) ``` ```bash cURL theme={null} curl -X POST https://platform.reducto.ai/parse \ -H "Authorization: Bearer $REDUCTO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "input": "reducto://your-file-id", "formatting": {"table_output_format": "html"} }' ``` ## Available Formats > [tab] dynamic > > **Default.** Automatically chooses between markdown and HTML based on table complexity. > > * Uses **markdown** for simple tables (30 cells or fewer AND 4 merged cells or fewer) > * Uses **HTML** for complex tables (more than 30 cells OR more than 4 merged cells) > > ```python Python theme={null} > formatting={"table_output_format": "dynamic"} > ``` > > ```javascript Node.js theme={null} > formatting: { table_output_format: 'dynamic' } > ``` > > ```go Go theme={null} > AdvancedOptions: reducto.F(shared.AdvancedProcessingOptionsParam{ > TableOutputFormat: reducto.F(shared.AdvancedProcessingOptionsTableOutputFormatDynamic), > }) > ``` > > ```bash cURL theme={null} > "formatting": {"table_output_format": "dynamic"} > ``` > > Best for RAG pipelines where you want clean, readable output for simple tables while preserving structure for complex ones. > [tab] html > > Full HTML table structure with proper support for merged cells. > > ```python Python theme={null} > formatting={"table_output_format": "html"} > ``` > > ```javascript Node.js theme={null} > formatting: { table_output_format: 'html' } > ``` > > ```bash cURL theme={null} > "formatting": {"table_output_format": "html"} > ``` > > ```html theme={null} > > > > >
Q1 Results
ProductRevenue
Widget A$10,000
> ``` > > Merged cells are encoded using `rowspan` and `colspan` attributes. Use for financial statements, regulatory filings, or any tables where cell merging carries meaning. > [tab] md > > GitHub-flavored markdown tables. > > ```python Python theme={null} > formatting={"table_output_format": "md"} > ``` > > ```javascript Node.js theme={null} > formatting: { table_output_format: 'md' } > ``` > > ```go Go theme={null} > AdvancedOptions: reducto.F(shared.AdvancedProcessingOptionsParam{ > TableOutputFormat: reducto.F(shared.AdvancedProcessingOptionsTableOutputFormatMd), > }) > ``` > > ```bash cURL theme={null} > "formatting": {"table_output_format": "md"} > ``` > > ```markdown theme={null} > | Header 1 | Header 2 | > | - | - | > | Data 1 | Data 2 | > ``` > > Markdown cannot represent merged cells. If your table has merged cells, they will be flattened. Use for simple tables where human readability matters. > [tab] json > > Nested arrays for programmatic access. > > ```python Python theme={null} > formatting={"table_output_format": "json"} > ``` > > ```javascript Node.js theme={null} > formatting: { table_output_format: 'json' } > ``` > > ```go Go theme={null} > AdvancedOptions: reducto.F(shared.AdvancedProcessingOptionsParam{ > TableOutputFormat: reducto.F(shared.AdvancedProcessingOptionsTableOutputFormatJson), > }) > ``` > > ```bash cURL theme={null} > "formatting": {"table_output_format": "json"} > ``` > > ```json theme={null} > [ > ["Header 1", "Header 2"], > ["Data 1", "Data 2"] > ] > ``` > > First row contains headers. All cell values are strings. Use when you need to process table data programmatically. > [tab] jsonbbox > > JSON with normalized bounding box coordinates for each cell. > > ```python Python theme={null} > formatting={"table_output_format": "jsonbbox"} > ``` > > ```javascript Node.js theme={null} > formatting: { table_output_format: 'jsonbbox' } > ``` > > ```go Go theme={null} > AdvancedOptions: reducto.F(shared.AdvancedProcessingOptionsParam{ > TableOutputFormat: reducto.F(shared.AdvancedProcessingOptionsTableOutputFormatJsonbbox), > }) > ``` > > ```bash cURL theme={null} > "formatting": {"table_output_format": "jsonbbox"} > ``` > > ```json theme={null} > [ > [ > {"text": "Header 1", "bbox": {"x": 0.1, "y": 0.2, "width": 0.3, "height": 0.04}}, > {"text": "Header 2", "bbox": {"x": 0.4, "y": 0.2, "width": 0.3, "height": 0.04}} > ] > ] > ``` > > Coordinates are normalized to \[0, 1] relative to page dimensions. Use when you need to know where each cell is located on the page. > > > [!NOTE] > > > > r-1 and r-1 mini do not yet support `jsonbbox`. With legacy Parse, agentic table enhancement is not compatible with `jsonbbox` and will be automatically disabled when this format is used. > [tab] csv > > Comma-separated values. > > ```python Python theme={null} > formatting={"table_output_format": "csv"} > ``` > > ```javascript Node.js theme={null} > formatting: { table_output_format: 'csv' } > ``` > > ```bash cURL theme={null} > "formatting": {"table_output_format": "csv"} > ``` > > ```csv theme={null} > Header 1,Header 2 > Data 1,Data 2 > ``` > > Minimal output, easy to import into spreadsheet software. Most token-efficient format. ## Additional Options ### Merge Tables When a logical table spans multiple pages, Reducto may detect it as separate tables. Enable `merge_tables` to combine consecutive tables with the same column count: ```python Python theme={null} formatting={ "table_output_format": "html", "merge_tables": True } ``` ```javascript Node.js theme={null} formatting: { table_output_format: 'html', merge_tables: true } ``` ```go Go theme={null} AdvancedOptions: reducto.F(shared.AdvancedProcessingOptionsParam{ TableOutputFormat: reducto.F(shared.AdvancedProcessingOptionsTableOutputFormatHTML), MergeTables: reducto.F(true), }) ``` ```bash cURL theme={null} "formatting": { "table_output_format": "html", "merge_tables": true } ``` The algorithm: 1. Identifies consecutive tables with identical column counts 2. Uses a language model to determine if the second table is a continuation of the first 3. Combines them into a single table, removing duplicate headers > [!WARNING] > > Tables are merged based on column count and semantic analysis. Tables with the same number of columns but different structures may be incorrectly merged. Review output when using this option on complex documents. ### Add Page Markers Inserts page boundary indicators into the content: ```python Python theme={null} formatting={"add_page_markers": True} ``` ```javascript Node.js theme={null} formatting: { add_page_markers: true } ``` ```go Go theme={null} AdvancedOptions: reducto.F(shared.AdvancedProcessingOptionsParam{ AddPageMarkers: reducto.F(true), }) ``` ```bash cURL theme={null} "formatting": {"add_page_markers": true} ``` Output includes markers like: ```markdown theme={null} [[START OF PAGE 1]] # Document Title Content from page 1... [[END OF PAGE 1]] [[START OF PAGE 2]] ``` Useful for page-specific extraction or citation tracking. ### Include Additional Metadata The `formatting` group also supports extracting comments, highlights, change tracking, hyperlinks, and signatures. See [Additional Document Data](/configs/parse/additional-document-data) for details. ## Choosing the Right Format **For LLM context (RAG, summarization):** * Use `dynamic` (default). It balances readability with structure preservation. * Markdown is easier for LLMs to parse than HTML for simple tables. * Complex tables benefit from HTML to preserve relationships between cells. **For programmatic data extraction:** * Use `json` when you need to iterate over rows and cells. * With legacy Parse, use `jsonbbox` when cell positions matter (highlighting, overlays). * Use `csv` for direct import into pandas, spreadsheets, or data pipelines. **For accuracy-critical applications:** * Use `html`. It's the only format that preserves merged cells. * Financial statements, regulatory filings, and complex reports need HTML to maintain correct structure. ## Troubleshooting > [accordion] Merged cells not appearing correctly > > Use `html` format. Markdown and JSON formats cannot represent merged cells and will flatten them. > [accordion] Tables split across pages > > Enable `merge_tables: True` to combine consecutive tables with the same column structure. > [accordion] Output is too verbose > > Use `csv` for minimal output. If you need structure but want fewer tokens, use `json` instead of `html`. > [accordion] Need to know cell positions > > With legacy Parse, use `jsonbbox` format. Each cell includes normalized (x, y, width, height) coordinates. r-1 and r-1 mini do not yet support this format. # Split Configuration Source: https://docs.reducto.ai/configs/split/configuration Configure how documents are divided into sections Split identifies sections in a document based on natural language descriptions. This page covers the configuration options that control how sections are identified and returned. For basic usage and the full workflow, see the [Split endpoint documentation](/split). ## split\_description The `split_description` array defines what sections to look for. Each entry has three fields: ```python Python theme={null} split_description=[ { "name": "Account Summary", "description": "Overview section with balances and totals at the top of the statement", "partition_key": "account_number" # Optional } ] ``` ```javascript Node.js theme={null} split_description: [ { name: 'Account Summary', description: 'Overview section with balances and totals at the top of the statement', partition_key: 'account_number' // Optional } ] ``` ```bash cURL theme={null} "split_description": [ { "name": "Account Summary", "description": "Overview section with balances and totals at the top of the statement", "partition_key": "account_number" } ] ``` **name**: The identifier returned in results. Use names that make sense for your downstream processing logic. **description**: Natural language description of the section's content. The LLM uses this to classify pages. Be specific about what makes this section recognizable: content type, position in document, visual characteristics. **partition\_key**: For sections that repeat with different identifiers (multiple accounts, multiple patients, multiple companies). When set, Split extracts the identifier value from the document and groups pages by that value. ### Writing Effective Descriptions The description is passed to an LLM that classifies each page. Vague descriptions lead to ambiguous classifications. ```python theme={null} # Vague - could match many things {"name": "Tables", "description": "Pages with tables"} # Specific - clear criteria for classification {"name": "Transaction History", "description": "Table showing individual transactions with dates, descriptions, and amounts. Usually appears after the account summary section."} ``` Include distinguishing characteristics: * Content type (tables, narrative text, forms) * Position (beginning, end, after section X) * Visual elements (headers, logos, signature lines) * What it does NOT include (to avoid confusion with similar sections) *** ## partition\_key Partition key handles a common scenario: the same section type repeating for different entities. A consolidated statement has holdings for multiple accounts. A medical record packet has intake forms for multiple patients. Without partition key, Split returns all matching pages as one group. You'd then need to figure out where one entity ends and the next begins. Partition key does this automatically. ```python Python theme={null} split_description=[ { "name": "Holdings", "description": "Investment holdings table for a specific account", "partition_key": "account number" } ] ``` ```javascript Node.js theme={null} split_description: [ { name: 'Holdings', description: 'Investment holdings table for a specific account', partition_key: 'account number' } ] ``` ```bash cURL theme={null} "split_description": [ { "name": "Holdings", "description": "Investment holdings table for a specific account", "partition_key": "account number" } ] ``` The response includes partitions with extracted identifier values: ```json theme={null} { "result": { "splits": [ { "name": "Holdings", "pages": [1, 2, 3, 7, 8, 9, 10, 11], "conf": "high", "partitions": [ {"name": "1234-5678", "pages": [1, 2, 3], "conf": "high"}, {"name": "8765-4321", "pages": [7, 8, 9, 10, 11], "conf": "high"} ] } ] } } ``` The `name` in each partition is the actual value extracted from the document. If the document shows "Account #1234-5678" on pages 1-3 and "Account #8765-4321" on pages 7-11, those become your partition names. **The partition key is semantic, not literal.** If you set `partition_key` to "account number" but the document says "Acct #1234" or "Portfolio ID: 5678", Split will still find it. Describe what the identifier represents, not the exact text format. ### When partition\_key values appear in tables By default, Split truncates table content to speed up processing. If your partition key values appear deep within tables (not in headers or the first few rows), the truncation might hide them. Set `table_cutoff` to `preserve` to keep full table content: ```python Python theme={null} result = client.split.run( input=upload.file_id, split_description=[ { "name": "Holdings", "description": "Investment holdings table", "partition_key": "account_number" } ], settings={"table_cutoff": "preserve"} ) ``` ```javascript Node.js theme={null} const result = await client.split.run({ input: upload.file_id, split_description: [ { name: 'Holdings', description: 'Investment holdings table', partition_key: 'account_number' } ], settings: { table_cutoff: 'preserve' } }); ``` ```bash cURL theme={null} curl -X POST https://platform.reducto.ai/split \ -H "Authorization: Bearer $REDUCTO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "document_url": "reducto://your-file-id", "split_description": [ { "name": "Holdings", "description": "Investment holdings table", "partition_key": "account_number" } ], "settings": {"table_cutoff": "preserve"} }' ``` This increases processing time but ensures partition keys aren't missed. *** ## split\_rules Controls how pages are assigned to sections. The default rule: ``` "Split the document into the applicable sections. Sections may only overlap at their first and last page if at all." ``` This means a page can belong to multiple sections only at boundaries. Page 5 can belong to both "Section A" and "Section B" only if it's the last page of A and the first page of B. Customize for your use case: ```python Python theme={null} # Allow full overlap (page can belong to multiple sections anywhere) split_rules="Pages can belong to multiple sections. A page with both summary data and transaction data should appear in both sections." # Force exclusive classification (each page belongs to exactly one section) split_rules="Each page must belong to exactly one section. Assign to the most relevant section." # Document-specific logic split_rules="The cover page (page 1) should not be assigned to any section. Start section detection from page 2." ``` ```javascript Node.js theme={null} // Allow full overlap split_rules: 'Pages can belong to multiple sections. A page with both summary data and transaction data should appear in both sections.' // Force exclusive classification split_rules: 'Each page must belong to exactly one section. Assign to the most relevant section.' // Document-specific logic split_rules: 'The cover page (page 1) should not be assigned to any section. Start section detection from page 2.' ``` ```bash cURL theme={null} "split_rules": "Pages can belong to multiple sections. A page with both summary data and transaction data should appear in both sections." ``` The string is passed directly to the LLM as instructions. Write it as you would write instructions for a person doing the classification. *** ## settings ### table\_cutoff Controls how table content is processed during section detection. ```python Python theme={null} settings={"table_cutoff": "truncate"} # Default settings={"table_cutoff": "preserve"} ``` ```javascript Node.js theme={null} settings: { table_cutoff: 'truncate' } // Default settings: { table_cutoff: 'preserve' } ``` ```bash cURL theme={null} "settings": {"table_cutoff": "truncate"} "settings": {"table_cutoff": "preserve"} ``` **truncate (default)**: Tables are shortened to the first few rows. Faster processing. Works for most cases where section identifiers appear in headers, titles, or surrounding text. **preserve**: Full table content is retained. Required when partition\_key values or section identifiers appear deep within table rows. Slower but more thorough. *** ## parsing Split runs Parse internally before classifying sections. The `parsing` parameter accepts all Parse configuration options. ```python Python theme={null} result = client.split.run( input=upload.file_id, split_description=[...], parsing={ "settings": { "page_range": {"start": 1, "end": 50}, "ocr_system": "standard" }, "enhance": { "agentic": [{"scope": "table"}] } } ) ``` ```javascript Node.js theme={null} const result = await client.split.run({ input: upload.file_id, split_description: [...], parsing: { settings: { page_range: { start: 1, end: 50 }, ocr_system: 'standard' }, enhance: { agentic: [{ scope: 'table' }] } } }); ``` ```bash cURL theme={null} curl -X POST https://platform.reducto.ai/split \ -H "Authorization: Bearer $REDUCTO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "document_url": "reducto://your-file-id", "split_description": [...], "parsing": { "settings": { "page_range": {"start": 1, "end": 50}, "ocr_system": "standard" }, "enhance": { "agentic": [{"scope": "table"}] } } }' ``` If you pass `jobid://` as input (reusing a previous Parse result), the `parsing` options are ignored since the document was already parsed. *** ## Response Structure ```json theme={null} { "result": { "splits": [ { "name": "Section Name", "pages": [1, 2, 3], "conf": "high", "partitions": null } ], "section_mapping": { "Section Name": [1, 2, 3] } }, "usage": { "num_pages": 10, "credits": 20.0 } } ``` **splits**: Array of found sections, one per entry in `split_description`. **splits\[].name**: The name you provided. **splits\[].pages**: Page numbers where this section appears (1-indexed). **splits\[].conf**: `"high"` or `"low"` indicating classification confidence. **splits\[].partitions**: When using `partition_key`, sub-sections grouped by extracted identifier values. Each partition has its own `name` (the extracted value), `pages`, and `conf`. **section\_mapping**: Legacy format mapping section names to page arrays. Use `splits` for new code. A section not found in the document still appears in results with an empty pages array. Always check that `pages` has content before processing. # Deep Split Source: https://docs.reducto.ai/configs/split/deep-split Achieve near-perfect accuracy on complex document splitting with our agentic loop ## What is Deep Split? Deep Split is an agentic splitting mode that iteratively refines its output to achieve near-perfect accuracy. Unlike standard split which classifies each page in a single pass, Deep Split runs an agentic loop that verifies and corrects its section assignments against the source document until a quality threshold is met. This is especially useful for complex documents where a single split pass may mislabel pages, miss boundaries between similar sections, or partition repeating sections inconsistently. Deep Split catches these issues by checking its own work and re-classifying until the results are accurate. *** ## When to Use It Deep Split is designed for splitting tasks where accuracy is critical and the cost of errors is high. Common use cases include: * **Consolidated financial statements**, where holdings, transactions, and summaries repeat across many accounts and must be partitioned correctly. * **Insurance claim packets**, where sections like medical records, billing statements, and adjuster notes are visually similar and easy to confuse. * **Loan and mortgage files**, where dozens of disclosures, appraisals, and supporting documents are interleaved and ordering matters for downstream processing. * **Multi-patient medical record bundles**, where intake forms, lab reports, and discharge summaries repeat per patient and must group cleanly by partition. * **Long legal binders**, where exhibits, contracts, and addenda span hundreds of pages and section boundaries are ambiguous. If your splitting task is simple (a handful of clearly distinct sections in a short document), standard split is sufficient. Use Deep Split when you need high reliability on complex or lengthy documents. *** ## How to Use It Enable Deep Split by setting `deep_split` to `true` inside the `settings` object: ```python Python theme={null} result = client.split.run( input=upload.file_id, split_description=[ { "name": "Account Summary", "description": "Overview section with balances and totals at the top of the statement" }, { "name": "Holdings", "description": "Investment holdings table for a specific account", "partition_key": "account_number" }, { "name": "Transaction History", "description": "Table of individual transactions with dates, descriptions, and amounts" } ], settings={"deep_split": True} ) ``` ```javascript Node.js theme={null} const result = await client.split.run({ input: upload.file_id, split_description: [ { name: 'Account Summary', description: 'Overview section with balances and totals at the top of the statement' }, { name: 'Holdings', description: 'Investment holdings table for a specific account', partition_key: 'account_number' }, { name: 'Transaction History', description: 'Table of individual transactions with dates, descriptions, and amounts' } ], settings: { deep_split: true } }); ``` ```bash cURL theme={null} curl -X POST https://platform.reducto.ai/split \ -H "Authorization: Bearer $REDUCTO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "input": "reducto://your-file-id", "split_description": [ { "name": "Account Summary", "description": "Overview section with balances and totals at the top of the statement" }, { "name": "Holdings", "description": "Investment holdings table for a specific account", "partition_key": "account_number" }, { "name": "Transaction History", "description": "Table of individual transactions with dates, descriptions, and amounts" } ], "settings": { "deep_split": true } }' ``` *** ## Best Practices ### Write specific, distinguishing section descriptions The agentic loop relies on your `split_description` entries to verify whether each page is in the right section. Vague descriptions give the agent nothing concrete to check, so include content, position, and visual cues that distinguish each section from its neighbors. ```python theme={null} split_description=[ { "name": "Transaction History", "description": ( "Table showing individual transactions with dates, descriptions, and amounts. " "Appears after the account summary and before the holdings detail. " "Does NOT include opening or closing balance rows." ) } ] ``` Other examples of effective distinguishing criteria: * **Repeating sections:** "Holdings table for a single account. Each block starts with an account number header and ends before the next account header." * **Visually similar sections:** "Lab report. Contains the laboratory name in the header and a results table with reference ranges. Distinct from imaging reports, which contain narrative findings instead of tables." * **Boundary-sensitive sections:** "Signature page. Always the last page of the contract block, immediately before any exhibits." ### Use with partition\_key for repeating sections When the same section repeats for different entities (multiple accounts, multiple patients, multiple companies), pair Deep Split with [`partition_key`](/configs/split/configuration#partition-key) so the agent verifies both the section assignment and the partition value extracted from the page. ### Pair with Parse configuration Deep Split can only verify what Parse sees. If the underlying parse output is missing data (for example, a table is not detected or a header is misread), Deep Split will not be able to recover the missing signal. Consider enabling [agentic mode](/configs/parse/agentic-modes) for tables, or using a higher-fidelity [OCR mode](/configs/parse/ocr-settings) when section identifiers live deep inside tables or in low-quality scans. *** ## Related > [card] [Split Overview](/split) > > Endpoint basics and parameters. > [card] [Split Configuration](/configs/split/configuration) > > split\_description, partition\_key, and table\_cutoff. > [card] [Deep Extract](/configs/extract/deep-extract) > > The same agentic loop pattern for schema-based extraction. # Edit Source: https://docs.reducto.ai/editing/edit-overview Fill forms and modify documents with natural language instructions Edit fills PDF forms and modifies DOCX documents using natural language instructions. You describe what values should go where, and Reducto handles field detection, mapping, and insertion. Edit completes the document lifecycle. Where [Parse](/parse/overview) reads documents and [Extract](/extract/overview) pulls data out, Edit writes data back in. This enables end-to-end workflows: classify an incoming document, extract data from it, and populate a different form or template, all within a single pipeline. > [!NOTE] > > The Edit endpoint is available in the Python SDK, Node.js SDK, and via cURL. The Go SDK does not yet support Edit. *** ## When to Use Edit Edit solves form filling at scale. Instead of manually clicking through fillable PDFs or templating Word documents, you describe what you want in natural language. **Common use cases:** * Filling government and tax forms (W-9, I-9, G-1145) with applicant data * Completing insurance applications and claim forms * Populating legal contracts with client information * Generating customized DOCX reports from extracted data PDF libraries like PyPDF require knowing exact field names and coordinates. Edit uses AI to understand field context, so "Fill in the applicant name" works even if the PDF field is named `topmostSubform[0].Page1[0].f1_1[0]`. *** ## Quick Start ```python Python theme={null} from pathlib import Path from reducto import Reducto client = Reducto() upload = client.upload(file=Path("w9_form.pdf")) result = client.edit.run( document_url=upload.file_id, edit_instructions=""" Fill this W-9 form with: - Name: Acme Corporation - Business name: Acme Corp LLC - Tax classification: LLC - Address: 123 Main Street, San Francisco, CA 94102 - TIN: 12-3456789 """ ) print(result.document_url) # Download the filled form ``` ```javascript Node.js theme={null} import Reducto from 'reductoai'; import fs from 'fs'; const client = new Reducto(); const upload = await client.upload({ file: fs.createReadStream('w9_form.pdf'), }); const result = await client.edit.run({ document_url: upload.file_id, edit_instructions: ` Fill this W-9 form with: - Name: Acme Corporation - Business name: Acme Corp LLC - Tax classification: LLC - Address: 123 Main Street, San Francisco, CA 94102 - TIN: 12-3456789 ` }); console.log(result.document_url); // Download the filled form ``` ```bash cURL theme={null} # First upload the file FILE_ID=$(curl -s -X POST https://platform.reducto.ai/upload \ -H "Authorization: Bearer $REDUCTO_API_KEY" \ -F "file=@w9_form.pdf" | jq -r '.file_id') # Then edit curl -X POST https://platform.reducto.ai/edit \ -H "Authorization: Bearer $REDUCTO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "document_url": "'$FILE_ID'", "edit_instructions": "Fill this W-9 form with: Name: Acme Corporation, Business name: Acme Corp LLC, Tax classification: LLC, Address: 123 Main Street, San Francisco, CA 94102, TIN: 12-3456789" }' ``` **What happens:** 1. Edit detects all fillable fields in the PDF (text boxes, checkboxes, dropdowns) 2. An LLM reads your instructions and field context (labels, surrounding text) 3. Each field gets mapped to the appropriate value 4. The filled PDF is returned as a downloadable URL *** ## PDF vs DOCX ### PDF PDFs have structured form widgets (text fields, checkboxes, dropdowns). For PDFs without existing form fields, Edit uses vision to detect where fillable areas should be. | Feature | Description | | - | - | | Text fields | Fill any text input area | | Checkboxes | Check or uncheck based on instructions | | Dropdowns | Choose from available options | | Form detection | Finds fields even in scanned forms | | Overflow | Long text can flow to appendix pages | ### DOCX Edit fills Word form controls in place and adds new text to table cells and paragraphs. | Feature | Description | | - | - | | Form fields | Modern (2007+) content controls and legacy text form fields. Edit fills them in place and keeps the formatting of any existing field text | | Checkboxes | Both modern and legacy controls | | Table cells | Add content as a new paragraph in the cell | | Text insertion | Insert content as a new paragraph after an existing paragraph | | Highlighting | Edits get highlighted in configurable color | *** ## Request Parameters ```python Python theme={null} result = client.edit.run( document_url="...", # Required: file to edit edit_instructions="...", # Required: what to fill edit_options={ "color": "#FF0000", # Highlight color (DOCX only) "enable_overflow_pages": False, # Appendix for long text (PDF only) "llm_provider_preference": "openai" # LLM provider (optional) }, form_schema=[...] # Optional: predefined field locations (PDF only) ) ``` ```javascript Node.js theme={null} const result = await client.edit.run({ document_url: '...', // Required: file to edit edit_instructions: '...', // Required: what to fill edit_options: { color: '#FF0000', // Highlight color (DOCX only) enable_overflow_pages: false, // Appendix for long text (PDF only) llm_provider_preference: 'openai' // LLM provider (optional) }, form_schema: [...] // Optional: predefined field locations (PDF only) }); ``` ```bash cURL theme={null} curl -X POST https://platform.reducto.ai/edit \ -H "Authorization: Bearer $REDUCTO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "document_url": "...", "edit_instructions": "...", "edit_options": { "color": "#FF0000", "enable_overflow_pages": false, "llm_provider_preference": "openai" }, "form_schema": [...] }' ``` ### document\_url The document to edit. Accepts the same formats as Parse: `reducto://` file IDs from upload, public URLs, or presigned S3/GCS URLs. ### edit\_instructions Natural language instructions describing what to fill. Be explicit about values and which fields they belong to: ``` Fill this form with: - Full Name: John David Smith - Date of Birth: March 15, 1985 - SSN: 123-45-6789 - Check "Yes" for US Citizen - Select "California" for state ``` Vague instructions like "Fill out John's information" perform poorly. Include formatting hints when the form expects specific formats (dates, phone numbers, SSNs). ### edit\_options | Option | Default | Description | | - | - | - | | `color` | `#FF0000` | Hex color for highlighting edits (DOCX only) | | `enable_overflow_pages` | `false` | Create appendix pages for text exceeding field capacity (PDF only) | | `llm_provider_preference` | `null` | LLM provider for form filling: `"openai"`, `"anthropic"`, or `"google"`. If null, defaults to Google. | ### form\_schema For repeatable form filling, define field locations explicitly. This skips detection, improving speed 3x and consistency. See [Form Schema](/configs/edit/form-schema). *** ## Response ```json theme={null} { "document_url": "https://storage.reducto.ai/filled-form.pdf?...", "form_schema": [ { "bbox": {"left": 0.1, "top": 0.2, "width": 0.4, "height": 0.03, "page": 1}, "description": "Name field in header section", "type": "text" } ], "usage": {"num_pages": 2, "credits": null} // credits is populated only for credit-based accounts } ``` | Field | Description | | - | - | | `document_url` | Presigned URL to download edited document. Valid 24 hours. | | `form_schema` | Detected field schema (PDF only). Save this to reuse for the same form type. | | `usage.credits` | Credits charged. Populated only for credit-based accounts; `null` for accounts on per-product pricing. See [Usage and Pricing](/reference/credit-usage#edit-endpoint). | | `usage.usage_breakdown` | Raw usage quantities. Present only for accounts on per-product pricing, where the credit fields are null. See [Pricing Migration Guide](/reference/pricing-migration#what-your-api-responses-look-like). | *** ## Async Processing For larger documents or webhook delivery, use the async endpoint: ```python Python theme={null} result = client.edit.run_job( document_url=upload.file_id, edit_instructions="...", ) print(result.job_id) # Poll /job/{job_id} or wait for webhook # Poll for results import time while True: job = client.job.get(result.job_id) if job.status == "Completed": print(job.result.document_url) break time.sleep(2) ``` ```javascript Node.js theme={null} const result = await client.edit.runJob({ document_url: upload.file_id, edit_instructions: '...', }); console.log(result.job_id); // Poll /job/{job_id} or wait for webhook // Poll for results while (true) { const job = await client.job.retrieve(result.job_id); if (job.status === 'Completed') { console.log(job.result.document_url); break; } await new Promise(r => setTimeout(r, 2000)); } ``` ```bash cURL theme={null} # Submit async job JOB_ID=$(curl -s -X POST https://platform.reducto.ai/edit_async \ -H "Authorization: Bearer $REDUCTO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "document_url": "reducto://your-file-id", "edit_instructions": "..." }' | jq -r '.job_id') # Poll for results while true; do STATUS=$(curl -s https://platform.reducto.ai/job/$JOB_ID \ -H "Authorization: Bearer $REDUCTO_API_KEY" | jq -r '.status') echo "Status: $STATUS" if [ "$STATUS" = "Completed" ]; then curl -s https://platform.reducto.ai/job/$JOB_ID \ -H "Authorization: Bearer $REDUCTO_API_KEY" | jq '.result.document_url' break fi sleep 2 done ``` Sync requests get priority by default. Async requests can set `priority=True`: workers poll the priority queue ahead of the standard queue, so priority jobs start sooner when there is queued work. *** ## How It Works ### PDF 1. **Detect** form widgets (or use vision if none exist) 2. **Analyze** context around each field (labels, headers) 3. **Map** your instructions to fields based on descriptions 4. **Fill** values into the PDF With a `form_schema`, steps 1-2 are skipped since you've defined field locations and descriptions. ### DOCX 1. **Tag** editable locations (paragraphs, table cells, form controls) 2. **Analyze** document structure against your instructions 3. **Generate** specific edits (fill form fields, check boxes, add text to cells and paragraphs) 4. **Apply** edits with optional highlighting *** ## Troubleshooting > [accordion] Fields remain empty > > Several things can cause unfilled fields: > > * **Instructions didn't match**: The LLM couldn't map your instructions to that field. Use terms that appear on the form itself. > * **Dropdown mismatch**: Value must exactly match an option ("CA" vs "California") > * **Detection missed it**: Use `form_schema` to explicitly define field locations > [accordion] No form fields detected > > The PDF has no widgets and vision couldn't detect fillable areas. Common causes: > > * Scanned image without clear form structure > * Fields blend into background or lack clear boundaries > * Document isn't actually a fillable form > > **Solution**: Provide a `form_schema` defining field locations. See [Form Schema](/configs/edit/form-schema). > [accordion] Text cut off in PDF fields > > PDF fields have fixed sizes. When content exceeds capacity, it gets truncated. > > **Solutions:** > > 1. Enable overflow: `edit_options={"enable_overflow_pages": True}` creates appendix pages > 2. Abbreviate in your instructions if the form expects short values > [accordion] Checkboxes not checking > > Be explicit: `Check "Yes" for US Citizen` works better than `US Citizen: Yes`. The LLM needs to understand you mean check a box, not fill text. > [accordion] Wrong field gets filled > > When fields have similar labels, the LLM may map incorrectly: > > 1. **Be more specific**: "Applicant First Name: John" rather than "Name: John" > 2. **Use form\_schema**: Define exactly which field is which using coordinates > 3. **Reference position**: "The name field in the top-left of page 1" > [accordion] Can I use Parse to get field locations? > > No. Parse finds existing content (labels like "Name:"). Edit finds empty fillable areas (the input box next to "Name:"). Form fields are blank rectangles with nothing for Parse to detect. > > Use Edit once without `form_schema` to detect fields, then save and reuse the returned schema. *** ## Limitations ### Partial Success Edit returns successfully even when some fields couldn't be filled. These situations don't raise errors: | Scenario | Behavior | | - | - | | Dropdown value not in options | Field skipped silently | | Instructions don't match any field | Document returned unchanged | | Unsupported widget (signatures, images) | Widget skipped | ### Format-Specific **PDF:** * Signature and image fields not supported * Radio buttons have limited support * Heavily designed forms may detect incorrectly **DOCX:** * Requires structured documents (form controls, tables) * Edit does not replace existing text, such as a `[Placeholder]` in a table cell or paragraph. It adds the value as a new paragraph without the surrounding formatting. To fill values in place, use form fields. * Edit does not support legacy dropdown form fields * Very large documents (100+ pages) take longer *** ## Next Steps > [card] [Form Schema](/form-schema) > > Pre-define field locations for faster, more consistent filling. > [card] [API Reference](/api-reference/edit) > > Complete endpoint specification. # Enterprise Readiness Source: https://docs.reducto.ai/enterprise/enterprise-readiness How Reducto meets security, reliability, and scale requirements at enterprise grade Reducto is built for production at enterprise scale. With 3B+ pages processed, hybrid and air-gapped deployment options, and SOC 2 + HIPAA compliance, Reducto meets the requirements of teams processing hundreds of millions of pages per month. The Enterprise tier adds contractual SLAs, dedicated compute, white-glove field engineering support, and flexible deployment models. All Enterprise capabilities are negotiable based on your requirements. [Contact our sales team](https://reducto.ai/contact) to discuss what's right for you. ## Availability & SLAs Enterprise customers receive contractual uptime SLAs of **up to 99.99%**. SLA terms are tailored to your deployment model and workload requirements. | | Standard | Growth | Enterprise | | - | - | - | - | | Uptime target | — | — | Up to 99.99% SLA (custom available) | | Contractual SLA | — | — | Included | | Custom MSA | — | — | Included | | Custom DPA | — | — | Included | | Incident response | — | — | 24/7 oncall support | Real-time service status is available at [status.reducto.ai](https://status.reducto.ai/). ## Throughput & Scaling Enterprise customers can negotiate custom concurrency limits based on their workload. | | Enterprise | | - | - | | Concurrency limits | Custom (raised tier caps) | | Live deployments | Up to 10+ concurrent | | Annual page volume | Up to billions | Reducto limits each account on concurrent work, not a per-second rate. r-1 and r-1 mini have fixed page concurrency caps per tier. See [Rate Limits](/reference/rate-limits) for the caps. Legacy Parse uses a concurrency ceiling that grows with traffic. See [Concurrency Throttle (Legacy)](/reference/throttling) for how the ceiling is calculated. [Contact sales](https://reducto.ai/contact) to discuss custom limits. ## Security & Compliance Reducto maintains enterprise-grade security across all tiers, with additional compliance options for Growth and Enterprise customers. | Capability | Standard | Growth | Enterprise | | - | - | - | - | | **SOC 2 Type II** | Yes | Yes | Yes | | **HIPAA (with signed BAA)** | — | Yes | Yes | | **Zero Data Retention** | — | Yes (Ephemeral) | Yes (Ephemeral) | | **SSO and SAML authentication** | — | — | Yes | | **Role-based access control** | — | — | Yes | | **Encryption at rest** | AES-256 | AES-256 | AES-256 | | **Encryption in transit** | TLS 1.2+ | TLS 1.2+ | TLS 1.2+ | | **Custom MSA & DPA** | — | — | Included | | **EU data residency** | — | Yes | Yes | Enterprise tier data is governed by your agreement with Reducto, including for model training. We do not train models on the content of your documents or outputs unless you opt in to custom fine-tuning. For full details, see [Data policies & compliance](/security/policies). ## Deployment Options VPC and on-premise deployments are available exclusively for Enterprise customers. Deployment services are included. * **SaaS** — Fully managed by Reducto on AWS. Zero infrastructure overhead. * **Hybrid VPC** — Data stays in your VPC; processing runs on Reducto's GPU infrastructure. * **Full VPC** — Entirely hosted in your cloud environment (AWS, GCP, or Azure). See [Deployment options](/onprem/enterprise_deployment_options) for architecture details. ## Platform & Tools | | Standard | Growth | Enterprise | | - | - | - | - | | Studio seats | Up to 5 | Unlimited | Unlimited | | Custom processing pipelines | — | — | Available | | Custom model fine-tuning | — | — | Available | | Early access program | — | — | Included | | Ongoing model updates | Yes | Yes | Yes | Enterprise customers can work with Reducto's team to build custom processing pipelines and fine-tune models on their specific document types, improving accuracy for domain-specific layouts, terminology, and edge cases. [Contact sales](https://reducto.ai/contact) to learn more. ## Support Enterprise support includes up to 24/7 coverage with dedicated personnel. | | Standard | Growth | Enterprise | | - | - | - | - | | Email support | Yes | Yes | Yes | | Slack channel | — | Yes | Yes | | Phone support | — | — | Yes | | Dedicated account manager | — | — | Available | | Forward deployed engineering | — | — | Available | | Support hours | Business hours | Business hours | Up to 24/7 | | Custom response time SLAs | — | — | Available | ## Volume Pricing Enterprise pricing is based on annual page volume commitments with per-page overage rates. Custom credit rates and committed-use discounts are available, scaling with volume. [Contact sales](https://reducto.ai/contact) to discuss pricing for your workload. # Extract Source: https://docs.reducto.ai/extract/overview Reducto Extract API pulls specific fields from documents as structured JSON. Reducto returns values matching your schema, handling OCR, layout detection, and more. Extract pulls specific fields from documents as structured JSON. You define a schema describing the data you need, and Reducto returns values matching that schema, handling OCR, layout detection, and LLM-based field location under the hood. Extract builds on [Parse](/parse/overview), which processes the document first, then uses AI to locate and return the specified fields accurately, even across complex layouts. For a higher-level view of extraction use cases and how Extract fits into production document workflows, see the [Extract product overview](https://reducto.ai/extract?utm_source=docs\&utm_medium=referral\&utm_campaign=docs_product_bridge\&utm_content=extract_overview_top). *** ## Parse vs Extract Both endpoints process documents, but they answer different questions. **Parse** answers: "What's in this document?" It returns all content as structured chunks with positions and types. Use Parse for RAG pipelines, document viewers, or when you need to feed full content to an LLM. **Extract** answers: "What is the value of X?" It returns only the specific fields you request. Extract runs Parse internally, then uses AI to pull out values matching your schema. The key insight is that **Extract can only return what Parse sees**. If a value doesn't appear in the Parse output (perhaps due to OCR issues or a table format problem), no amount of schema tweaking will extract it. > [!TIP] > > When debugging extraction issues, always verify the data exists in the Parse result first. *** ## Quick Start Given [this investment statement](https://studio.reducto.ai/share/md726aw3w7mfs46659ttkqry0s7se3pd?processor=kh7c9e30evkfb5a4h80dq4xke17sfwck\&fileId=js7e4hrtnh2tsyjqdbz114ceyn7sf1v1), we'll extract the portfolio value change, total income, and top holdings: ![Finance Statement](https://mintcdn.com/reducto/VmlAHm6-E3eI_let/images/finance-statement.png?fit=max\&auto=format\&n=VmlAHm6-E3eI_let\&q=85\&s=8dcb72304454123172977d5b2556e0cf) ```python Python theme={null} from pathlib import Path from reducto import Reducto client = Reducto() upload = client.upload(file=Path("fidelity-example.pdf")) result = client.extract.run( input=upload.file_id, instructions={ "schema": { "type": "object", "properties": { "portfolio_increase": { "type": "number", "description": "Increase in total portfolio value" }, "total_income_ytd": { "type": "number", "description": "Total income year-to-date" }, "top_holdings": { "type": "array", "items": {"type": "string"}, "description": "Names of top holdings" } } }, "system_prompt": "Extract financial data from this investment statement." } ) print(result.result) ``` ```javascript Node.js theme={null} import Reducto from 'reductoai'; import fs from 'fs'; const client = new Reducto(); const upload = await client.upload({ file: fs.createReadStream('fidelity-example.pdf'), }); const result = await client.extract.run({ input: upload.file_id, instructions: { schema: { type: 'object', properties: { portfolio_increase: { type: 'number', description: 'Increase in total portfolio value' }, total_income_ytd: { type: 'number', description: 'Total income year-to-date' }, top_holdings: { type: 'array', items: { type: 'string' }, description: 'Names of top holdings' } } }, system_prompt: 'Extract financial data from this investment statement.' } }); console.log(result.result); ``` ```go Go theme={null} package main import ( "context" "fmt" "io" "os" reducto "github.com/reductoai/reducto-go-sdk" "github.com/reductoai/reducto-go-sdk/option" "github.com/reductoai/reducto-go-sdk/shared" ) func main() { client := reducto.NewClient(option.WithAPIKey(os.Getenv("REDUCTO_API_KEY"))) file, _ := os.Open("fidelity-example.pdf") defer file.Close() upload, _ := client.Upload(context.Background(), reducto.UploadParams{ File: reducto.F[io.Reader](file), }) schema := map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ "portfolio_increase": map[string]interface{}{ "type": "number", "description": "Increase in total portfolio value", }, "total_income_ytd": map[string]interface{}{ "type": "number", "description": "Total income year-to-date", }, "top_holdings": map[string]interface{}{ "type": "array", "items": map[string]interface{}{"type": "string"}, "description": "Names of top holdings", }, }, } result, _ := client.Extract.Run(context.Background(), reducto.ExtractRunParams{ ExtractConfig: reducto.ExtractConfigParam{ DocumentURL: reducto.F[reducto.ExtractConfigDocumentURLUnionParam]( shared.UnionString(upload.FileID), ), Schema: reducto.F[interface{}](schema), SystemPrompt: reducto.F("Extract financial data from this investment statement."), }, }) fmt.Printf("%+v\n", result.Result) } ``` ```bash cURL theme={null} # First upload the file FILE_ID=$(curl -s -X POST https://platform.reducto.ai/upload \ -H "Authorization: Bearer $REDUCTO_API_KEY" \ -F "file=@fidelity-example.pdf" | jq -r '.file_id') # Then extract with schema curl -X POST https://platform.reducto.ai/extract \ -H "Authorization: Bearer $REDUCTO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "input": "'$FILE_ID'", "instructions": { "schema": { "type": "object", "properties": { "portfolio_increase": { "type": "number", "description": "Increase in total portfolio value" }, "total_income_ytd": { "type": "number", "description": "Total income year-to-date" }, "top_holdings": { "type": "array", "items": {"type": "string"}, "description": "Names of top holdings" } } }, "system_prompt": "Extract financial data from this investment statement." } }' ``` **What this does:** 1. **Upload** the PDF to get a `file_id` 2. **`Call /extract`** with the file reference and a JSON schema defining the three fields you want 3. **Get back** JSON with exactly those fields populated from the document ```json theme={null} { "result": [ { "portfolio_increase": 21000.37, "total_income_ytd": 23278.62, "top_holdings": [ "Johnson & Johnson (JNJ)", "Apple Inc (AAPL)", "NH Portfolio 2015 Delphi", "Corp Jr Sb Nt Slm Corp", "Spi Lkd Nt (OSM)" ] } ], "job_id": "9531166f-9725-4854-8096-459785a33972", "usage": {"num_fields": 7, "num_pages": 3, "credits": 10.0}, "studio_link": "https://studio.reducto.ai/job/9531166f-..." } ``` The `result` is an array containing objects matching your schema. When you enable citations, the response format changes to wrap each value with its source location. > [card] [Response Format Details](/extract/response-format) > > Full breakdown of result structure, citations, and usage fields. *** ## Request Parameters ```python theme={null} result = client.extract.run( input="...", # Required: file_id, jobid://, or URL instructions={ "schema": {...}, # JSON schema defining fields to extract "system_prompt": "..." # Context for the LLM about the document }, settings={ "array_extract": False, # Segment document for long arrays "citations": { "enabled": False, # Return source locations "numerical_confidence": True }, "include_images": False, "optimize_for_latency": False }, parsing={...} # Parse options (ignored if using jobid://) ) ``` ### input (required) The document to process. Accepts several formats: | Format | Example | When to use | | - | - | - | | Upload response | `reducto://abc123` | Local files uploaded via `/upload` | | Public URL | `https://example.com/doc.pdf` | Publicly accessible documents | | Presigned URL | `https://bucket.s3.../doc.pdf?X-Amz-...` | Files in your cloud storage | | Job ID | `jobid://7600c8c5-...` | Reuse a previous Parse result | | Job ID list | `["jobid://...", "jobid://..."]` | Combine multiple parsed documents | Using `jobid://` skips the parsing step entirely, which is useful when you want to try different extraction schemas on the same document without re-parsing, or when combining data from multiple documents into a single extraction. ```python Python theme={null} # Combine multiple parsed documents result = client.extract.run( input=["jobid://job-1", "jobid://job-2", "jobid://job-3"], instructions={"schema": schema} ) ``` ```javascript Node.js theme={null} // Combine multiple parsed documents const result = await client.extract.run({ input: ['jobid://job-1', 'jobid://job-2', 'jobid://job-3'], instructions: { schema } }); ``` ```go Go theme={null} // Combine multiple parsed documents result, _ := client.Extract.Run(context.Background(), reducto.ExtractRunParams{ ExtractConfig: reducto.ExtractConfigParam{ DocumentURL: reducto.F[reducto.ExtractConfigDocumentURLUnionParam]( reducto.ExtractConfigDocumentURLArrayParam{ shared.UnionString("jobid://job-1"), shared.UnionString("jobid://job-2"), shared.UnionString("jobid://job-3"), }, ), Schema: reducto.F[interface{}](schema), }, }) ``` ```bash cURL theme={null} curl -X POST https://platform.reducto.ai/extract \ -H "Authorization: Bearer $REDUCTO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "input": ["jobid://job-1", "jobid://job-2", "jobid://job-3"], "instructions": {"schema": {...}} }' ``` ### instructions | Field | Purpose | | - | - | | `schema` | JSON schema defining target fields and types. Field names and descriptions directly influence extraction quality because the LLM uses them to locate values. A field called `invoice_total` with description `"The total amount due, typically at the bottom of the invoice"` performs better than a generic `total` field. | | `system_prompt` | Document-level context. Describe what kind of document this is or highlight edge cases. Field-specific instructions belong in schema descriptions, not here. | ### settings | Field | Default | Purpose | | - | - | - | | `array_extract` | `false` | Deprecated. Use `deep_extract` instead (higher cost and latency). Previously used for documents with repeating data (line items, transactions): segments the document, extracts from each segment, and merges results. | | `deep_extract` | `false` | Agentic extraction mode that iteratively refines its output to achieve near-perfect accuracy. Best for complex documents where accuracy is critical. See [Deep Extract](/configs/extract/deep-extract). | | `citations.enabled` | `false` | Return page number, bounding box, and source text for each extracted value. Useful for verification and debugging. | | `citations.numerical_confidence` | `true` | When citations are enabled, include a 0-1 confidence score instead of just "high"/"low". | | `include_images` | `false` | Include page images in the extraction context. Can help with visually complex documents but increases cost. | | `optimize_for_latency` | `false` | Prioritize speed at 2x credit cost. Jobs get higher priority in the processing queue. | > [!WARNING] > > Citations cannot be used with chunking. If you enable `settings.citations.enabled`, the parsing step automatically disables chunking. This is because citations require knowing exactly where each piece of content came from, which chunking obscures. ### parsing Since Extract runs Parse internally, you can configure how parsing works. These options are ignored if your `input` is a `jobid://` reference. Common options: ```python Python theme={null} result = client.extract.run( input=upload.file_id, instructions={"schema": schema}, parsing={ "enhance": { "agentic": [{"scope": "table"}] # LLM correction for tables }, "formatting": { "table_output_format": "html" # Better for complex tables }, "settings": { "page_range": {"start": 1, "end": 10}, # Process specific pages "document_password": "secret" # For encrypted PDFs } } ) ``` ```javascript Node.js theme={null} const result = await client.extract.run({ input: upload.file_id, instructions: { schema }, parsing: { enhance: { agentic: [{ scope: 'table' }] // LLM correction for tables }, formatting: { table_output_format: 'html' // Better for complex tables }, settings: { page_range: { start: 1, end: 10 }, // Process specific pages document_password: 'secret' // For encrypted PDFs } } }); ``` ```go Go theme={null} result, _ := client.Extract.Run(context.Background(), reducto.ExtractRunParams{ ExtractConfig: reducto.ExtractConfigParam{ DocumentURL: reducto.F[reducto.ExtractConfigDocumentURLUnionParam]( shared.UnionString(upload.FileID), ), Schema: reducto.F[interface{}](schema), Options: reducto.F(shared.BaseProcessingOptionsParam{ TableOutputFormat: reducto.F(shared.BaseProcessingOptionsTableOutputFormatHTML), PageRange: reducto.F(shared.PageRangeParam{ Start: reducto.F(int64(1)), End: reducto.F(int64(10)), }), DocumentPassword: reducto.F("secret"), }), AdvancedOptions: reducto.F(shared.AdvancedProcessingOptionsParam{ Agentic: reducto.F([]shared.AgenticModeConfigParam{ {Scope: reducto.F(shared.AgenticModeConfigScopeTable)}, }), }), }, }) ``` ```bash cURL theme={null} curl -X POST https://platform.reducto.ai/extract \ -H "Authorization: Bearer $REDUCTO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "input": "reducto://your-file-id", "instructions": {"schema": {...}}, "parsing": { "enhance": { "agentic": [{"scope": "table"}] }, "formatting": { "table_output_format": "html" }, "settings": { "page_range": {"start": 1, "end": 10}, "document_password": "secret" } } }' ``` > [card] [Parse Configuration](/parse/overview#configuration) > > All available parsing options. *** ## Schema vs Schemaless Extract supports two modes of operation: schema-based extraction (the default) and schemaless extraction. **Schema-based extraction** is what most users need. You define a JSON schema specifying exactly which fields to extract and their types. The model returns data matching your schema structure. This gives you predictable, typed output that integrates cleanly with your application code. ```python Python theme={null} # Schema-based: you define the exact structure result = client.extract.run( input=upload.file_id, instructions={ "schema": { "type": "object", "properties": { "invoice_number": {"type": "string"}, "total": {"type": "number"} } } } ) ``` ```javascript Node.js theme={null} // Schema-based: you define the exact structure const result = await client.extract.run({ input: upload.file_id, instructions: { schema: { type: 'object', properties: { invoice_number: { type: 'string' }, total: { type: 'number' } } } } }); ``` ```go Go theme={null} // Schema-based: you define the exact structure schema := map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ "invoice_number": map[string]interface{}{"type": "string"}, "total": map[string]interface{}{"type": "number"}, }, } result, _ := client.Extract.Run(context.Background(), reducto.ExtractRunParams{ ExtractConfig: reducto.ExtractConfigParam{ DocumentURL: reducto.F[reducto.ExtractConfigDocumentURLUnionParam]( shared.UnionString(upload.FileID), ), Schema: reducto.F[interface{}](schema), }, }) ``` ```bash cURL theme={null} curl -X POST https://platform.reducto.ai/extract \ -H "Authorization: Bearer $REDUCTO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "input": "reducto://your-file-id", "instructions": { "schema": { "type": "object", "properties": { "invoice_number": {"type": "string"}, "total": {"type": "number"} } } } }' ``` **Schemaless extraction** lets the model decide what to extract based on a natural language prompt. Instead of providing a schema, you describe what you want in plain English. The model analyzes the document and returns whatever it deems relevant. This is useful for exploration or when you don't know the document structure in advance. ```python Python theme={null} # Schemaless: the model decides what to extract result = client.extract.run( input=upload.file_id, instructions={ "system_prompt": "Extract all the key financial information from this invoice" } ) ``` ```javascript Node.js theme={null} // Schemaless: the model decides what to extract const result = await client.extract.run({ input: upload.file_id, instructions: { system_prompt: 'Extract all the key financial information from this invoice' } }); ``` ```bash cURL theme={null} curl -X POST https://platform.reducto.ai/extract \ -H "Authorization: Bearer $REDUCTO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "input": "reducto://your-file-id", "instructions": { "system_prompt": "Extract all the key financial information from this invoice" } }' ``` Use schema-based extraction for production workflows where you need consistent output structure. Use schemaless extraction when exploring new document types or building prototypes. > [card] [Schema Best Practices](/extraction/best-practices-extract) > > Detailed guidance on schema design, naming conventions, and descriptions. *** ## Array Extraction Standard extraction works well for short documents, but for documents with many repeating items (hundreds of transactions, long invoice line items), you need array extraction. The problem: LLMs have context limits. When a document is too long, items toward the end may be truncated or missed. Array extraction solves this by segmenting the document, extracting from each segment, and merging the results. ```python Python theme={null} result = client.extract.run( input=upload.file_id, instructions={ "schema": { "type": "object", "properties": { "transactions": { "type": "array", "items": { "type": "object", "properties": { "date": {"type": "string"}, "description": {"type": "string"}, "amount": {"type": "number"} } } } } } }, settings={"array_extract": True} ) ``` ```javascript Node.js theme={null} const result = await client.extract.run({ input: upload.file_id, instructions: { schema: { type: 'object', properties: { transactions: { type: 'array', items: { type: 'object', properties: { date: { type: 'string' }, description: { type: 'string' }, amount: { type: 'number' } } } } } } }, settings: { array_extract: true } }); ``` ```go Go theme={null} schema := map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ "transactions": map[string]interface{}{ "type": "array", "items": map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ "date": map[string]interface{}{"type": "string"}, "description": map[string]interface{}{"type": "string"}, "amount": map[string]interface{}{"type": "number"}, }, }, }, }, } result, _ := client.Extract.Run(context.Background(), reducto.ExtractRunParams{ ExtractConfig: reducto.ExtractConfigParam{ DocumentURL: reducto.F[reducto.ExtractConfigDocumentURLUnionParam]( shared.UnionString(upload.FileID), ), Schema: reducto.F[interface{}](schema), ArrayExtract: reducto.F(shared.ArrayExtractConfigParam{ Enabled: reducto.F(true), }), }, }) ``` ```bash cURL theme={null} curl -X POST https://platform.reducto.ai/extract \ -H "Authorization: Bearer $REDUCTO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "input": "reducto://your-file-id", "instructions": { "schema": { "type": "object", "properties": { "transactions": { "type": "array", "items": { "type": "object", "properties": { "date": {"type": "string"}, "description": {"type": "string"}, "amount": {"type": "number"} } } } } } }, "settings": {"array_extract": true} }' ``` > [!NOTE] > > Array extraction requires at least one top-level property of type `array` in your schema. If your schema has no arrays, the endpoint returns an error. > [card] [Array Extraction Guide](/configs/extract/array-extraction) > > Detailed configuration and algorithm options. *** ## Citations Citations link each extracted value back to its source location in the document. Enable them when you need to verify extractions or show users where values came from. ```python Python theme={null} result = client.extract.run( input=upload.file_id, instructions={"schema": schema}, settings={ "citations": { "enabled": True } } ) # With citations enabled, result is a dict with wrapped values field = result.result["total_amount"] print(f"Value: {field.value}") print(f"Found on page {field.citations[0].bbox.page}") print(f"Confidence: {field.citations[0].confidence}") ``` ```javascript Node.js theme={null} const result = await client.extract.run({ input: upload.file_id, instructions: { schema }, settings: { citations: { enabled: true } } }); // With citations enabled, result is an object with wrapped values const field = result.result.total_amount; console.log(`Value: ${field.value}`); console.log(`Found on page ${field.citations[0].bbox.page}`); console.log(`Confidence: ${field.citations[0].confidence}`); ``` ```go Go theme={null} result, _ := client.Extract.Run(context.Background(), reducto.ExtractRunParams{ ExtractConfig: reducto.ExtractConfigParam{ DocumentURL: reducto.F[reducto.ExtractConfigDocumentURLUnionParam]( shared.UnionString(upload.FileID), ), Schema: reducto.F[interface{}](schema), GenerateCitations: reducto.F(true), }, }) // With citations enabled, access wrapped values resultMap := result.Result.(map[string]interface{}) field := resultMap["total_amount"].(map[string]interface{}) fmt.Printf("Value: %v\n", field["value"]) citations := field["citations"].([]interface{}) bbox := citations[0].(map[string]interface{})["bbox"].(map[string]interface{}) fmt.Printf("Found on page %v\n", bbox["page"]) ``` ```bash cURL theme={null} curl -X POST https://platform.reducto.ai/extract \ -H "Authorization: Bearer $REDUCTO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "input": "reducto://your-file-id", "instructions": {"schema": {...}}, "settings": { "citations": { "enabled": true } } }' ``` When citations are enabled, the response format changes. Each value is wrapped in an object containing `value` and `citations`: ```json theme={null} { "result": { "total_amount": { "value": 23278.62, "citations": [ { "type": "Table", "content": "Total: $23,278.62", "bbox": {"left": 0.04, "top": 0.26, "width": 0.45, "height": 0.50, "page": 3}, "confidence": "high" } ] } } } ``` Each citation includes: * **Page number** where the value was found * **Bounding box** coordinates (normalized 0-1) * **Confidence** as `"high"` or `"low"` * **Source text** the original text that was extracted from > [card] [Citations Guide](/configs/extract/citations) > > Working with bounding boxes and confidence scores. *** ## Troubleshooting > [accordion] Outputs differ between runs > > LLM outputs are inherently non-deterministic. Small variations are normal. To reduce variance: > > 1. Use enums to constrain possible values > 2. Make field descriptions more specific > 3. Add examples in your system prompt > > If you need identical outputs for identical inputs, consider caching results by document hash. > [accordion] Only the first pages are processed > > This typically happens with long documents containing arrays. Enable `array_extract` to process the full document: > > ```python Python theme={null} > result = client.extract.run( > input=upload.file_id, > instructions={"schema": schema}, > settings={"array_extract": True} > ) > ``` > > ```javascript Node.js theme={null} > const result = await client.extract.run({ > input: upload.file_id, > instructions: { schema }, > settings: { array_extract: true } > }); > ``` > > ```go Go theme={null} > result, _ := client.Extract.Run(context.Background(), reducto.ExtractRunParams{ > ExtractConfig: reducto.ExtractConfigParam{ > DocumentURL: reducto.F[reducto.ExtractConfigDocumentURLUnionParam]( > shared.UnionString(upload.FileID), > ), > Schema: reducto.F[interface{}](schema), > ArrayExtract: reducto.F(shared.ArrayExtractConfigParam{ > Enabled: reducto.F(true), > }), > }, > }) > ``` > > ```bash cURL theme={null} > curl -X POST https://platform.reducto.ai/extract \ > -H "Authorization: Bearer $REDUCTO_API_KEY" \ > -H "Content-Type: application/json" \ > -d '{ > "input": "reducto://your-file-id", > "instructions": {"schema": {...}}, > "settings": {"array_extract": true} > }' > ``` > > You can also add guidance in your system prompt: "Process all pages in the document, not just the beginning." > [accordion] Missing values from schema > > When expected fields come back empty: > > 1. **Check the Parse output first.** Extract can only find what Parse sees. Run `client.parse.run(input=upload.file_id)` and verify the value appears in the content. > 2. **If it's in Parse output**, refine your schema. Add better field descriptions that match how the value appears in the document. > 3. **If it's not in Parse output**, adjust your parsing configuration. Try enabling agentic mode for tables, or changing the table output format to HTML. > > For long arrays, also try enabling `array_extract`. > [accordion] Hallucinated or computed values > > Extract returns only what's on the document. If you request calculated fields (like "annual cost" when only monthly appears), the model may fabricate values. > > **Solution**: Extract raw values and compute in your code: > > ```python Python theme={null} > monthly_cost = result.result["monthly_cost"].value > annual_cost = monthly_cost * 12 # Compute yourself > ``` > > ```javascript Node.js theme={null} > const monthlyCost = result.result.monthly_cost.value; > const annualCost = monthlyCost * 12; // Compute yourself > ``` > > ```go Go theme={null} > resultMap := result.Result.(map[string]interface{}) > monthlyCost := resultMap["monthly_cost"].(map[string]interface{})["value"].(float64) > annualCost := monthlyCost * 12 // Compute yourself > ``` > > Enable citations to verify source locations for any suspicious values. > [accordion] Schema is too large > > Very large schemas may exceed LLM token limits and fail with a 422 error. Solutions: > > 1. Flatten deeply nested structures > 2. Remove unnecessary fields > 3. Split into multiple extraction calls > > As a rule of thumb, keep schemas under 50 fields. If you need more, consider breaking the extraction into logical groups. > [accordion] Citations and chunking error > > If you see "Citations and chunking cannot be enabled at the same time", you have conflicting options. > > When citations are enabled, chunking is automatically disabled in the parsing step. If you're explicitly setting chunking options in `parsing.retrieval.chunking`, either remove them or disable citations. > [accordion] Password-protected PDF > > Pass the document password in parsing settings: > > ```python Python theme={null} > result = client.extract.run( > input=upload.file_id, > instructions={"schema": schema}, > parsing={ > "settings": {"document_password": "your-password"} > } > ) > ``` > > ```javascript Node.js theme={null} > const result = await client.extract.run({ > input: upload.file_id, > instructions: { schema }, > parsing: { > settings: { document_password: 'your-password' } > } > }); > ``` > > ```go Go theme={null} > result, _ := client.Extract.Run(context.Background(), reducto.ExtractRunParams{ > ExtractConfig: reducto.ExtractConfigParam{ > DocumentURL: reducto.F[reducto.ExtractConfigDocumentURLUnionParam]( > shared.UnionString(upload.FileID), > ), > Schema: reducto.F[interface{}](schema), > Options: reducto.F(shared.BaseProcessingOptionsParam{ > DocumentPassword: reducto.F("your-password"), > }), > }, > }) > ``` > > ```bash cURL theme={null} > curl -X POST https://platform.reducto.ai/extract \ > -H "Authorization: Bearer $REDUCTO_API_KEY" \ > -H "Content-Type: application/json" \ > -d '{ > "input": "reducto://your-file-id", > "instructions": {"schema": {...}}, > "parsing": { > "settings": {"document_password": "your-password"} > } > }' > ``` *** ## Next Steps > [card] [Response Format](/extract/response-format) > > Full breakdown of the response structure. > [card] [Best Practices](/extraction/best-practices-extract) > > Schema design and prompt writing tips. > [card] [Array Extraction](/configs/extract/array-extraction) > > Handle long documents with repeating data. > [card] [Citations](/configs/extract/citations) > > Trace values back to source locations. > [card] [Comparing extraction tools](https://reducto.ai/guides/best-ai-data-extraction-tools-unstructured-documents) > > How extraction tools differ on complex PDFs, scans, and document packets. > [card] [Enterprise extraction requirements](https://reducto.ai/guides/best-enterprise-data-extraction-software) > > What to check when extraction runs on long documents and complex tables. # Extract Response Format Source: https://docs.reducto.ai/extract/response-format Understanding extracted data, citations, and usage Extract returns your extracted data as structured JSON matching your schema. The response format differs depending on whether citations are enabled. *** ## Response Structure ### Without Citations (Default) When citations are disabled (the default), `result` contains an array of objects with your extracted values directly: ```json theme={null} { "job_id": "9531166f-9725-4854-8096-459785a33972", "result": [ { "invoice_number": "INV-2024-001", "total": 1575.00, "line_items": [ { "description": "Professional Services", "quantity": 10, "amount": 1500.00 }, { "description": "Materials", "quantity": 1, "amount": 75.00 } ] } ], "usage": { "num_pages": 1, "num_fields": 8, "credits": 8.0 }, "studio_link": "https://studio.reducto.ai/job/9531166f-..." } ``` ### Top-Level Fields | Field | Type | Description | | - | - | - | | `job_id` | string | Unique identifier for this extraction job. Use this to retrieve results later or reference in support requests. | | `result` | array or object | Without citations: an array containing your extracted data. With citations: an object with wrapped values. | | `usage.num_pages` | integer | Number of document pages processed. | | `usage.num_fields` | integer | Total number of fields extracted, including nested fields in arrays. | | `usage.credits` | number | Credits consumed for this extraction. | | `studio_link` | string | Link to view and debug this extraction in Reducto Studio. | *** ## Accessing Values ### Without Citations When citations are disabled, access values directly from the result array: ```python Python theme={null} # Access the first (usually only) result object data = result.result[0] # Access scalar fields directly invoice_number = data["invoice_number"] total = data["total"] # Access array items for item in data["line_items"]: print(f"{item['description']}: ${item['amount']}") ``` ```javascript Node.js theme={null} // Access the first (usually only) result object const data = result.result[0]; // Access scalar fields directly const invoiceNumber = data.invoice_number; const total = data.total; // Access array items for (const item of data.line_items) { console.log(`${item.description}: $${item.amount}`); } ``` ```go Go theme={null} // Access the first result object data := result.Result[0] // Access fields from the map invoiceNumber := data["invoice_number"] total := data["total"] // Access array items lineItems := data["line_items"].([]interface{}) for _, item := range lineItems { itemMap := item.(map[string]interface{}) fmt.Printf("%s: $%.2f\n", itemMap["description"], itemMap["amount"]) } ``` ### With Citations When citations are enabled, values are wrapped in objects with `value` and `citations` fields: ```python Python theme={null} # With citations, result is a dict (not an array) invoice_number = result.result["invoice_number"].value total = result.result["total"].value # Access array items for item in result.result["line_items"]: print(f"{item['description'].value}: ${item['amount'].value}") ``` ```javascript Node.js theme={null} // With citations, result is an object (not an array) const invoiceNumber = result.result.invoice_number.value; const total = result.result.total.value; // Access array items for (const item of result.result.line_items) { console.log(`${item.description.value}: $${item.amount.value}`); } ``` ```go Go theme={null} // With citations, result is a map with wrapped values invoiceNumber := result.Result["invoice_number"].(map[string]interface{})["value"] total := result.Result["total"].(map[string]interface{})["value"] // Access array items lineItems := result.Result["line_items"].([]interface{}) for _, item := range lineItems { itemMap := item.(map[string]interface{}) desc := itemMap["description"].(map[string]interface{})["value"] amount := itemMap["amount"].(map[string]interface{})["value"] fmt.Printf("%s: $%.2f\n", desc, amount) } ``` When a field cannot be extracted, it may appear as `null` or be absent entirely, depending on whether it was marked as required in your schema. *** ## Citations When `settings.citations.enabled` is `true`, the response format changes. The `result` becomes an object (not an array), and each value is wrapped with citation data: ```json theme={null} { "result": { "total": { "value": 1575.00, "citations": [ { "type": "Table", "content": "Total Due: $1,575.00", "bbox": { "left": 0.65, "top": 0.82, "width": 0.25, "height": 0.03, "page": 1, "original_page": 1 }, "confidence": "high", "granular_confidence": { "extract_confidence": 0.95, "parse_confidence": 0.91 }, "parentBlock": { "type": "Table", "content": "Invoice Total\nTotal Due: $1,575.00", "bbox": {"left": 0.60, "top": 0.78, "width": 0.35, "height": 0.08, "page": 1} } } ] } } } ``` ### Citation Fields | Field | Description | | - | - | | `type` | Block type where the value was found: `Text`, `Table`, `Key Value`, etc. | | `content` | The source text from which the value was extracted. May differ slightly from the extracted value due to formatting normalization. | | `bbox` | Bounding box coordinates for the source location. | | `confidence` | Overall confidence as `"high"` or `"low"`. | | `granular_confidence` | Detailed confidence breakdown with `extract_confidence` (0-1) and `parse_confidence` (0-1). | | `parentBlock` | The larger Parse block containing this citation. Useful for context when the citation is very granular. | ### Bounding Box Coordinates All coordinates are normalized to the range \[0, 1] relative to page dimensions: | Field | Description | | - | - | | `left` | Distance from the left edge. 0 is the left margin, 1 is the right margin. | | `top` | Distance from the top edge. 0 is the top, 1 is the bottom. | | `width` | Width as a fraction of page width. | | `height` | Height as a fraction of page height. | | `page` | Page number (1-indexed) in the processed document. | | `original_page` | Page number in the original document. Differs from `page` when using `page_range` to process a subset. | To convert to pixel coordinates, multiply by the page dimensions: ```python Python theme={null} # If your page is 612x792 pixels (standard letter) bbox = citation.bbox pixel_left = bbox.left * 612 pixel_top = bbox.top * 792 pixel_width = bbox.width * 612 pixel_height = bbox.height * 792 ``` ```javascript Node.js theme={null} // If your page is 612x792 pixels (standard letter) const bbox = citation.bbox; const pixelLeft = bbox.left * 612; const pixelTop = bbox.top * 792; const pixelWidth = bbox.width * 612; const pixelHeight = bbox.height * 792; ``` ```go Go theme={null} // If your page is 612x792 pixels (standard letter) bbox := citation.Bbox pixelLeft := bbox.Left * 612 pixelTop := bbox.Top * 792 pixelWidth := bbox.Width * 612 pixelHeight := bbox.Height * 792 ``` ### Array Citations For array fields, each item in the array has its own citations. The structure mirrors the data: ```json theme={null} { "line_items": [ { "description": { "value": "Professional Services", "citations": [{"bbox": {...}, "content": "Professional Services", ...}] }, "amount": { "value": 1500.00, "citations": [{"bbox": {...}, "content": "$1,500.00", ...}] } }, { "description": { "value": "Materials", "citations": [{"bbox": {...}, "content": "Materials", ...}] }, "amount": { "value": 75.00, "citations": [{"bbox": {...}, "content": "$75.00", ...}] } } ] } ``` Each field within each array item has its own citation pointing to where that specific value was found. *** ## Spreadsheet Citations Excel and other spreadsheet formats use a different coordinate system because they have cells, not continuous pages. ### Coordinate Differences | Aspect | PDFs/Images | Spreadsheets | | - | - | - | | Coordinate system | Normalized 0-1 range | Cell positions (1-indexed) | | `left` | Fraction of page width | Column number (1 = A, 2 = B, etc.) | | `top` | Fraction of page height | Row number (1 = first row) | | `width` | Fraction of page width | Number of columns spanned | | `height` | Fraction of page height | Number of rows spanned | | `page` | Page number | Sheet number (1-indexed position in the workbook) | ### Example Spreadsheet Citation ```json theme={null} { "bbox": { "left": 2, // Column B (1-indexed) "top": 5, // Row 5 (1-indexed) "width": 1, // Single column "height": 1, // Single row "page": 1, // Sheet1 "original_page": 1 } } ``` This citation points to cell B5 on the first sheet. To convert to Excel notation, use `top` directly as the row number and map `left` to a letter (1 = A, 2 = B, etc.). *** ## Confidence Scores Confidence indicates how certain the extraction is about a value. Each citation includes both summary and detailed confidence information. ### Summary Confidence The `confidence` field provides a quick assessment: ```json theme={null} "confidence": "high" ``` Values are either `"high"` or `"low"` based on internal thresholds. ### Granular Confidence The `granular_confidence` object provides detailed numerical scores: ```json theme={null} "granular_confidence": { "extract_confidence": 0.95, "parse_confidence": 0.91 } ``` | Score | Description | | - | - | | `extract_confidence` | How confident the extraction LLM is about this value (0-1). May be `null` for array items. | | `parse_confidence` | How confident the parsing stage was about the source text (0-1). Reflects OCR and layout detection quality. | Use granular confidence when you need to set custom thresholds or debug extraction issues. Low `parse_confidence` suggests the source document may have OCR or layout problems. Low `extract_confidence` suggests the schema description may need refinement. *** ## Usage and Credits The `usage` object shows what was processed and what it cost: ```json theme={null} { "usage": { "num_pages": 3, "num_fields": 24, "credits": 12.0 } } ``` | Field | Description | | - | - | | `num_pages` | Document pages that were processed. Affected by `page_range` settings. | | `num_fields` | Total leaf fields extracted. A schema with 5 scalar fields and an array of 10 objects with 2 fields each would report 25 fields. | | `credits` | Credits charged. Based on pages processed plus complexity factors like agentic modes and latency optimization. | | `usage_breakdown` | Raw usage quantities. Present only for accounts on per-product pricing, where the credit fields are null. See [Pricing Migration Guide](/reference/pricing-migration#what-your-api-responses-look-like). | Credit calculation varies based on: * Number of pages processed * Whether agentic parsing modes were used * Whether `optimize_for_latency` was enabled (2x multiplier) * Spreadsheet complexity (cell count for Excel files) See [Credit Usage](/faq/credit-usage-overview#extract-endpoint) for detailed pricing. *** ## Complete Example > [accordion] Full response with citations enabled > > ```json theme={null} > { > "job_id": "543d1950-068c-4e38-981d-98903326b554", > "result": { > "invoice_number": { > "value": "INV-2024-001", > "citations": [ > { > "type": "Text", > "content": "Invoice #INV-2024-001", > "bbox": {"left": 0.70, "top": 0.08, "width": 0.20, "height": 0.02, "page": 1, "original_page": 1}, > "confidence": "high", > "granular_confidence": {"extract_confidence": 0.98, "parse_confidence": 0.95} > } > ] > }, > "date": { > "value": "2024-01-15", > "citations": [ > { > "type": "Text", > "content": "Date: January 15, 2024", > "bbox": {"left": 0.70, "top": 0.11, "width": 0.15, "height": 0.02, "page": 1, "original_page": 1}, > "confidence": "high", > "granular_confidence": {"extract_confidence": 0.96, "parse_confidence": 0.94} > } > ] > }, > "total": { > "value": 1575.00, > "citations": [ > { > "type": "Table", > "content": "Total: $1,575.00", > "bbox": {"left": 0.75, "top": 0.85, "width": 0.15, "height": 0.02, "page": 1, "original_page": 1}, > "confidence": "high", > "granular_confidence": {"extract_confidence": 0.97, "parse_confidence": 0.91} > } > ] > }, > "line_items": [ > { > "description": { > "value": "Professional Services", > "citations": [ > { > "type": "Table", > "content": "Professional Services", > "bbox": {"left": 0.10, "top": 0.45, "width": 0.35, "height": 0.02, "page": 1, "original_page": 1}, > "confidence": "high", > "granular_confidence": {"extract_confidence": null, "parse_confidence": 0.93} > } > ] > }, > "amount": { > "value": 1500.00, > "citations": [ > { > "type": "Table", > "content": "$1,500.00", > "bbox": {"left": 0.78, "top": 0.45, "width": 0.12, "height": 0.02, "page": 1, "original_page": 1}, > "confidence": "high", > "granular_confidence": {"extract_confidence": null, "parse_confidence": 0.93} > } > ] > } > }, > { > "description": { > "value": "Materials", > "citations": [ > { > "type": "Table", > "content": "Materials", > "bbox": {"left": 0.10, "top": 0.48, "width": 0.20, "height": 0.02, "page": 1, "original_page": 1}, > "confidence": "high", > "granular_confidence": {"extract_confidence": null, "parse_confidence": 0.91} > } > ] > }, > "amount": { > "value": 75.00, > "citations": [ > { > "type": "Table", > "content": "$75.00", > "bbox": {"left": 0.78, "top": 0.48, "width": 0.10, "height": 0.02, "page": 1, "original_page": 1}, > "confidence": "high", > "granular_confidence": {"extract_confidence": null, "parse_confidence": 0.91} > } > ] > } > } > ] > }, > "usage": { > "num_pages": 1, > "num_fields": 8, > "credits": 6.0 > }, > "studio_link": "https://studio.reducto.ai/job/543d1950-068c-4e38-981d-98903326b554" > } > ``` *** ## Related > [card] [Extract Overview](/extract/overview) > > Quick start and parameters. > [card] [Citations Guide](/configs/extract/citations) > > Working with source locations. > [card] [Array Extraction](/configs/extract/array-extraction) > > Handle long documents with repeating data. > [card] [Best Practices](/extraction/best-practices-extract) > > Schema design and prompt tips. # Schema Keyword Support Source: https://docs.reducto.ai/extract/schema-keywords Which JSON Schema keywords Extract guarantees in the output, and which it treats as guidance Extract accepts a JSON Schema and returns JSON that matches it. Not every keyword carries the same guarantee. Some keywords are enforced: the output always satisfies them. Others are best effort: Extract reads them as guidance and follows them in most cases, but you should validate them in your own code. This page applies to the `v4` extract model, the current default. See [Model Versions](/reference/model-versions) for version status. *** ## How Extract uses your schema Your schema does two jobs at once: 1. **Output shape.** Extract constrains the response so it always has the fields and types you declared. 2. **Guidance.** Field names, descriptions, and constraints tell Extract what to look for and how to format it. Enforced keywords do both jobs. Best-effort keywords only do the second one. For example, `"pattern": "^[A-Z]{2}-\\d{4}$"` on a string guarantees the output matches. `"format": "email"` does not guarantee a valid address, but it does steer Extract toward one. *** ## Enforced keywords The output satisfies these keywords, with the exceptions noted in each row. | Keyword | Notes | | - | - | | `type` | Includes type lists such as `["string", "null"]`. Scalar fields can also be `null` when the value is not in the document. | | `enum` | The output value is always one of the listed values, or `null` when the value is not in the document. | | `properties` | Every declared field is always present. | | `required` | Treated as all fields required. Every declared field is always in the output. | | `additionalProperties` | Objects are closed by default: Extract only returns declared fields. | | `items` | Every array item matches the item schema. | | `prefixItems` | Positional item schemas are honored. | | `minItems`, `maxItems` | Array length stays inside the bounds during generation. Extract then drops empty items (`null`, `[]`, or objects with only `null` values), so a returned array can be shorter than `minItems`. | | `minLength`, `maxLength` | String length stays inside the bounds. | | `pattern` | The whole string must match. Patterns are treated as anchored even without `^` and `$`. | | `minimum`, `maximum`, `exclusiveMinimum`, `exclusiveMaximum` | Exact for integers. For non-integer numbers, exclusive bounds can behave like inclusive bounds. | | `anyOf`, `oneOf` | The output matches at least one branch. | | `patternProperties`, `propertyNames`, `minProperties` | Object key constraints are honored. | | `$ref` to `#/$defs/...` | Local references are resolved before extraction. | | `nullable: true` | Treated as adding `null` to `type`. | ### Every field is always returned Extract returns every declared field on every object, even if you list only some of them in `required`. Scalar fields (string, number, integer, boolean, enum) with no supporting value in the document come back as `null`. Object and array fields keep their declared shape: an array with no matches is `[]`, and an object has all of its own fields set to `null`. If a field must not be `null` in your system, check for it in your code. ```json theme={null} { "type": "object", "properties": { "invoice_number": { "type": "string" }, "po_number": { "type": "string" } }, "required": ["invoice_number"] } ``` Output for a document with no PO number: ```json theme={null} { "invoice_number": "INV-2041", "po_number": null } ``` *** ## Best-effort keywords Extract reads these keywords as guidance. Output usually follows them, but it is not guaranteed. Validate these in your code if they matter. | Keyword | What to expect | | - | - | | `description`, `title`, `examples` | Guidance only. Descriptions are the strongest lever you have for extraction quality. See [Best Practices](/extraction/best-practices-extract). | | `format` | Steers formatting for values such as `date`, `date-time`, `email`, and `uuid`. Use `pattern` when the exact shape must be guaranteed. | | `const` | Not guaranteed. Extract usually returns the declared value. Use a single-value `enum` when the value must be enforced. | | `uniqueItems` | Not guaranteed. Duplicate array items can appear. Deduplicate in your code when uniqueness matters. | | `multipleOf` | Not guaranteed. | | `allOf` | Not guaranteed. Merge the branches into a single schema when possible. | | `not` | Not guaranteed. | | `if`, `then`, `else`, `dependentRequired`, `dependentSchemas` | Conditional rules are not guaranteed. | | `contains`, `minContains`, `maxContains` | Not guaranteed. | ### Prefer `pattern` over `format` `format` improves how Extract writes a value, but only `pattern` guarantees the shape. ```json theme={null} { "type": "string", "description": "Invoice date in ISO 8601 format", "pattern": "^\\d{4}-\\d{2}-\\d{2}$" } ``` *** ## Ignored keywords These keywords are accepted but have no effect on extraction: `default`, `$comment`, `$schema`, `deprecated`, `readOnly`, `writeOnly`. *** ## Schema validation Extract validates your schema before it runs. Invalid schemas fail with a `400` and a message that points at the problem. Common causes: * The schema is not valid JSON Schema (draft 2020-12). * An object declares no `properties`. * An array declares no `items`. * `$ref` points outside `#/$defs`, or forms a cycle. * A `required` entry names a field that is not in `properties` on a closed object. * An `enum` value does not match the declared `type` or violates a sibling constraint, such as `"enum": ["ab"]` with `"minLength": 3`. *** ## Troubleshooting **A value comes back `null` even though it is in the document.** Extract works from the Parse output, so your Parse configuration affects extraction accuracy. Use [Parse r-1](/parse/r-1) or [agentic modes](/configs/parse/agentic-modes) for the best results. If the value is still missing, tighten the field `description` to say where the value appears. **A string does not match my `format`.** `format` is best effort. Add a `pattern` for the exact shape. **An extra field I did not declare is missing.** Extract only returns declared fields. Declare the field in `properties`. **My `allOf` constraint is not respected.** `allOf` is best effort. Combine the branches into one object schema with the union of their properties and constraints. # Extract Best Practices Source: https://docs.reducto.ai/extraction/best-practices-extract Schema design and prompt writing for reliable extractions Reliable extractions come from understanding the system's architecture. Extract uses an LLM to find and pull values from parsed content, so the quality of your results depends on two things: whether the data exists in the Parse output, and whether your schema helps the LLM locate it. *** ## Start with Parse When extractions return incorrect values, the root cause is often parsing, not extraction. > [!NOTE] > > Extract never works directly on your original file. It only sees the structured output generated by Parse. Think of Parse as the ground truth layer. Extract is a filter on top of that layer, shaping the parsed content into your schema. If the foundation is wrong, extraction cannot fix it. If the value you need isn't in the Parse output, no amount of schema tweaking will help. You'll need to adjust your Parse configuration first. Common fixes include: * **Enabling agentic mode** for tables with misaligned columns or OCR errors * **Changing table format to HTML** for complex tables with merged cells * **Adding formatting detection** for signatures, change tracking, and hyperlinks * **Setting a document password** for password-protected PDFs Once you confirm the data exists in Parse output, then focus on improving your extraction schema. *** ## Schema Design Principles Your schema is the primary input to the extraction LLM. It determines not just the output structure, but also what the model looks for in the document. For which JSON Schema keywords are guaranteed in the output and which are guidance, see [Schema Keyword Support](/extract/schema-keywords). ### Use descriptive field names The LLM uses field names as search hints. A field called `po_number` will be matched against text like "PO Number" or "Purchase Order #" in the document. Generic names like `field1` or `data` give the model nothing to work with. ```python theme={null} # Effective: name matches document terminology schema = { "properties": { "invoice_total": {"type": "number"}, "due_date": {"type": "string"}, "bill_to_address": {"type": "string"} } } # Problematic: generic names schema = { "properties": { "amount": {"type": "number"}, "date": {"type": "string"}, "address": {"type": "string"} } } ``` When your document has multiple dates or amounts, specific names help the model distinguish between them. ### Write descriptions that locate values Field descriptions aren't just documentation. The LLM reads them to understand what to extract. A good description tells the model where to look and what distinguishes this field from similar ones. ```python theme={null} schema = { "properties": { "contract_date": { "type": "string", "description": "The date the contract was signed, typically found near the signature block at the end of the document" }, "effective_date": { "type": "string", "description": "The date when the contract terms take effect, usually stated in the first section" }, "expiration_date": { "type": "string", "description": "The date when the contract expires, found in the termination clause" } } } ``` Each date field is now distinguishable because the description explains where it appears and what it represents. ### Constrain values with enums When a field has a known set of possible values, use an enum. This prevents hallucination and ensures consistent output formatting. ```python theme={null} schema = { "properties": { "document_type": { "type": "string", "enum": ["invoice", "receipt", "purchase_order", "credit_memo"], "description": "The type of financial document" }, "payment_status": { "type": "string", "enum": ["paid", "unpaid", "partial", "overdue"], "description": "Current payment status" } } } ``` Without enums, the model might return "Invoice", "INVOICE", "invoice document", or other variations. Enums force a canonical format. ### Keep nesting shallow Deeply nested schemas reduce extraction accuracy. Each level of nesting adds cognitive load for the LLM, increasing the chance of structural errors. ```python theme={null} # Avoid: deeply nested schema = { "properties": { "parties": { "type": "object", "properties": { "buyer": { "type": "object", "properties": { "contact": { "type": "object", "properties": { "address": {"type": "string"} } } } } } } } } # Better: flattened schema = { "properties": { "buyer_name": {"type": "string"}, "buyer_address": {"type": "string"}, "buyer_contact_email": {"type": "string"} } } ``` If you need nested output for your application, extract flat data and restructure it in your code. ### Extract only what exists Extract can only return values that appear in the document. If you request calculated fields or inferred data, the model may hallucinate. This principle extends to any transformation: currency conversion, date formatting, string concatenation. Extract the raw data and transform it yourself. *** ## System Prompts The system prompt provides document-level context. It's where you describe what kind of document this is and how to handle ambiguity. The system prompt should have the following: * **Document type context**: "This is a commercial real estate lease agreement" or "These are bank statements from various institutions" * **Global extraction rules**: "Extract all individual transactions. Exclude summary rows, headers, and running totals." * **Edge case handling**: "Some invoices split line items across pages. Treat these as single items." * **Precision guidance**: "Be thorough and process all pages in the document." Field-specific instructions work better as descriptions because they're attached to the relevant field. For instance, putting "use YYYY-MM-DD format" in the system prompt would apply to all dates, which might not be what you want for different date fields. *** ## Citations Citations link each extracted value back to its source location in the document. Enable them when you need to audit extractions, show users where values came from, or debug extraction accuracy. ```python Python theme={null} result = client.extract.run( input=upload.file_id, instructions={"schema": schema}, settings={"citations": {"enabled": True}} ) ``` ```javascript Node.js theme={null} const result = await client.extract.run({ input: upload.file_id, instructions: { schema }, settings: { citations: { enabled: true } } }); ``` ```go Go theme={null} result, _ := client.Extract.Run(context.Background(), reducto.ExtractRunParams{ ExtractConfig: reducto.ExtractConfigParam{ DocumentURL: reducto.F[reducto.ExtractConfigDocumentURLUnionParam]( shared.UnionString(upload.FileID), ), Schema: reducto.F[interface{}](schema), GenerateCitations: reducto.F(true), }, }) ``` ```bash cURL theme={null} curl -X POST https://platform.reducto.ai/extract \ -H "Authorization: Bearer $REDUCTO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "input": "reducto://your-file-id", "instructions": {"schema": {...}}, "settings": {"citations": {"enabled": true}} }' ``` When citations are enabled, chunking is automatically disabled because citations require knowing exactly where each piece of content came from. *** ## Related > [card] [Array Extraction](/configs/extract/array-extraction) > > Handle long documents with repeating data. > [card] [Citations](/configs/extract/citations) > > Link values to source locations. > [card] [Extract Overview](/extract/overview) > > Endpoint basics and parameters. # Reducto MCP Server Source: https://docs.reducto.ai/mcp-server Connect AI agents to Reducto via the Model Context Protocol. The Reducto MCP server connects AI agents directly to Reducto. Once installed, agents in Claude Desktop, Claude Code, Codex, Cursor, VS Code, Windsurf, or any other [Model Context Protocol](https://modelcontextprotocol.io) client can classify, parse, extract, split, and edit documents as part of their reasoning loop, without custom integration code. ## Agent quick path If you are setting this up for a coding agent, choose local MCP for local files and hosted MCP for public URLs. ```bash Claude Code theme={null} uvx mcp-server-reducto --login claude mcp add -s user reducto -- uvx mcp-server-reducto ``` ```toml Codex theme={null} [mcp_servers.reducto] command = "uvx" args = ["mcp-server-reducto"] ``` ```json Hosted HTTP theme={null} { "mcpServers": { "reducto": { "type": "http", "url": "https://mcp.reducto.ai/mcp", "headers": { "Authorization": "Bearer your-api-key" } } } } ``` After setup, ask the agent to call: ```text theme={null} parse_document(document_url="https://cdn.reducto.ai/samples/fidelity-example.pdf") ``` For local files, ask the agent to call `upload_file("./document.pdf")` first, then pass the returned `reducto://` URL to `parse_document`. ## What is MCP? The Model Context Protocol is an open standard that lets AI agents call external tools. An MCP server exposes a set of tools (here, Reducto's APIs) that the agent can invoke as part of its reasoning loop. You install the server once, point your client at it, and the agent figures out which tool to call and how to chain results. If you've never used MCP before, the mental model is simple: you describe what you want in plain English, and the agent decides which Reducto tool to call, when to upload a file, and how to pass results between steps. ## When to use the MCP server Use the MCP server when you want an agent to: * Answer questions about a PDF, spreadsheet, or scanned document inside Claude Desktop or another chat client. * Read documents and write code against the result inside Claude Code, Cursor, or VS Code Copilot. * Run multi-step workflows (parse, then extract, then split) without you writing glue code. * Prototype a Reducto integration by letting the agent show you the right API calls, schemas, and response shapes. For non-agent workflows (scripting, batch jobs, CI), use the [Reducto CLI](/cli) or one of the [SDKs](/sdk/python) instead. ## Quick Start Pick one of two options. The hosted server is fastest to set up. The local server lets agents read files directly from your machine. ### Option A: Hosted server (no install) The hosted server runs at `https://mcp.reducto.ai/mcp`. Add this block to your MCP client config and replace `your-api-key` with a key from [studio.reducto.ai/api-keys](https://studio.reducto.ai/api-keys): ```json theme={null} { "mcpServers": { "reducto": { "type": "http", "url": "https://mcp.reducto.ai/mcp", "headers": { "Authorization": "Bearer your-api-key" } } } } ``` > [!WARNING] > > The hosted server runs in the cloud, so it cannot read files from your local filesystem. `upload_file` only accepts public URLs through the hosted server. To upload local files directly, use Option B. ### Option B: Local server (runs on your machine) The local server runs via [`uvx`](https://docs.astral.sh/uv/) and accepts local file paths, public URLs, and `reducto://` references. **1. Install uv** ```bash theme={null} curl -LsSf https://astral.sh/uv/install.sh | sh ``` **2. Authenticate** ```bash theme={null} uvx mcp-server-reducto --login ``` This opens an OAuth device-code flow in your browser. Once approved, your API key is saved to `~/.reducto/config.yaml` with `chmod 600`. If you already use the [Reducto CLI](/cli), you're authenticated already. The MCP server reads from the same credential file. **3. Add to your MCP client** ```json theme={null} { "mcpServers": { "reducto": { "command": "uvx", "args": ["mcp-server-reducto"] } } } ``` No API key in the config: the server reads it from `~/.reducto/config.yaml` automatically. To pass the key explicitly instead (for CI or shared machines), use the `env` field: ```json theme={null} { "mcpServers": { "reducto": { "command": "uvx", "args": ["mcp-server-reducto"], "env": { "REDUCTO_API_KEY": "your-api-key" } } } } ``` ### Hosted vs local | | Hosted (`mcp.reducto.ai`) | Local (`uvx mcp-server-reducto`) | | - | - | - | | Install | None | Python 3.11+, `uvx` | | Auth | Bearer token in headers | Browser login or env var | | Local file uploads | Public URLs only | Local paths supported | | Shares creds with `reducto` CLI | No | Yes | | Best for | Web/Studio users, quick demos | Desktop agents, local workflows | ## Client Setup Most MCP clients use the same JSON shape, but the config file location differs. ### Claude Desktop Edit the config file at `~/Library/Application Support/Claude/claude_desktop_config.json` on macOS, or `%APPDATA%\Claude\claude_desktop_config.json` on Windows: ```json theme={null} { "mcpServers": { "reducto": { "command": "uvx", "args": ["mcp-server-reducto"] } } } ``` Restart Claude Desktop after saving. Reducto appears in the MCP servers list. ### Claude Code Add to your project's `.mcp.json` to share the server with collaborators, or run `claude mcp add -s user reducto -- uvx mcp-server-reducto` to enable it across all projects (stored in `~/.claude.json`): ```json theme={null} { "mcpServers": { "reducto": { "command": "uvx", "args": ["mcp-server-reducto"] } } } ``` ### Codex Codex uses TOML, not JSON. Edit `~/.codex/config.toml` (or a project-scoped `.codex/config.toml`) to add the local server: ```toml theme={null} [mcp_servers.reducto] command = "uvx" args = ["mcp-server-reducto"] ``` To use the hosted server instead, configure it as a Streamable HTTP server. Set `REDUCTO_API_KEY` in your environment first, then add: ```toml theme={null} [mcp_servers.reducto] url = "https://mcp.reducto.ai/mcp" bearer_token_env_var = "REDUCTO_API_KEY" ``` You can also add the local server via the CLI: ```bash theme={null} codex mcp add reducto -- uvx mcp-server-reducto ``` ### Cursor Add to `.cursor/mcp.json` in your project root: ```json theme={null} { "mcpServers": { "reducto": { "command": "uvx", "args": ["mcp-server-reducto"] } } } ``` ### VS Code (Copilot) Add to `.vscode/mcp.json` in your project root: ```json theme={null} { "servers": { "reducto": { "command": "uvx", "args": ["mcp-server-reducto"] } } } ``` ### Windsurf Add to `~/.codeium/windsurf/mcp_config.json`: ```json theme={null} { "mcpServers": { "reducto": { "command": "uvx", "args": ["mcp-server-reducto"] } } } ``` ### HTTP transport (self-hosted) To run the server in HTTP mode for shared use on a single machine: ```bash theme={null} REDUCTO_API_KEY=your-key \ REDUCTO_MCP_TRANSPORT=http \ REDUCTO_MCP_PORT=8000 \ uvx mcp-server-reducto ``` Connect MCP clients to `http://your-host:8000/mcp`. ## Concepts A few small ideas to internalize before reading the tool reference. Most chained operations rely on these. ### Document URL schemes Every `document_url` parameter accepts one of four schemes: | Scheme | Meaning | Where it comes from | | - | - | - | | `https://` / `http://` | A public URL Reducto can fetch | You provide it | | `reducto://` | A file in Reducto's temporary storage (24-hour TTL) | Returned by `upload_file` | | `jobid://` | A reference to a previous processing job | Returned by `parse_document`, `extract_data`, `split_document`, `classify_document`, `edit_document` | Other schemes (`s3://`, `file://`, raw paths) fail validation. Use `upload_file` to bring local files or other URLs into Reducto. ### Job chaining with `jobid://` Every processing tool returns a `job_id`. Pass `jobid://` as `document_url` to a follow-up tool to reuse the work, with no re-upload and no re-parse: ```text theme={null} upload_file("./report.pdf") → reducto://abc (file_id) parse_document("reducto://abc") → jobid://xyz123 (job_id) extract_data("jobid://xyz123", ...) → reuses parsed text split_document("jobid://xyz123", ...) → reuses parsed text ``` This is the cheapest and fastest way to run multiple operations against the same document. ### Response shape Every tool returns a JSON object with a consistent set of fields. A representative parse response: ```json theme={null} { "job_id": "xyz123", "duration_seconds": 4.1, "studio_link": "https://studio.reducto.ai/jobs/xyz123", "usage": { "num_pages": 12, "credits": 12 }, "num_blocks": 42, "block_type_counts": { "Text": 30, "Table": 6, "Figure": 6 }, "blocks": [], "next_steps": "Use jobid://xyz123 as document_url for extract_data, split_document, or classify_document." } ``` Common fields: * `job_id`: pass to `get_job` later, or chain via `jobid://`. * `studio_link`: open in [Reducto Studio](https://studio.reducto.ai) to inspect the result visually. * `usage`: pages and credits consumed. * `next_steps`: a hint the server adds describing the recommended follow-up call. ### Large or async results When a result is too large to inline, the response contains a URL-backed payload: ```json theme={null} { "job_id": "xyz123", "result_type": "url", "result_url": "https://...", "result_access_warning": "Result content is URL-backed; call get_job(job_id='xyz123') before reading it." } ``` When `result_type` is `"url"`, call `get_job(job_id=...)` to fetch the materialized result before reading any fields. Do not read `result` directly. ### Response truncation If a response (typically the `blocks` array from `parse_document`) exceeds `REDUCTO_MCP_MAX_RESPONSE_SIZE` (defaults to `50000` characters), the server truncates it and adds: ```json theme={null} { "truncated": true, "truncation_note": "Response truncated (showing 20 of 150 blocks). Use get_job(job_id='xyz123') for full results, or narrow with page_range." } ``` When `truncated` is `true`, call `get_job(job_id=...)` for the full result, or re-run with a narrower `page_range`. ### The `options` escape hatch Most tools accept an `options` parameter for any [Reducto API field](/api-reference) not exposed as a top-level argument. * **Top-level params win.** When a top-level argument and `options` set the same key, the top-level value takes precedence. Nested config dicts get a one-level shallow merge. * **JSON strings are accepted.** Some MCP clients struggle to pass nested JSON. `options`, `schema`, and `categories` therefore each accept either a native object or a JSON-encoded string. Both work identically. ### Page range syntax `page_range` accepts a string with **1-based** page numbers: * `"1-5"`: pages 1 through 5 * `"3,7,10-12"`: pages 3, 7, 10, 11, and 12 ## Tools ### Which tool to use | If you need to... | Use | | - | - | | Look up working SDK or REST examples for any Reducto endpoint | `get_documentation` | | Bring a local file or arbitrary URL into Reducto | `upload_file` | | Get text, tables, figures, and layout from a document | `parse_document` | | Pull specific fields into JSON using a schema | `extract_data` | | Divide a document into named page-range sections | `split_document` | | Categorize a document into one of N labels | `classify_document` | | Fill a form or modify a PDF or DOCX | `edit_document` | | Fetch a full, URL-backed, truncated, or async result | `get_job` | | Inspect recent jobs | `list_jobs` | ### `get_documentation` Returns Reducto SDK and REST documentation, including install commands, auth setup, working code examples, and response shapes for a specific topic. Call this **before** writing any Reducto integration code so the agent works from the current API surface, not training memory. | Name | Type | Required | Description | | - | - | - | - | | `topic` | string | Yes | One of: `quickstart`, `parse`, `extract`, `split`, `classify`, `edit`, `upload`, `auth` | | `language` | string | No | `node`, `python`, or `http`. Omit to receive all three. | ### `parse_document` Parses a document into structured text, tables, and figures. Supports PDFs, images, spreadsheets, DOCX, PPTX, and 30+ other formats. | Name | Type | Required | Description | | - | - | - | - | | `document_url` | string | Yes | `https://`, `reducto://`, or `jobid://` URL | | `table_output_format` | string | No | `html`, `json`, `md`, `csv`, `dynamic`, `jsonbbox` (defaults to `dynamic`) | | `page_range` | string | No | e.g. `"1-5"` or `"3,7,10-12"` (1-based) | | `chunk_mode` | string | No | `disabled`, `variable`, `section`, `page`. See [parse reference](/api-reference/parse). | | `agentic` | list\[string] | No | Subset of `["text", "table", "figure", "layout"]`. With r-1, use agentic processing for custom prompts or specialized augmentation. Legacy Parse uses it as a general accuracy enhancement. Adds latency. | | `add_page_markers` | bool | No | Insert page-boundary markers in the output | | `return_images` | list\[string] | No | Subset of `["figure", "table", "page"]` to return as images | | `options` | dict / JSON string | No | Any other `ParseOptions` field | ### `extract_data` Extracts structured data from a document using a JSON schema. | Name | Type | Required | Description | | - | - | - | - | | `document_url` | string | Yes | Pass `jobid://` to skip re-parsing | | `schema` | dict / JSON string | Yes | JSON Schema describing the target fields | | `system_prompt` | string | No | Custom extraction instructions | | `citations` | bool | No | Include source references for each extracted field | | `array_extract` | bool | No | Deprecated flag for repeating items. Prefer `deep_extract`, or model the array directly in your schema. | | `deep_extract` | bool | No | Iterative agentic refinement for harder documents | | `include_images` | bool | No | Add page images to the LLM context | | `page_range` | string | No | Limit pages to process | | `options` | dict / JSON string | No | Any other `ExtractOptions` field | ### `split_document` Segments a document into labeled sections by topic. Returns each section's name, page range, and a confidence score. | Name | Type | Required | Description | | - | - | - | - | | `document_url` | string | Yes | Document to split | | `categories` | list / JSON string | Yes | `[{"name": "Disclosures", "description": "Legal and risk disclosures"}, ...]` | | `split_rules` | string | No | Natural-language splitting guidance | | `page_range` | string | No | Limit pages | | `options` | dict / JSON string | No | Any other `SplitOptions` field | ### `classify_document` Categorizes a document into one of the provided categories. | Name | Type | Required | Description | | - | - | - | - | | `document_url` | string | Yes | Document to classify | | `categories` | list / JSON string | Yes | `[{"category": "invoice", "criteria": ["has billing info", "has line items"]}, ...]` | | `page_range` | string | No | Defaults to first 5 pages | | `document_metadata` | string | No | Additional context to bias classification | ### `edit_document` Fills forms or modifies a document (PDF or DOCX). Returns a download URL for the edited file plus a `form_schema` describing the fields it found. The first edit on a new form returns a `form_schema` and a hint: ```json theme={null} { "document_url": "https://.../edited.pdf", "form_schema": {}, "form_schema_note": "Cache this form_schema and pass it via options.form_schema for repeated edits." } ``` For repeated edits to the same form template, cache `form_schema` and pass it back via `options.form_schema` to skip re-detection. | Name | Type | Required | Description | | - | - | - | - | | `document_url` | string | Yes | Document to edit | | `edit_instructions` | string | Yes | Natural-language instructions | | `options` | dict / JSON string | No | `edit_options`, `form_schema`, `priority` | ### `upload_file` Uploads a document to Reducto's temporary storage (24-hour TTL) and returns a `reducto://` URL for use in other tools. Accepts: * **Local file paths**: `./report.pdf`, `/Users/me/docs/x.pdf`, `~/inbox/y.pdf` (local server only). * **Public URLs**: `https://example.com/report.pdf` (downloaded server-side, then uploaded). > [!NOTE] > > The hosted server (`mcp.reducto.ai`) does not support local paths. Use a public URL or run the server locally. | Name | Type | Required | Description | | - | - | - | - | | `file_url` | string | Yes | Local file path or public URL | ### `get_job` Returns the status and result of a previous processing job. Use this to fetch URL-backed results, full content for truncated responses, or to poll an async job. | Name | Type | Required | Description | | - | - | - | - | | `job_id` | string | Yes | Job ID returned by any processing tool | ### `list_jobs` Returns recent processing jobs. | Name | Type | Required | Description | | - | - | - | - | | `limit` | int | No | Max jobs to return (defaults to `10`) | ## Usage Patterns ### Basic parse > "Parse this PDF and show me the content." The agent calls `parse_document(document_url="https://example.com/report.pdf")`. ### Extract with a schema > "Extract the invoice number, date, and total from this document." The agent calls `extract_data` with a JSON schema describing the three fields. ### End-to-end chain A full workflow using `upload_file`, `parse_document`, and chained `extract_data`: ```text theme={null} 1. upload_file("./contracts/q4-msa.pdf") → { "file_id": "reducto://abc", "next_steps": "Pass reducto://abc as document_url ..." } 2. parse_document("reducto://abc") → { "job_id": "xyz123", "studio_link": "...", "next_steps": "Use jobid://xyz123 ..." } 3. extract_data( "jobid://xyz123", schema={ "type": "object", "properties": { "effective_date": {"type": "string"}, "parties": {"type": "array", "items": {"type": "string"}}, "termination_clauses": {"type": "array", "items": {"type": "string"}} }, "required": ["effective_date", "parties"] }, citations=True ) → structured fields, with citations ``` The same `jobid://xyz123` can also be reused by `split_document` or `classify_document` without re-parsing. ### Triage a mixed document set > "Classify each of these PDFs as `invoice`, `contract`, or `lab_report`, then run the right extraction schema for each." The agent calls `classify_document` per file, then routes each to `extract_data` with a category-specific schema. ### Fill a recurring form > "Fill out a W-9 for each of these vendors using the data in `vendors.json`." The first call to `edit_document` returns a `form_schema`. Subsequent calls pass the cached `form_schema` via `options` to skip re-detection. ### Handle large or async results If a response includes `result_type: "url"`, call `get_job` before reading fields: ```text theme={null} parse_document("reducto://big-doc") → { "job_id": "j1", "result_type": "url", "result_url": "...", "result_access_warning": "Result content is URL-backed; call get_job(job_id='j1') before reading it." } get_job(job_id="j1") → full materialized result ``` If a response includes `truncated: true`, either call `get_job(job_id=...)` for the full result or re-run with a narrower `page_range`. ## Authentication The server resolves API keys in this order: 1. **`REDUCTO_API_KEY` environment variable** (highest priority). Useful for CI or explicit config. 2. **`~/.reducto/config.yaml`**, the shared credential store with the Reducto CLI. Three ways to authenticate: ```bash theme={null} # Browser login (recommended) uvx mcp-server-reducto --login # Reuse an existing CLI session reducto login # Set the env var directly export REDUCTO_API_KEY=your-key ``` `--login` runs an OAuth device-code flow: it prints a code, opens your browser to the verification page, and waits for you to approve. Once approved, the key is written to `~/.reducto/config.yaml` with `chmod 600`. Use `--login --force` to replace an existing saved key. The hosted server (`mcp.reducto.ai`) does not use this flow. Pass your key as `Authorization: Bearer ` in your client config instead. ## Environment Variables | Variable | Required | Default | Description | | - | - | - | - | | `REDUCTO_API_KEY` | No\* | none | API key (or authenticate via `--login`) | | `REDUCTO_BASE_URL` | No | `https://platform.reducto.ai` | Override the API base URL (EU region, on-prem) | | `REDUCTO_MCP_MAX_RESPONSE_SIZE` | No | `50000` | Response truncation threshold in characters | | `REDUCTO_MCP_TIMEOUT` | No | `300` | Request timeout in seconds | | `REDUCTO_MCP_TRANSPORT` | No | `stdio` | Transport mode: `stdio` or `http` | | `REDUCTO_MCP_PORT` | No | `8000` | Port when using HTTP transport | | `REDUCTO_TELEMETRY` | No | `1` | Set to `0` to opt out of anonymous usage telemetry | \*Required only if you have not run `mcp-server-reducto --login` or `reducto login`. ## Telemetry The MCP server sends a small amount of anonymous usage telemetry to PostHog so the team can prioritize improvements. Collected: * Lifecycle events: `mcp.installed` (once per machine, on first run) and `mcp.start` (once per server boot). * Per-tool invocation events: `tool..invoked` with `tool` name, `status` (`ok`, `error`, or `exception`), and `latency_ms`. * Environment fingerprint on every event: client name and version, transport (`stdio` or `hosted`), Python version, OS platform. Never collected: * Tool arguments (document URLs, file IDs, schemas, prompts, page ranges). * Tool responses or document content. * API keys. The user identifier on each event is `sha256(api_key)[:16]`, which is one-way. To opt out, set `REDUCTO_TELEMETRY=0` in the environment that runs the MCP server. ## Debugging ### MCP Inspector Test the server interactively: ```bash theme={null} npx @modelcontextprotocol/inspector -- uvx mcp-server-reducto ``` This opens a web UI where you can discover tools, call them, and inspect responses. Requires prior authentication via `--login`. ### Studio links Most parse and extract responses include a `studio_link`. Open it to inspect the same job interactively in [Reducto Studio](https://studio.reducto.ai). This is the fastest way to compare parser output side by side with the original document. ### Logs The server logs to stderr. Stdout is reserved for the MCP transport. Set `LOG_LEVEL=DEBUG` for verbose output. ## Troubleshooting > [accordion] No Reducto API key found > > **Problem**: The server starts but every tool call fails with an authentication error. > > **Solution**: Run `mcp-server-reducto --login`, or set `REDUCTO_API_KEY` in your client config's `env` block. > [accordion] Invalid URL scheme > > **Problem**: A tool call fails with a validation error about `document_url`. > > **Solution**: `document_url` must start with `https://`, `http://`, `reducto://`, or `jobid://`. For local files, call `upload_file` first to get a `reducto://` URL. > [accordion] Local file paths are not supported on the hosted server > > **Problem**: You passed a local path to `upload_file` while connected to `mcp.reducto.ai`. > > **Solution**: Switch to the local server (Option B above), or pass a public URL. > [accordion] Tools not appearing in the client > > **Problem**: After editing your client config, no Reducto tools show up. > > **Solution**: Restart your MCP client. Then test the server directly by running `uvx mcp-server-reducto` in your terminal to confirm it starts cleanly. > [accordion] Timeout on large documents > > **Problem**: A long-running parse or extract returns a timeout error. > > **Solution**: Increase `REDUCTO_MCP_TIMEOUT` (defaults to `300` seconds), or narrow the request with `page_range`. > [accordion] Result not visible in the response > > **Problem**: A response references content but the `result` field is empty or missing. > > **Solution**: Check for `result_type: "url"` or `truncated: true`. In either case, call `get_job(job_id=...)` to fetch the full materialized result. ## Next Steps > [card] [Reducto CLI](/cli) > > Run parse, extract, and edit from your terminal. > [card] [Agent Guide](/agent-guide) > > A self-contained API reference designed for AI coding agents. > [card] [Parse Overview](/parse/overview) > > How parse turns documents into structured text, tables, and figures. > [card] [Extract Overview](/extract/overview) > > Schema-driven structured data extraction. # Azure Blob Storage authentication Source: https://docs.reducto.ai/onprem/azure_blob_storage Configure account-key or workload-identity signing for Azure Blob Storage in a self-hosted deployment. Reducto can sign Azure Blob Storage result links without an account key. Use workload-identity signing when your storage account disables shared-key access. This configuration applies to the default storage backend in a self-hosted deployment. For Hybrid VPC, see [Hybrid VPC with Azure Blob Storage](/onprem/hybrid-vpc-azure), which supports a managed identity without an account key. The same workload identity can also call Azure Vision OCR and Azure OpenAI. See [Azure workload identity](/onprem/azure_workload_identity). ## Choose signing credentials Reducto selects the signing method automatically. There is no signing-mode setting. * If an account key is configured, Reducto signs with that key. A key in `AccountKey` in the connection string or in `AZURE_STORAGE_ACCOUNT_KEY` takes precedence over workload identity. Reducto resolves the account name from `AccountName` in the connection string, then `AZURE_STORAGE_ACCOUNT_NAME`, then the Azure SDK client. If the name cannot be resolved, URL signing fails instead of selecting delegation; normal storage I/O and startup remain available. * Without account-key signing credentials, Reducto uses `WorkloadIdentityCredential` to request a user-delegation key. It signs a read-only SAS for one blob, valid for 12 hours by default. **Remove the dummy account key at cutover.** Remove both `AccountKey` from the connection string and `AZURE_STORAGE_ACCOUNT_KEY` from the pod configuration. A dummy key still selects account-key signing and produces unusable links. A failed account-key signature does not trigger a fallback to workload identity. Normal uploads, downloads, and deletes continue to use `AZURE_STORAGE_CONNECTION_STRING`. For an account with shared-key access disabled, supply a SAS connection string that permits those operations. The signing identity is separate from these I/O credentials. ## Opt in without changing existing deployments Without an account key, leaving `AZURE_CLIENT_ID`, `AZURE_TENANT_ID`, and `AZURE_FEDERATED_TOKEN_FILE` unset preserves the existing startup and connection-string I/O behavior. Only a request that needs a presigned result link fails with the missing-signing-credentials error. `AZURE_AUTHORITY_HOST` alone does not enable workload-identity signing. Supplying any of the three identity variables opts in. All three must then be nonblank, or startup fails with `StartupError` naming the missing settings. Explicitly empty or whitespace-only values count as an attempted opt-in. A configured account key continues to take precedence and requires no identity configuration. ## Configure the customer identity Create a managed identity in your Entra tenant. Add a federated identity credential that trusts your Kubernetes cluster's OIDC issuer and the pod's service account subject. For Helm installations, the chart creates `-reducto`, so use `system:serviceaccount::-reducto`. For example, release `reducto` in namespace `reducto` uses `system:serviceaccount:reducto:reducto-reducto`. For pods managed outside Helm, use their actual namespace and `serviceAccountName`. Set the federated credential audience to `api://AzureADTokenExchange`. Assign these roles to the signing identity: | Role | Scope | Purpose | | - | - | - | | Storage Blob Delegator | Storage account | Request user-delegation keys | | Storage Blob Data Reader | Each container that contains result artifacts | Authorize blob reads through signed links | A different data role can supply the required read permission, but Reader is sufficient for signing download links. The identity that supplies normal I/O credentials separately needs permission for its upload, download, and delete operations. ## Mount the federated token Choose one of the following paths. Both mount a rotating Kubernetes service-account token file. The Azure Identity library exchanges that token for an Entra access token. It does not create the mount or inject environment variables itself. Keep `serviceAccount.create: true` so the pod's subject matches the federated credential above. ### AKS workload-identity webhook On AKS with the OIDC issuer and workload-identity webhook enabled, the existing chart supports webhook injection through service-account annotations and pod labels. Leave `azureWorkloadIdentity.enabled` disabled (its default). Merge these settings into your existing values, keeping each streaq worker's other settings: ```yaml Webhook values theme={null} serviceAccount: create: true annotations: azure.workload.identity/client-id: "" azure.workload.identity/tenant-id: "" http: podLabels: azure.workload.identity/use: "true" worker: podLabels: azure.workload.identity/use: "true" streaqWorkerDefaults: podLabels: azure.workload.identity/use: "true" ``` The worker labels also apply to priority workers. The upgrade-preflight hook copies only `azure.workload.identity/use` from HTTP pod labels, so the webhook can inject its identity settings without applying unrelated HTTP labels to the hook. Streaq defaults apply to all pools, including full-image CPU workers; preserve the label if a pool overrides it. Add the same label to other enabled application components such as GPU workers. The webhook injects the token volume, mount, and `AZURE_*` identity variables. Configure the webhook for the appropriate Azure cloud. See [Microsoft's AKS workload identity configuration](https://learn.microsoft.com/azure/aks/workload-identity-overview). ### Explicit projection without a webhook For a chart that includes `azureWorkloadIdentity`, merge these values into your deployment: ```yaml Explicit projection values theme={null} serviceAccount: create: true azureWorkloadIdentity: enabled: true clientId: "" tenantId: "" authorityHost: https://login.microsoftonline.com/ audience: api://AzureADTokenExchange expirationSeconds: 3600 ``` The chart mounts `/var/run/secrets/azure/tokens/azure-identity-token` read-only and sets `AZURE_CLIENT_ID`, `AZURE_TENANT_ID`, `AZURE_AUTHORITY_HOST`, and `AZURE_FEDERATED_TOKEN_FILE`. Shared application helpers cover HTTP, legacy workers, priority workers, all streaq pools, and application cron/preflight pods. The token mount uses no `subPath`, so kubelet can replace the file as it rotates. The `cleanup` and `retry-stale` CronJobs also run `cron.py`, which imports storage at startup. With no account key and none of the three identity variables set, these jobs start unchanged. If any identity variable is supplied, all three must be nonblank, even though the jobs do not sign result links. The startup check only validates the variable values; it does not open the token file. The `azureWorkloadIdentity` block supplies the complete identity configuration and token mount to both jobs. The chart renders `retry-stale` when a legacy, priority, or GPU worker is enabled. | Value | Default and requirement | | - | - | | `enabled` | `false`; opt in to explicit projection | | `clientId` | Empty; required when enabled | | `tenantId` | Empty; required when enabled | | `authorityHost` | `https://login.microsoftonline.com/`; HTTPS authority host | | `audience` | `api://AzureADTokenExchange`; must match the federated credential | | `expirationSeconds` | `3600`; supported range `3600` through `86400` | Explicit projection needs a reachable OIDC issuer and matching Entra federated trust. It does not require the AKS workload-identity webhook, its service-account annotations, or its pod labels. Do not combine this block with webhook injection or manually configured identity variables in `env`/`extraEnv`. ### Older charts: Kustomize post-renderer Until the updated chart ships, use a Helm post-renderer to add the token volume and mount. Do not set `azureWorkloadIdentity` on an older chart, whose schema rejects that value. When opting in, set the identity variables through the existing top-level `env` values below so every application Deployment and both CronJobs receive the complete configuration. Pods with none of the three identity variables continue to start in legacy mode; pods with only some of them fail startup validation. ```yaml Older-chart identity values theme={null} serviceAccount: create: true env: AZURE_CLIENT_ID: "" AZURE_TENANT_ID: "" AZURE_AUTHORITY_HOST: https://login.microsoftonline.com/ AZURE_FEDERATED_TOKEN_FILE: /var/run/secrets/azure/tokens/azure-identity-token ``` Save the following as `azure-token-post-renderer.sh`. It requires `kubectl` with Kustomize support and Bash. Set `RELEASE_NAME` to the Helm release name. `AZURE_DEPLOYMENTS` lists deployment suffixes; include every custom streaq pool key and any other enabled application deployment. The default list covers the standard application tiers. The renderer preserves existing volumes, mounts, env, sidecars, and Helm hook annotations. ```bash azure-token-post-renderer.sh theme={null} #!/usr/bin/env bash set -euo pipefail : "${RELEASE_NAME:?Set RELEASE_NAME to the Helm release name}" work_dir=$(mktemp -d) trap 'rm -rf "$work_dir"' EXIT cat > "$work_dir/rendered.yaml" cat > "$work_dir/kustomization.yaml" <<'YAML' apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization resources: [rendered.yaml] patches: YAML patch_pod() { local kind=$1 suffix=$2 container=$3 indent=$4 api=apps/v1 if [[ "$kind" != Deployment ]]; then api=batch/v1; fi local name="${RELEASE_NAME}-reducto-${suffix}" cat >> "$work_dir/kustomization.yaml" <> "$work_dir/kustomization.yaml" fi cat >> "$work_dir/kustomization.yaml" < rendered.yaml # Inspect rendered.yaml, then install the same chart and values: helm upgrade --install "$RELEASE_NAME" ./reducto-chart --namespace reducto \ -f values.yaml --post-renderer ./azure-token-post-renderer.sh ``` For a custom audience or token lifetime, change those fields in the renderer and align the federated credential. When the chart update ships, remove the post-renderer and the four manually configured identity variables, then enable `azureWorkloadIdentity` instead. ### Pods managed outside Helm For deployments that do not use the Reducto Helm chart, merge this fragment into the actual pod template of each API and worker Deployment that uses Azure storage. Replace the container name and identity IDs with your values. Create the example `reducto` service account in the pod's namespace, or replace `serviceAccountName` with your existing account. Its federated subject must match `system:serviceaccount::reducto` for this example. Helm installations instead use `-reducto` and the chart procedures above; pasting this fragment into Helm values does not configure the token mount. ```yaml Standalone pod-spec fragment theme={null} spec: serviceAccountName: reducto containers: - name: reducto env: - name: AZURE_CLIENT_ID value: "" - name: AZURE_TENANT_ID value: "" - name: AZURE_AUTHORITY_HOST value: https://login.microsoftonline.com/ - name: AZURE_FEDERATED_TOKEN_FILE value: /var/run/secrets/azure/tokens/azure-identity-token volumeMounts: - name: azure-identity-token mountPath: /var/run/secrets/azure/tokens readOnly: true volumes: - name: azure-identity-token projected: sources: - serviceAccountToken: audience: api://AzureADTokenExchange expirationSeconds: 3600 path: azure-identity-token ``` This fragment needs no workload-identity webhook. Keep your existing storage connection-string secret and container configuration as described below. ### Storage credentials remain separate Supply `AZURE_STORAGE_CONNECTION_STRING` through your existing secret configuration and set `AZURE_STORAGE_CONTAINER`. The connection string should contain `BlobEndpoint=https://.blob.core.windows.net/;SharedAccessSignature=`, with no `AccountKey`. Keep its SAS valid for normal I/O for the pod's lifetime, or replace pods with fresh I/O credentials before it expires. This feature does not refresh the connection-string SAS. | Variable | Requirement or default | | - | - | | `AZURE_CLIENT_ID` | Required for delegation signing; customer managed identity's client ID | | `AZURE_TENANT_ID` | Required for delegation signing; customer Entra tenant ID | | `AZURE_FEDERATED_TOKEN_FILE` | Required for delegation signing; readable mounted-token path | | `AZURE_AUTHORITY_HOST` | Optional; Azure Identity defaults to `https://login.microsoftonline.com` | | `AZURE_STORAGE_ACCOUNT_NAME` | Optional if the account name can be inferred from the blob endpoint | For Azure Government, set `azureWorkloadIdentity.authorityHost: https://login.microsoftonline.us/` for explicit chart projection, or `AZURE_AUTHORITY_HOST=https://login.microsoftonline.us/` for the older-chart stopgap. For webhook injection, configure the webhook for Azure Government. Use `https://.blob.core.usgovcloudapi.net/` in the connection string. The authority, tenant, identity, and storage endpoint must belong to the same cloud. Use the same audience on the federated credential and in the chart: `api://AzureADTokenExchange` works in Azure Government, but if your federated credential uses `api://AzureADTokenExchangeUSGov`, set `azureWorkloadIdentity.audience` to match. Validate token exchange and signed downloads in your Government staging environment before rollout. ## Credential and key lifecycle Reducto creates a new workload-identity credential and a separate signing client whenever it fetches or renews a delegation key. It caches the delegation key in memory and reuses it across links. Normal blob operations retain their separate connection-string client. For the default 12-hour links, Reducto renews the key when it cannot cover the link expiry plus a one-hour safety margin. Key and delegated SAS start times are backdated by five minutes to allow for clock skew. Backdating does not shorten the requested link lifetime. ## Troubleshoot startup and signing Entra authentication failures and other non-retryable HTTP errors during delegation-key acquisition return non-retryable `InvalidConfigError` (HTTP 400) with federated-trust and storage-role checks. Azure 429/5xx and network failures remain retryable. | Symptom | Action | | - | - | | `StartupError` lists missing identity variables | Without an account key, supplying any of `AZURE_CLIENT_ID`, `AZURE_TENANT_ID`, or `AZURE_FEDERATED_TOKEN_FILE` requires all three to be nonblank. Supply the settings named in the error, or unset all three to preserve legacy startup and I/O without delegation signing. Account-key deployments require none of them. | | URL signing fails because the account name cannot be resolved | Supply `AZURE_STORAGE_ACCOUNT_NAME`, particularly for a custom endpoint. Keep the name and endpoint aligned. Startup and ordinary storage I/O remain available; Reducto does not switch from a configured key to workload identity. | | Token file is missing or unreadable | Verify the path, volume mount, and file permissions in each API and worker pod. Startup validation checks the configured variable, not file existence or federated trust; file access happens when signing needs a key. | | Entra rejects the token, including AADSTS700213 / AADSTS70021 | Check the federated credential's issuer and subject (`system:serviceaccount::-reducto` for Helm), plus the audience, which must equal `azureWorkloadIdentity.audience` (default `api://AzureADTokenExchange`). Verify the tenant, client ID, and cloud authority. Reducto reports `InvalidConfigError`. | | Delegation-key request returns 401/403 | Reducto reports `InvalidConfigError`. Check federated issuer/subject and Storage Blob Delegator on the storage account, plus Storage Blob Data Reader on the result container. | | A generated link returns 403 | Without Storage Blob Data Reader on the result container, key acquisition can succeed but every signed link returns 403 when opened. Check that role, link expiry, and whether a dummy account key remains configured. | | Azure returns 429 or 5xx during key acquisition | Reducto reports a transient error. Check Azure availability and throttling. | Do not include token contents, account keys, delegation-key values, SAS URLs, or connection strings in diagnostic logs. Share error codes and redacted configuration instead. To return to account-key signing, restore a valid account key and restart the pods. This requires shared-key access to be enabled on the storage account; a key cannot bypass `allowSharedKeyAccess=false`. # Azure workload identity Source: https://docs.reducto.ai/onprem/azure_workload_identity Use one Entra ID workload identity for Azure Blob Storage, Azure Vision OCR, and Azure OpenAI in a self-hosted deployment. Reducto can call every Azure service with one Entra ID workload identity instead of keys. Use it when your Azure policy disables key (local) authentication on storage accounts or Azure AI services, as many Azure Government tenants require. Each Reducto pod exchanges its Kubernetes service-account token for Entra ID tokens, and every Azure client in the pod shares that identity and its token cache. ## What the identity covers | Service | How Reducto selects the identity | Role to assign | Scope | | - | - | - | - | | Azure Blob Storage result links | Automatic when no account key is configured. See [Azure Blob Storage authentication](/onprem/azure_blob_storage). | Storage Blob Delegator, and Storage Blob Data Reader | Storage account, and each result container | | Azure Vision OCR | `AZURE_AI_SERVICES_AUTH=workload_identity` | Cognitive Services User | Each Vision resource | | Azure OpenAI | `AZURE_AI_SERVICES_AUTH=workload_identity` | Cognitive Services OpenAI User | Each Azure OpenAI resource | Normal blob uploads, downloads, and deletes still use `AZURE_STORAGE_CONNECTION_STRING`. See [Azure Blob Storage authentication](/onprem/azure_blob_storage) for a SAS connection string on an account with shared-key access disabled. ## Set up the identity 1. Create a user-assigned managed identity in your Entra tenant. 2. Add a federated identity credential that trusts your cluster's OIDC issuer. Set the subject to `system:serviceaccount::-reducto`. Its audience must equal `azureWorkloadIdentity.audience` (default `api://AzureADTokenExchange`). If your tenant created the credential with another audience, set the chart value to match. 3. Assign the roles in the table above for the services you use. 4. Mount the federated token on the pods, with the AKS webhook or the chart's `azureWorkloadIdentity` values. Follow [Mount the federated token](/onprem/azure_blob_storage#mount-the-federated-token). With the webhook, label every Reducto component (`http`, `worker`, and every streaq worker pool). The chart fails the render when `AZURE_AI_SERVICES_AUTH=workload_identity` is set and a component has no identity. 5. Allow outbound HTTPS from the pods to the Entra authority host (`login.microsoftonline.us` in Azure Government; the `AzureActiveDirectory` service tag). Token exchange has no private endpoint, also when your Azure services use private endpoints. ```yaml Helm values (explicit projection) theme={null} serviceAccount: create: true azureWorkloadIdentity: enabled: true clientId: "" tenantId: "" authorityHost: https://login.microsoftonline.us/ # Azure Government audience: api://AzureADTokenExchange env: AZURE_AI_SERVICES_AUTH: workload_identity ``` The chart sets `AZURE_CLIENT_ID`, `AZURE_TENANT_ID`, `AZURE_AUTHORITY_HOST`, and `AZURE_FEDERATED_TOKEN_FILE` on every Reducto pod. ## Azure AI services `AZURE_AI_SERVICES_AUTH` controls Azure Vision OCR and Azure OpenAI together. It defaults to `key`. | Value | Azure Vision OCR sends | Azure OpenAI sends | | - | - | - | | `key` | `AZURE_VISION_KEY`, or the `key` of each `AZURE_VISION_ARRAY` entry | `AZURE_OPENAI_API_KEY` | | `workload_identity` | An Entra ID token of the workload identity | An Entra ID token of the workload identity | With `workload_identity`: * Remove `AZURE_VISION_KEY`, every `key` in `AZURE_VISION_ARRAY`, and `AZURE_OPENAI_API_KEY`. Reducto refuses to start with a key in this mode, so a leftover key cannot keep authenticating calls. * Use each resource's custom subdomain as its endpoint, for example `https://.cognitiveservices.azure.us/` or `https://.openai.azure.us/`. Entra ID authentication does not work with a regional endpoint. * Private endpoints work for the services. The custom subdomain resolves to the private IP address through your private DNS zone. Token exchange still needs outbound HTTPS to the Entra authority host. Reducto checks this configuration in two places: * `helm template` and `helm upgrade` fail, and name the component, when `AZURE_AI_SERVICES_AUTH=workload_identity` is set and a component has no workload identity. * A pod fails to start when `AZURE_VISION_KEY`, `AZURE_OPENAI_API_KEY`, or a `key` in `AZURE_VISION_ARRAY` is still set in this mode. Pods that call no Azure AI service, such as the inference pod, start without the identity. So a deployment outside the Helm chart that sets the mode with no identity starts, and its first OCR or Azure OpenAI call fails with an error that names the missing variables. ## Azure Government and other clouds Reducto takes the cloud from `AZURE_AUTHORITY_HOST`, which the chart sets from `azureWorkloadIdentity.authorityHost`, and requests every Azure AI services token for that cloud's audience. A custom domain or a gateway in front of a service therefore gets the right audience. | `AZURE_AUTHORITY_HOST` | Token audience | | - | - | | `https://login.microsoftonline.us/` | `https://cognitiveservices.azure.us` (Azure Government) | | `https://login.chinacloudapi.cn/` | `https://cognitiveservices.azure.cn` (Azure China) | | `https://login.microsoftonline.com/` or unset | `https://cognitiveservices.azure.com` | With `AZURE_AI_SERVICES_AUTH=workload_identity`, an authority host of no known cloud fails the first OCR or Azure OpenAI call instead of sending a token for the wrong cloud. Blob Storage tokens use the storage account's own host as the audience, which works in every cloud. ## Troubleshooting **OCR or LLM calls fail with an authentication error and no retry.** The identity is missing, its token file cannot be read, or Entra rejected the token exchange. Reducto does not retry it, because every attempt and every endpoint fails the same way. For OCR, the job fails at once with an internal error. For an LLM call, Reducto skips the Azure OpenAI model and tries your other configured models; if every model fails this way, the job fails with an internal error that names the identity. Check the token mount (`/var/run/secrets/azure/tokens/azure-identity-token`), the federated credential's issuer and subject, and the role assignments. **Azure returns 401 with a valid token.** The token audience does not match the resource's cloud, or the endpoint is a regional endpoint. Check `azureWorkloadIdentity.authorityHost`, and use the custom subdomain of the resource. **Calls time out getting a token.** The pods cannot reach the Entra authority host. Allow outbound HTTPS to it. Each token attempt waits at most 5 seconds to connect and 10 seconds to read, with one retry, so a page fails within about 30 seconds instead of hanging. **Azure returns 403.** The identity lacks the role on that resource. Role assignments can take several minutes to apply. ## Roll back Set `AZURE_AI_SERVICES_AUTH=key` and restore the keys, then restart the pods. A Reducto release older than the one that added `AZURE_AI_SERVICES_AUTH` ignores the setting and needs the keys, so restore the keys before you roll back the release. During an upgrade, disable key authentication on your Azure resources only after every Reducto pod runs the new release. Blob Storage signing switches back to account-key signing when you restore `AccountKey` in the connection string or `AZURE_STORAGE_ACCOUNT_KEY`. # On-premise changelog Source: https://docs.reducto.ai/onprem/changelog Release notes for on-premise deployments of Reducto **v1.12.22** 2026-10-31 * feat: Optional redaction of document filenames and URLs from telemetry. * feat: Opt-in per-batch PDF upload; its read modes let each batch of a large PDF download only its own pages (`BATCH_PDF_HANDOFF`). * Azure deployments: Opt-in workload identity signs result links without an account key (`azureWorkloadIdentity` values); a configured account key still takes precedence. See [Azure Blob Storage authentication](/onprem/azure_blob_storage). **v1.12.21** 2026-10-29 * LibreOffice parses untrusted documents. On nodes that allow pods to reach the instance metadata service (IMDSv1, or an IMDSv2 hop limit of 2), it can still reach the node's instance role; set the hop limit to 1 or block 169.254.169.254 from the namespace. * security: Patch CVEs flagged in the runtime images. Upgrade to pick up the fixes. * security: Patch a CVE in the HTML-to-PDF render (CVE-2026-113344) and block it from fetching external URLs. * security: Patch a document conversion CVE (CVE-2024-12425). * security: Refresh the CA trust store in the runtime images. * security: Extract resolves only in-schema schema references and never fetches remote or local files. * fix: OCR retries a failed page across the configured providers before the built-in fallback. * fix: OCR fallback exhaustion returns a retryable error within the time budget. * fix: Larger spreadsheets process in the standard path; password-protected or corrupt spreadsheets fail fast with a clear error. * fix: Parse handles multi-frame (MPO) photos and oversized JPEGs instead of failing. * fix: More accurate table merging across page boundaries. * fix: Large or malformed HTML no longer exhausts worker memory; a bad document fails its own job. * fix: Document conversion tries the newest LibreOffice version first. **v1.12.20-3** 2026-10-23 * security: Refresh the CA trust store; distrust the emSign Root CA - C1 and ECC Root CA - C3. Upgrade to pick up the fix. **v1.12.20-2** 2026-10-22 * fix: Office document conversions work again on the updated document conversion image. Upgrade to pick up the fix. **v1.12.20-1** 2026-10-21 * security: Refresh the CA trust store in the runtime images. Upgrade to pick up the fix. * feat: Optional scheduled minimum worker replicas (cron windows), with validation against conflicting scaling settings. **v1.12.20** 2026-10-10 * fix: Extract citations handle custom schema field names more robustly. * fix: Spreadsheet parsing fixes for empty cells and cell text in large workbooks. * fix: Lower memory use when rendering spreadsheets and when correcting glyphs on very large pages. * fix: Multi-document extract reports every source document when a spreadsheet is included. * fix: Worker file handoff fixes for converted documents. * fix: Azure deployments: Blob Storage inputs from another storage account are handled correctly. * security: Patch CVEs flagged in the runtime images. **v1.12.19** 2026-10-05 * feat: Cloud OCR runs as an ordered provider chain with per-page retries. * fix: OCR falls back per page across the configured providers. * fix: Glyph correction tolerates a single bad font or glyph. * feat: Spreadsheets return hyperlinks as Markdown links and handle sparse sheets more efficiently. * fix: Multi-document extract reads every source document when a spreadsheet is included. * fix: GCP deployments: the `GCP_REGION` warning fires only when a call would fail. See [LLM options](/onprem/llm_options). **v1.12.18** 2026-09-28 * action required: Default OpenAI models move to GPT-6 Luna and GPT-6 Sol. Allow them if you restrict model access. See [LLM options](/onprem/llm_options). * security: Patch the AnyIO CVE (CVE-2026-63374). Upgrade to pick up the fix. * security: Optional hardened container security context in the chart (nonroot, read-only root filesystem, dropped capabilities). * feat: Fair queuing across users and a per-user pages-per-second throttle. See [fair queueing](/onprem/fair_queueing). * feat: Parse accepts camera RAW images (ARW, CR2, CR3, NEF, DNG, RAF, ORF, RW2, PEF). * feat: Opt-in CPU tuning for OCR. See [OCR options](/onprem/ocr_options). * fix: Table formatting fixes at page continuation boundaries and for cells containing equals signs. * fix: More resilient PDF rendering for malformed documents. * fix: Extract citations recover when parse blocks clip OCR words. * fix: Cancelled and timed-out jobs stay failed instead of completing. * fix: The post-deploy hook retries package installs across mirror outages. * fix: CronJob disruption protection is independently configurable. * perf: Faster spreadsheet processing and lower worker memory between tasks. **v1.12.17** 2026-09-18 * security: Patch the HEIF image-parsing CVEs (HEIF Heist). Upgrade to pick up the fix. * fix: Table merging across pages is more accurate on the fallback path. * fix: DOCX edits no longer drop or shift answers in long documents. **v1.12.16** 2026-09-16 * chore: Move default Gemini models to Gemini 3.5 Flash Lite, 3.7 Flash, and 3.8 Flash following Google's deprecation of the 2.5 series. Deployments that restrict Gemini model access should allow the new models. **v1.12.15** 2026-09-16 * feat: Additional configurable profiles for the inference service. * fix: The inference gateway no longer retains decoded page images after each layout prediction, which removes a memory leak that could reach several gigabytes per pod. * fix: Extract no longer counts pages whose only block is empty toward usage. * fix: Embedding the PDF text layer no longer fails on documents with empty OCR lines. * fix: OCR skips text candidates whose polygon collapses instead of failing the page. **v1.12.14** 2026-09-15 * chore: Bump the Chainguard `python-fips` base image to 3.11.16-dev (glibc 2.44). * chore: Bump dependencies flagged by the Trivy vulnerability scan. **v1.12.13** 2026-09-15 * feat: Route OpenAI traffic through an OpenAI-compatible gateway or proxy, with optional custom request headers. Azure OpenAI and LiteLLM proxy routing are unchanged. See [LLM options](/onprem/llm_options). * feat: Attach per-request tags to a job for cost attribution in LiteLLM. See [LLM options](/onprem/llm_options). * feat: Parse-only deployments on the new worker architecture can run without the IO worker. * feat: The legacy worker can run alongside the new workers during the streaq migration without sharing inference capacity, and rollouts wait for in-flight legacy jobs to finish. See [operations](/onprem/operations). * feat: Optional lockstep scaling keeps in-cluster inference capacity proportional to the number of CPU worker pods. * feat: Configurable profiles for the inference service. * feat: Extract and Deep Extract responses include the job duration. * feat: Extraction that exceeds the model's output limit returns a 413 with remediation guidance instead of a 500. * fix: Workers recover from lost Redis connections without leaking connection slots, and stop accepting new work until the connection is back. * fix: A crashed CPU worker job moves to a larger worker instead of retrying in place. * fix: Serialize PDF rendering calls to prevent worker crashes when multiple regions render concurrently. * fix: Fine-grained extract citations report the correct original page when the upstream parse used a page range. * fix: Align the embedded-text OCR overlay with the visible text on pages whose page box origin is offset. * fix: Quieter stdout logs. Removed noise, stray prints, and secret values. **v1.12.12** 2026-09-03 * feat: Add an optional CPU-based inference service that isolates inference workloads from the API and worker pods. * feat: Add optional per-queue admission caps for the new worker architecture. Off by default. See [load shedding](/onprem/operations#load-shedding). * feat: Add an optional setting to keep a warm spare compute subprocess in each CPU worker. * feat: Make agentic table concurrency and timeouts configurable with `AGENTIC_TABLE_CONCURRENCY`, `AGENTIC_TABLE_PHASE_DEADLINE_S`, and `AGENTIC_TABLE_MODEL_TIMEOUT_S`. * feat: Return a top-level `duration` on `/split` responses. * fix: Workers reconnect after a lost Redis connection instead of crashing the pod, and draining pods finish in-flight tasks instead of re-running them. * fix: `/edit` supports DOCX files with embedded documents (altChunk) and preserves BOM-prefixed XML parts. **v1.12.11** 2026-08-28 * feat: `/split` and `/edit` now return a `job_id`. * feat: Accept `ignore_watermarks` on both v2 and v3 request shapes. * fix: Segment large CSV clusters with semantic seams and ground embedding summaries. * fix: `/edit` treats `font_size` as a ceiling and auto-sizes text for PDF form fill. **v1.12.10** 2026-08-26 * refactor: `/extract` and `/split` now run entirely on the fused CPU `job_task`. * fix: Stop a spurious warning when a non-reasoning model alias inherits `reasoning_effort`. **v1.12.9** 2026-08-26 * feat: Convert Word-family files (`.doc`, `.rtf`, and similar) to DOCX for `/edit`. * perf: Stop RSS accumulating across batches in long-lived workers, reducing memory growth and OOM risk. **v1.12.8** 2026-08-25 * security: Upgrade `av` to 18.1.0 to remediate a critical CVE in the bundled ffmpeg (CVE-2026-40962). Upgrade on-prem deployments to pick up this fix, see [operations](/onprem/operations) and [securing Reducto](/onprem/securing_reducto). * feat: Add `settings.alpha.return_image_max_dimension` to downscale returned images. * feat: Dynamic document-conversion timeout and an updated DOCX conversion library. * fix: Reject `PreferSameZone` traffic distribution on Kubernetes 1.31 to 1.33, and retry the post-deploy hook once on a transient failure. **v1.12.7** 2026-08-21 * security: Add an SSRF guard for customer-controlled URL fetching, see [securing Reducto](/onprem/securing_reducto). * security: Bump vulnerable transitive dependencies (starlette, gitpython, lxml) to remediate on-prem image CVEs. Upgrade on-prem deployments to pick up these fixes, see [operations](/onprem/operations). * feat: Support `.eml` email documents by converting them to HTML, and route csv/msg/eml conversion onto the CPU path. * fix: Unbind Word `@page` rules that clip wide email tables during HTML-to-PDF conversion. * fix: Read PDF metadata without mutating the document and tolerate page-mapping mismatches. * fix: Preserve rotation polygons in Azure Vision OCR output. * fix: Stop labelling subprocess-pool starvation as `DOCUMENT_CORRUPT`. **v1.12.6** 2026-08-18 * feat: Guard upgrades to the new Reducto architecture. Helm requires Redis plus healthy io/cpu workers on v1.12.6 or later before it permits the upgrade. * fix: Adapt region-crop DPI to page font size for better small-text quality. **v1.12.5** 2026-08-15 * feat: Add a DOCX to PDF fallback path for more reliable conversion. * fix: Improve Office file conversion reliability and return clearer errors for files that cannot convert. * fix: Kill the whole LibreOffice process group on conversion timeout to prevent stuck conversions. * fix: Fall back to raw OCR text when text enrichment fails or returns malformed output. * feat: Add PodDisruptionBudget coverage, topology spread, service-account token hardening, and `priorityClassName` knobs to the Helm chart. * fix: Load the GPU OCR model before serving and gate traffic on readiness. **v1.12.4** 2026-08-10 * feat: `/parse` supports `formatting.include=["hyperlinks"]` for HTML inputs. * feat: Expose embedded document properties behind `extract_document_properties`. * fix: Harden managed Redis and Azure storage compatibility for on-prem deployments. * fix: Safely requeue in-flight worker tasks during shutdown to avoid dropped jobs. * fix: Make spreadsheet agent citations 1-based and sheet-aware. * fix: Stop hidden row/column exclusion from dropping visible content or splitting tables. * fix: `/edit` toggles printed checkboxes in place instead of duplicating the option. * fix: Return a clearer error instead of blaming `page_range` when a document has zero pages. * chore: Bump python-calamine-reducto for a spreadsheet boolean-cell fix. **v1.12.3** 2026-08-04 * feat: Support private Redis CA certificates for on-prem deployments. * fix: Fix GPU image builds by including the required NVIDIA dependencies. * fix: Surface structured error code and name on failed async jobs, and preserve `SERVICE_UNAVAILABLE` on legacy 503 responses. * fix: Preserve checkbox, empty, and signature tags from agentic table output. * fix: Improve OCR page numbering and spreadsheet figure bounding boxes and merged-range handling. **v1.12.2** 2026-07-20 * feat: Native DOCX comment extraction now returns comments anchored to the relevant document blocks. * feat: `/edit` can synthesize form fields for questionnaires that do not include them. * feat: v3 parse supports customer-managed encryption with a KMS key (alpha). * fix: Improve citation handling and page-range support for `/extract` requests. * fix: Resolve failures in document ordering and checkbox detection. * fix: Improve handling of rotated PDFs in embed and parse results. **v1.12.1** 2026-07-08 * feat: Improve reliability and performance for large spreadsheet processing. * feat: Add asynchronous job deletion endpoints. * feat: Improve extraction quality and reliability for large documents. * fix: `/edit`: preserve AcroForm through overflow-page assembly, render filled text at the correct size, and write radio button values by name. * fix: Correct embedded text-layer placement on rotated (90/270) PDF pages. * fix: Honor `/extract` page-range settings and improve citation accuracy. * fix: Improve reliability when embedding or converting large PDFs. * fix: Handle corrupt TIFF palettes in image load paths. * perf: Reduce memory usage when processing dense and scanned documents. **v1.12.0** 2026-06-26 * feat: Self-hosted Office conversion for DOCX and PPTX files (alpha). * feat: `REDUCTO_AGENTIC_URL` to point the agentic model at an on-prem endpoint. * fix: Improve text extraction in FIPS-compatible environments and from documents with complex fonts. * fix: Improve isolation of stored job results across organizations. * fix: Improve stability during layout inference and document processing. * fix: Respect EXIF orientation on image inputs and drive scanned-page render DPI off the embedded scan resolution. * fix: Reconcile AcroForm fields with filled widgets in `/edit` so forms render correctly in Preview/PDFKit. * fix: Improve resilience to temporary LLM provider failures. * perf: Reduce peak memory use across document, spreadsheet, and OCR processing. **v1.11.81** 2026-06-11 * perf: Reduce peak memory use when processing large documents. * perf: Improve PDF rendering and OCR performance. * fix: Preserve inline markup when merging table rows across page breaks. * fix: Improve reliability for text-heavy PDFs and Office document conversion. * fix: Improve the completeness and clarity of public OpenAPI schemas. * feat: Console logging is on by default, with a `NO_LOG` override. * feat: Return detected languages in OCR data when available. **v1.11.80** 2026-06-08 * perf: Bound concurrent agentic image memory, preventing worker OOMs on heavy documents. * fix: Resolve spurious `DocumentCorruptError` failures on DOCX files that reference external templates. **v1.11.79** 2026-06-05 * feat: Added `/openapi-onprem-full.json` for on-prem deployments. This schema includes the full HTTP pod API surface, including routes hidden from the hosted public API reference. * feat: Added on-prem deployment controls and worker/runtime reliability improvements for supported customer environments. * fix: Improved job finalization, result streaming, document rendering, and customer-facing error handling for better stability under load. * docs: Added RSS-ready metadata for on-prem changelog entries. The generated feed is available at [onprem-changelog-rss.reducto.ai/rss.xml](https://onprem-changelog-rss.reducto.ai/rss.xml). **v1.11.78** 2026-05-29 * feat: Add `num_pages` field to the `/classify` API response. * feat: Add `max` mode for agentic tables for higher-fidelity table extraction. * fix: Fail jobs on OCR transient errors instead of silently returning an empty response. * fix: More accurate HTTP error status codes so client-side validation failures return 4xx instead of 5xx. * fix: Multipart S3 upload for result payloads over 100MB, with a new `OversizedResultError` for limit cases. **v1.11.77** 2026-05-22 * chore: Removed legacy sandbox setup that is no longer used by supported on-prem workflows. * fix: Improved `/jobs` source redaction and added release validation for `/version` interpolation. * fix: Strengthened authentication header handling for webhook-related endpoints. * docs: Added clearer guidance for the on-prem shared security model, securing Reducto deployments, and observability access controls. See [On-prem security model](/onprem/security_model), [Securing Reducto](/onprem/securing_reducto), and [Observability & Monitoring](/onprem/observability). **v1.11.76** 2026-05-14 * feat: Extend the SIGUSR2 stack-trace dumper to the DB-queue worker pods (`reducto-worker`, `reducto-priority-worker`, `reducto-gpu-worker`). Previously the handler was only installed on HTTP and streaq worker pods; this completes coverage so `kill -s USR2 ` produces a stderr stack dump on every Reducto pod type. See [Observability](/onprem/observability#pod-stack-trace-dumps-sigusr2). **v1.11.75** 2026-05-13 * perf: Higher embed pool concurrency for improved throughput * fix: Pre-resolve OCR before vision-model citations with stricter containment threshold for improved accuracy * fix: Preserve 5xx semantics for PDF text extraction subprocess crashes **v1.11.70** 2026-05-12 * perf: Faster spreadsheet processing + fix images on text-empty sheets * perf: Higher PDF render pool concurrency with eager background respawn * fix: Replace non-ASCII chars that break obfuscation build **v1.11.69** 2026-05-12 * feat: Azure Vision OCR client migrated to the async aio SDK with hard wall-clock cancellation via `asyncio.wait_for`. The new `AZURE_VISION_TOTAL_CALL_TIMEOUT` (default 45s) closes the underlying aiohttp socket when it fires, so workers no longer wedge on slow Azure responses. Per-call connect/read timeouts and SDK retries remain configurable via `AZURE_VISION_CONNECTION_TIMEOUT`, `AZURE_VISION_READ_TIMEOUT`, and `AZURE_VISION_MAX_RETRIES`. See [OCR options → Azure Vision Read](/onprem/ocr_options#azure-vision-read) for the full env var reference. * feat: SIGUSR2 stack-trace dumper installed in HTTP gunicorn workers and streaq worker processes. `kubectl exec` into a pod and run `kill -s USR2 ` to print every thread's stack to stderr (visible via `kubectl logs`) for diagnosing hung tasks, wedged event loops, or contended thread pools. See [Observability](/onprem/observability#pod-stack-trace-dumps-sigusr2). * feat: Kubernetes liveness watchdog + exec probe for `reducto-worker`, `reducto-priority-worker`, and `reducto-gpu-worker` pods. A pod is only restarted when the in-process watchdog reports an in-flight task running longer than `WORKER_STUCK_TASK_THRESHOLD_SEC` (default 1800s) or the watchdog itself stops heartbeating (event loop wedged). Idle workers are never restarted. Configurable via `worker.livenessProbe.*` Helm values. See [Operations](/onprem/operations#worker-liveness-probe). * feat: Hard-killable subprocess pool with per-renderer timeouts for PDF renders * feat: Hard-killable subprocess pool for PDF flatten + embed timeouts * fix: Skip zero-dimension crops in fine-grained citations * fix: Killable timeout for stuck PDF text extraction * fix: HTTP worker graceful shutdown timeout set to 120s * fix: Strip control characters from extracted strings * fix: Preserve overlay text selectability when embedding into existing PDFs * fix: Protect figures from signature detection * fix: Return 4xx for images with extreme aspect ratios * refactor: More reliable PDF widget ingestion in `/edit` **v1.11.68** 2026-05-04 * fix: Azure Vision OCR SDK retry behavior on-prem * perf: Spreadsheet memory optimizations * perf: Faster OCR cropping for table prediction * perf: Faster PDF render path * perf: Faster Google Cloud Vision response and rotation hint processing * feat: Make all fields in auto-generated extraction schemas optional * fix: Memory leak in PDF processing * fix: Avoid PDF library hang on dense-tiling-pattern PDFs via fallback reader * fix: Corrupt zip-based documents now return 400 instead of 500 * fix: Preserve citations on scalar-only schema adherence corrections * fix: Cap spreadsheet agent instructions to avoid input token limits * fix: Correct ordering of table enrichment relative to markup application * fix: Clean up spurious 5xx on cancels and HTML to PDF hangs **v1.11.67** 2026-04-30 * feat: New Azure Vision OCR strategy for on-prem deployments * feat: Datadog dashboard for on-prem k8s deployments * feat: Expose `keep_line_breaks` in V3 `settings.alpha` * feat: Optimized inference backend for table router with safety wrapper * fix: Faster usage logging via append-only buffer to reduce DB contention * fix: Tolerate quoted-printable colspan in HTML parsing * fix: Stabilize table router builds with pinned model export version * chore: Bound embed feature with per-call wall-clock budget **v1.11.66** 2026-04-27 * feat: Dynamic rendering for small-font metadata extraction * feat: Remove Adobe-added watermark layers from documents that have them * feat: Default `fast_embed_pdf_metadata` to True * fix: Preserve PDF optional content properties through page extraction for watermark layer stripping * fix: Extract always returns V3 shape when citations are missing * fix: Extract citations no longer dropped when `page_range` skips page 1 * fix: Clean up downloaded PDF temp files on batch failure * refactor: LLM client dependency bump for CVE remediation * refactor: Azure Vision array OCR failover with retryable errors only * chore: Patch critical and high CVEs in Docker images * chore: Return S3 links instead of raw JSON to avoid response size limits **v1.11.65** 2026-04-21 * fix: Error attribution added to `GET /job` endpoint * chore: Pass prompts as files instead of args to avoid bytes overflow **v1.11.64** 2026-04-20 * fix: Skip parse-ID overlay in `/edit` when `form_schema` is provided * fix: Filter out uninitialized providers in model config resolution * fix: Page range validation parity in parse pipeline * refactor: Classify enhancements **v1.11.63** 2026-04-17 * feat: Azure Vision multi-endpoint failover with load balancing * feat: Enhanced image-to-PDF conversion * feat: Per-chunk embed PDF in pipeline * feat: Run LLM enrichments in parallelized DAG for lower latency * fix: Correct argument handling in offline entrypoint for org/job\_id * fix: Hybrid OCR prefers metadata over garbled OCR when text is reordered * refactor: Remove unused models from on-prem image builds for slim images **v1.11.62** 2026-04-08 * fix: `max_completion_tokens` / `check_schema` escape hatch for reasoning models * fix: Office document conversion with CJK fonts * fix: Deliver webhook after `persist_results` to avoid S3 race condition * chore: Upgrade LLM client dependencies **v1.11.61** 2026-04-07 * feat: New `force_simple_page` config option * feat: Enable `fast_flatten` for legacy early flatten in pipeline orchestration * fix: `max_completion_tokens` schema validation for reasoning vs. non-reasoning models **v1.11.60** 2026-04-06 * fix: `max_tokens` and other arg handling for Azure LLM inference **v1.11.59** 2026-04-04 * feat: Increased sheet processing timeout from 600s to 900s * feat: Fast PDF flatten, selective rasterization replaces full-document rasterization * fix: Validation for custom experimental options on on-prem * fix: Reduce DB lock contention on batch completion * fix: Equation enrichment correctly preserves line/word offsets **v1.11.58** 2026-04-01 * feat: New fast embed via env settings * feat: Configurable timeouts for OCR and embed text metadata steps * feat: Per-page billing feature breakdown in parse response * feat: Auto formatting per page * perf: 10x faster PDF text overlay rendering * perf: Parallelize figure classification with higher LLM concurrency * perf: Optimized batch result aggregation * fix: CSV parsing column count uses max row width; no longer drops columns when first row is narrower * fix: OCR PDF no longer drops pages on multi-chunk documents * fix: Allow None in override schema for bool fields * fix: Correct `num_pages` reporting on jobs * fix: Image conversion error handling * fix: Strip markup noise before language detection to avoid false unknowns * fix: Avoid page overlaps in `/split` endpoint output * fix: Guard against errors in layout postprocess * fix: Reclassify corrupt PDF annotation failures as proper 4xx status codes * chore: Raise default overflow chunk limit to 500 **v1.11.57** 2026-03-26 * perf: Parallelize KV fallback to prevent task deadline breaches **v1.11.56** 2026-03-26 * feat: Native DOCX XML parsing pipeline (alpha) with `.pages` support * feat: HEIC image support and section-based chunking for Numbers files * feat: Formatting and images support for Numbers files * feat: Extract model and internal prompt overrides for v2/v3 configurations * feat: YAML extract and citations models added to Helm chart for GPU deployments * feat: KV repetition detection with Gemini fallback replacing repetition\_penalty * feat: OTEL pipeline routing and K8s metrics collection * fix: Auth for chained jobs * fix: Required fields on extraction schema * fix: Equation detection TypeError from tuple/list concatenation * fix: Move sync blocking calls off the event loop in HTTP handlers * fix: Use encrypted DB URL and disable k8s metrics in on-prem environments * fix: Fail fast on hung PDF renders * fix: Parallelize S3 batch result loading for faster retrieval * perf: Optimized layout postprocessing * perf: Optimized hybrid OCR processing * refactor: Graceful fallbacks for XML conversion issues * chore: Cron retries increased from 1 to 2 for improved reliability * chore: Send total credits for on-prem customers **v1.11.51** 2026-03-05 * feat: OCR-based table citations for deep extract * feat: Add API key prefix filter parameter for /jobs endpoint * fix: On-prem presigned URL upload path mismatch * fix: new layout postprocessing * fix: Empty OCR fallback handling * fix: Python version in sandbox runtime * chore: Upgraded enhanced figure summary models **v1.11.49** 2026-03-01 * fix: on-prem deployments start without Redis configured **v1.11.48** 2026-03-01 * feat: On-prem usage logging for customer tracking **v1.11.47** 2026-03-01 * feat: Schemaless deep extract * feat: Granular citations in deep extract * feat: Deep extract available for on-prem deployments * feat: Spreadsheet sheet-name page\_range support * feat: Suppress citation content feature flag for extract * fix: Garbled DOCX for change tracking * fix: Recursive render to handle nested tables for edit * fix: Rotation in embed metadata * fix: GCP API key requirement for on-prem * fix: Remove libpq options from DB connect\_args for RDS Proxy compatibility * fix: Lock timeout batches and non-locking last-batch check * fix: Background threads no longer block main processing * fix: Transient classify inference issues * perf: Merge tables speedup from O(N^2) to O(N) * perf: Retry improvements to avoid double work * refactor: Decompose batch pipeline into composable phases * chore: Upgrade Gemini models **v1.11.46** 2026-02-25 * fix: Middleware context propagation and ordering for proper trace handling * fix: Cron job retry logic and syntax improvements * fix: HTTP startup patched for hashlib.md5 in FIPS environments (GCS support) * perf: Decaying timeout for retries with improved retry\_on\_timeout behavior * perf: Updated timeout and max batch configurations **v1.11.45** 2026-02-24 * feat: Chunk overlap configuration for including text context from previous/next chunks * feat: summarize\_all\_figures option in v3 alpha config * feat: Deep extraction optimizations for improved structured data quality * fix: Lazy loading for HTTP/worker modules to avoid unnecessary dependency imports * fix: Guard against empty document\_url list in pipeline and split endpoints * fix: Cron job improvements * fix: More reliable page orientation detection * fix: Exception handling on deep extraction completion * refactor: Deep extract sandbox image * chore: Upgraded Anthropic models * chore: New table detection model with improved accuracy **v1.11.44** 2026-02-17 * fix: Add bounds check for page index in PDF text embedding to prevent IndexError crashes * fix: Skip cover pages for PDF portfolios during attachment concatenation * fix: Fallback to original pages when portfolio has no PDF attachments * fix: Fast-fail URL download on non-success HTTP status codes * fix: Random checkbox YOLO crash when Conv has no batch normalization * fix: HuggingFace model downloads for builds * fix: Lazily import probing modules to avoid Modal dependency in on-prem * feat: Custom agentic layout postprocessing * feat: Routing for parse batches * feat: Classify concurrency improvements * refactor: Set reducto environment to `onprem` by default * chore: Upgrade pytorch and torchvision dependencies **v1.11.42** 2026-02-13 * fix: Detect visual redlines (colored strikethrough/underline) in DOCX change tracking * fix: Use min instead of max for checkbox detection * fix: CSV parsing truncation and scientific notation for large integers * fix: Recover in-progress batches alongside pending ones * feat: Fallback to Gemini Flash for improved reliability * feat: Intelligent ordering fallbacks * feat: Schema adherence model updates * feat: Add ONNX model integrity verification with forced fresh model download * refactor: Database lock timeout for sync DB engine **v1.11.41** 2026-02-09 * feat: New OCR recognition model for select deployments * fix: Recover in-progress batches alongside pending ones * fix: Add ONNX model integrity verification and force fresh model download **v1.11.40** 2026-02-06 * feat: Classify endpoint with parallelized Gemini Flash Lite probes for document classification * feat: Add bucket\_name as alpha option in v3 parse config * feat: Enable bucket & KMS ARN override for hybrid VPC deployments * feat: Auto region routing for Gemini models * feat: Native office conversion alpha flag in v3 config * feat: Inference helm charts and kv-base routing * feat: Enable flatten for edit endpoint * feat: Improved models for standard figure summary * feat: Add dimension limit handling for AWS environments * fix: Memory leaks and file descriptor leaks in PIL Image handling across OCR and processing pipelines * fix: N-squared completion pattern in batch processing for significantly improved performance at scale * fix: Race condition in parse completion job processing * fix: Argument order bug in pdftext multiprocessing extraction * fix: V3 config fixes for on-prem deployments * fix: Initialize empty sheets to prevent errors on blank spreadsheets * fix: Force resize to fit AWS dimension limits for large documents * fix: Image conversion failures now return proper 415 error instead of 500 * fix: PyPDFForm version update to resolve form filling bug * fix: Classify endpoint fixes for improved reliability * fix: Offset\_in\_chunk calculation for empty blocks * fix: Exclude veryHidden sheets when exclude\_hidden\_sheets is enabled * fix: Checkbox detection bug * fix: Prioritize S3/BUCKET over GCS when both GCP\_PROJECT\_ID and BUCKET are set * fix: cron.py Kubernetes usage * fix: Distributed traces with LOGFIRE\_DISTRIBUTED\_TRACING * fix: Temperature 0.1 for promptable layout for more deterministic results * fix: local-full Dockerfile fix by adding gcc and python3-dev to apt install * perf: Hydrate SharedBatchWorker.process\_org\_batch before ThreadPoolExecutor for improved concurrency * refactor: Remove enhanced enrich tables, default to same model for simpler table processing * chore: Upgrade table models **v1.11.38** 2026-01-30 * feat: V3 config overrides for v2-only and on-prem-only settings * feat: List item support and chunk offsets in blocks for improved extraction * fix: handle\_required\_fields not adding missing fields to array items in extraction * fix: Page marker blocks now include correct page and original\_page values * fix: Multi-batch recovery when job processing is interrupted * fix: Division by zero error for images with corrupted EXIF data **v1.11.37** 2026-01-29 * fix: Restore V2 OCR defaults (highres OCR system) in V3 on-prem config for consistent behavior **v1.11.36** 2026-01-29 * chore: Bookworm image build configuration for CD pipeline **v1.11.35** 2026-01-29 * feat: Bookworm Dockerfile variant for improved on-prem DOCX→PDF conversion reliability **v1.11.34** 2026-01-28 * fix: DOCX→PDF conversion using LibreOffice from Trixie backports for improved reliability * feat: Super-agent integration into /extract pipeline for improved structured data extraction * fix: Traceparent propagation for API requests * perf: Per-image table predictions for better performance **v1.11.33** 2026-01-27 * feat: Hybrid VPC routing based on header with default AU/EU/US regions * feat: Add docx fallbacks for malformed XML and OOXML-format .doc files * feat: Change default presigned URL expiration from 1 hour to 12 hours * fix: Table edit pattern improvements and preferred edit model changes **v1.11.32** 2026-01-24 * feat: Schema adherence for required keys in extraction * feat: Improved table edit granularity * fix: Properly propagate password errors for password-protected PDFs **v1.11.31** 2026-01-20 * fix: Anthropic Bedrock on-prem edit calls * feat: Add raw XML repair fallback for malformed docx files * fix: Local parse hanging for multi-batch documents * feat: Schema Optimization Agent for improved extraction accuracy **v1.11.29** 2026-01-16 * fix: Hyperlinks being dropped when OCR extraction mode is enabled * feat: Add line level offsets when config is enabled * feat: Intelligent Ordering Model API integration **v1.11.25** 2025-12-15 * feat: Add document\_password support to pipeline API for password-protected documents * feat: Implement character-level DOCX change tracking * fix: Hidden rows and columns handling for spreadsheets * feat: Cloudflare R2 Storage Class support **v1.11.21** 2025-12-02 * fix: Settings Overrides for streamlined API config/env var customization **v1.11.20** 2025-12-02 * chore: inference parallelization * feat: OCR word and line rotation data propagation * fix: layout prediction improvements * feat: extract schema adherence * fix: empty table model output **v1.11.19** 2025-12-01 * refactor: Document fetching logic * feat: Updated ordering model * feat(settings): more streamlined customization for models and prompts via API configuration / env variables **v1.11.18** 2025-11-25 * feat: Customizable models for AWS Bedrock using environment variables * refactor: Default models updated for AWS Bedrock to `us.anthropic.claude-sonnet-4-5-20250929-v1:0` **v1.11.17** 2025-11-24 * feat: support more edge case custom file mimetypes **v1.11.16** 2025-11-21 * fix: table chunking * fix: make enrich tables more robust **v1.11.15** 2025-11-21 * refactor: optimize some DB transactions to not be left open too long * feat: Add signatures as a formatting option in v3 config * feat: extract schema adherence * refactor: optimize enrich tables latency * feat: new hybrid OCR implementation * feat: add force file mimetype to extension config option **v1.11.14** 2025-11-17 * feat: env var based customization for local KV prompt/model * feat: Add priority-based worker routing to skip shared/dedicated workers when priority is not set * chore: adding latency sensitive for fast mode in Spreadsheet Agent * feat: add OpenAI Responses LLM Provider * feat: Allow direct DataDog Tracing with Beta Headers and Logfire Service name handling **v1.11.13** 2025-11-12 * fix: table block chunking **v1.11.12** 2025-11-11 * fix: Chainguard image dependencies **v1.11.11** 2025-11-10 * fix: md5 for FIPS environments * fix: numbers file parsing **v1.11.10** 2025-11-07 * fix: allow invalid surrogates when encoding * feat: Add logfire gauge metrics for K8s queue lengths **v1.11.9** 2025-11-07 * feat: Add Azure Blob Storage authentication support for private endpoints * fix: race condition with in progress batch -> job completion enqueue * fix: logfire logging if logfire token is set **v1.11.8** 2025-11-06 * fix: embed pdf metadata * fix: persist results before webhook * fix: update cancel\_all and wipe endpoints for on-prem and secure them correctly * refactor: cron cleanup function + running frequency * fix: Chainguard image dependency issues **v1.11.7** 2025-11-04 * fix: PgDog Helm Chart application version configuration for on-premise deployments **v1.11.6** 2025-11-04 * feat: V3 API config with improved spreadsheet response format and citations support * feat: Enhanced table block chunking for better extraction of large tables * feat: Agent-in-the-loop (AITL) extraction with generalizable configuration for multiple fields * feat: Spreadsheet figure summary support for better data visualization * feat: LLM provider preference configuration for v3 API (specify OpenAI, Anthropic, Google, etc.) * feat: Helm chart PgDog dependency for PostgreSQL monitoring * feat: Affinity and topologySpreadConstraints support in Helm charts for advanced pod scheduling * fix: OCR system handling in v3 config * fix: Race condition for single batch jobs * fix: Webhook delivery on Kubernetes environments * fix: Parse job update batching for improved database performance * fix: DOCX timeout increased for large document processing * fix: Table merging with XML parsing improvements * fix: Underline/strikethrough character threshold adjustments * chore: Datadog integration for enhanced monitoring **v1.11.5** 2025-10-30 * feat: Reduce PDF output size by avoiding text layer rasterization * feat: Custom chunking response format support * fix: AITL configuration handling for proper field validation **v1.11.4** 2025-10-28 * feat: Agent-in-the-loop (AITL) documentation exposed and configuration updated to handle multiple fields * feat: Hyperlink extraction support in PDF parsing - preserves document links in output * feat: PostgreSQL Helm dependency migrated to OCI registry for better reliability * feat: Spreadsheet figure summary generation for visual data extraction * fix: OCR system switching for v3 config * fix: Change tracking for accurate document diff detection **v1.11.3** 2025-10-25 * feat: Helm charts now support affinity and topologySpreadConstraints for advanced Kubernetes pod placement control * feat: Table merging heuristics improved * fix: Webhook delivery on Kubernetes fixed for reliable notification * fix: Underline and strikethrough detection threshold adjusted for better accuracy * fix: Safe fill implementation used everywhere in PDF form filling * chore: Datadog monitoring integration **v1.11.2** 2025-10-22 * feat: V3 API config support - new configuration format for improved extraction control * feat: Naive table merging for bulk processing with better cross-page detection * feat: Figure summary enhancements and configuration via API * feat: LLM provider preference support in v3 config * feat: Tool use support for Anthropic provider * feat: /openapi.json and /openapi-legacy.json endpoints for API schema access * feat: Split implementation improvements * fix: Database transaction handling in Kubernetes - don't keep transactions open * fix: Experimental table citations now default to true in v3 * fix: Extract confidence concurrency handling * chore: Figure summarization adjusted for more thorough output **v1.11.1** 2025-10-12 * fix: Helm chart labels for retry stale jobs cronjob **v1.11.0** 2025-10-12 * fix: Build configuration cleanup **v1.10.38** 2025-10-12 * feat: Support for custom extract models via LLM service, enabling on-premise model configurations * fix: PDF form dropdown filling improvements with proper context and option handling * fix: Excel column to string conversion using openpyxl **v1.10.37** 2025-10-10 * feat: New /jobs endpoint with cursor-based pagination for efficient job listing and filtering * feat: New PDF edit flow using parse pipeline for improved form filling accuracy and performance * feat: Schema-less extraction generation - automatically infer extraction schemas when not provided * feat: Enhanced table merging across pages in HTML documents with improved row/column detection * feat: Improved spreadsheet agent with citations support and performance optimizations * feat: Parallelized batch results loading from storage for faster retrieval * fix: Multi-page TIFF and JPEG handling for proper page extraction * fix: Password-protected landscape PDF processing * fix: Text overlay visibility issues during edit flow * fix: Spreadsheet agent formatting values in preview mode * chore: Docker base image upgraded to Debian Trixie for better security and compatibility * chore: Enhanced mode set as default for better quality **v1.10.36** 2025-10-08 * feat: Priority handling for time-sensitive extraction requests with improved page mapping reasoning * feat: Improved Vertex AI Gemini region configuration * fix: Array extract error handling - prevents crashes from malformed LLM output * fix: Better concurrency management for key-value extraction * fix: Split configuration handling improvements * chore: OpenAI API retry logic for handling slow responses * chore: Exponential backoff for split operations **v1.10.35** 2025-10-07 * feat: Improved layout inference with reduced latency **v1.10.34** 2025-10-04 * feat: PDF edit overlay improvements using OCR-B font for better text rendering * fix: Timeout configuration improvements for long-running operations **v1.10.33** 2025-10-01 * fix: Worker stability improvements and bug fixes **v1.10.32** 2025-09-29 * feat: Spreadsheet extraction agent enhancements for better cell and table detection * fix: Citation formatting improvements across extraction outputs **v1.10.31** 2025-09-24 * feat: Cross-page table merging improvements with naive row merging implementation * fix: Performance optimizations for large document processing **v1.10.30** 2025-09-24 * feat: Enhanced extraction pipeline with improved data handling * fix: Error handling improvements throughout the system **v1.10.29** 2025-09-18 * feat: GCP Workload Identity support for Google Cloud deployments * feat: AWS region override configuration for flexible cloud deployments * fix: Worker stability enhancements **v1.10.28** 2025-09-17 * feat: Prometheus alerting integration for monitoring * fix: Reliability enhancements for long-running jobs **v1.10.27** 2025-09-17 * feat: opt-in or opt-out to send billing usage to license server * feat: block OpenAI invocation with BLOCK\_OPENAI env var * feat: signature detection * fix: helm chart template rendering * fix: update figure summarization to correctly override default prompt when user wants to override * refactor: update equations detection to use on premise-provided LLMs **v1.10.24** 2025-01-16 * feat: configurable S3 endpoint url **v1.10.21** 2025-09-08 * feat: character-level support for azure in hybrid mode * feat: support for docx comments **v1.10.16** 2025-09-03 * feat: split support with gemini on vertex ai **v1.10.13** 2025-09-01 * refactor: updated LLM service with vision/text * fix: ensure formatted text (i.e. underline, strikethroughs) is not subsumed by key value detection **v1.10.12** 2025-08-28 * feat: secret management in helm chart * feat: add .msg file support **v1.10.11** 2025-08-26 * feat: HEIC file format support for image processing * feat: character-level OCR detection for strikethrough and underline formatting * feat: parallelize and optimize PDF metadata embedding for improved performance * fix: add locks to prevent race conditions * fix: timeout handling for DOCX to PDF conversion with proper 400 status codes **v1.10.8** 2025-08-20 * feat: configurable S3 SSL options for boto * feat: /billing-usage API for exporting usage in air-gapped deployments **v1.10.7** 2025-08-15 * feat: Support for Google Cloud Storage gs\:// document url * feat: timeout and fail jobs and batches when queued for GLOBAL\_QUEUE\_TIMEOUT\_SEC * feat: BackendConfig support in Helm Chart for GCP **v1.10.4** 2025-08-01 * feat: generate extract schema if no schema was provided * feat: adding form schema for edit documentation **v1.10.3** 2025-08-01 * feat: improve cold starts * fix: fine-grained citation fixes **v1.10.2** 2025-07-31 * feat: DOCX improvements * feat: added schema token limits * feat: add customizations to auth via environment variables * feat: faster model inference optimizations **v1.10.1** 2025-07-30 * fix: OCR image resizing improvements **v1.10.0** 2025-07-29 * feat: implement fault-tolerant webhook delivery * feat: fix table headers for html parsing * feat: add secret metadata parameter to /job/ endpoint * feat: include config when include\_metadata is enabled for job endpoint * feat: adding sheet color to output * feat: clean up refs in extract output * feat: excel table color mapping implementation * feat: enhance merge tables * feat: table feedback loop using the enrich table flag * feat: add litellm proxy model for 'best' **v1.9.94** 2025-07-19 * feat: long-polling with timeout (seconds) query param for `/job/{job_id}` **v1.9.93** 2025-07-16 * fix: job type and add duration field in `/jobs` endpoint * fix: Preserve all decimals in md tables * feat: support for GCP **v1.9.92** 2025-07-14 * feat: presentation detection and kv-disabling **v1.9.91** 2025-07-11 * fix: Strike underline tuning **v1.9.90** 2025-07-11 * feat: implement rtf * fix: Fix offsets for tables extracted from excel sheets * feat: add optional confidence fields to OCRWord and OCRLine * feat: add source in `/jobs` * feat: initial change detection implementation **v1.9.89** 2025-07-10 * docs: clarify Excel citation coordinate system differences * feat: Integrate spreadsheet agent for extract * feat: Convert images to pdf for pdf\_url * Fix: Allow Gemini to output `` and `` fields for key-value * chore: update textract quota * feat: option for multiplatform builds for onprem * docs: Add Model Governance Policy to security section * fix: Split regex fix for subcategory * fix: Add retries to spreadsheet agent * fix: Merging splits in the new format * fix: Merge array\_extract citations based on extract results * Add chart extraction documentation page * feat: ship both small/large models in built images * feat: allow changing default use\_gpu\_ocr config value based on env var **v1.9.88** 2025-07-03 * feat: handle multipart/form-data content type errors on /split endpoint * Latency fix: Agentic unicode changes * fix: sanitize html file upload path to s3 * Add strict typing for SplitResult.splits * feat: Helm chart and values for GPU-based OCR deployment **v1.9.86** 2025-06-23 * feat: enhanced DOCX change tracking with improved underline detection and formatting accuracy * fix: optimized model server initialization to reduce startup time and improve processing performance **v1.9.85** 2025-06-23 * fix: resolved document conversion hangs caused by separate executor processes for improved reliability **v1.9.84** 2025-06-20 * feat: added cancel\_all endpoint for on-prem deployments to cancel all running jobs at once * feat: enhanced extraction with schema key normalization and improved page range references in citations * feat: added global timeout overrides for better performance control and reliability * fix: resolved document conversion hangs with global timeout implementation * fix: improved change tracking validation with proper error handling **v1.9.83** 2025-06-18 * feat: enhanced DOCX metadata extraction for improved change tracking capabilities * fix: improved Excel citation handling when OCR data is not available **v1.9.82** 2025-06-18 * feat: added on-prem licensing alerts when connection to license.reducto.ai fails * feat: implemented timeout functionality for improved processing performance and reliability * fix: improved authentication on /upload and /cancel endpoints for better security **v1.9.81** 2025-06-13 * feat: enhanced multilingual OCR text embedding with support for Latin, CJK, Cyrillic, and Devanagari scripts using custom Unifont font * feat: file-based authentication system for on-prem deployments with Kubernetes secret mounting support * feat: automatic file cleanup system with configurable retention windows (default 60 minutes) to manage storage usage * fix: improved authentication reliability with retry logic for API validation calls * fix: enhanced extraction pipeline to handle None extract\_outputs and improve data merging * fix: native office conversion now skips files over 150MB and falls back to LibreOffice for better reliability * fix: improved citation confidence handling when confidence values are null * fix: batch processing improvements to keep batches alive for large documents **v1.9.80** 2025-06-11 * feat: enhanced array extraction to work with non-array fields for improved data extraction flexibility * feat: improved LLM error handling and timeout support for more reliable model calls * feat: added support for OpenDocument Text (.odt) file uploads through existing LibreOffice conversion pipeline * fix: improved block merging logic to properly update table content during document enrichment * fix: added retry logic for database errors on job status requests to improve reliability * fix: preserve empty blocks (such as figures with no content) in final document layout **v1.9.79** 2025-06-09 * feat: default to big extract model * feat: automatic file cleanup * fix: support for azure openai * feat: add hidden sheet/row/column filtering for Excel processing * feat: Fix jsonbbox and citations for excel * fix: pdf processing timeout * fix: Fix DOCX to PDF conversion error status code from 500 to 400 **v1.9.78** 2025-06-05 * feat: enable change tracking capability * fix: remove the large figure filter in dfine layout model postprocessing **v1.9.77** 2025-06-04 * feat: Split blocks for array\_extract on excel to separate pages **v1.9.76** 2025-06-04 * feat: Persist the full result for url results to persist bucket * feat: Query jobs by user-id and fair queueing docs * fix: Edit conditionals so full tables aren't returned in citations * fix: Fix job type error when cancelling a job * fix: on-prem changelog auth on light and dark mode * fix: Rename file to include guessed extension if one isn't already included * fix: handle empty bbox arrays in layout postprocess calculations **v1.9.75** 2025-06-02 * feat: update replicated helm chart * feat: surface all table citations in v2 **v1.9.74** 2025-05-30 * feat: internal webhook via IPC on job completion * feat: expose table citations in extraction results * feat: add persist config option that persists parsebatches and results * fix: keep batch alive for large HTML documents * fix: check for empty document\_url list in extract * refactor: PDF editing with PyPDFForm for improved form handling **v1.9.73** 2025-05-29 * feat: update extraction pipeline with improved array handling * fix: f-string usage in logfire calls across the codebase **v1.9.72** 2025-05-28 * fix: OpenAI vision LLM calls in LLM router **v1.9.71** 2025-05-28 * fix: root level acroform rendering (preserve form values) * feat: add onprem config option to enable figure summaries for all figures * feat: add exclude\_configs query param to /jobs endpoint to reduce response size * fix: onprem CD now corrects the `/version` url to the latest version number **v1.9.70** 2025-05-23 Enhanced support for OCR text embedding in PDFs with `embed_text_metadata_pdf` flag. **v1.9.69** 2025-05-23 Added support for routing on-premise deployments to v2 extraction pipeline and improved exception handling for subscription errors in Stripe usage logging. Fixed Pydantic AI Agent tools configuration in the document editing functionality. **v1.9.68** 2025-05-23 Added Azure OpenAI support and improved table model with a new fallback mechanism. Enhanced webhook validation and added support for selective customer notifications via target channels parameter. **v1.9.67** 2025-05-19 Added support for LiteLLM Proxy configuration via environment variables: * `LITELLM_PROXY_URL`: URL of the LiteLLM Proxy * `LITELLM_PROXY_FAST_MODEL`: Fast model to route to via the proxy * `LITELLM_PROXY_ACCURATE_MODEL`: Accurate model to route to via the proxy When using the proxy configuration: * Both fast and accurate models must be defined if using the proxy URL * Existing LiteLLM routing options are overridden when proxy settings are active This enables easier integration with centralized proxy setups for model routing and observability. **v1.9.48** 2025-04-07 Add a `/wipe` endpoint to the On-Prem API to wipe the database of all parse jobs, batches, and tasks. This is only available to on prem customers and is a good fail safe. Ensure that this is not available or exposed to the users. Should be a backend only failsafe. Please let us know if you'd like this disabled or removed in your deployment. **v1.9.47** 2025-04-07 In this release, we make some query optimizations to significantly reduce CPU usage of the Postgres DB at high document volumes (e.g. > 1k pg/min). In Google Cloud environments, we improved the skew detection capability by updating our thresholds to more intelligently detect skew in certain cases. Some additional bug fixes were made for folks who specify an LLM provider preference. # Database configuration Source: https://docs.reducto.ai/onprem/database_configuration Configure PostgreSQL connection pooling, timeouts, and performance settings for on-premise deployments ## Connection architecture Each Reducto pod (HTTP server or worker) maintains its own SQLAlchemy connection pool to PostgreSQL. Connections are not shared across pods. | Pod type | Processes per pod | Pool size per process | Max connections per pod | | - | - | - | - | | **HTTP** | 8 gunicorn workers (default) | `pool_size` + `max_overflow` | 8 × (4 + 8) = **96** | | **Worker** | 1 | `pool_size` + `max_overflow` | 4 + 8 = **12** | The number of HTTP workers is controlled by the `HTTP_WORKERS` environment variable (default: `8`). ## Default pool settings These are the application-level defaults for on-premise deployments: | Setting | Default | Description | | - | - | - | | `DB_POOL_SIZE` | `4` | Persistent connections kept open per process | | `DB_MAX_OVERFLOW` | `8` | Additional connections created under load (closed when idle) | | `DB_POOL_TIMEOUT` | `30` (seconds) | Max time to wait for a connection from the pool | | `DB_POOL_PRE_PING` | `true` | Validate connections before use (handles stale connections) | | `DB_POOL_RECYCLE` | `-1` (disabled) | Max connection lifetime in seconds before recycling | | `DB_LOCK_TIMEOUT_MS` | `15000` | PostgreSQL `lock_timeout` per transaction | | `DB_STATEMENT_TIMEOUT_MS` | `20000` | PostgreSQL `statement_timeout` per transaction | All settings can be overridden via environment variables in your Helm values. ## Connection pooling with PgBouncer or RDS Proxy We strongly recommend running an external connection pooler between Reducto and PostgreSQL. Without one, connection storms during pod scaling (especially KEDA-driven autoscaling) can overwhelm the database. ### Azure (built-in PgBouncer) Azure Database for PostgreSQL Flexible Server includes a built-in PgBouncer. Our [Azure on-prem Terraform module](https://github.com/reductoai/reducto-onprem-azure) enables it by default: ```hcl theme={null} variable "postgres_pgbouncer_enabled" { description = "Enable PgBouncer for built-in connection pooling" type = bool default = true } ``` When enabled, the database URL automatically points to port `6432` (PgBouncer) instead of `5432` (direct PostgreSQL). No application-level changes are needed. To verify PgBouncer is active, check the Azure Portal under your PostgreSQL Flexible Server > Server parameters > `pgbouncer.enabled`. ### AWS (RDS Proxy) Our [AWS on-prem Terraform module](https://github.com/reductoai/reducto-onprem-infra) provisions an RDS Proxy by default. The Helm chart automatically uses the pooled database URL: ```yaml theme={null} env: DATABASE_URL: ``` RDS Proxy handles connection multiplexing, failover, and credential rotation transparently. ## Estimating total database connections To estimate your peak connection count: ``` Total connections = (HTTP pods × HTTP_WORKERS × (DB_POOL_SIZE + DB_MAX_OVERFLOW)) + (Worker pods × (DB_POOL_SIZE + DB_MAX_OVERFLOW)) ``` **Example** with 2 HTTP pods and 10 worker pods (default settings): ``` HTTP: 2 × 8 × (4 + 8) = 192 Workers: 10 × (4 + 8) = 120 Total: 312 ``` Azure Flexible Server and AWS RDS have connection limits based on instance size. With a connection pooler, the actual backend connections will be much lower than this number, since the pooler multiplexes idle application connections onto fewer database connections. ### Right-sizing for your workload If you see connection timeout errors or pool exhaustion: 1. **Increase `DB_POOL_SIZE`** if connections are frequently at capacity during steady state 2. **Increase `DB_MAX_OVERFLOW`** if you see spikes during burst traffic 3. **Decrease `DB_POOL_RECYCLE`** (e.g., `300`) if you're behind a pooler that has its own idle timeout — this prevents the application from trying to use connections the pooler has already closed If you are using an external pooler (PgBouncer or RDS Proxy), keep `DB_POOL_PRE_PING` set to `true`. This ensures the application validates connections before use, which is important when the pooler may close idle backend connections. ## Timeout tuning The `lock_timeout` and `statement_timeout` values are set per transaction using `SET LOCAL`, which is compatible with all connection poolers (direct, PgBouncer, RDS Proxy). If you process very large documents (100+ pages) and see timeout errors, you may want to increase these: ```yaml theme={null} env: DB_STATEMENT_TIMEOUT_MS: "30000" # 30 seconds DB_LOCK_TIMEOUT_MS: "20000" # 20 seconds ``` Keep `statement_timeout` higher than `lock_timeout` so that lock contention surfaces as a lock timeout rather than a generic statement timeout. # Deployment options (Cloud, On-prem, Hybrid) Source: https://docs.reducto.ai/onprem/enterprise_deployment_options Flexible, secure deployment options designed to meet enterprise data-governance, performance, and scalability requirements Reducto offers flexible, secure deployment options designed to meet enterprise data-governance, performance, and scalability requirements. Customers can choose between a dedicated VPC deployment, a hybrid VPC model, and a fully hosted SaaS model, balancing control, cost, and compute performance. ## Option 1: Hybrid VPC deployment A hybrid model balancing privacy and compute efficiency. All data and storage reside in the customer's VPC, while ephemeral processing is handled by Reducto's dedicated GPU infrastructure. The GPU infrastructure on Reducto's side can be made single-tenant if desired or shared access can be provided. See the [Hybrid VPC deployment guide](/onprem/hybrid-vpc-deployment) for setup on AWS S3, Azure Blob Storage, Google Cloud Storage, or Box. ### Architecture S3 Bucket/Database lives in a customer environment. Ephemeral workers, which do not persist data beyond memory, are deployed solely for your tenant and connect with your DB via whitelisted outgoing IP. ### Benefits * Maintains data sovereignty (storage & databases stay in the customer's cloud with separate data and compute planes) * Offloads model inference to Reducto's GPU cluster for cost and latency efficiency * Faster auto-scaling and avoid having to provision GPU capacity in your own cloud * Faster iteration speeds for feature resolution, reduced devops burden * Customers can connect their own LLMs or use Reducto's built-in ZDR agreements with processors (OpenAI, Vertex, etc.) ### Architecture variants * **Dynamic Workers** - auto-scales compute based on demand (can scale to zero) * **Always-on Workers** - reserved compute for predictable throughput ## Option 2: Reducto SaaS API Reducto offers a SaaS option that eliminates operational overhead while providing enterprise-level security and compliance, making it ideal for teams who want to focus on building applications rather than managing infrastructure. ### Architecture * Built on Amazon Web Services (AWS) and Modal Labs as the primary cloud providers * Uses AWS S3 for secure data storage with encryption at rest and in transit ### Benefits * Zero infrastructure management * HIPAA compliance available for Growth and Enterprise tiers * Zero data retention for Growth and Enterprise tiers * Automatic updates & reliability * SOC 2 Type II compliant with comprehensive security audits ## Option 3: Full VPC deployment A dedicated deployment fully hosted within the customer's cloud environment (AWS, GCP, or Azure), ensuring complete data isolation and compliance control. In this model, the customer owns the runtime security boundary. Reducto supplies the application and deployment artifacts, while the customer controls network exposure, API access, Kubernetes policy, storage policy, observability access, and egress. Review the [on-prem security model](/onprem/security_model) before production rollout. ### Architecture Reducto provides a container image containing all proprietary models. * Deployment is orchestrated via Helm chart and Terraform templates for seamless setup * Runs on Kubernetes with PostgreSQL as the minimum database requirement * GPU usage is configurable depending on workload * Reducto's models are optimized to run on CPU where possible ### Benefits Data never leaves the customer's VPC. ### Integration & options * Customers can connect their own LLMs (OpenAI, Google Vertex AI, Bedrock, etc.) * Customers can use Reducto's post-trained LLMs requiring GPU clusters * GPU-based extraction models (30B extraction, 7B citation) can be deployed alongside the main application for higher accuracy extraction. Requires H200/H100 GPUs. See [LLM & service configuration](/onprem/llm_options#gpu-based-extraction-models) for details. # Self-hosted fair queueing with Reducto Source: https://docs.reducto.ai/onprem/fair_queueing Give users turns at batch processing in the self-hosted PostgreSQL queue Fair queueing rotates available worker slots among users with queued work. A large document receives a separate turn for each batch, so its backlog cannot occupy every subsequent slot ahead of another user. ### When to use fair queueing * Several users share the legacy PostgreSQL compute workers. * One user's multi-batch documents delay other users' queued work. * You need fair dispatch opportunities alongside pages-per-second admission limits. ### Enable fair queueing `ONPREM_FAIR_QUEUE_ENABLED` defaults to `false`. Set it to `true` on every legacy on-prem worker after upgrading the database, API pods, and worker pods. This setting applies to the PostgreSQL compute queue, including legacy traffic during a streaq migration. It does not change streaq scheduling. Upgrade in this order: 1. Apply all three database migrations with the flag disabled. They add queue indexes, backfill identities from saved job metadata where available, and install the PostgreSQL claim function. 2. Upgrade all API and worker pods with the flag still disabled. New requests persist their scheduling identity even while fair queueing is off. 3. Pause new submissions and drain existing work if all queued work must receive per-user fairness immediately. Tasks enqueued while disabled remain runnable as one anonymous group after enabling; there is no automatic queue backfill. 4. Set `ONPREM_FAIR_QUEUE_ENABLED=true` and restart the API and all legacy worker pods. Mixed FIFO and fair consumers do not provide a fairness guarantee until every consumer uses fair scheduling. The migration backfill and index builds hold queue-table locks. Schedule a maintenance window based on the existing queue size, even with the flag disabled. Set `ONPREM_FAIR_QUEUE_ENABLED=false` and restart the API and all legacy worker pods to restore priority-first FIFO and the original metrics query without per-user logs. Leaving the variable unset also disables fair queueing and these logs. Queued tasks stay in the same tables and do not need to move. Keep the additive database migrations in place when rolling back the flag. While disabled, queue operations use the original columns without identity lookup queries, fair-queue cursor access, or per-user metrics queries. Existing queue tables continue to process work, and PostgreSQL still maintains installed indexes and column defaults. The same drain guidance applies when re-enabling. ### Identify users Send a stable `user-id` header with requests to `/parse` or `/parse_async`. Queue identity is independent of the pages-per-second throttle setting. Enabling fair queueing does not change page-throttle admission limits or rejection behavior. ### Configuration | Setting or header | Default | Behavior | | - | - | - | | `ONPREM_FAIR_QUEUE_ENABLED` | `false` | Enables fair scheduling on legacy workers and per-user scrape logs on API pods. Restart the pods after changing it. | | `user-id` | Unset, anonymous group | Scheduling identity for on-prem requests; at most 1,024 characters when fairness is enabled. | When fair queueing is enabled, on-prem requests whose selected identity exceeds the limit return HTTP 400 before creating a job. Disabled and hosted requests retain their existing header behavior. Use opaque identifiers: when enabled, the operator's scrape logs include these IDs. Keep personal data, secrets, and credentials out of the headers; truncating logged IDs is not redaction. ### Setup and request examples After the migrations and application upgrades above, set the flag on both Deployments. The Kubernetes example uses your namespace and Deployment names. When Helm manages these workloads, also save the environment setting in your deployment configuration so subsequent upgrades retain it. Set `REDUCTO_API_URL` to your self-hosted API URL and `REDUCTO_API_KEY` to its API key for the request examples. ```bash Kubernetes theme={null} kubectl --namespace "$NAMESPACE" set env \ deployment/"$API_DEPLOYMENT" deployment/"$WORKER_DEPLOYMENT" \ ONPREM_FAIR_QUEUE_ENABLED=true kubectl --namespace "$NAMESPACE" rollout status deployment/"$API_DEPLOYMENT" kubectl --namespace "$NAMESPACE" rollout status deployment/"$WORKER_DEPLOYMENT" ``` ```python Python theme={null} import os import requests response = requests.post( os.environ["REDUCTO_API_URL"].rstrip("/") + "/parse_async", json={"input": "https://ci.reducto.ai/onepager.pdf"}, headers={ "user-id": "document-review-app", "Authorization": "Bearer " + os.environ["REDUCTO_API_KEY"], }, timeout=600, ) response.raise_for_status() print(response.json()) ``` ```javascript Node.js theme={null} const baseUrl = process.env.REDUCTO_API_URL.replace(/\/$/, ""); const response = await fetch(`${baseUrl}/parse_async`, { method: "POST", headers: { "Content-Type": "application/json", "Authorization": `Bearer ${process.env.REDUCTO_API_KEY}`, "user-id": "document-review-app", }, body: JSON.stringify({ input: "https://ci.reducto.ai/onepager.pdf" }), }); if (!response.ok) throw new Error(`Request failed: ${response.status}`); console.log(await response.json()); ``` ```bash cURL theme={null} curl --fail-with-body "${REDUCTO_API_URL%/}/parse_async" \ -H "Authorization: Bearer $REDUCTO_API_KEY" \ -H "Content-Type: application/json" \ -H "user-id: document-review-app" \ --data '{"input":"https://ci.reducto.ai/onepager.pdf"}' ``` ### Response structure The asynchronous request returns a job ID after acceptance. Fair queueing does not add fields to the response or change how you retrieve the result. ```json theme={null} { "job_id": "7600c8c5-a52f-49d2-8a7d-d75d1b51e141" } ``` Use this ID to [retrieve the job result](/workflows/async-overview). Synchronous `/parse` requests return the existing [Parse response](/parse/response-format) after processing finishes. ### Scheduling guarantees * Users take turns before priority is considered. Within a user, priority tasks run before normal tasks, and tasks at the same priority start in FIFO order. * Each turn starts one compute task: a batch or job preparation. A single-batch job keeps its inline processing path within its preparation turn. * One active user can use every free worker. Running work finishes normally; newly active users wait for an available slot. * Turns do not guarantee equal pages per second, CPU time, or completion order. Batch sizes and processing times can differ. Completion work keeps its existing IO queue scheduling. ### Database load The scheduler uses indexed lookups for the next user and task. It does not count, sort, or rank the full backlog on each claim. Each claim runs as one PostgreSQL function call in autocommit mode. Its shared cursor lock covers only database work, with no intervening client round trips or separate commit request. Claims are serialized; batches execute concurrently after the locks are released. More submitted tasks still require more writes and storage. Keep PostgreSQL autovacuum healthy and avoid long-lived transactions that prevent cleanup of deleted queue rows. Monitor claim latency and cursor lock waits as worker concurrency increases. Existing exact queue-depth metrics use `COUNT(*)`, so their database cost grows with backlog independently of fair scheduling. ### Queue-depth logs With `ONPREM_FAIR_QUEUE_ENABLED=true` on the API pods, every on-prem `/metrics` scrape logs an `onprem.queue_depth.snapshot` summary and up to 100 `onprem.queue_depth.user` entries, selecting the largest queued compute backlogs. This also applies to `/prometheus` and requires no separate logging setting. When the flag is false or unset, scrapes use the original total-count query with no per-user counting or queue-depth logs. Restart the API pods after changing the flag. JSON and Prometheus responses keep their existing metrics; user IDs are not added as Prometheus labels. User entries contain `user_id`, `user_key`, `normal_depth`, `priority_depth`, and their sum, `queue_depth`. Logged IDs are limited to 256 characters; `user_id_truncated=true` marks shortened values. The full hashed `user_key` distinguishes IDs with the same displayed prefix, including older oversized IDs. Anonymous work has `user_id=null` and `user_key="anonymous"`. The shared `scrape_id` ties entries to their summary, which is logged even when the queues are empty. Its `user_count`, `logged_user_count`, and `omitted_user_count` distinguish a complete listing from capped detail. Overall queue totals remain exact regardless of the detail limit. Counts describe waiting compute tasks, including job preparation; running tasks are excluded. Completion queues contribute only to the summary's `io_depth`, `priority_io_depth`, and overall totals. The scrape groups the existing count scan by indexed user key and reads one identity payload only for groups selected for logging. It reuses these counts for the response and holds no scheduler locks. Exact counting still scales with queued tasks. Each scrape emits at most 101 queue-depth log entries; scrape frequency still affects total log volume. ### Inspect recent jobs `/jobs` lists jobs in the authenticated organization. On deployments that use `user-id` as the organization ID, send the same header used to submit the job. The examples set `exclude_configs=true` to omit saved request configurations; this option defaults to `false`. ```python Python theme={null} import os import requests response = requests.get( os.environ["REDUCTO_API_URL"].rstrip("/") + "/jobs", params={"exclude_configs": True}, headers={ "user-id": "document-review-app", "Authorization": "Bearer " + os.environ["REDUCTO_API_KEY"], }, timeout=30, ) response.raise_for_status() print(response.json()) ``` ```javascript Node.js theme={null} const baseUrl = process.env.REDUCTO_API_URL.replace(/\/$/, ""); const response = await fetch(`${baseUrl}/jobs?exclude_configs=true`, { headers: { "Authorization": `Bearer ${process.env.REDUCTO_API_KEY}`, "user-id": "document-review-app", }, }); if (!response.ok) throw new Error(`Request failed: ${response.status}`); console.log(await response.json()); ``` ```bash cURL theme={null} curl --fail-with-body "${REDUCTO_API_URL%/}/jobs?exclude_configs=true" \ -H "Authorization: Bearer $REDUCTO_API_KEY" \ -H "user-id: document-review-app" ``` For example, a newly accepted job can appear as: ```json theme={null} { "jobs": [ { "job_id": "7600c8c5-a52f-49d2-8a7d-d75d1b51e141", "status": "Pending", "type": "Parse", "raw_config": "", "created_at": "2026-09-24T12:00:00Z", "source": null, "num_pages": null, "total_pages": null, "duration": null, "bucket": null } ], "next_cursor": null } ``` `next_cursor=null` means there are no more results. Otherwise, send the returned cursor as the next request's `cursor` query parameter. ### Troubleshooting #### A user's tasks appear in the anonymous group **Problem**: Queue-depth logs show `user_id=null` for work you expected to identify. **Solution**: Send a nonempty, stable `user-id`. Confirm your proxy forwards the header. Jobs submitted before identity capture was available can remain anonymous; new headers cannot relabel them. #### Metrics scrapes produce no per-user logs **Problem**: `/metrics` returns queue totals but no `onprem.queue_depth.user` entries. **Solution**: Set `ONPREM_FAIR_QUEUE_ENABLED=true` on the API pods as well as the legacy workers, then restart them. This logging path requires PostgreSQL. Empty compute queues produce only the snapshot summary; running tasks do not contribute to queue depth. Check `omitted_user_count` when a particular user is absent from the largest 100 backlogs. #### Another user still waits after enablement **Problem**: A newly submitted job does not start immediately. **Solution**: Verify every legacy worker has restarted with the flag enabled. Workers finish running batches before taking another turn; fair queueing does not preempt work. Check that clients send distinct identities and that the work uses the PostgreSQL queue. Streaq scheduling follows its own policy. #### A request returns HTTP 400 for an identity header **Problem**: The error says the selected header must not exceed 1,024 characters. **Solution**: Use a shorter stable identifier. This input limit applies only when fair scheduling is enabled. # Automatic file cleanup Source: https://docs.reducto.ai/onprem/file_cleanup Configure automatic deletion of uploaded files in Reducto ## Overview When `FILE_CLEANUP_ENABLED` is set, Reducto records the keys of every object written to customer storage and automatically deletes any object that is older than the configured retention window (default 60 minutes). The cleanup runs as part of the hourly `cleanup.py` cronjob. **Note:** Because cleanup is performed hourly, the minimum effective retention interval is 60 minutes (1 hour). ## Configuration | Variable | Description | Default | | - | - | - | | `FILE_CLEANUP_ENABLED` | Set to any value to enable automatic deletion of stored objects. | – | | `FILE_RETENTION_MINUTES` | Retention window in minutes before an object is permanently removed. The minimum is 60 minutes (1 hour), as cleanup runs hourly. | `60` | Example: ```bash theme={null} export FILE_CLEANUP_ENABLED=1 export FILE_RETENTION_MINUTES=120 # keep files for two hours ``` # Hybrid VPC — AWS S3 Source: https://docs.reducto.ai/onprem/hybrid-vpc-aws Set up Hybrid VPC with AWS S3 storage and optional PrivateLink This guide covers setting up Hybrid VPC with AWS S3 as your storage backend. Reducto assumes an IAM role in your AWS account to read and write documents. ## Prerequisites * **AWS account(s)**: Can use separate accounts for development, staging, and production * **Terraform 1.2+**: For infrastructure provisioning * **Values from Reducto** (provided during onboarding): * ExternalId for secure role assumption * Endpoint Service name and region (if using PrivateLink, also listed below) * **If using PrivateLink**: Send Reducto every AWS account ID that will create a VPC endpoint, including separate dev, staging, production, or organizational accounts. Reducto must allow-list each account on the VPC Endpoint Service before endpoint creation succeeds. ### Reducto regions Reducto runs separate deployments in the US, EU, and AU. Each deployment uses only buckets in its own area and assumes your IAM role with its own principals. Find the Reducto region for your bucket's AWS region, then use that row's principal ARNs and API base URL. | Reducto region | Bucket AWS regions | API base URL | | - | - | - | | **US** | `us-*`, `ca-*` | `https://platform.reducto.ai` | | **EU** | `eu-*` | `https://eu.platform.reducto.ai` | | **AU** | `ap-southeast-2` | `https://au.platform.reducto.ai` | Buckets in other AWS regions are not supported. ### Principal ARNs Trust all three ARNs for the Reducto region that serves your bucket: | Reducto region | EKS Role ARN | Modal ARNs | | - | - | - | | **US** | `arn:aws:iam::731106932034:role/reducto-prod` | `arn:aws:iam::731106932034:user/modal-prod`, `arn:aws:iam::731106932034:role/modal-prod` | | **EU** | `arn:aws:iam::731106932034:role/reducto-prod-eu` | `arn:aws:iam::731106932034:user/modal-prod-eu`, `arn:aws:iam::731106932034:role/modal-prod-eu` | | **AU** | `arn:aws:iam::731106932034:role/reducto-prod-au` | `arn:aws:iam::731106932034:user/modal-prod-au`, `arn:aws:iam::731106932034:role/modal-prod-au` | > [!WARNING] > > Principal ARNs are region-specific. The US ARNs do not work for an EU or AU bucket, and the EU and AU ARNs work only in their own region. If your role trusts another region's principals, every job fails with `AccessDenied` on `sts:AssumeRole`. > [!NOTE] > > Trust both Modal ARNs for your region. Reducto's Modal compute assumes your role either as the IAM user or as the IAM role of the same name. Omitting the role ARN causes `AccessDenied` on `sts:AssumeRole` for jobs that finalize on Modal. ### VPC Endpoint Service Configuration If using PrivateLink, use the endpoint service for the Reducto region that serves your bucket, and create your VPC endpoint in one of its supported VPC regions: | Reducto region | VPC Endpoint Service Name | Service region | Supported VPC regions | DNS Name | | - | - | - | - | - | | **US** | `com.amazonaws.vpce.us-west-2.vpce-svc-0929182c8ed77b7a8` | `us-west-2` | `us-east-1`, `us-east-2`, `us-west-1`, `us-west-2`, `eu-west-1` | `hybrid.platform.reducto.ai` | | **EU** | `com.amazonaws.vpce.eu-central-1.vpce-svc-0a231d441f3a482a0` | `eu-central-1` | `eu-central-1`, `eu-west-1`, `eu-west-2`, `eu-west-3`, `eu-north-1` | `hybrid.eu.platform.reducto.ai` | | **AU** | `com.amazonaws.vpce.ap-southeast-2.vpce-svc-0da3ceba709035c36` | `ap-southeast-2` | `ap-southeast-2` | `hybrid.au.platform.reducto.ai` | > [!NOTE] > > If your VPC is in a different region from the service region, the endpoint connects cross-region. Set `reducto_endpoint_service_region` to the service region. To connect from a VPC region that is not listed, contact Reducto. > [!WARNING] > > PrivateLink endpoint creation only works after Reducto has allow-listed the AWS account that creates the endpoint. If your organization uses separate AWS accounts for dev, staging, production, or separate business units, provide each account ID before setup. ## Setup **1. Clone the infrastructure repository** ```bash theme={null} git clone https://github.com/reductoai-collab/reducto-hybrid-infra.git cd reducto-hybrid-infra ``` **2. Create terraform.tfvars** Use the principal ARNs for the Reducto region that serves your bucket: > [tab] US > > ```hcl theme={null} > name_prefix = "reducto-us" > > reducto_principal_arns = [ > "arn:aws:iam::731106932034:role/reducto-prod", > "arn:aws:iam::731106932034:user/modal-prod", > "arn:aws:iam::731106932034:role/modal-prod" > ] > reducto_external_id = "" > ``` > [tab] EU > > ```hcl theme={null} > name_prefix = "reducto-eu" > > reducto_principal_arns = [ > "arn:aws:iam::731106932034:role/reducto-prod-eu", > "arn:aws:iam::731106932034:user/modal-prod-eu", > "arn:aws:iam::731106932034:role/modal-prod-eu" > ] > reducto_external_id = "" > ``` > [tab] AU > > ```hcl theme={null} > name_prefix = "reducto-au" > > reducto_principal_arns = [ > "arn:aws:iam::731106932034:role/reducto-prod-au", > "arn:aws:iam::731106932034:user/modal-prod-au", > "arn:aws:iam::731106932034:role/modal-prod-au" > ] > reducto_external_id = "" > ``` Optional variables: ```hcl theme={null} # Bucket name (default: -reducto-) # bucket_name = "my-company-reducto-data" # Object retention in days (default: 1, minimum: 1) # lifecycle_expiration_days = 1 tags = { Environment = "production" Project = "reducto-hybrid" } ``` **3. Initialize and apply** The root module has no provider block, so Terraform creates the bucket in the region from your AWS environment. Set it to your bucket's region: ```bash theme={null} export AWS_REGION=ap-southeast-2 # your bucket's region terraform init terraform plan terraform apply ``` **4. Share outputs with Reducto** ```bash theme={null} terraform output -json integration_values ``` Example output: ```json theme={null} { "access_mode": "assume_role", "account_id": "987654321098", "bucket_name": "reducto-au-reducto-a1b2c3d4", "privatelink_endpoint_id": null, "region": "ap-southeast-2", "role_arn": "arn:aws:iam::987654321098:role/reducto-au-reducto-access" } ``` Send this output to your Reducto team. Reducto registers the bucket and role for your organization and confirms when it is active. ### Components provisioned | Component | Purpose | Required | | - | - | - | | S3 Bucket | Document and artifact storage with lifecycle expiration, SSE-S3 encryption, and public access blocked | Yes | | IAM Role | `-reducto-access`: cross-account access for Reducto with ExternalId protection | Yes | | VPC Endpoint | PrivateLink endpoint for private API access, with a security group allowing HTTPS from the VPC CIDR | Optional | ## Access Mode Keep the default, `access_mode = "assume_role"`. Reducto reaches your bucket by assuming the IAM role with your ExternalId. > [!WARNING] > > The module also accepts `access_mode = "bucket_policy"`, which grants the principals access through a bucket policy and creates no IAM role. Reducto's Hybrid VPC storage requires a role ARN and ExternalId, so it cannot use a bucket set up this way. Do not use `bucket_policy` for Hybrid VPC. ## PrivateLink Setup (Optional) For private-only API access without traversing the public internet: **1. Request PrivateLink enablement** Provide the following to your Reducto team: * **AWS Account ID(s)**: Every account where you'll create a VPC endpoint, including dev, staging, production, or separate organizational accounts * **Region(s)**: The AWS regions of the VPCs that will connect, and the Reducto region that serves your bucket Reducto will add each account root as an allowed principal on the VPC Endpoint Service. Wait for Reducto's confirmation before you create the endpoint. **2. Configure VPC endpoint** Add to your `terraform.tfvars`, using the service name and service region for your Reducto region from the [table above](#vpc-endpoint-service-configuration): ```hcl theme={null} enable_privatelink = true vpc_id = "vpc-0123456789abcdef0" subnet_ids = ["subnet-abc123", "subnet-def456"] reducto_endpoint_service_name = "com.amazonaws.vpce.ap-southeast-2.vpce-svc-0da3ceba709035c36" reducto_endpoint_service_region = "ap-southeast-2" ``` The module creates a security group that allows HTTPS from the VPC's primary CIDR. If clients connect from other networks, such as peered VPCs, Transit Gateway, or VPN, pass your own security groups in `privatelink_security_group_ids`. **3. Configure your client** Use the DNS name for your Reducto region: ```python theme={null} from reducto import Reducto client = Reducto( api_key="your-api-key", base_url="https://hybrid.au.platform.reducto.ai", # US: hybrid.platform.reducto.ai, EU: hybrid.eu.platform.reducto.ai ) ``` > [!WARNING] > > Private DNS must be enabled on the VPC endpoint so the `hybrid.*.platform.reducto.ai` name resolves to it inside your VPC. The module enables it. If you create the endpoint another way, enable private DNS yourself. ### Require PrivateLink By default, your organization can also call the public API base URL. Ask Reducto to enable PrivateLink-only access for your organization. Reducto then rejects requests that do not arrive through PrivateLink with HTTP 403. ## Validation Checklist After `terraform apply`, verify your setup: * [ ] **Terraform apply succeeded** without errors * [ ] **S3 bucket has lifecycle rule**: ```bash theme={null} aws s3api get-bucket-lifecycle-configuration --bucket your-bucket-name ``` * [ ] **S3 bucket blocks public access**: All four settings are `true` ```bash theme={null} aws s3api get-public-access-block --bucket your-bucket-name ``` * [ ] **IAM role trust policy is correct**: The principals are all three ARNs for your Reducto region, and the `sts:ExternalId` condition matches the ExternalId from Reducto ```bash theme={null} aws iam get-role --role-name "$(terraform output -raw assumable_role_name)" --query 'Role.AssumeRolePolicyDocument' ``` * [ ] **If PrivateLink enabled**: Reducto has confirmed that every endpoint-creating AWS account is allow-listed * [ ] **If PrivateLink enabled**: Endpoint status shows "available" ```bash theme={null} aws ec2 describe-vpc-endpoints --vpc-endpoint-ids vpce-xxx ``` * [ ] **Smoke test**: After Reducto confirms the setup, run a small job against your Reducto region's API base URL and verify objects appear in the bucket ## Troubleshooting ### AccessDenied on sts:AssumeRole **Problem**: Jobs fail because Reducto gets `AccessDenied` when it assumes your role. **Solution**: Check the trust policy with the `aws iam get-role` command from the [Validation Checklist](#validation-checklist). The common causes are: * **Wrong region's principals**: The role trusts the US ARNs, but your bucket is served by EU or AU (or the reverse). Set `reducto_principal_arns` to the [Principal ARNs](#principal-arns) row for your bucket's Reducto region and run `terraform apply` again. * **Missing Modal role ARN**: The role trusts `user/modal-*` but not `role/modal-*`. Add the role ARN for your region. * **ExternalId mismatch**: The `sts:ExternalId` value in the trust policy must exactly match the ExternalId Reducto registered for your organization. * **Role ARN mismatch**: The role ARN Reducto registered must match the role you created. The module names it `-reducto-access`. ### AccessDenied on S3 after the role is assumed **Problem**: Reducto assumes the role, but reads or writes fail with `AccessDenied`. **Solution**: Check that no bucket policy denies the role. If the bucket uses SSE-KMS with a customer managed key, grant the KMS permissions in [Encryption](#encryption). To read `s3://` inputs from a bucket other than the Reducto bucket, add `s3:GetObject` on that bucket to the role's policy. ### Region cannot be used with this deployment **Problem**: The API returns an error that your Hybrid VPC region or environment cannot be used with this deployment, or that your organization is not configured for this region. **Solution**: Call the API base URL for the Reducto region that serves your bucket (see [Reducto regions](#reducto-regions)). With PrivateLink, use that region's endpoint service and DNS name. ### VPC endpoint service does not exist **Problem**: AWS returns `InvalidServiceName` or says the VPC Endpoint Service does not exist, but the service name and region match the table above. **Solution**: Send Reducto the AWS account ID for the account creating the endpoint. Reducto will allow-list the account root on the endpoint service. After Reducto confirms the change, retry endpoint creation. Repeat this for each account that will create an endpoint. Also confirm that your VPC's region is one of the service's supported VPC regions. ## Multi-Region Setup Deploy one bucket and IAM role per region. Each role must trust the principals of the Reducto region that serves its bucket. Give each region its own `name_prefix`, because the IAM role name (`-reducto-access`) must be unique in an AWS account. Copy the `minimal` example into one directory per region. Each directory keeps its own Terraform state: ```bash theme={null} mkdir -p regions cp -r examples/minimal regions/us-east-1 cp -r examples/minimal regions/eu-central-1 cp -r examples/minimal regions/ap-southeast-2 ``` Create a `terraform.tfvars` in each directory with that region's principal ARNs: ```hcl theme={null} # regions/us-east-1/terraform.tfvars aws_region = "us-east-1" name_prefix = "reducto-us" reducto_principal_arns = [ "arn:aws:iam::731106932034:role/reducto-prod", "arn:aws:iam::731106932034:user/modal-prod", "arn:aws:iam::731106932034:role/modal-prod" ] reducto_external_id = "" ``` ```hcl theme={null} # regions/eu-central-1/terraform.tfvars aws_region = "eu-central-1" name_prefix = "reducto-eu" reducto_principal_arns = [ "arn:aws:iam::731106932034:role/reducto-prod-eu", "arn:aws:iam::731106932034:user/modal-prod-eu", "arn:aws:iam::731106932034:role/modal-prod-eu" ] reducto_external_id = "" ``` ```hcl theme={null} # regions/ap-southeast-2/terraform.tfvars aws_region = "ap-southeast-2" name_prefix = "reducto-au" reducto_principal_arns = [ "arn:aws:iam::731106932034:role/reducto-prod-au", "arn:aws:iam::731106932034:user/modal-prod-au", "arn:aws:iam::731106932034:role/modal-prod-au" ] reducto_external_id = "" ``` Apply each region and send Reducto every `integration_values` output: ```bash theme={null} cd regions/us-east-1 && terraform init && terraform apply cd ../eu-central-1 && terraform init && terraform apply cd ../ap-southeast-2 && terraform init && terraform apply ``` Each Reducto region then uses the bucket in its own area. For example, a request to `https://au.platform.reducto.ai` reads and writes your `ap-southeast-2` bucket. ## Multi-Environment Setup For organizations with separate AWS accounts for dev/staging/prod, create one directory per environment under `examples/multi-environment/`, and copy `main.tf` and `terraform.tfvars.example` (renamed to `terraform.tfvars`) into each. The template's module source (`../../../`) expects this location: ``` examples/multi-environment/ ├── dev/ │ ├── main.tf │ └── terraform.tfvars ├── staging/ │ ├── main.tf │ └── terraform.tfvars └── prod/ ├── main.tf └── terraform.tfvars ``` Each environment should use a separate Terraform state file and its own S3 bucket and IAM role. If the environments share the same Reducto org, Reducto can register them as named Hybrid VPC environments instead of separate orgs. ## Multiple Buckets for One Reducto Org For workflows that need client-specific buckets under one Reducto organization, Reducto registers each bucket/role pair as a named environment: ```json theme={null} { "default_environment": "client-a", "environments": { "client-a": { "region": "us-east-1", "bucket": "client-a-reducto-data", "role_arn": "arn:aws:iam::987654321098:role/reducto-client-a" }, "client-b": { "region": "us-east-1", "bucket": "client-b-reducto-data", "role_arn": "arn:aws:iam::987654321098:role/reducto-client-b" } } } ``` An environment's `region` must belong to the Reducto region you call. For environments in more than one Reducto region, Reducto can set a default per region with `us_default_environment`, `eu_default_environment`, and `au_default_environment`. If your organization has several environments, set a default or name an environment on every request. Without a default, a request that names no environment either fails with an error that lists the available environments or, if Reducto also registered regional storage for your organization, uses that storage. Select the environment on each request. Where the setting goes depends on the endpoint: > [tab] Parse > > ```json theme={null} > { > "input": "s3://client-b-reducto-data/documents/invoice.pdf", > "settings": { > "hybrid_vpc": { > "environment": "client-b" > } > } > } > ``` > [tab] Extract and Split > > ```json theme={null} > { > "input": "s3://client-b-reducto-data/documents/invoice.pdf", > "parsing": { > "settings": { > "hybrid_vpc": { > "environment": "client-b" > } > } > } > } > ``` > [tab] Upload > > `/upload` has no JSON body, so pass the environment as a query parameter: > > ``` > POST /upload?hybrid_vpc_environment=client-b > ``` > [!WARNING] > > On `/extract` and `/split`, a top-level `settings.hybrid_vpc` is ignored and the request uses your default environment. Put it under `parsing.settings`. Reducto reads `s3://` inputs with the selected environment's role, so that role needs `s3:GetObject` on the input bucket. ## Security ### ExternalId protection The ExternalId in the IAM role trust policy prevents [confused deputy attacks](https://docs.aws.amazon.com/IAM/latest/UserGuide/confused-deputy.html). Only requests with the correct ExternalId can assume the role. ### Role sessions Reducto assumes the role with the session name `reducto-hybrid-vpc` and a one-hour session that it renews automatically. CloudTrail in your account records these `AssumeRole` calls. URLs that Reducto returns for objects in your bucket, such as URL results and images, are presigned with this session. They stop working when the session expires, within one hour, so download them promptly. ### Principle of least privilege The IAM role grants only the permissions necessary for Reducto operations: * `s3:GetObject`: read documents * `s3:PutObject`: write results and artifacts * `s3:DeleteObject`: delete objects, for example when you delete a job * `s3:ListBucket`, `s3:GetBucketLocation`: bucket-level access * `s3:AbortMultipartUpload`, `s3:ListMultipartUploadParts`, `s3:ListBucketMultipartUploads`: handle large file uploads ### Encryption The module turns on default encryption with SSE-S3 (`AES256`). If you switch the bucket to SSE-KMS with a customer managed key, add `kms:Decrypt` and `kms:GenerateDataKey` on that key to the role's policy, and allow the role in the key policy. To have Reducto request that key on every write, send Reducto the key ARN. ### Automatic data cleanup Objects expire automatically based on the lifecycle configuration (default: 1 day), and incomplete multipart uploads are aborted after 1 day. This ensures no long-term data persistence, compliance with retention policies, and automatic cleanup of intermediate artifacts. The rule covers the whole bucket. It also deletes source documents that you store there, and async job results can no longer be retrieved after they expire. Keep documents you want to retain in another bucket. # Hybrid VPC — Azure Blob Storage Source: https://docs.reducto.ai/onprem/hybrid-vpc-azure Set up Hybrid VPC with Azure Blob Storage for data sovereignty This guide covers setting up Hybrid VPC with Azure Blob Storage as your storage backend. Reducto reads and writes documents in one container of your Storage account, and signs short-lived, read-only SAS links to results in that container. Reducto connects in one of two ways: * **Managed identity (recommended)**: Reducto signs in to your Entra ID tenant as a user-assigned managed identity that you own. You give Reducto no secret, and your Storage account can disable shared key access. * **Connection string**: You give Reducto a Storage account connection string that contains an account key. ## Managed identity You create a user-assigned managed identity in your tenant and add a federated identity credential to it. The credential trusts Reducto's AWS account, so Reducto signs in as the identity with a short-lived token from AWS instead of a stored secret. Azure role assignments decide what the identity can reach. ### Architecture ```mermaid theme={null} flowchart LR subgraph reducto["Reducto Infrastructure"] direction TB role["Reducto federation role
(AWS IAM, one per region)"] workers["Compute Workers"] role --> workers end subgraph customer["Your Azure tenant"] direction TB identity["User-assigned managed identity
(federated credential)"] blob["Azure Blob Storage
(documents, results, artifacts)"] identity -->|"role assignments"| blob end workers -->|"1. AWS token for your organization"| entra["Microsoft Entra ID"] entra -->|"2. token for your identity"| workers workers <-->|"3. read / write"| blob client["Your application"] -->|"SAS link (read-only, expires)"| blob ``` 1. Reducto's workers request a short-lived token from AWS. The token names Reducto's federation role for the region and an audience that contains your Reducto organization ID. 2. Microsoft Entra ID accepts the token only if your identity's federated credential matches its issuer, subject, and audience. Entra ID then issues a token for your identity. 3. Reducto uses that token to read your input documents and to write results and intermediate artifacts to your container. 4. When a response contains a URL to a result or an image, that URL is a read-only user delegation SAS link to one blob in your container. Reducto signs it with a user delegation key that it requests as your identity. The link expires after at most 12 hours. Reducto derives the audience from the organization that sends the request. Another Reducto customer who enters your tenant ID and client ID gets a token for their own organization ID, which your federated credential does not trust. ### Federated credential values Add one federated credential to the identity for each Reducto region that processes your documents. All three fields must match exactly. | Field | Value | | - | - | | Issuer | `https://a1fb3098-741e-48ff-bd36-481253997312.tokens.sts.global.api.aws` | | Subject | The federation role of the region (see the table below) | | Audience | `api://reducto-hybrid-vpc/` | | Data residency | Subject | | - | - | | US | `arn:aws:iam::731106932034:role/reducto-entra-federation-prod` | | EU | `arn:aws:iam::731106932034:role/reducto-entra-federation-prod-eu` | | AU | `arn:aws:iam::731106932034:role/reducto-entra-federation-prod-au` | Microsoft Entra ID allows one federated credential per issuer and subject on an identity. Use a separate identity for each Reducto organization that uses the same region. ### Prerequisites * **Azure subscription** with permission to create a managed identity and a Storage account, and to assign roles on the Storage account (Owner, or User Access Administrator plus Contributor) * **Terraform 1.2+** with the AzureRM provider 4.65 or later (optional, for automated setup) * **Blob data access for the Terraform identity**: The Storage account disables shared key access, so the provider calls the Blob data plane with Entra ID (`storage_use_azuread = true`). Before you apply, assign the user or service principal that runs Terraform **Storage Blob Data Contributor** on the subscription, because the example creates the resource group during the apply. If you adapt the example to use an existing resource group, you can assign the role on that group instead. Owner and Contributor alone do not include Blob data actions * **Your Reducto organization ID** (provided during onboarding) ### Setup > [tab] Terraform > > **1. Create main.tf** > > ```hcl theme={null} > terraform { > required_version = ">= 1.2" > required_providers { > azurerm = { > source = "hashicorp/azurerm" > version = ">= 4.65" # user_assigned_identity_id on the federated credential > } > } > } > > provider "azurerm" { > features {} > storage_use_azuread = true # The Storage account disables shared key access > } > > variable "name_prefix" { > type = string > } > > variable "location" { > type = string > default = "eastus" > } > > variable "reducto_organization_id" { > type = string > } > > variable "reducto_regions" { > description = "Reducto regions that process your documents: prod (US), prod-eu, prod-au" > type = set(string) > default = ["prod"] > } > > locals { > reducto_issuer = "https://a1fb3098-741e-48ff-bd36-481253997312.tokens.sts.global.api.aws" > } > > resource "azurerm_resource_group" "this" { > name = "${var.name_prefix}-reducto-rg" > location = var.location > } > > resource "azurerm_storage_account" "this" { > name = substr(replace("${var.name_prefix}reducto", "-", ""), 0, 24) > resource_group_name = azurerm_resource_group.this.name > location = azurerm_resource_group.this.location > account_tier = "Standard" > account_replication_type = "LRS" > min_tls_version = "TLS1_2" > allow_nested_items_to_be_public = false > shared_access_key_enabled = false # Reducto signs in with the managed identity > } > > resource "azurerm_storage_container" "this" { > name = "reducto-documents" > storage_account_id = azurerm_storage_account.this.id > container_access_type = "private" > } > > resource "azurerm_storage_management_policy" "this" { > storage_account_id = azurerm_storage_account.this.id > rule { > name = "reducto-auto-expire" > enabled = true > filters { > blob_types = ["blockBlob"] > prefix_match = ["${azurerm_storage_container.this.name}/"] # Only the Reducto container > } > actions { > base_blob { > delete_after_days_since_modification_greater_than = 1 > } > } > } > } > > resource "azurerm_user_assigned_identity" "reducto" { > name = "${var.name_prefix}-reducto" > resource_group_name = azurerm_resource_group.this.name > location = azurerm_resource_group.this.location > } > > resource "azurerm_federated_identity_credential" "reducto" { > for_each = var.reducto_regions > > name = "reducto-${each.key}" > user_assigned_identity_id = azurerm_user_assigned_identity.reducto.id > issuer = local.reducto_issuer > subject = "arn:aws:iam::731106932034:role/reducto-entra-federation-${each.key}" > audience = ["api://reducto-hybrid-vpc/${var.reducto_organization_id}"] > } > > resource "azurerm_role_assignment" "data" { > scope = azurerm_storage_container.this.id > role_definition_name = "Storage Blob Data Contributor" > principal_id = azurerm_user_assigned_identity.reducto.principal_id > principal_type = "ServicePrincipal" > } > > resource "azurerm_role_assignment" "delegator" { > scope = azurerm_storage_account.this.id > role_definition_name = "Storage Blob Delegator" > principal_id = azurerm_user_assigned_identity.reducto.principal_id > principal_type = "ServicePrincipal" > } > > output "tenant_id" { > value = azurerm_user_assigned_identity.reducto.tenant_id > } > > output "client_id" { > value = azurerm_user_assigned_identity.reducto.client_id > } > > output "account_url" { > value = azurerm_storage_account.this.primary_blob_endpoint > } > > output "container" { > value = azurerm_storage_container.this.name > } > ``` > > **2. Initialize and apply** > > ```bash theme={null} > terraform init > # Replace acme, eastus, and the organization ID with your own values. > # Add "prod-eu" or "prod-au" to reducto_regions for EU or AU data residency. > terraform apply \ > -var name_prefix=acme \ > -var location=eastus \ > -var reducto_organization_id="your-reducto-organization-id" \ > -var 'reducto_regions=["prod"]' > ``` > > **3. Read the values for Reducto** > > ```bash theme={null} > terraform output > ``` > > Send `tenant_id`, `client_id`, `account_url`, and `container` to your Reducto team. None of them is a secret. > [tab] Azure CLI > > **1. Create the Storage account and container** > > ```bash theme={null} > RG=acme-reducto-rg > ACCOUNT=acmereducto > CONTAINER=reducto-documents > LOCATION=eastus > > az group create -n $RG -l $LOCATION > az storage account create -n $ACCOUNT -g $RG -l $LOCATION --sku Standard_LRS \ > --allow-shared-key-access false --allow-blob-public-access false --min-tls-version TLS1_2 > ACCOUNT_ID=$(az storage account show -n $ACCOUNT -g $RG --query id -o tsv) > > # Creating a container with --auth-mode login needs a data role for your own user. > az role assignment create --assignee "$(az ad signed-in-user show --query id -o tsv)" \ > --role "Storage Blob Data Owner" --scope $ACCOUNT_ID > az storage container create -n $CONTAINER --account-name $ACCOUNT --auth-mode login > ``` > > A new role assignment can take a few minutes to apply. If the container command returns `AuthorizationPermissionMismatch`, wait and retry. > > **2. Create the managed identity and its federated credential** > > ```bash theme={null} > IDENTITY=acme-reducto > ORG_ID="your-reducto-organization-id" > > az identity create -n $IDENTITY -g $RG -l $LOCATION > > # One credential per Reducto region: prod (US), prod-eu, prod-au > for REGION in prod; do > az identity federated-credential create -n "reducto-$REGION" \ > --identity-name $IDENTITY -g $RG \ > --issuer "https://a1fb3098-741e-48ff-bd36-481253997312.tokens.sts.global.api.aws" \ > --subject "arn:aws:iam::731106932034:role/reducto-entra-federation-$REGION" \ > --audiences "api://reducto-hybrid-vpc/$ORG_ID" > done > ``` > > **3. Assign the two roles** > > ```bash theme={null} > PRINCIPAL=$(az identity show -n $IDENTITY -g $RG --query principalId -o tsv) > > az role assignment create --assignee-object-id $PRINCIPAL --assignee-principal-type ServicePrincipal \ > --role "Storage Blob Data Contributor" \ > --scope "$ACCOUNT_ID/blobServices/default/containers/$CONTAINER" > az role assignment create --assignee-object-id $PRINCIPAL --assignee-principal-type ServicePrincipal \ > --role "Storage Blob Delegator" --scope $ACCOUNT_ID > ``` > > **4. Read the values for Reducto** > > ```bash theme={null} > az account show --query tenantId -o tsv > az identity show -n $IDENTITY -g $RG --query clientId -o tsv > echo "https://$ACCOUNT.blob.core.windows.net" > echo $CONTAINER > ``` > > Send these four values to your Reducto team. None of them is a secret. ### Components provisioned | Component | Purpose | Required | | - | - | - | | Storage account | Azure Blob Storage. Shared key access can be off | Yes | | Blob container | Container for documents, results, and artifacts | Yes | | User-assigned managed identity | The identity that Reducto signs in as | Yes | | Federated identity credential | Trusts Reducto's federation role for one region and your organization | Yes, one per region | | Storage Blob Data Contributor on the container | Read and write documents, results, and artifacts | Yes | | Storage Blob Delegator on the Storage account | Request user delegation keys to sign result links | Yes | | Lifecycle management rule | Automatic blob expiry (default: 1 day) | Recommended | ### Integration values | Value | Description | Where to find | | - | - | - | | `tenant_id` | Directory (tenant) ID of the identity | `az account show --query tenantId` | | `client_id` | Client ID of the managed identity | Managed identity → Overview | | `account_url` | Blob endpoint, `https://.blob.core.windows.net` | Storage account → Endpoints | | `container` | Blob container name | Storage account → Containers | | Data residency | `us`, `eu`, or `au`: the Reducto region that processes your documents | Your choice | Reducto supports the Azure public cloud (`*.blob.core.windows.net`) for managed identity connections. ### Revoking access * **Remove the role assignments** to stop Reducto's access to your data. Azure applies the change within minutes. * **Revoke user delegation keys** to invalidate result links that Reducto has already signed: `az storage account revoke-delegation-keys -n "your-storage-account" -g "your-resource-group"`. This revokes every user delegation SAS on the Storage account. * **Delete the federated credential** to stop new sign-ins. An access token that Reducto already holds stays valid until it expires, for up to 24 hours, so also remove the role assignments for immediate effect. ## Connection string Reducto connects with a Storage account connection string that contains an account key. Use this option if you cannot create a managed identity. ### Prerequisites * **Azure subscription** with permission to create a Storage account and read its access keys * **Shared key access enabled** on the Storage account (`allowSharedKeyAccess = true`, the Azure default) * **Terraform 1.2+** with the AzureRM provider (optional, for automated setup) * **Values from Reducto** (provided during onboarding): your Reducto organization ID ### Architecture ```mermaid theme={null} flowchart LR subgraph customer["Customer Azure Subscription"] direction TB blob["Azure Blob Storage
(documents, results, artifacts)"] key["Storage account key
(in the connection string)"] key --> blob end subgraph reducto["Reducto Infrastructure"] direction TB profile["Hybrid VPC environment
(connection string)"] workers["Compute Workers"] profile --> workers end workers <-->|"read / write"| blob client["Your application"] -->|"SAS link (read-only, expires)"| blob ``` 1. You create a Storage account and a private container. 2. You send Reducto the container name and the Storage account connection string. 3. Reducto stores the connection string in a Hybrid VPC environment for your organization. API responses and audit logs never show it after it is saved. 4. Reducto workers use the connection string to read your input documents and to write results and intermediate artifacts to the container. 5. When a response contains a URL to a result or an image, that URL is a read-only SAS link to one blob in your container. Reducto signs it with the account key from the connection string. The link expires after at most 12 hours. ### Setup > [tab] Terraform > > **1. Create main.tf** > > ```hcl theme={null} > terraform { > required_version = ">= 1.2" > required_providers { > azurerm = { > source = "hashicorp/azurerm" > version = ">= 4.0" > } > } > } > > provider "azurerm" { > features {} > } > > variable "name_prefix" { > type = string > } > > variable "location" { > type = string > default = "eastus" > } > > resource "azurerm_resource_group" "this" { > name = "${var.name_prefix}-reducto-rg" > location = var.location > } > > resource "azurerm_storage_account" "this" { > name = substr(replace("${var.name_prefix}reducto", "-", ""), 0, 24) > resource_group_name = azurerm_resource_group.this.name > location = azurerm_resource_group.this.location > account_tier = "Standard" > account_replication_type = "LRS" > min_tls_version = "TLS1_2" > allow_nested_items_to_be_public = false > shared_access_key_enabled = true # Required: Reducto authenticates with the account key > } > > resource "azurerm_storage_container" "this" { > name = "reducto-documents" > storage_account_id = azurerm_storage_account.this.id > container_access_type = "private" > } > > resource "azurerm_storage_management_policy" "this" { > storage_account_id = azurerm_storage_account.this.id > rule { > name = "reducto-auto-expire" > enabled = true > filters { > blob_types = ["blockBlob"] > prefix_match = ["${azurerm_storage_container.this.name}/"] # Only the Reducto container > } > actions { > base_blob { > delete_after_days_since_modification_greater_than = 1 > } > } > } > } > > output "container_name" { > value = azurerm_storage_container.this.name > } > > output "connection_string" { > value = azurerm_storage_account.this.primary_connection_string > sensitive = true > } > ``` > > **2. Initialize and apply** > > ```bash theme={null} > terraform init > # Replace acme and eastus with your own prefix and Azure region > terraform apply -var name_prefix=acme -var location=eastus > ``` > > **3. Read the values for Reducto** > > ```bash theme={null} > terraform output container_name > terraform output -raw connection_string > ``` > > Send both values to your Reducto team through a secure channel. Do not paste the connection string into email or chat. > [tab] Azure Portal (manual) > > **1. Create a Storage account** > > 1. Go to **Azure Portal** → **Storage accounts** → **Create** > 2. Configure: > * **Performance**: Standard > * **Redundancy**: LRS (or your preferred level) > 3. Under **Advanced**, keep **Enable storage account key access** turned on > 4. Under **Networking**, keep public network access enabled (see [Network access](#network-access)) > > **2. Create a Blob container** > > 1. Open the new Storage account > 2. Go to **Data storage** → **Containers** → **+ Container** > 3. Name: `reducto-documents` > 4. **Anonymous access level**: Private > > **3. Add a lifecycle rule (recommended)** > > 1. Go to **Data management** → **Lifecycle management** → **Add a rule** > 2. Limit the rule to the Reducto container with the blob prefix `reducto-documents/` > 3. Delete block blobs 1 day after they were last modified (see [Data Lifecycle](#data-lifecycle)) > > **4. Copy the connection string** > > 1. Go to **Security + networking** → **Access keys** > 2. Select **Show** next to **key1** → **Connection string** and copy it > 3. Send the connection string and the container name to your Reducto team through a secure channel ### Components provisioned | Component | Purpose | Required | | - | - | - | | Storage account | Azure Blob Storage, with shared key access enabled | Yes | | Blob container | Container for documents, results, and artifacts | Yes | | Lifecycle management rule | Automatic blob expiry (default: 1 day) | Recommended | ### Integration values | Value | Description | Where to find | | - | - | - | | `container` | Blob container name | Storage account → Containers | | `connection_string` | Connection string that contains `AccountName` and `AccountKey` | Storage account → Access keys | | Data residency | `us`, `eu`, or `au`: the Reducto region that processes your documents | Your choice | The connection string has this form: ```text theme={null} DefaultEndpointsProtocol=https;AccountName=;AccountKey=;EndpointSuffix=core.windows.net ``` > [!WARNING] > > A SAS-only connection string (one that contains `SharedAccessSignature=` and no `AccountKey=`) is not supported. Reducto can read and write blobs with it, but requests that return result or image URLs fail because Reducto cannot sign SAS links without the account key. ### Rotating the account key The connection string contains one of the two account keys. To rotate without downtime: 1. Copy the connection string for the key that Reducto does **not** use (for example, **key2**). 2. Send the new connection string to your Reducto team, and wait for confirmation that the environment uses it. 3. Wait 12 hours. SAS links that Reducto signed with **key1** before the switch stay valid for up to 12 hours. 4. Regenerate the old key (**key1**) in **Access keys**. When you regenerate a key, every SAS link signed with that key stops working immediately. When you regenerate the key that Reducto currently uses, requests also fail until Reducto has the new connection string. ## Environments Reducto registers your values as a Hybrid VPC environment for your organization, with one data residency: `us`, `eu`, or `au`. One organization can have more than one environment, for example one Storage account per data residency or per client (see [Multiple Environments](/onprem/hybrid-vpc-deployment#multiple-environments)). Send requests to the Reducto API of the environment's data residency: | Data residency | API base URL | | - | - | | US | `https://platform.reducto.ai` | | EU | `https://eu.platform.reducto.ai` | | AU | `https://au.platform.reducto.ai` | A request that names no environment uses your organization's default environment for the region of the API that you call. To select an environment, pass its name in `settings.hybrid_vpc.environment` for `/parse`, or in `parsing.settings.hybrid_vpc.environment` for `/extract` and `/split`. A request that names an environment of another data residency fails with HTTP 400. ## Usage After Reducto enables the environment, Reducto writes results and intermediate artifacts for your organization to your container. You can also pass a blob in the same Storage account as the input. Reducto reads it with the environment's credentials, so the blob can stay private. With a managed identity, Reducto can read only the containers the identity has a role on. To read inputs from a container other than the Reducto container, assign Storage Blob Data Reader on that container to the identity. If you configure the recommended lifecycle rule, keep source documents that you want to retain outside the Reducto container, because the rule deletes them (see [Data Lifecycle](#data-lifecycle)). The examples read your Reducto API key from the `REDUCTO_API_KEY` environment variable: ```bash theme={null} export REDUCTO_API_KEY="your-api-key" ``` ```python Python theme={null} from reducto import Reducto client = Reducto() # Reads REDUCTO_API_KEY from environment result = client.parse.run( input="https://acmereducto.blob.core.windows.net/reducto-documents/documents/contract.pdf", ) ``` ```javascript Node.js theme={null} import Reducto from "reductoai"; const client = new Reducto(); // Reads REDUCTO_API_KEY from environment const result = await client.parse.run({ input: "https://acmereducto.blob.core.windows.net/reducto-documents/documents/contract.pdf", }); ``` ```bash cURL theme={null} curl -X POST "https://platform.reducto.ai/parse" \ -H "Authorization: Bearer $REDUCTO_API_KEY" \ -H "Content-Type: application/json" \ -d '{"input": "https://acmereducto.blob.core.windows.net/reducto-documents/documents/contract.pdf"}' ``` Replace `acmereducto` with your Storage account name and `reducto-documents` with your container name. For EU or AU data residency, use the regional API base URL from [Environments](#environments). ## Network access Reducto workers connect to your Storage account over its public blob endpoint (`https://.blob.core.windows.net`). Azure Private Link is not supported for Azure Blob Storage. If you restrict **Public network access** to selected networks, contact Reducto before you do so, or Reducto cannot read or write your container. SAS links in responses also use the public blob endpoint. The application that opens those links must be able to reach your Storage account. ## Data Lifecycle Configure lifecycle management to automatically delete blobs after processing. The Terraform examples above create this rule; the Azure CLI steps do not. The rule below deletes every block blob in the Reducto container one day after it was last modified, including documents that you upload there, so keep source documents that you want to retain in another container. Scope the rule to the Reducto container with `prefixMatch`, so that it does not delete blobs in other containers of the same Storage account: ```json theme={null} { "rules": [ { "name": "auto-expire", "type": "Lifecycle", "definition": { "actions": { "baseBlob": { "delete": { "daysAfterModificationGreaterThan": 1 } } }, "filters": { "blobTypes": ["blockBlob"], "prefixMatch": ["reducto-documents/"] } } } ] } ``` ## Security * **No stored secret (managed identity)**: Reducto signs in with a short-lived AWS token, and your federated credential accepts it only for your organization. Your Storage account can disable shared key access. * **Least privilege (managed identity)**: The identity holds Storage Blob Data Contributor on the Reducto container only, and Storage Blob Delegator to sign links. * **Account key scope (connection string)**: The account key gives full access to the Storage account, not only to the container. Use a dedicated Storage account for Reducto so that the key cannot reach other data. API responses and audit logs redact the connection string after it is saved. * **Short-lived links**: Every URL that Reducto returns is a read-only SAS link to one blob. It expires after at most 12 hours. * **Automatic cleanup**: Lifecycle management policies ensure no long-term data persistence. ## Troubleshooting | Error | Cause and fix | | - | - | | `Reducto could not sign in as this managed identity` (HTTP 400) | The tenant ID or client ID is wrong, or no federated credential matches. Check the issuer, the subject for your region, and the audience `api://reducto-hybrid-vpc/`. A new federated credential can take a few minutes to apply. | | `Reducto has no access to this storage` (HTTP 400) | The identity lacks a role. Assign Storage Blob Data Contributor on the container and Storage Blob Delegator on the Storage account, then wait a few minutes. | | `Azure container ... does not exist` (HTTP 400) | The container name does not match a container in the Storage account. | | A result link returns HTTP 403 | The link expired, the user delegation keys were revoked, or the identity lost Storage Blob Data Contributor. | | `Azure connection string must include AccountName and AccountKey` | The connection string is SAS-only. Use a connection string with an account key, or use a managed identity. | # Hybrid VPC — Box Source: https://docs.reducto.ai/onprem/hybrid-vpc-box Set up Hybrid VPC with Box as your document storage backend This guide covers setting up Hybrid VPC with Box as your storage backend. Reducto uses a Box enterprise application with Client Credentials Grant (CCG) to read and write documents in your Box environment. This integration is ideal for organizations that already manage documents in Box and want to process them with Reducto without moving data to a separate object store. ## Prerequisites * **Box Enterprise account** with admin access * **Box Developer Console** access for creating custom apps * Values provided during onboarding from Reducto (Organization ID) ## Architecture ```mermaid theme={null} flowchart LR subgraph customer["Customer Box Environment"] direction TB folder["Box Folder
(documents, artifacts)"] app["Box Custom App
(CCG auth)"] app --> folder end subgraph reducto["Reducto Infrastructure"] direction TB workers["Compute Workers"] api["Reducto API + Database"] workers --> api end workers <--> folder ``` ## Setup **1. Create a Box Custom Application** 1. Go to the [Box Developer Console](https://app.box.com/developers/console) 2. Click **Create New App** → **Custom App** 3. Select **Server Authentication (Client Credentials Grant)** as the authentication method 4. Name the app (e.g., "Reducto Integration") 5. Click **Create App** **2. Configure app permissions** In the app's **Configuration** tab: 1. Under **Application Scopes**, enable: * **Read all files and folders stored in Box** * **Write all files and folders stored in Box** 2. Under **App Access Level**, select **App Access Only**. Reducto signs in as the app's service account and never acts as another user, so it does not need **App + Enterprise Access** 3. Click **Save Changes** **3. Authorize the app in Admin Console** 1. Go to [Box Admin Console](https://app.box.com/master) → **Apps** → **Custom Apps** 2. Click **Add App** 3. Enter the **Client ID** from your app's Configuration tab 4. Click **Authorize** > [!WARNING] > > This step requires Box Enterprise Admin privileges. If you're not an admin, ask your Box admin to authorize the app. **4. Create a dedicated folder** Create a folder in Box for Reducto to use: 1. In Box, create a new folder (e.g., "Reducto Processing") 2. Note the **Folder ID** from the URL (e.g., `https://app.box.com/folder/123456789` → folder ID is `123456789`) 3. Invite the app's service account (created automatically with your app, listed in the app's **General Settings** as `AutomationUser_...@boxdevedition.com`) to the folder as an **Editor**. Reducto uploads, lists, and versions files in this folder > [!NOTE] > > Folder ID `0` is the root of the app's service account, not the root of your enterprise. Files that Reducto writes there are visible only to the service account. Reducto uses `0` when you do not provide a folder ID, so we recommend a dedicated folder. **5. Share credentials with Reducto** From the app's **Configuration** tab, securely share: | Value | Where to find | | - | - | | **Client ID** | Configuration → OAuth 2.0 Credentials | | **Client Secret** | Configuration → OAuth 2.0 Credentials | | **Enterprise ID** | General Settings → Enterprise ID (or Admin Console → Account & Billing) | | **Folder ID** | URL of the target folder in Box | | **Data residency** | `us`, `eu`, or `au`: the Reducto region that processes your documents | ## Integration Values | Value | Description | | - | - | | `client_id` | Box app OAuth 2.0 Client ID | | `client_secret` | Box app OAuth 2.0 Client Secret | | `enterprise_id` | Your Box Enterprise ID | | `folder_id` | Numeric ID of the folder where Reducto writes files. Optional, defaults to `0` (the service account's root) | | Data residency | `us`, `eu`, or `au`: the Reducto region that processes your documents | Box has no region setting in Reducto. The data residency value decides which Reducto region processes your documents. Send requests to that region's API (`https://platform.reducto.ai`, `https://eu.platform.reducto.ai`, or `https://au.platform.reducto.ai`). ## Environments Reducto registers your values as a Hybrid VPC environment for your organization. One organization can have more than one environment, for example one Box folder per data residency or per client. Select an environment per request with `settings.hybrid_vpc.environment` (see [Multiple Environments](/onprem/hybrid-vpc-deployment#multiple-environments)). For `/parse`, set `settings.hybrid_vpc.environment`. For `/extract` and `/split`, set it under `parsing`: `parsing.settings.hybrid_vpc.environment`. Their top-level `settings` object ignores `hybrid_vpc`. For example, the relevant part of an `/extract` request body: ```json theme={null} { "input": "box://123456789", "parsing": { "settings": { "hybrid_vpc": { "environment": "client-a" } } } } ``` ## How It Works 1. **Authentication**: Reducto authenticates using Client Credentials Grant (CCG) — no user interaction required. The app's service account gets an access token automatically. 2. **Document storage**: Uploads, results, and intermediate artifacts are stored in the configured Box folder. Reducto names each file after its storage key and refers to it by its Box file ID afterward. When a file with the same name already exists, Reducto uploads a new version of that file. 3. **Result access**: Result and image URLs in API responses are Box web-app links of the form `https://app.box.com/file/`. They are not shared links and do not expire. To open one, sign in to Box as a user who has access to the Reducto folder, or browse the folder in Box. ## Processing an Existing Box File Pass a Box file ID explicitly with the `box://` scheme: ```python Python theme={null} from reducto import Reducto client = Reducto() result = client.parse.run(input="box://123456789") ``` ```javascript Node.js theme={null} import Reducto from "reductoai"; const client = new Reducto(); const result = await client.parse.run({ input: "box://123456789" }); ``` ```bash cURL theme={null} curl -X POST "https://platform.reducto.ai/parse" \ -H "Authorization: Bearer $REDUCTO_API_KEY" \ -H "Content-Type: application/json" \ -d '{"input":"box://123456789"}' ``` Reducto retrieves the file using the Box credentials configured for your Hybrid VPC organization. The file can be in any folder, but the app's service account must be able to read it, for example as a collaborator on that folder. `box://` inputs work only for organizations with Box Hybrid VPC storage. Use `box://` rather than an `app.box.com` web URL, which is intended for an interactive browser session. The same input works with `/extract` and `/split`. To process a specific historical version, add the Box file-version ID as a query parameter: ```text theme={null} box://123456789?version=987654321 ``` The `version` value is the immutable Box file-version ID, not the human-facing version number. If omitted, Reducto processes the file's current version. The file ID and version ID must both be numeric, and `version` is the only query parameter accepted. ## Data Lifecycle Box does not have built-in lifecycle expiration like S3 or Azure, and Reducto does not delete the files it writes to your Box folder. The job and upload deletion endpoints (`DELETE /job/{job_id}` and `DELETE /upload/{file_id}`) support only S3-backed storage and do not remove files from Box. To manage data retention: > [accordion] Option 1: Box retention policies (recommended) > > Box Enterprise supports [retention policies](https://support.box.com/hc/en-us/articles/360043697334-Managing-Retention-Policies) that can auto-delete content after a specified period. Set up a retention policy on the Reducto folder. Keep source documents that you want to retain outside the Reducto folder. > [accordion] Option 2: Manual cleanup > > Periodically review and delete files from the Reducto folder. Not recommended for production use. ## Security * **No user credentials required**: CCG authentication uses app-level credentials, not user passwords * **Scoped access**: The service account can read and write only the content it owns or is a collaborator on, so you control access with Box collaborations * **Enterprise admin approval**: The app must be explicitly authorized by a Box admin * **Audit trail**: Box provides detailed audit logs of all file access and modifications * **Credential rotation**: When you reset the Client Secret in the Box Developer Console, send the new secret to Reducto. Requests fail until Reducto has the new secret ## Limitations | Limitation | Impact | Mitigation | | - | - | - | | No Terraform provider | Setup is manual (not automated via IaC) | Documented step-by-step process above | | Rate limits | Box API has more aggressive rate limits than S3/Azure | Reducto handles rate limiting and retries automatically | | File-based API | Box uses file/folder hierarchy, not flat key-value | Reducto maps keys to filenames transparently | | No native lifecycle expiry | Unlike S3, no automatic per-object TTL, and Reducto does not delete Box files | Use Box retention policies | ## Troubleshooting > [accordion] Errors for box:// inputs > > | Box response or input | Reducto error | > | - | - | > | Malformed URI (non-numeric file ID, a path, or a query parameter other than a numeric `version`) | `400`: `Invalid Box URL format - use box://file-id or box://file-id?version=file-version-id` | > | Organization without Box Hybrid VPC storage | `400`: `Box URLs require Box Hybrid VPC storage` | > | Box returns `401` or `403` | `400`: `Access denied reading the Box file. Check that the configured Box application can read it.` | > | Box returns `404` | `404`: the Box file was not found | > | Box returns `408`, `429`, or `5xx`, or the connection fails | Retried as a temporary failure. If it persists, `503`: `Document download is temporarily unavailable. Please retry shortly.` | > [accordion] 403 Forbidden when accessing files > > Verify that: > > 1. The app is authorized in the Box Admin Console > 2. The service account has collaborator access to the target folder > 3. The app scopes include read and write permissions > [accordion] Rate limit errors (429) > > Reducto handles rate limiting automatically with exponential backoff. If you see persistent rate limit errors, contact Reducto support — we may need to adjust concurrency settings for your account. > [accordion] Files not appearing in Box web UI > > Files created by the service account are owned by that account. To view them in the Box web UI, add yourself as a collaborator on the Reducto folder, or use the service account's credentials to browse. # Hybrid VPC Deployment Source: https://docs.reducto.ai/onprem/hybrid-vpc-deployment Deploy Reducto with your data in your own cloud account and compute managed by Reducto Hybrid VPC deployment provides a balance between data sovereignty and operational simplicity. Your data stays in your cloud account while Reducto manages all compute infrastructure. ## Overview In a Hybrid VPC deployment: * **Data stays in your cloud account**: Documents you upload, intermediate artifacts, and results are stored in your storage * **Compute runs on Reducto's infrastructure**: GPU processing and model inference are handled by Reducto * **You control retention**: Objects stay in your storage until your lifecycle rule or retention policy deletes them * **Multiple storage providers**: AWS S3, Azure Blob Storage, Google Cloud Storage, and Box are supported * **Three regions**: US, EU, and AU, each with its own API endpoint > [card] [AWS S3](/onprem/hybrid-vpc-aws) > > Cross-account IAM role with ExternalId protection. Optional PrivateLink for private-only API access. > [card] [Azure Blob Storage](/onprem/hybrid-vpc-azure) > > A managed identity in your tenant with no stored secret, or a Storage account connection string. Reducto signs read-only SAS links. > [card] [Box](/onprem/hybrid-vpc-box) > > Box enterprise app with Client Credentials Grant. Ideal for organizations already using Box for document management. > [card] [Google Cloud Storage](/onprem/hybrid-vpc-gcs) > > A service account key that you scope to one bucket. Configure it yourself in Studio. ### Key benefits | Benefit | Description | | - | - | | Data sovereignty | Storage remains in your cloud account | | No GPU management | Offload model inference to Reducto's optimized GPU cluster | | Cost efficiency | Avoid provisioning and maintaining GPU capacity | | Fast auto-scaling | Scale to zero when idle, scale up on demand | | Reduced DevOps burden | Faster iteration, no infrastructure maintenance | ## Regions Each Reducto region has its own API endpoint. Send each request to the region whose storage it should use. | Region | API base URL | PrivateLink base URL (AWS only) | Storage regions it accepts | | - | - | - | - | | US | `https://platform.reducto.ai` | `https://hybrid.platform.reducto.ai` | US and Canada (`us-*`, `ca-*`, `northamerica-*`) | | EU | `https://eu.platform.reducto.ai` | `https://hybrid.eu.platform.reducto.ai` | Europe (`eu-*`, `europe-*`) | | AU | `https://au.platform.reducto.ai` | `https://hybrid.au.platform.reducto.ai` | Australia (`ap-southeast-2`, `australia-*`) | A request fails if it selects storage in another region's area, for example an EU bucket on `platform.reducto.ai`. For EU processing guarantees, see [EU data residency](/security/eu-data-residency). > [!WARNING] > > The AU region runs `/parse` on the R-1 model only. For most organizations, `/extract`, `/split`, `/classify`, `/edit`, and `/pipeline` return `403 REGION_UNAVAILABLE` on `au.platform.reducto.ai`, and so do parse options that need OCR or other models. Contact Reducto before you plan an AU deployment to confirm what your organization can use. ## Architecture ```mermaid theme={null} flowchart LR subgraph customer["Customer Cloud Account"] direction TB storage["Object Storage
(S3 / Azure Blob / GCS / Box)"] auth["Access Credentials
(IAM Role / Azure Managed Identity or Account Key / GCP Service Account / Box App)"] auth --> storage end subgraph reducto["Reducto Infrastructure"] direction TB workers["Compute Workers"] api["Reducto API + Database"] workers --> api end workers <--> storage ``` ### Data flow 1. You upload documents to your storage (or use Reducto's `/upload` endpoint, which writes to your storage) 2. You call the Reducto API with a reference to your document 3. Reducto uses your configured credentials to access the document 4. Processing occurs on Reducto's compute infrastructure 5. Results and artifacts are written back to your storage. Large results and images come back as links to objects in your storage (see your provider guide for the link format and expiry) 6. Objects expire based on the lifecycle rule you configure on your storage Reducto's database keeps a record of each job, such as its status, request settings, and where its result is stored. `GET /job/{job_id}` reads an async job's result from your storage. ## Choosing a Storage Provider > [accordion] AWS S3 — Recommended for AWS-native organizations > > Best choice if your organization already uses AWS. Provides cross-account IAM role assumption with ExternalId protection against confused deputy attacks. Optional AWS PrivateLink keeps all traffic off the public internet. Terraform module provided for automated setup. > [accordion] Azure Blob Storage — For Azure-native organizations > > Best choice if your organization uses Azure. Reducto signs in as a user-assigned managed identity in your tenant through a federated credential, so you give Reducto no secret and can disable shared key access. A Storage account connection string with an account key is also supported. Terraform configuration provided for automated setup. > [accordion] Box — For Box-first document workflows > > Best choice if your organization already manages documents in Box. Uses Box enterprise app authentication (Client Credentials Grant). No Terraform provider available — setup is done through the Box Admin Console. > [accordion] Google Cloud Storage — For GCP-native organizations > > Best choice if your organization uses GCP. Reducto accesses your bucket with a service account key that you create and scope to the bucket. You add the environment and verify access in Studio. ## Document Handoff There are multiple ways to provide documents to Reducto APIs, regardless of which storage provider you use: Document handoff is a trust boundary. Only trusted services should submit document URLs or uploads to Reducto. If a caller can submit arbitrary URLs, that caller can ask Reducto to fetch any network location reachable from the deployment or hybrid worker environment. Use your gateway, storage policy, and egress policy to limit what callers can request. > [tab] Upload Endpoint > > Use Reducto's `/upload` endpoint to upload documents directly. Files are stored in your configured storage: > > ```python theme={null} > from pathlib import Path > from reducto import Reducto > > client = Reducto(api_key="your-api-key") > > upload = client.upload(file=Path("contract.pdf")) > result = client.parse.run(input=upload.file_id) > ``` > > `/upload` has no request settings, so it writes to your default environment. To upload into a [named environment](#multiple-environments), pass the `hybrid_vpc_environment` query parameter, and select the same environment on the request that reads the file: > > ```python theme={null} > upload = client.upload( > file=Path("contract.pdf"), > extra_query={"hybrid_vpc_environment": "client-a"}, > ) > result = client.parse.run( > input=upload.file_id, > settings={"hybrid_vpc": {"environment": "client-a"}}, > ) > ``` > [tab] Presigned / Shared URL > > Generate a temporary URL from your storage provider and pass it to Reducto: > > ```python theme={null} > from reducto import Reducto > > # Generate a presigned/shared URL from your storage provider > # (S3 presigned URL, Azure SAS URL, or GCS signed URL) > document_url = "https://..." > > client = Reducto(api_key="your-api-key") > result = client.parse.run(input=document_url) > ``` > [tab] Direct Reference > > Pass a reference to a private object. Reducto reads it with your configured credentials: > > | Provider | Input | > | - | - | > | AWS S3 | `s3://your-bucket/documents/contract.pdf` | > | Google Cloud Storage | `gs://your-bucket/documents/contract.pdf` | > | Azure Blob Storage | `https://.blob.core.windows.net//` in the configured Storage account | > | Box | `box://` | > > ```python theme={null} > from reducto import Reducto > > client = Reducto(api_key="your-api-key") > result = client.parse.run(input="s3://your-bucket/documents/contract.pdf") > ``` > > To read objects outside the Reducto bucket, container, or folder, grant the extra permissions listed in your provider guide. For production workflows: * Prefer direct uploads, `reducto://` file IDs, or tightly scoped storage-provider URLs. * Keep presigned URLs short-lived and scoped to a single object. * Do not pass URLs containing long-lived credentials. * Restrict worker egress to expected document sources when your workflow allows it. * Block cloud metadata endpoints and internal admin services from document-fetching egress. > [!NOTE] > > For PrivateLink connections (AWS only), specify the region-specific hybrid endpoint as `base_url`: > > * **US**: `https://hybrid.platform.reducto.ai` > * **EU**: `https://hybrid.eu.platform.reducto.ai` > * **AU**: `https://hybrid.au.platform.reducto.ai` ## Integration Contract Reducto registers your storage as a Hybrid VPC environment for your organization. For AWS S3 and Google Cloud Storage, an organization admin can add, verify, and edit environments in Studio under **Settings** → **Hybrid VPC** once Reducto turns this on for your organization. For Azure and Box, or if you do not see the Hybrid VPC tab, send the values below to Reducto, along with the region (US, EU, or AU) that processes each environment's documents. > [tab] AWS S3 > > | Value | Description | > | - | - | > | `bucket_name` | S3 bucket name | > | `region` | AWS region (e.g., `us-east-1`) | > | `role_arn` | IAM role ARN for Reducto to assume | > | `external_id` | ExternalId for secure role assumption | > | AWS account ID(s) | Every AWS account that will create a VPC endpoint (if using PrivateLink) | > [tab] Azure > > Managed identity (recommended): > > | Value | Description | > | - | - | > | `tenant_id` | Directory (tenant) ID of the managed identity | > | `client_id` | Client ID of the managed identity | > | `account_url` | Blob endpoint, `https://.blob.core.windows.net` | > | `container` | Blob container name | > > Connection string: > > | Value | Description | > | - | - | > | `container` | Blob container name | > | `connection_string` | Storage account connection string with `AccountName` and `AccountKey` (SAS-only strings are not supported) | > [tab] Google Cloud Storage > > | Value | Description | > | - | - | > | `region` | GCP region of the bucket (e.g., `us-central1`) | > | `bucket` | GCS bucket name | > | `project_id` | GCP project ID that owns the bucket | > | `bucket_folder` | Optional key prefix for all objects | > | `service_account_json` | Service account key JSON with access to the bucket | > [tab] Box > > | Value | Description | > | - | - | > | `client_id` | Box app client ID | > | `client_secret` | Box app client secret | > | `enterprise_id` | Box enterprise ID | > | `folder_id` | Target folder ID for document storage | ## Multi-Region Setup For organizations needing storage in multiple regions for latency or compliance requirements, register one environment per region and set a default environment for each region you use. A request that does not select an environment uses the default for the region it is sent to (US, EU, or AU). See the provider-specific setup guides linked above for region-specific credentials. ## Multiple Environments If one Reducto organization needs multiple dedicated storage locations, Reducto can register named Hybrid VPC environments under the same org. Each environment points to one bucket, container, or folder, with its own credentials and region. Then select the environment per request: ```python theme={null} from reducto import Reducto client = Reducto(api_key="your-api-key") result = client.parse.run( input="s3://client-a-bucket/documents/contract.pdf", settings={ "hybrid_vpc": { "environment": "client-a" } } ) ``` The field that selects the environment depends on the endpoint: | Endpoint | Field | | - | - | | `/parse`, `/parse_async` | `settings.hybrid_vpc.environment` | | `/extract`, `/extract_async`, `/split`, `/split_async` | `parsing.settings.hybrid_vpc.environment` | | `/classify`, `/classify_async` | `hybrid_vpc.environment` (top level) | | `/upload` | `hybrid_vpc_environment` query parameter | | `GET /job/{job_id}` | None. Reducto reads the result from the environment the job ran in | > [!WARNING] > > On `/extract` and `/split`, a top-level `settings.hybrid_vpc` is ignored and the job uses your default environment. Put the environment under `parsing.settings`: > > ```python theme={null} > result = client.extract.run( > input="s3://client-a-bucket/documents/contract.pdf", > instructions={"schema": schema}, > parsing={"settings": {"hybrid_vpc": {"environment": "client-a"}}}, > ) > ``` A request without an environment uses the default environment for its region. A request fails if it names an environment that is not configured, or one whose storage region is outside the API region's area. Use separate Reducto orgs only when you need separate API keys, admins, billing, quotas, or customer-visible tenancy. For client-specific buckets inside the same customer account, named environments are the recommended path. ## Security All storage integrations follow least-privilege principles: * **AWS**: ExternalId prevents confused deputy attacks; IAM policy limits access to S3 operations only * **Azure**: A managed identity whose federated credential trusts only your Reducto organization, with no stored secret; or an account key in a connection string for a dedicated Storage account. Responses carry read-only SAS links that expire within 12 hours * **Google Cloud Storage**: A service account key that you scope to the bucket and rotate on your own schedule. Responses carry signed links that expire after 12 hours * **Box**: An App Access Only service account that sees only the folders you invite it to; enterprise admin approval required * **All providers**: Configure a lifecycle rule or retention policy on your storage to delete Reducto objects after processing # Hybrid VPC — Google Cloud Storage Source: https://docs.reducto.ai/onprem/hybrid-vpc-gcs Set up Hybrid VPC with Google Cloud Storage for data sovereignty This guide covers setting up Hybrid VPC with Google Cloud Storage (GCS) as your storage backend. Reducto writes documents, results, and intermediate artifacts to a GCS bucket in your GCP project and signs in as a service account that you create. ## How access works * Reducto authenticates with a **JSON key for a service account in your project**. Reducto never falls back to its own Google identity: a GCS environment without a key is rejected when you save it. * The key is not tied to a Reducto region, so GCS setup needs no Reducto principal ARNs, ExternalId, or per-region trust configuration. One key works for US, EU, and AU processing. * Keyless access (Workload Identity Federation or service account impersonation) is not supported for GCS. * After you save the key, Reducto never returns it. The configuration API and audit log show `service_account_json_configured: true` instead. ## Prerequisites * **GCP project** that owns the bucket, with permission to create buckets, service accounts, and service account keys * **Service account key creation allowed** in that project (see the warning in step 4) * **Owner or Admin role** in your Reducto organization, to configure Hybrid VPC in Studio * **`gcloud` CLI** (optional, for the commands below) ## Setup **1. Create the bucket** Choose a location in the data residency where Reducto should process your documents (see [Data residency](#data-residency)). ```bash theme={null} gcloud storage buckets create gs://acme-reducto-data \ --project=acme-prod \ --location=us-central1 \ --uniform-bucket-level-access \ --public-access-prevention ``` GCS bucket names must not start with `goog` or contain `google`. **2. Create a service account** ```bash theme={null} gcloud iam service-accounts create reducto-hybrid-vpc \ --project=acme-prod \ --display-name="Reducto Hybrid VPC" ``` Use a dedicated service account for Reducto, with no other roles. **3. Grant the service account access to the bucket** Grant both roles on the bucket only, not on the project: ```bash theme={null} SA="reducto-hybrid-vpc@acme-prod.iam.gserviceaccount.com" gcloud storage buckets add-iam-policy-binding gs://acme-reducto-data \ --member="serviceAccount:$SA" --role="roles/storage.objectAdmin" gcloud storage buckets add-iam-policy-binding gs://acme-reducto-data \ --member="serviceAccount:$SA" --role="roles/storage.legacyBucketReader" ``` | Role | Why Reducto needs it | | - | - | | `roles/storage.objectAdmin` | Read input documents, write results and artifacts, and delete temporary objects | | `roles/storage.legacyBucketReader` | Read bucket metadata (`storage.buckets.get`). Verification checks the bucket before it tests object access | Signing result URLs needs no extra role: Reducto signs them with the key itself, not through the IAM `signBlob` API. **4. Create a JSON key** ```bash theme={null} gcloud iam service-accounts keys create reducto-key.json \ --iam-account="reducto-hybrid-vpc@acme-prod.iam.gserviceaccount.com" ``` > [!WARNING] > > Many GCP organizations enforce the `iam.disableServiceAccountKeyCreation` organization policy, which blocks this command. Ask your GCP organization admin for an exception on the project that holds the Reducto service account. Reducto cannot connect to GCS without a key. **5. Add a lifecycle rule (recommended)** Delete Reducto objects automatically one day after they are created. See [Data lifecycle](#data-lifecycle). **6. Configure the environment in Studio** 1. In Studio, go to **Settings** → **Hybrid VPC**. 2. Add a new environment and choose **Google Cloud Storage** as the provider. 3. Enter the region, bucket name, project ID, and optional bucket folder (see [Integration values](#integration-values)). 4. Paste the full contents of `reducto-key.json` into `service_account_json`. 5. Click **Verify storage access**. Reducto reads the bucket metadata, then writes, reads, and deletes a test object named `.reducto-hybrid-vpc-verify.txt` in the bucket folder. 6. Save the configuration after verification succeeds. After you save, delete your local copy of `reducto-key.json` or move it to your secret manager. If you do not see the **Hybrid VPC** tab, ask Reducto to turn on self-serve Hybrid VPC for your organization, or send the [integration values](#integration-values) to your Reducto team through a secure channel. > [!NOTE] > > If your Reducto team manages your organization's Hybrid VPC environment profile, ask them to make changes. A Reducto-managed profile takes precedence over environments saved in Studio. ## Integration values | Value | Description | | - | - | | `storage_type` | `gcs` | | `region` | GCP region of the bucket, for example `us-central1`, `europe-west1`, or `australia-southeast1`. Reducto uses it only to choose the [data residency](#data-residency) | | `bucket` | GCS bucket name, without `gs://` | | `project_id` | ID of the GCP project that owns the bucket (6–30 lowercase letters, numbers, or hyphens). Use the project ID, not the project number | | `bucket_folder` | Optional object prefix for everything Reducto writes, for example `reducto/`. Must not start with `/` or contain `..` | | `service_account_json` | Required. The full JSON key of the service account from step 4 | GCS-only configurations do not need an `external_id`. That value applies only to S3 environments. ### Data residency The `region` decides which Reducto region processes the environment's documents. Send requests for the environment to the matching API endpoint: | `region` starts with | Residency | API endpoint | | - | - | - | | `us-`, `northamerica-` | US | `https://platform.reducto.ai` | | `europe-` | EU | `https://eu.platform.reducto.ai` | | `australia-` | AU | `https://au.platform.reducto.ai` | Other GCP regions, such as `asia-*` or `southamerica-*`, are not supported. For a multi-region or dual-region bucket (for example location `US` or `EU`), enter a region in the same residency, such as `us-central1` or `europe-west1`. Reducto does not check the region against the bucket's location. A request sent to an endpoint whose residency does not match the environment's region fails with HTTP 400. ## Multiple environments You can add several GCS environments, and mix them with S3 environments, under one Reducto organization, for example `staging`, `prod`, `staging-gcp`, and `prod-gcp`. Each environment has its own bucket, service account key, and region. A saved configuration applies to all Reducto regions. The Hybrid VPC configuration has this shape: ```json theme={null} { "default_environment": "prod-gcp", "environments": { "prod-gcp": { "storage_type": "gcs", "region": "us-central1", "bucket": "acme-reducto-data", "project_id": "acme-prod", "bucket_folder": "reducto/", "service_account_json": { "type": "service_account", "...": "..." } }, "staging-gcp": { "storage_type": "gcs", "region": "us-central1", "bucket": "acme-reducto-staging", "project_id": "acme-staging", "service_account_json": { "type": "service_account", "...": "..." } } } } ``` Environment names use 1–80 letters, numbers, underscores, periods, or hyphens. A request without an environment uses `default_environment`. When the organization has more than one environment, set a default or name an environment on every request. Without a default, a request that names no environment either fails or, if Reducto also registered regional S3 storage for your organization, writes to that S3 storage instead of GCS. ## Usage Select the environment per request. **Where the setting goes depends on the endpoint:** * `/parse`: `settings.hybrid_vpc.environment` * `/extract` and `/split`: `parsing.settings.hybrid_vpc.environment` > [!WARNING] > > `/extract` and `/split` ignore a top-level `settings.hybrid_vpc`. The request then runs on your default environment instead of the one you named, without an error. You can pass a `gs://bucket/object-key` URI as the input. Reducto downloads it with the environment's service account, so the object can stay private. The URI names its own bucket and key, so `bucket_folder` does not apply to it. To read from a bucket other than the Reducto bucket, grant the service account `roles/storage.objectViewer` on that bucket. If you configure the recommended lifecycle rule, keep source documents that you want to retain outside the Reducto bucket folder. > [!NOTE] > > Reducto reads a `gs://` input only with the credentials of the GCS environment selected for the request. If your default environment is S3, name the GCS environment in the request. ```python Parse theme={null} from reducto import Reducto client = Reducto() # Reads REDUCTO_API_KEY from the environment result = client.parse.run( input="gs://acme-reducto-data/documents/contract.pdf", settings={"hybrid_vpc": {"environment": "prod-gcp"}}, ) ``` ```python Extract theme={null} from reducto import Reducto client = Reducto() result = client.extract.run( input="gs://acme-reducto-data/documents/contract.pdf", instructions={"schema": {"type": "object", "properties": {"party": {"type": "string"}}}}, parsing={"settings": {"hybrid_vpc": {"environment": "prod-gcp"}}}, ) ``` ```javascript Node.js theme={null} import Reducto from "reductoai"; const client = new Reducto(); // Reads REDUCTO_API_KEY from the environment const parsed = await client.parse.run({ input: "gs://acme-reducto-data/documents/contract.pdf", settings: { hybrid_vpc: { environment: "prod-gcp" } }, }); const extracted = await client.extract.run({ input: "gs://acme-reducto-data/documents/contract.pdf", instructions: { schema: { type: "object", properties: { party: { type: "string" } } } }, parsing: { settings: { hybrid_vpc: { environment: "prod-gcp" } } }, }); ``` ```bash cURL theme={null} curl -X POST "https://platform.reducto.ai/parse" \ -H "Authorization: Bearer $REDUCTO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "input": "gs://acme-reducto-data/documents/contract.pdf", "settings": {"hybrid_vpc": {"environment": "prod-gcp"}} }' ``` ### Result URLs URLs in responses, such as large results and images, are V4 signed `GET` URLs on `storage.googleapis.com` for one object in your bucket. They expire after 12 hours. The application that opens them must be able to reach `storage.googleapis.com`. ## Data lifecycle Configure a lifecycle rule to delete Reducto objects automatically. Scope it to the bucket folder with `matchesPrefix`. Omit the prefix only if the bucket holds nothing but Reducto data: ```json lifecycle.json theme={null} { "rule": [ { "action": { "type": "Delete" }, "condition": { "age": 1, "matchesPrefix": ["reducto/"] } } ] } ``` ```bash theme={null} gcloud storage buckets update gs://acme-reducto-data --lifecycle-file=lifecycle.json ``` A one-day rule outlives result URLs, which expire after 12 hours. ## Rotating the key 1. Create a new key for the same service account. 2. In Studio, paste the new key into `service_account_json` for the environment, verify, and save. Saving other changes without a new key keeps the stored key. 3. Wait 12 hours. Result URLs signed with the old key stop working when you delete it. 4. Delete the old key: `gcloud iam service-accounts keys delete --iam-account="$SA"`. To revoke Reducto's access immediately, remove the role bindings from the bucket or disable the key. ## Network access Reducto connects to your bucket over the public GCS endpoint (`storage.googleapis.com`). PrivateLink and Private Service Connect are not supported for GCS. If the project is inside a VPC Service Controls perimeter, contact Reducto before setup, because the perimeter blocks requests from outside it. ## Validation checklist * [ ] **Verify storage access** in Studio succeeds for each environment * [ ] The service account has only `roles/storage.objectAdmin` and `roles/storage.legacyBucketReader`, granted on the bucket: ```bash theme={null} gcloud storage buckets get-iam-policy gs://acme-reducto-data ``` * [ ] The bucket enforces public access prevention and uses uniform bucket-level access * [ ] The lifecycle rule is in place: ```bash theme={null} gcloud storage buckets describe gs://acme-reducto-data --format="default(lifecycle_config)" ``` * [ ] **Smoke test**: Run a small job with the environment selected and check that objects appear under the bucket folder ## Troubleshooting | Error | Cause and fix | | - | - | | `service_account_json credentials were not provided` | The environment has no key. Paste the service account JSON key and save again. | | `service account credentials are invalid` | The pasted value is not a valid service account key. Paste the complete contents of the JSON key file. | | `unable to resolve service account credentials` | Google rejected the key, usually because it was deleted or disabled. Create a new key. | | `Access denied — grant Reducto access to this bucket` | The service account lacks a role on the bucket. Grant both roles from step 3. A missing `roles/storage.legacyBucketReader` also causes this error. | | `Bucket not found — check the bucket and project` | The bucket name is wrong, or the bucket belongs to a different project. | | `The customer storage configuration is invalid` (HTTP 400, on a job) | The environment's region is in a different residency from the endpoint you called, or the environment has no usable key. Call the endpoint for the environment's residency, and run **Verify storage access** again. | | `The configured storage credentials could not be used` | Google rejected the key while Reducto read a `gs://` input. Create a new key and save it. | | `Access denied reading the document from storage` | The service account cannot read the `gs://` input. Grant `roles/storage.objectViewer` on that bucket, and check that the request selects the GCS environment. | Studio allows about one verification every 30 seconds per organization. ## Security * **Dedicated identity**: Reducto acts only as your service account, never as its own Google identity. * **Least privilege**: The service account holds two roles on one bucket and nothing at the project level. * **Key handling**: Reducto never returns the key in API responses or audit logs. You own its rotation and can disable it at any time. * **Short-lived links**: Every URL that Reducto returns is a signed read-only link to one object that expires after 12 hours. * **Automatic cleanup**: Lifecycle rules ensure no long-term data persistence. # LLM & service configuration options Source: https://docs.reducto.ai/onprem/llm_options Complete guide to LLM configuration and environment variables for Reducto ## OCR service configuration For detailed OCR provider configuration (AWS Textract, Azure Vision, GCP Vision API, cross-cloud OCR, and GPU OCR deployment), see the dedicated [OCR provider configuration](/onprem/ocr_options) page. ## LLM provider environment variables Reducto supports multiple LLM providers through environment variables. Below is a complete list of supported providers and their required environment variables. ### LiteLLM proxy | Variable | Description | Required | | - | - | - | | `LITELLM_PROXY_URL` | URL of the LiteLLM Proxy | Yes | | `LITELLM_PROXY_FAST_MODEL` | Fast model to route to via the proxy | Yes | | `LITELLM_PROXY_ACCURATE_MODEL` | Accurate model to route to via the proxy | Yes | | `LITELLM_PROXY_DEFAULT_TAGS` | JSON object of key/value tags added to every proxy request as `key:value` `metadata.tags`, for example `{"deployment": "prod-us"}` | No | #### Per-request tags Callers can attach key/value tags to a request with `settings.llm_tags`. Reducto forwards each pair as a `key:value` entry in LiteLLM `metadata.tags` on every downstream LLM call the job makes, including retries and async jobs. Use it for cost attribution in LiteLLM (for example `accountId` and `userId`). Tags do not affect parsing output or caching. Limits: at most 16 entries, keys and values up to 128 characters, no `:` in keys. ```python Python theme={null} from reducto import Reducto client = Reducto() result = client.parse.run( input="https://example.com/doc.pdf", settings={"llm_tags": {"accountId": "acc_1", "userId": "usr_2"}}, ) ``` ```typescript TypeScript theme={null} import Reducto from "reductoai"; const client = new Reducto(); const result = await client.parse.run({ input: "https://example.com/doc.pdf", settings: { llm_tags: { accountId: "acc_1", userId: "usr_2" } }, }); ``` ```bash cURL theme={null} curl -X POST https://your-reducto-host/parse \ -H "Authorization: Bearer $REDUCTO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "input": "https://example.com/doc.pdf", "settings": { "llm_tags": {"accountId": "acc_1", "userId": "usr_2"} } }' ``` This produces the LiteLLM tags `accountId:acc_1` and `userId:usr_2`. The field is also available under `parsing.settings.llm_tags` on `/extract` and `/split`. On the legacy v2 API, pass the same object under `experimental_options.llm_tags`: ```python Python theme={null} from reducto import Reducto client = Reducto() result = client.parse.run( document_url="https://example.com/doc.pdf", experimental_options={ "llm_tags": {"accountId": "acc_1", "userId": "usr_2"} }, ) ``` ```typescript TypeScript theme={null} import Reducto from "reductoai"; const client = new Reducto(); const result = await client.parse.run({ document_url: "https://example.com/doc.pdf", experimental_options: { llm_tags: { accountId: "acc_1", userId: "usr_2" }, }, }); ``` ```bash cURL theme={null} curl -X POST https://your-reducto-host/parse \ -H "Authorization: Bearer $REDUCTO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "document_url": "https://example.com/doc.pdf", "experimental_options": { "llm_tags": {"accountId": "acc_1", "userId": "usr_2"} } }' ``` Tags from `LITELLM_PROXY_DEFAULT_TAGS` are merged with per-request tags. When a key appears in both, the request value wins. ### OpenAI | Variable | Description | Required | | - | - | - | | `OPENAI_API_KEY` | Your OpenAI API key | Yes | | `OPENAI_BASE_URL` | Base URL for all OpenAI requests. Set this to route traffic through an OpenAI-compatible gateway or proxy. Defaults to `https://api.openai.com/v1` | No | | `OPENAI_DEFAULT_HEADERS` | JSON object of extra headers sent on every OpenAI request. Use this for gateway authentication or routing headers. Keys and values must be strings | No | #### Route OpenAI through a gateway Set all three variables on every Reducto workload: ```bash theme={null} OPENAI_API_KEY=sk-... OPENAI_BASE_URL='https:///v1' OPENAI_DEFAULT_HEADERS='{"": ""}' ``` Example for Portkey: ```bash theme={null} OPENAI_BASE_URL=https://api.portkey.ai/v1 OPENAI_DEFAULT_HEADERS='{"x-portkey-api-key": "", "x-portkey-provider": "openai"}' ``` Reducto applies `OPENAI_BASE_URL` and `OPENAI_DEFAULT_HEADERS` to the OpenAI clients used by Parse, Extract, Split, and Edit (Chat Completions and Responses APIs). The optional super-agent sandbox (`SANDBOX_ENDPOINT`) uses them too, and reaches Anthropic through `ANTHROPIC_BASE_URL` when set. They do not apply to Azure OpenAI or to the LiteLLM proxy. A malformed `OPENAI_DEFAULT_HEADERS` value fails at startup. ### Azure OpenAI | Variable | Description | Required | | - | - | - | | `AZURE_OPENAI_API_KEY` | Your Azure OpenAI API key | Yes, unless `AZURE_AI_SERVICES_AUTH=workload_identity` | | `AZURE_OPENAI_ENDPOINT` | Azure OpenAI endpoint (e.g., `https://your-resource-name.openai.azure.com/`) | Yes | | `OPENAI_API_VERSION` | Azure OpenAI API version (e.g., `2024-10-21`) | Yes | | `AZURE_OPENAI_MODEL_MAP` | Comma-separated map (or single default deployment) used to translate model names to Azure deployment names. Reducto uses the following models and each should resolve to a deployment unless a single default is supplied: `gpt-4o-2024-08-06`, `gpt-4o`, `gpt-4o-mini-2024-07-18`, `gpt-4o-mini`, `gpt-4.1`, `o1`. Example mappings: `my-default-deployment` (single default) or `gpt-4o=my-prod-dep, gpt-4o-mini=gpt4o-mini-dep` | Yes | To call Azure OpenAI without a key, for a resource with key authentication disabled, set `AZURE_AI_SERVICES_AUTH=workload_identity` and remove `AZURE_OPENAI_API_KEY`. Reducto then sends an Entra ID token of the pod's workload identity. Use the resource's custom subdomain as `AZURE_OPENAI_ENDPOINT`, and assign the identity Cognitive Services OpenAI User on the resource. See [Azure workload identity](/onprem/azure_workload_identity). ### Anthropic | Variable | Description | Required | | - | - | - | | `ANTHROPIC_API_KEY` | Your Anthropic API key | Yes | ### Google | Variable | Description | Required | | - | - | - | | `GOOGLE_APPLICATION_CREDENTIALS` | Service account key json with `roles/aiplatform.user` role for Vertex AI | Yes | | `GCP_PROJECT_ID` | GCP project for Cloud Vision API | Yes | | `GCP_REGION` | Region for Vertex AI. When unset, empty, or `global`, calls use the `global` endpoint and fall back to the Gemini API (`GEMINI_API_KEY`) on a quota or server error. Any other value pins every call to that region with no Gemini API fallback. Most Gemini 3.x models are served only from `global` and the `us` and `eu` multi-region endpoints, so a `us-*` or `europe-*` pin routes those calls to `us` or `eu`. The Gemini 3.x preview models are served only from `global`. A pin that cannot serve a model logs a warning, and the call fails. | No | | `GCP_API_KEY` | [API key](https://console.cloud.google.com/apis/credentials) with no Application or API restrictions to access Cloud Vision API | Yes | ### Gemini | Variable | Description | Required | | - | - | - | | `GEMINI_API_KEY` | Your Gemini API key | Yes | ### AWS Bedrock | Variable | Description | Required | | - | - | - | | `USE_CLAUDE_BEDROCK` | Set to any value to enable Claude via AWS Bedrock | Yes | | `AWS_ACCESS_KEY_ID` | AWS access key ID | Yes, when using Bedrock | | `AWS_SECRET_ACCESS_KEY` | AWS secret access key | Yes, when using Bedrock | | `AWS_REGION` | AWS region name | Yes, when using Bedrock | ## GPU-based extraction models Reducto offers GPU-based models for structured data extraction and fine-grained citations. For best results, deploy both models together. Each model is one `gpuModels.` block in your values. The chart ships a block per model with its image, weights pin and engine settings filled in. You set `enabled`, `gpu`, and the storage for the weights cache. ### Deploy a model ```yaml theme={null} gpuModels: citation: enabled: true gpu: "H200" # or "H100"; must be in the block's allowedGpus weights: cache: size: "50Gi" storageClassName: "your-storage-class" yaml-extract: enabled: true gpu: "H200" weights: cache: size: "100Gi" storageClassName: "your-storage-class" ``` Each enabled block renders a Deployment, a Service named `-reducto-`, and a PersistentVolumeClaim for the weights cache. The chart injects the model's URL into every worker and HTTP pod as the env var in `urlEnv`: `REDUCTO_CITATION_URL` for `citation`, `REDUCTO_YAML_EXTRACT_URL` for `yaml-extract`. Extraction uses the YAML extract model when its URL is set, and citations use the citation model when its URL is set. Without them, both fall back to your configured external LLM provider. **GPU requirements.** `yaml-extract` (30B) needs one NVIDIA H200. `citation` (7B) needs one H100 or H200. Both engines are vLLM 0.31.0, built on CUDA 13, so GPU nodes need NVIDIA driver 580 or newer; upgrade node drivers before upgrading the chart, or leave the model disabled until you do. Each block lists its qualified GPUs in `allowedGpus`; a `gpu` outside that list fails the render instead of scheduling a pod that cannot load the model. `gpu` selects the engine flags for that GPU; it does not pin the pod to it. On a cluster with more than one GPU type, add the node label your cluster uses to the block's `nodeSelector` (for example `nvidia.com/gpu.product: NVIDIA-H200` from the GPU feature discovery, or your node pool label) so the pod lands on the GPU you set in `gpu`. ### Images Each release ships the GPU engine image with the chart, pulled through Reducto's registry proxy as `-vllm`; release packaging writes that image into each block's `image.repository` and `image.tag` (and records them as `image.packagedRepository` and `image.packagedTag`). Air-gapped installs mirror that tag. To use your own mirror, set `image.repository` to the mirror and keep the published `image.tag`; to run a custom engine, set both fields. The chart rejects a changed tag on the packaged Reducto repository, since an upstream version tag does not exist there. The engine runs as uid 1000; the model pods set `fsGroup: 1000` and mount a writable home directory, so a custom `podSecurityContext.runAsUser` also works. ### Model weights By default (`weights.source: oci`) an init container copies the weights from a data-only weights image, verifies its checksums and caches them on the claim. The image reference is `:`, with `weights.oci.repository` falling back to the chart's `image.repository`: mirror the weights image into the same repository as the application image, or set `weights.oci.repository`. The weights images ship with the chart release that lists their tags in `values.yaml`; until your release carries one, use the `hf` source below. Alternatives: | `weights.source` | Behavior | Needs | | - | - | - | | `oci` (default) | Copy from the weights image and verify | The weights image in your registry | | `hf` | Download the pinned revision from Hugging Face in the server image | A Kubernetes secret with the token Reducto provides, in `weights.hf.tokenSecret.name` | | `prepopulated` | The cache was filled out of band; only its sentinel is checked | A claim in `weights.cache.existingClaim` | For the `hf` source, create the secret first: ```bash theme={null} kubectl create secret generic reducto-hf-token --from-literal=HF_TOKEN=hf_... ``` ```yaml theme={null} gpuModels: citation: enabled: true weights: source: hf hf: tokenSecret: name: reducto-hf-token ``` `weights.cache.mode: emptyDir` copies the weights on every pod start instead of keeping a claim; set `weights.cache.sizeLimit` with it. ### Block reference | Field | Default | Description | | - | - | - | | `enabled` | `false` | Render the model | | `gpu` | The first entry of `allowedGpus` | The GPU this cluster schedules the model on | | `allowedGpus` | From Reducto | The GPUs the model is qualified on | | `image.repository`, `image.tag` | From Reducto | The engine image. Empty values use the application image repository and `-` | | `model.repo`, `model.revision`, `model.servedNames` | From Reducto | The weights pin and every name the engine serves | | `engine.runtime`, `engine.args`, `engine.env`, `engine.profiles` | From Reducto | The engine (`vllm` or `sglang`), its flags, its env, and per-GPU flags appended for the selected `gpu` | | `weights` | `source: oci` | Where the weights come from and how they are cached (see above) | | `resources` | Per model | Requests and limits; keep `nvidia.com/gpu: "1"` | | `replicaCount` | `1` | Replicas | | `nodeSelector`, `tolerations`, `affinity`, `podAnnotations`, `podLabels` | Per model | Scheduling | | `extraEnv` | `[]` | Extra env entries for the engine container | | `port`, `service.port` | `8000`, `80` | The container port and the Service port | | `urlEnv` | `REDUCTO__URL` | The env var the worker and HTTP pods receive | | `probe.startupSeconds` | `630` (vllm), `960` (sglang) | Time the engine may take to load before the pod restarts | Fields marked "From Reducto" come from the model's registry file through the chart release. Leave them unless Reducto directs you to change them. ### Legacy `yamlExtract` and `citationModel` blocks Values written before the `gpuModels` block, with `yamlExtract.enabled` or `citationModel.enabled`, keep working unchanged: the same Service names, claim names (`-yaml-extract-model-pvc`, `-citation-model-pvc`), env vars and container settings render as before, including `modelStorage`, whose init container syncs the weights at start. Those blocks keep their own engines: `citationModel` runs the vLLM engine shipped as `-vllm`, `yamlExtract` the SGLang engine shipped as `-sglang`. They serve the pinned commit in `pinnedRevision` for the default `modelPath`; a different `modelPath` serves that repository's default branch, or the commit you set in `revision`. Set `offline: true` for an air-gapped install with a pre-populated model cache; `hfTokenSecret` controls authentication only, so public repositories download without a token. To move a cluster to the pinned `gpuModels` block, disable the legacy block and enable its twin (`citationModel` to `gpuModels.citation`, `yamlExtract` to `gpuModels.yaml-extract`) in the same upgrade. The Service name and the env var stay the same. The weights are cached on a new claim, and the upgrade deletes the legacy claim (`-citation-model-pvc` or `-yaml-extract-model-pvc`) with its download, so have the weights image mirrored, or the token secret in place for the `hf` source, before the upgrade. Enabling a legacy block and its twin together fails the render. The model pods set `fsGroup: 1000` by default, so the weights cache is writable for the init container (user 65532 on the Chainguard chart line, where `minimalContainerSecurityContext.enabled` is true) and readable for the engine (user 1000). A `podSecurityContext` of your own keeps that `fsGroup` unless it sets one; a storage driver that ignores `fsGroup` needs `weights.cache.mode: emptyDir` or a prepared claim in `weights.cache.existingClaim`. The chart serves a pinned commit of the default repositories. A different `modelPath` serves that repository's default branch, or the commit you set in `revision`. ### Extraction without GPU models If you do not deploy either GPU model, extraction uses your configured external LLM provider (OpenAI, Anthropic, Google, Azure, or Bedrock). No additional configuration is needed. ### Fine-tuned OpenAI extraction model (alternative) | Variable | Description | Required | | - | - | - | | `LOCAL_EXTRACT_CITATIONS_MODEL` | Fine-tuned OpenAI model ID (e.g., `openai:ft:gpt-4.1-2025-04-14:...`) | No | When set, this takes priority over the self-hosted extraction model. ## Request-level LLM overrides In addition to environment variables, on-prem deployments can override LLM configuration at the request level using the `overrides` parameter in `experimental_options`. ### Key-value processing overrides Override the model and add custom instructions for key-value (form) region processing: ```json theme={null} { "document_url": "https://example.com/form.pdf", "experimental_options": { "overrides": { "key_value": { "model": "google:gemini-3.5-flash-lite", "custom_instructions": "Pay special attention to date fields. Use MM/DD/YYYY format." } } } } ``` | Field | Description | | - | - | | `model` | Model alias (`fast`, `accurate`) or `provider:model` format | | `custom_instructions` | Additional instructions appended to the default prompt | ### Resolution order **Model resolution:** 1. **Request override** - `experimental_options.overrides.key_value.model` 2. **Environment variable** - `LOCAL_KV_MODEL` 3. **Code default** - Based on deployment configuration **Prompt resolution:** 1. **Base prompt** - `LOCAL_KV_PROMPT` env var, or built-in default 2. **Custom instructions** - Appended from `overrides.key_value.custom_instructions` ### Environment variable defaults | Variable | Description | Default | | - | - | - | | `LOCAL_KV_MODEL` | Override model for KV processing | None (uses built-in cascade) | | `LOCAL_KV_PROMPT` | Base prompt for KV processing (can be fully replaced) | Built-in prompt | ## AI usage tracking Reducto includes a comprehensive AI usage tracking system that monitors language model consumption throughout the document processing pipeline. This feature provides detailed insights into token usage, request counts, and model utilization for billing and optimization purposes. ### How AI usage tracking works The AI usage tracking system operates at the block level within the parsing pipeline: 1. **Token Counting**: Each AI operation (table summarization, figure analysis, key-value extraction, etc.) records token consumption 2. **Request Tracking**: The system counts API calls made to each model 3. **Model Identification**: Usage is tracked per model type with provider information 4. **Aggregation**: Usage is aggregated across all blocks and pages for comprehensive reporting ### Available via /parse API AI usage information is **currently only available through the `/parse` API endpoint** using the `custom_format` parameter. This feature is not available in other API endpoints. ### Usage information structure When enabled, the system returns an `AIUsageInfo` object containing: ```json theme={null} { "did_use_ai_models": true, "ai_usage_info": [ { "promptTokenCount": 1500, "completionTokenCount": 300, "cachedTokenCount": 0, "requestCount": 2, "modelType": "us.anthropic.claude-3-7-sonnet-20250219-v1:0", "modelProvider": "anthropic", "modelRateLimitFamily": "us.anthropic.claude-3-7-sonnet" } ] } ``` ### Field descriptions * **`did_use_ai_models`**: Boolean indicating whether any AI models were used during processing * **`ai_usage_info`**: Array of usage information objects, one per model type used * **`promptTokenCount`**: Total input tokens sent to the model * **`completionTokenCount`**: Total output tokens generated by the model * **`cachedTokenCount`**: Total cached tokens used (when supported by provider) * **`requestCount`**: Number of API calls made to this model * **`modelType`**: Standardized model identifier * **`modelProvider`**: Provider name (e.g., "anthropic", "openai") * **`modelRateLimitFamily`**: Rate limiting group for the model ### Enabling AI usage tracking To retrieve AI usage information, set the `custom_format` parameter to `"ai_usage"` in your `/parse` request: ```json theme={null} { "input": "your_document_url", "settings": { "custom_format": "ai_usage" } } ``` ### Tracked AI operations The system tracks usage from these AI-powered features: * **Table Summarization**: Analysis and description of complex tables * **Figure Summarization**: Analysis and description of images and charts * **Key-Value enrichment**: Enrichment for form-like regions within documents ### Model name standardization The system automatically standardizes model names for consistent reporting: * Internal model identifiers are mapped to standard formats * Provider information is automatically added * Rate limit families are identified for capacity planning ### Possible model identifiers The following model identifiers may appear in the `modelType` field of AI usage tracking responses, if you have OpenAI and Anthropic access enabled: #### OpenAI models * `gpt-4o-2024-08-06` * `gpt-4o-mini-2024-07-18` #### Anthropic models * `claude-haiku-4-5-20251001` * `claude-3-7-sonnet-20250219` If you enable other model providers, they have their own prefixes which will appear. # Observability & Monitoring Source: https://docs.reducto.ai/onprem/observability Built-in observability stack for on-premise Reducto deployments ## Overview On-premise Reducto deployments include a built-in observability stack called **ClickStack**, which provides: * **HyperDX**: Unified observability UI for logs, traces, and metrics * **ClickHouse**: High-performance analytics database for telemetry storage * **OTEL Collector**: OpenTelemetry collector for ingesting and routing telemetry data ClickStack is enabled by setting `clickstack.enabled: true` in your Helm values. Everything else is automatic. No additional setup required. Telemetry is part of your on-premise security boundary. Reducto emits logs to stdout and can route traces, metrics, and logs through OpenTelemetry, but you control where telemetry is stored, who can access it, and how long it is retained. See the [on-prem security model](/onprem/security_model) for the shared responsibility model. Reducto telemetry is designed for operational metadata. It should not contain document content, OCR text, extracted values, prompts, model outputs, API tokens, access keys, secrets, or other customer content. File names and URLs can reveal customer identity and should be redacted or avoided before telemetry leaves the deployment. ## Accessing HyperDX ### Default Credentials When ClickStack is enabled, a seed admin user is automatically created on first install with default credentials. Contact the Reducto team for the default login details, or configure your own credentials in your Helm values (see [Configuration](#configuration) below). > [!WARNING] > > Change the default password immediately after first login. ### Accessing the UI HyperDX can be exposed via: * **Ingress**: Set `clickstack.hyperdx.ingress.enabled: true` with your domain * **Tailscale**: Set `clickstack.hyperdx.exposure.tailscale.enabled: true` for private access * **Cloudflare Tunnel**: Set `clickstack.hyperdx.exposure.cloudflareTunnel.enabled: true` * **Port-forward** (for testing): `kubectl port-forward svc/-clickstack-app 3000:3000` ## Prometheus Scraping Prometheus endpoints are intended for internal scraping by monitoring systems. Do not expose `/metrics`, `/prometheus`, ClickStack, HyperDX, ClickHouse, or the OTEL collector to the public internet. To collect metrics from services that expose Prometheus endpoints (like NGINX ingress controllers), enable the Prometheus receiver on the OTEL collector together with the Target Allocator (TA) subchart. TA shards scrape targets across collector replicas via consistent-hashing so each target is scraped exactly once. See [Scaling the OTEL Collector](#scaling-the-otel-collector) for the multi-replica rationale. ```yaml theme={null} prometheusScrape: enabled: true targetAllocator: enabled: true targetAllocator: config: collector_selector: matchlabels: app.kubernetes.io/instance: app.kubernetes.io/name: otel-collector config: scrape_configs: - job_name: nginx-ingress scrape_interval: 30s static_configs: - targets: - ingress-nginx-controller-metrics.ingress-nginx.svc.cluster.local:10254 - job_name: kube-state-metrics scrape_interval: 30s static_configs: - targets: - prometheus-stack-kube-state-metrics.monitoring.svc.cluster.local:8080 ``` `scrape_configs` follows the standard Prometheus scrape config schema — any `static_configs`, `kubernetes_sd_configs`, `relabel_configs`, etc. are supported. > [!NOTE] > > When `targetAllocator.enabled: true`, the OTEL collector pod must expose `POD_NAME` via the downward API so each replica gets a distinct `collector_id` for consistent-hashing. Render fails fast otherwise. > > ```yaml theme={null} > otelCollector: > extraEnvs: > - name: POD_NAME > valueFrom: > fieldRef: > fieldPath: metadata.name > ``` Scraped metrics route to ClickHouse by default. See [Routing](#routing) to fan out to other sinks. ## Per-user page throughput and throttling The PostgreSQL page throttle is opt-in. After applying database migrations, set `USER_PAGE_THROTTLE_ENABLED=true` on both the API and legacy worker pods and restart them. It defaults to `false`. Requests are limited only when they carry a user identity with an explicit row in `user_page_throttles`. Identity comes from `user-id`. Deployments configured with an alternate client-app header use that header first and fall back to `user-id`; configure limits for the ID that is sent. These identities are supplied by the deployment's trusted gateway, not bound to Reducto API keys. The gateway must strip caller-supplied identity headers and set the verified user/app ID. Missing identities and IDs without configured limits intentionally bypass throttling. This is an opt-in scheduling control, not an authentication boundary. When disabled, application code does not read or write `user_page_throttles` or `page_throttle_events`; schema migrations still create the tables. Processing, scrapes, and periodic logs skip throttle queries. The throttle admin routes and `/metrics/page_throttles` return 503 to authenticated operators without querying Postgres. Enable the feature before configuring limits through those routes. `/admin/page_throttles` and `/metrics/page_throttles` require the bearer token configured in `PAGE_THROTTLE_ADMIN_TOKEN` on the API pods. Provision a unique, random token per deployment through your secret manager, and restart the API pods after configuring or rotating it. Unset or empty denies access; ordinary document-processing API keys and the legacy shared admin credential are rejected. The existing `/metrics` and `/prometheus` scrape endpoints remain private to the monitoring network, as described above. When enabled, each `/metrics` or `/prometheus` scrape reads the current throttle snapshot and logs `page_throttle.metrics_snapshot` with the same values. JSON `/metrics` includes a `page_throttle` object. With `METRICS_FORMAT=prometheus`, `/metrics` exports Prometheus gauges; `/prometheus` always exports Prometheus. Throttle gauges do not depend on `PROMETHEUS_MULTIPROC_DIR`. With the feature disabled, these snapshot reads, logs, and extra response fields are omitted. | Gauge | Meaning | | - | - | | `reducto_pages_per_second` | Overall observed pages/sec | | `reducto_page_throttle_observed_pages_per_second{user_id}` | Observed pages/sec for the user | | `reducto_page_throttle_observed_pages{user_id}` | Pages charged in the window | | `reducto_page_throttle_observed_jobs{user_id}` | Distinct charged jobs in the window | | `reducto_page_throttle_limit_pages_per_second{user_id}` | Configured pages/sec limit | | `reducto_page_throttle_capacity_pages{user_id}` | Burst capacity | | `reducto_page_throttle_tokens{user_id}` | Current credit; negative values represent debt | | `reducto_page_throttle_rejected_requests{user_id}` | Throttle rejections in the window | Overall and unattributed page, job, and rejection counts are also exported. Observed throughput is admitted/debited pages divided by `PAGE_THROTTLE_METRICS_WINDOW_SECONDS` (default `60`, capped at the one-hour event retention). It is not a completed-page rate or an instantaneous rate. Counts and rates are sliding-window gauges, so do not apply Prometheus `rate()` to them. Configured users with no recent traffic report zero throughput; users without configured limits still contribute observed throughput. These are deployment-wide PostgreSQL values: do not sum duplicate scrapes across API replicas. Legacy workers additionally emit `worker.queue_metrics` every `QUEUE_METRICS_LOG_INTERVAL_SECONDS` when explicitly configured (for example, `60`). The default is `0`, which disables periodic worker logs and their queue queries; it does not disable scrape logs or metrics export. Before enabling throttling, verify the BYO integration handles HTTP 429 and `Retry-After` for page-rate rejections. These responses include `status.success=false` and `error_code="RATE_LIMITED"`; other BYO failures retain their existing HTTP 200 envelope. This integration check is required before rollout; automated route tests do not verify the customer's client behavior. ## Scaling the OTEL Collector The OTEL collector deploys as a Deployment with `otelCollector.replicaCount` replicas (HPA optional). Two receivers need explicit coordination once `replicaCount > 1`, otherwise every replica emits the same data and ClickHouse storage grows linearly with the replica count. | Receiver | Coordination mechanism | Values key | | - | - | - | | `prometheus` (scrape) | Target Allocator subchart shards targets via consistent-hashing | `targetAllocator.enabled` | | `k8s_cluster` + `k8sobjects` | `k8s_leader_elector` extension — only the lease-holder emits | `k8sMetrics.leaderElector.enabled` | ### Leader Elector for Kubernetes Metrics & Events `k8s_cluster` (cluster-level pod/node/container metrics) and `k8sobjects` (K8s events watch) are cluster-singletons — every replica running them independently produces an identical stream. Enable the leader-elector extension so only the lease-holder emits: ```yaml theme={null} k8sMetrics: enabled: true leaderElector: enabled: true ``` The chart renders a namespace-scoped `Role` + `RoleBinding` for the `coordination.k8s.io` lease, gated on `leaderElector.enabled`. Lease hand-off on pod rollover is automatic. Both `targetAllocator.enabled` and `k8sMetrics.leaderElector.enabled` default to `false` — single-replica deployments work out of the box with no extra RBAC or subchart. ## Configuration ### Seed User Configure the admin user credentials in your Helm values: ```yaml theme={null} clickstack: hyperdx: seedUser: email: "admin@yourcompany.com" password: "your-secure-password" teamName: "Your Team" ``` For production deployments, use a Kubernetes secret instead of a plaintext password: ```yaml theme={null} clickstack: hyperdx: seedUser: email: "admin@yourcompany.com" existingSecret: "my-hyperdx-secret" secretKey: "HYPERDX_ADMIN_PASSWORD" teamName: "Your Team" ``` ### ClickHouse Storage and Memory ClickHouse requests 500m CPU and 6Gi memory by default, with a 6Gi memory limit. It uses at most 90% of the limit, so raise it for high telemetry volume. Set requests and limits together, because Helm merges your values into the defaults. ```yaml theme={null} clickstack: clickhouse: persistence: dataSize: 50Gi # Adjust based on expected telemetry volume logSize: 10Gi resources: # Raise memory for high telemetry volume requests: cpu: 500m memory: 6Gi limits: memory: 6Gi ``` ### Data Retention Telemetry data retention is controlled by the OTEL exporter TTL: ```yaml theme={null} otelConfig: exporters: clickhouse: ttl: 360h # 15 days (default: 72h) ``` ## Telemetry Pipeline The OTEL collector receives telemetry from multiple sources and routes it to configured sinks: | Source | What it collects | Default sink | | - | - | - | | Application traces/metrics | OTLP from Reducto services | All enabled sinks | | Kubernetes metrics | Cluster-level pod, node, container metrics via k8s\_cluster receiver; node-level kubelet stats via DaemonSet | All enabled sinks | | Kubernetes events | K8s events via k8sobjects receiver | All enabled sinks | | Prometheus scrape | Metrics from any Prometheus endpoint | ClickHouse | | Application logs | OTLP logs from Reducto services | ClickHouse | ### Routing Each source can be independently routed to any combination of sinks: ```yaml theme={null} otelConfig: routing: traces: [tinybird, datadog, clickhouse, iceberg] # default metrics: [tinybird, datadog, clickhouse, iceberg] # default k8sMetrics: [tinybird, datadog, clickhouse, iceberg] # default k8sEvents: [tinybird, datadog, clickhouse, iceberg] # default prometheusScrape: [clickhouse] # default logs: [clickhouse] # default ``` Available sinks: `clickhouse`, `tinybird`, `datadog`, `iceberg`. Each sink must also be enabled in `otelConfig.exporters`. The defaults list all sinks, but only sinks that are both listed **and** enabled will actually receive data, so the defaults are safe for any exporter combination. ## Pod stack trace dumps (SIGUSR2) Every Reducto worker and HTTP pod installs a `SIGUSR2` handler that dumps a per-thread stack trace to `stderr` when signalled. Use this when a pod is unresponsive (stuck event loop, hung downstream call, contended thread pool) and `kubectl logs` alone doesn't explain why. Coverage: | Pod | Process labelled as | | - | - | | `reducto-http` (gunicorn) | `http-worker-` | | `reducto-streaq-worker` | `streaq--worker` (e.g. `streaq-io-worker`, `streaq-cpu-worker`) | | `reducto-worker`, `reducto-priority-worker`, `reducto-gpu-worker` (DB-queue) | `k8s-worker` (or the value of `LOGFIRE_SERVICE_NAME` when set) | The handler is always installed, with no Helm flag to disable it. Output is written directly to `stderr` (not through the structured logger), so the trace appears even if the application logging pipeline is itself wedged. ### Triggering a dump ```bash theme={null} # Pick a pod that's misbehaving kubectl get pods -n reducto -l app=reducto-worker # Find the worker PID (gunicorn / python process) kubectl exec -n reducto -- ps -eo pid,cmd | grep -E 'gunicorn|streaq|python' # Send SIGUSR2 to that PID kubectl exec -n reducto -- kill -s USR2 # Read the dump from the pod log kubectl logs -n reducto --tail=500 ``` You'll see a single-line banner followed by one frame block per thread: ``` USR2 signal received [http-worker-42]; dumping thread stacks USR2 triggered stack trace: Thread "MainThread" (most recent call first): File "/app/.venv/bin/gunicorn", line 8, in sys.exit(run()) ... Thread "asyncio-loop-0" (most recent call first): ... ``` ### Notes * Forked gunicorn and streaq child processes each register their own handler, so signalling the main PID alone won't dump child stacks. Signal each child PID individually if you need full process-tree coverage. * `SIGUSR2` is not used by any other component in the worker/HTTP processes, so triggering a dump is safe in production. The signal handler is async-safe and only enqueues work onto a dedicated daemon thread. * For wider diagnostics (CPU profile, off-CPU sampling), consider `py-spy dump --pid ` from a debug container. `SIGUSR2` is the lowest-friction option and works without an extra binary. ## Telemetry controls Reducto telemetry is designed to avoid customer content. Logs, traces, and metrics are for debugging and performance analysis, not for storing customer data or business records. Recommended controls: * Keep observability UIs and scrape endpoints on private networks. * Use SSO, VPN, Zero Trust access, or equivalent controls for operator access. * Send telemetry only to approved sinks. * Set retention periods that match your security and compliance requirements. * Review telemetry exports for customer content, file names, URLs, prompts, model outputs, and secrets before sharing them outside your organization. ### Redacting file names and URLs Set `REDACT_FILENAMES_IN_TELEMETRY` to keep uploaded file names and document URLs out of logs and traces. When it is unset, redaction is on in Apple environments and off everywhere else. Set it to `false` to turn it off in an Apple environment. ```yaml theme={null} env: REDACT_FILENAMES_IN_TELEMETRY: "true" ``` With it on, Reducto: * drops request arguments and query strings from HTTP server spans, and records a validation error as its type only * logs a document URL as its scheme, host, and file extension (`https://bucket.s3.amazonaws.com/.pdf`) * names downloaded files with a random ID instead of the original name * leaves the original error message out of download failure logs Reducto-generated IDs (`reducto://`, `jobid://`) are kept. Values you send in `extra_metadata` are still added to logs, so do not put file names there. # OCR provider configuration Source: https://docs.reducto.ai/onprem/ocr_options Configure cloud OCR providers for on-premise Reducto installations Reducto supports local OCR on CPU or GPU, as well as cloud OCR providers. By default, Reducto uses its own local OCR models, but you can configure cloud OCR providers for broader language support. ## Provider overview | Provider | Credentials needed | Best for | | - | - | - | | **Reducto local OCR** | None (default) | General-purpose, privacy-sensitive, GPU-equipped deployments | | **AWS Textract** | `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` | AWS-native deployments | | **Azure Vision Read** | `AZURE_VISION_ENDPOINT` + `AZURE_VISION_KEY` (or `AZURE_VISION_ARRAY`), or workload identity | Azure-native deployments | | **GCP Vision API** | `GOOGLE_APPLICATION_CREDENTIALS` + `GCP_PROJECT_ID` (or `GCP_SERVICE_ACCOUNT_EMAIL` for workload identity) | GCP-native deployments, cross-cloud deployments wanting GCP OCR, 60+ language support | ## How OCR provider is selected The `ocr_system` parameter in API requests controls provider routing. Only two values are available to API callers: `standard` (default) and `legacy`. **`standard` (default) routing priority:** 1. GCP Vision API, if GCP credentials are configured 2. Azure Vision or AWS Textract, if configured (Azure is preferred over Textract) **`legacy` routing priority:** 1. Azure Vision or AWS Textract, if configured (Azure is preferred over Textract) 2. GCP Vision API, as fallback if only GCP credentials are available In practice: if you configure GCP credentials, `standard` requests use GCP Vision. If you only have Azure/AWS credentials, both `standard` and `legacy` use those. ### Auto-detection: GCP-only environments When **only** GCP credentials are configured (no AWS or Azure), Reducto auto-detects this and routes all OCR through GCP Vision regardless of the `ocr_system` value. This requires: * No Azure credentials (`AZURE_VISION_ENDPOINT` and `AZURE_VISION_ARRAY` both unset) * No AWS credentials (`AWS_ACCESS_KEY_ID` unset) * GCP credentials available (`GOOGLE_APPLICATION_CREDENTIALS` or `GCP_SERVICE_ACCOUNT_EMAIL`) * `GCP_PROJECT_ID` is set *** ## AWS Textract ### Environment variables | Variable | Description | Required | | - | - | - | | `AWS_ACCESS_KEY_ID` | AWS access key ID | Yes | | `AWS_SECRET_ACCESS_KEY` | AWS secret access key | Yes | | `TEXTRACT_REGIONS` | Comma-separated `region:quota` pairs for load balancing | No | Default regions: `us-east-2:100,us-east-1:100,us-west-2:100,ap-south-1:5,eu-west-1:5` ```bash theme={null} # Format: region:quota pairs (quota defaults to 1 if omitted) TEXTRACT_REGIONS=us-east-1:50,us-west-2:25,eu-west-1:10 # Government Cloud TEXTRACT_REGIONS=us-gov-west-1:10,us-gov-east-1:10 ``` ### Required IAM permissions ```json theme={null} { "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Action": "textract:DetectDocumentText", "Resource": "*" } ] } ``` *** ## Azure Vision Read ### Environment variables **Single endpoint:** | Variable | Description | Required | | - | - | - | | `AZURE_VISION_ENDPOINT` | Azure Computer Vision endpoint URL | Yes (unless using `AZURE_VISION_ARRAY`) | | `AZURE_VISION_KEY` | Azure Computer Vision API key | Yes (unless using `AZURE_VISION_ARRAY` or workload identity) | **Multiple endpoints (load balancing and failover):** | Variable | Description | Required | | - | - | - | | `AZURE_VISION_ARRAY` | JSON array of `{"endpoint": "...", "key": "..."}` objects | No | | `AZURE_VISION_ARRAY_STRATEGY` | `load_balance` (default) or `priority` | No | ```bash theme={null} AZURE_VISION_ARRAY='[ {"endpoint": "https://vision-east.cognitiveservices.azure.com/", "key": ""}, {"endpoint": "https://vision-west.cognitiveservices.azure.com/", "key": ""} ]' ``` | Strategy | Behavior | | - | - | | `load_balance` (default) | Randomly selects an endpoint per request. On transient error, retries then fails over to next. | | `priority` | Tries endpoints in order. On transient error, retries then fails over to next. | **Timeout and retry tuning:** | Variable | Default | Description | | - | - | - | | `AZURE_VISION_TOTAL_CALL_TIMEOUT` | `45` (seconds) | Hard cancellation cap per `analyze` call, including all retries. | | `AZURE_VISION_WALL_TIMEOUT` | `120` (seconds) | SDK-level wall-clock timeout (checked between retries). | | `AZURE_VISION_CONNECTION_TIMEOUT` | `10` (seconds) | Per-attempt TCP connect timeout. | | `AZURE_VISION_READ_TIMEOUT` | `30` (seconds) | Per-attempt socket read timeout. | | `AZURE_VISION_MAX_RETRIES` | `2` | Retries per endpoint. Total attempts = `1 + MAX_RETRIES`. | Transient errors (408, 429, 5xx, network errors) trigger retries and failover. Non-retryable 4xx errors surface immediately. **API version:** `AZURE_VISION_API` defaults to `image_analysis_4.0`, which calls Image Analysis 4.0. Some regions, such as US Gov Arizona, do not offer Image Analysis 4.0, so every OCR call there returns 404. Set `AZURE_VISION_API=read_3.2` in those regions to call the v3.2 Read API. It uses the same endpoints, keys or workload identity, retries, and timeouts. > [!NOTE] > > When `AZURE_VISION_ARRAY` is set, `AZURE_VISION_ENDPOINT` and `AZURE_VISION_KEY` are ignored. ### Workload identity (no API key) When your Azure AI services resource disables key authentication, set `AZURE_AI_SERVICES_AUTH=workload_identity` and leave out every key. Reducto then signs each Azure Vision call with an Entra ID token of the pod's workload identity, the same identity that signs Blob Storage result links and calls Azure OpenAI. See [Azure workload identity](/onprem/azure_workload_identity) for the setup, the roles, and Azure Government. ```bash theme={null} AZURE_AI_SERVICES_AUTH=workload_identity AZURE_VISION_ENDPOINT=https://.cognitiveservices.azure.us/ # Or several endpoints, with no keys AZURE_VISION_ARRAY='[ {"endpoint": "https://vision-east.cognitiveservices.azure.us/"}, {"endpoint": "https://vision-west.cognitiveservices.azure.us/"} ]' ``` *** ## GCP Vision API ### Environment variables | Variable | Description | Required | | - | - | - | | `GOOGLE_APPLICATION_CREDENTIALS` | Path to service account key JSON file, or the raw JSON content. Handles both OCR routing detection and Vision API authentication via Application Default Credentials. | Yes (unless using `GCP_SERVICE_ACCOUNT_EMAIL`) | | `GCP_PROJECT_ID` | GCP project ID for quota attribution | Yes | | `GCP_SERVICE_ACCOUNT_EMAIL` | Service account email for workload identity auth (alternative to `GOOGLE_APPLICATION_CREDENTIALS`). | No | | `GCP_API_KEY` | [API key](https://console.cloud.google.com/apis/credentials) with Cloud Vision API enabled. Optional; if set, the Vision API client uses this instead of ADC. | No | | `GCP_REGION` | Region for Vertex AI | No (default: `us-central1`) | ### Authentication methods **Option 1: Service account key (recommended)** `GOOGLE_APPLICATION_CREDENTIALS` handles everything: Reducto uses it to detect GCP availability for routing, and the Vision API client picks it up via Application Default Credentials (ADC). ```bash theme={null} GOOGLE_APPLICATION_CREDENTIALS=/path/to/service-account-key.json GCP_PROJECT_ID=your-project-id ``` `GOOGLE_APPLICATION_CREDENTIALS` can be a file path or the raw JSON content of the service account key. **Option 2: Workload identity (GKE)** Set `GCP_SERVICE_ACCOUNT_EMAIL` to use GKE workload identity. No key file needed. ```bash theme={null} GCP_SERVICE_ACCOUNT_EMAIL=ocr-sa@your-project.iam.gserviceaccount.com GCP_PROJECT_ID=your-project-id ``` ### Required GCP API and roles * **Cloud Vision API** must be enabled on the project (`GCP_PROJECT_ID`). * If using a service account, it needs at minimum the `roles/cloudvision.user` role. * If using `GOOGLE_APPLICATION_CREDENTIALS` for broader GCP features (storage, Vertex AI), the service account also needs: * `roles/aiplatform.user` (for Vertex AI / Gemini LLM calls) * `roles/storage.objectAdmin` (if using GCS for file storage) *** ## Cross-cloud OCR: using GCP Vision on non-GCP infrastructure If you run on Azure or AWS but want GCP Vision for OCR, set `GCP_OCR_ONLY=true` so Reducto uses GCP only for Vision API calls and does not initialize GCS storage. | Variable | Description | Default | | - | - | - | | `GCP_OCR_ONLY` | Use GCP for Vision API OCR only, not for storage. | `false` | ### Example: Azure infrastructure with GCP Vision OCR ```bash theme={null} # --- Azure storage (unchanged) --- AZURE_STORAGE_CONNECTION_STRING=DefaultEndpointsProtocol=https;AccountName=... # --- Azure Vision (optional, keep as fallback or remove) --- # If you want to remove Azure Vision entirely, delete these. # If you keep them, 'legacy' OCR requests will still use Azure Vision, # while 'standard' (default) requests will use GCP Vision. AZURE_VISION_ENDPOINT=https://your-vision.cognitiveservices.azure.com/ AZURE_VISION_KEY=your-azure-vision-key # --- GCP Vision OCR --- GOOGLE_APPLICATION_CREDENTIALS=/path/to/service-account-key.json GCP_PROJECT_ID=your-gcp-project-id GCP_OCR_ONLY=true ``` With this configuration: * **Default API requests** (`ocr_system=standard` or unset) route to **GCP Vision API** * **`ocr_system=legacy` requests** route to **Azure Vision** (if Azure Vision credentials are still set) or **GCP Vision** (if Azure Vision credentials are removed) * **File storage** remains on **Azure Blob Storage** ### Example: AWS infrastructure with GCP Vision OCR ```bash theme={null} # --- AWS storage --- AWS_ACCESS_KEY_ID=your-aws-key AWS_SECRET_ACCESS_KEY=your-aws-secret BUCKET=your-s3-bucket # --- GCP Vision OCR --- GOOGLE_APPLICATION_CREDENTIALS=/path/to/service-account-key.json GCP_PROJECT_ID=your-gcp-project-id GCP_OCR_ONLY=true ``` ### Migration path: switching from Azure Vision to GCP Vision If you are currently using Azure Vision and want to switch to GCP Vision as your primary OCR: 1. **Add GCP credentials** to your deployment: ```bash theme={null} GOOGLE_APPLICATION_CREDENTIALS=/path/to/service-account-key.json GCP_PROJECT_ID=your-gcp-project-id GCP_OCR_ONLY=true ``` 2. **Test with a few requests.** The default `ocr_system=standard` will now route to GCP Vision. Verify OCR quality meets your expectations. 3. **Optionally remove Azure Vision credentials.** If you no longer need Azure Vision as a fallback for `legacy` requests, remove `AZURE_VISION_ENDPOINT` and `AZURE_VISION_KEY` (or `AZURE_VISION_ARRAY`). This simplifies the configuration. 4. **`GCP_OCR_ONLY` should remain set** as long as you are using non-GCP file storage. *** ## Local CPU OCR tuning The legacy worker can tune its in-process ONNX OCR detector and CRNN recognizer without a separate inference service. Enable the settings below explicitly; they apply only when those models use `CPUExecutionProvider` exclusively. * `OCR_CPU_MEM_ARENA` defaults to `false`. Set it to `true` to reuse CPU allocations between inference calls. This can retain more memory. * `OCR_CPU_INTRA_OP_THREADS` defaults to unset. Set a positive per-model thread count to also use one inter-op thread and disable idle spinning in both pools. For a CPU worker with an eight-vCPU limit, a candidate to validate is `OCR_CPU_MEM_ARENA=true` with `OCR_CPU_INTRA_OP_THREADS=8`. Choose the thread count against available CPU per pod and the number of pods sharing each node. Set these environment variables on the worker deployment and restart workers to recreate the model sessions. These controls leave memory patterns disabled and do not change CUDA sessions, Paddle recognition, or layout/table model settings. `OCR_THREADS` controls the OCR service request executor separately; it does not limit ONNX model threads. Runtime session overrides, such as benchmark `sitecustomize.py` hooks, must not overwrite these settings. Preserve any separate tuning needed by non-OCR models. Before enabling arena reuse across a fleet, validate completed jobs and peak memory with all worker models loaded at the intended pod density. Roll back by setting `OCR_CPU_MEM_ARENA=false`, removing `OCR_CPU_INTRA_OP_THREADS`, and restarting workers. This restores the previous OCR session options. ## GPU OCR deployment (`OCR_ONLY` mode) For deployments that want to use Reducto's own OCR models (which run on GPU) as a dedicated service, set `OCR_ONLY=true`. This starts a lightweight service exposing only the `/ocr` endpoint. | Variable | Description | Default | | - | - | - | | `OCR_ONLY` | Enable OCR-only mode | `false` | | `OFFLINE` | Must be `1` for on-prem deployments | - | | `HTTP_WORKERS` | HTTP worker processes | `8` | | `MAX_PROCESSING_REQUESTS` | Max concurrent OCR requests per worker | `1` | | `MAX_QUEUED_REQUESTS` | Max queued requests per worker | `0` | | `OCR_THREADS` | Thread pool size for OCR | `5` | | `NUM_GPUS` | GPUs available (for CUDA device assignment) | `0` | | `REQUIRE_GPU` | Fail startup if ONNX Runtime cannot reach the GPU. Defaults to on for GPU images. Set `false` to intentionally run a GPU image on CPU nodes. | (GPU images: `true`) | > [!WARNING] > > GPU images refuse to start when ONNX Runtime cannot actually reach the GPU, rather > than silently falling back to CPU at a fraction of the throughput. The startup log > names the cause and the commands to confirm it. > > The most common cause on NVSwitch hardware (HGX/SXM H100, H200, A100) is > `nvidia-fabricmanager` not running, or running at a version that does not exactly > match the installed driver. Note that `nvidia-smi` still reports a perfectly healthy > GPU in this state, because NVML does not touch fabric state. To confirm on the node: > > ```sh theme={null} > nvidia-smi -q | grep -i -A3 fabric # expect "State: Completed" > systemctl status nvidia-fabricmanager > cat /proc/driver/nvidia/version # must match the fabricmanager package version > ``` > > Repair the node before upgrading the image: a deployment that was previously serving > degraded CPU-only OCR will now fail its readiness check instead. If you need to run a > GPU image on CPU nodes deliberately, set `REQUIRE_GPU=false`. ```yaml theme={null} # Helm values gpuOcr: enabled: true replicaCount: 1 resources: requests: nvidia.com/gpu: 1 limits: nvidia.com/gpu: 1 ``` The Helm chart sets `OCR_ONLY=true` and `OFFLINE=1` automatically. The readiness probes require a GPU OCR image that includes the `/ready` endpoint; use the image built from this change or a later release. Because the chart and image are independently upgradeable, both probes are disabled by default for compatibility with existing pinned images. Enable both together after upgrading the image. If `gpuOcrImage.tag` is pinned to an older image, upgrade that image first; an older image with the probes enabled returns `404` and never becomes Ready. ### Startup behavior and readiness The pod loads its OCR models **before** accepting traffic, rather than on the first request. When the probes are enabled, a pod on a node whose GPU is unusable never becomes Ready, so Kubernetes routes no traffic to it and the problem is visible from `kubectl get pods` at deploy time instead of surfacing later as failed jobs. Every worker process loads its own copy, one at a time. With the probes enabled, the pod reports Ready only after all `HTTP_WORKERS` finish. Reporting Ready earlier would advertise full capacity while most workers were still loading, and `MAX_QUEUED_REQUESTS` defaults to `0`, so the surplus comes back as `429`. After the pod has been fully warm once it stays in service while any worker can serve, so losing one worker later reduces capacity instead of removing the pod. Size `HTTP_WORKERS` against GPU and host memory, not just latency: every worker holds its own copy of the models, and the pod now needs all of them resident before it serves. Set it under `gpuOcr.extraEnv`, so changing the OCR worker count does not also resize the main HTTP tier: ```yaml theme={null} gpuOcr: extraEnv: - name: HTTP_WORKERS value: "4" ``` The shared environment remains the fallback for existing installations; when a name appears in both places, `gpuOcr.extraEnv` wins for the GPU OCR pod only. Read the real per-worker load time off the startup logs to check it against the readiness budget: ```sh theme={null} kubectl logs deploy/-reducto-gpu-ocr | grep "OCR model load complete" ``` Warm-up is roughly that duration times `HTTP_WORKERS`, because the loads are serialized. If that exceeds the probe budget below, lower `HTTP_WORKERS` or raise `failureThreshold`. The rollout deadline also includes image pull and GPU-node scheduling time. When the cause is the GPU check, the pod stays running (not `CrashLoopBackOff`) so you can inspect it for as long as the `startupProbe` budget lasts, about 500s with the shipped values. After that the kubelet kills the container and Kubernetes restarts it, so a persistently unusable GPU does end in a restart loop; use `kubectl logs --previous` once that starts. Any other model-load failure, such as missing weights or an out-of-memory kill, ends the container immediately. `/ready` returns `503` until every worker is warm and reports how many are; `/health` remains a plain liveness signal. Once the pod has been fully warm it stays in service while any worker can serve, so losing a worker later reduces capacity instead of removing the pod. ```sh theme={null} kubectl get pods -l app=reducto-gpu-ocr # 0/1 means the models did not load kubectl exec deploy/-reducto-gpu-ocr -- curl -s localhost:80/ready # replace 80 if gpuOcr.port is overridden kubectl logs deploy/-reducto-gpu-ocr | grep -i "OCR model init failed" ``` Three consequences worth planning for: * **Startup is slower**, by roughly `HTTP_WORKERS` model loads. Lower `HTTP_WORKERS` to shorten it. There is no `livenessProbe`, so a slow load never restarts a pod that is making progress; the `startupProbe` does restart one that never warms up. * **A deployment previously serving degraded CPU-only OCR will stop serving.** Repair the node first, or set `REQUIRE_GPU=false` to deliberately run on CPU. * **Running degraded rejects the surplus.** Once the pod has been fully warm it stays in service while any worker can serve, but the OCR client does not retry, so requests beyond the remaining capacity fail rather than queue. Prefer more replicas over a high `HTTP_WORKERS` if losing a worker must not fail jobs. * **A worker that cannot reach the GPU exits and is respawned.** It never serves, so requests are not routed to a handler that would fail them. A transient cause, such as a pod scheduled while `nvidia-fabricmanager` is still starting on a fresh node, therefore recovers without operator action. A persistent one logs the reason on every attempt and eventually restarts the container. * **One bad GPU on a multi-GPU node stops the whole pod.** Workers are pinned per device (`CUDA_VISIBLE_DEVICES = pid % NUM_GPUS`), so only the workers landing on the bad device fail, but the initial gate needs every worker warm, so the pod never becomes Ready and is restarted rather than serving at reduced capacity. The symptom is a never-Ready pod, not an obvious GPU error, so grep the logs for `OCR model init failed` to identify it. Two probes, because the two windows differ. `startupProbe` absorbs the warm-up; Kubernetes suppresses readiness until it passes, which lets `readinessProbe` stay tight and pull a pod that stops serving in about 30s instead of waiting out the whole warm-up budget. ```yaml theme={null} gpuOcr: progressDeadlineSeconds: 1800 startupProbe: enabled: true periodSeconds: 10 timeoutSeconds: 5 failureThreshold: 50 # 500s of warm-up, with rollout margin readinessProbe: enabled: true periodSeconds: 10 timeoutSeconds: 5 failureThreshold: 3 # ~30s to stop routing to a pod that goes bad ``` > [!NOTE] > > `gpuOcr.port` sets the container port, the Service target, the readiness probe and > `HTTP_PORT` together, so the port the app binds always matches the one Kubernetes > probes. It defaults to `80` to preserve rolling upgrades of existing standard GPU > images. Chainguard images run as a non-root user and require `gpuOcr.port: 8888`. > The Service still publishes `80`, so callers are unaffected. Set the port before > switching image families; changing it and the image in one upgrade is a port > migration, not a transparent rolling update. # Operations Source: https://docs.reducto.ai/onprem/operations Runtime configuration for on-premise Reducto worker health and restart behavior Runtime knobs operators use to keep on-prem Reducto worker pods healthy: liveness behavior, restart policy, and watchdog tuning. Related references: * [Observability → Pod stack trace dumps](/onprem/observability#pod-stack-trace-dumps-sigusr2) for live thread-stack diagnostics via `SIGUSR2`. * [Database configuration](/onprem/database_configuration) for DB pool sizing and timeouts. * [OCR options → Azure Vision Read](/onprem/ocr_options#azure-vision-read) for OCR provider timeouts, retries, and failover. ## v1.12.6 to v1.13 streaq migration v1.13 requires a healthy Redis-backed streaq deployment. Upgrade to the stable v1.12.6 release (not a prerelease) first and keep the legacy PostgreSQL queue workers running while streaq is validated and traffic drains. Streaq is opt-in on v1.12.6 patches: a routine install or upgrade that never sets `WORKER_PROVIDER: STREAQ_LOCAL` is not affected by anything below and will not have its release blocked. The checks described here only activate once you configure streaq, and they stay mandatory from that point on — including for any later v1.12.6+ patch upgrade, not just the v1.13 cutover. Configure v1.12.6 with a small, fixed streaq pool: ```yaml theme={null} env: WORKER_PROVIDER: STREAQ_LOCAL streaqWorkers: io: enabled: true workerName: io replicaCount: 1 kedaScaler: false cpu: enabled: true workerName: cpu useFullImage: true workerCount: 1 replicaCount: 1 kedaScaler: false ``` Also either set `redis.enabled: true` or supply `REDIS_URL` through `env`, `secretEnv`, or `envFrom`. The v1.12.6 Helm post-upgrade check sends a real `ping` task through both the `io` and `cpu` queues. The release fails if Redis is unreachable, either worker is missing, or the values above are incomplete. Before the v1.13 upgrade, run several representative `/parse` requests with the request-scoped streaq canary enabled: ```json theme={null} { "input": "https://example.com/document.pdf", "settings": { "alpha": { "use_streaq_parse": true, "compute_target_override": "STREAQ_LOCAL" } } } ``` Perform the v1.13 cutover as separate Helm applies: Use `--timeout 10m` for each Helm upgrade in this migration. The preflight Job has a nine-minute deadline, including pod scheduling and up to six minutes for worker health checks, so Helm's five-minute default timeout is insufficient. On v1.12.6, the health check is a post-upgrade hook that only runs once streaq is configured: a failure marks the Helm release failed after its resources are applied. Correct the reported topology or Redis issue and rerun the upgrade, or roll back. The v1.13 check runs pre-upgrade, and runs unconditionally, regardless of whether streaq was ever configured, blocking resource changes when its prerequisites fail. 1. Upgrade to v1.13 with `worker.enabled: true`. The pre-upgrade check requires an installed release marker from v1.12.6 or a later v1.12 patch and repeats the live Redis worker round trips before changing resources. 2. Scale the streaq pools for production load, then set both the trainable and non-trainable `*_STREAQ_*_ROLLOUT_RATIO` values to `1.0` for parse, extract, split, edit, and pipeline. Keep the legacy worker enabled while existing PostgreSQL-queue work drains. Leave `INFERENCE_BACKENDS` at its default during the ramp. With `WORKER_PROVIDER: STREAQ_LOCAL` and `inference.enabled: true`, the streaq workers pin inference to the in-cluster inference service on their own, while the DB-queue worker pods (`reducto-worker`, `reducto-priority-worker`, `reducto-gpu-worker`) keep the in-process chain they ran before the upgrade. The two arms therefore share one `env` map and still run different inference paths; no per-pod override is needed. Set `INFERENCE_BACKENDS: '["LOCAL_SERVICE", "IN_PROCESS"]'` only after the legacy worker is disabled, or when the legacy worker runs on the lean `orchestratorImage` and must call the service itself. The in-cluster inference service runs in Triton mode. Each inference pod runs two release images from the same repository as `image`: the Triton model server, `-triton`, and the gateway that the workers call, `-gateway`. Chainguard releases use `-cg-triton` and the same `-gateway`. Air-gapped sites mirror both. Releases do not publish a `-inference` image, so `inference.enabled: true` without Triton mode leaves the inference pods unable to pull. To keep inference in-process, leave `inference.enabled: false`. ```yaml theme={null} inferenceImage: repository: tag: -gateway inference: enabled: true triton: enabled: true # pulls -triton modelConfigs: profile: cpu-ort # or cpu-amx-bf16, cpu-ov-f32 ``` Set `inferenceImage` explicitly: older charts fail to render in Triton mode without it. The chart points the workers at the service (`LOCAL_INFERENCE_URL`), and the streaq workers use it with no further setting. A deployment that already runs a separate Triton and gateway fleet through the chart keeps it: the streaq cpu workers call the same gateway Service. Raise `worker.terminationGracePeriodSeconds` (default 30, the Kubernetes default) to the longest job the legacy worker runs so a rollout does not kill work in flight. The worker and priority worker roll with `maxSurge: 0%` and `maxUnavailable: 50%`, so a longer grace lengthens each rollout wave; the gpu worker uses `Recreate`. Raise the Helm `--timeout` to cover two waves of the new grace. ```yaml theme={null} worker: terminationGracePeriodSeconds: 600 ``` 3. Poll `GET /drain_status` until `drained` is `true`. Then set `worker.enabled: false`, `worker.priorityCount: 0`, and `gpuWorker.enabled: false`. The Helm pre-upgrade check blocks this final apply unless the currently running HTTP deployment reports every effective rollout ratio as `1.0` and the live drain check passes. Ratios supplied via `env`, `secretEnv`, or `envFrom` are all read from that running process, so deploy the ratio change before the separate worker-disable apply. The drain check deliberately has no age-based exception for PostgreSQL queue rows. A 12-hour batch-queue SLA is not a row TTL, and the legacy queue has no default global queue timeout, so an old row can still represent valid work. The v1.12.6 bridge keeps the stale-job recovery CronJob and legacy worker alive until the queue is empty; the final apply removes both. If drain remains blocked, inspect the reported queue and active-job counts instead of deleting or ignoring rows based only on `created_at`. This drain check is not limited to the v1.13 cutover: once streaq is configured, setting `worker.enabled: false` on any v1.12.6+ patch is treated the same way — the apply is blocked unless every rollout ratio is `1.0` and `GET /drain_status` reports drained. There is no earlier, unguarded way to disable the legacy worker after streaq is armed. Re-enable `worker.enabled` and restore the rollout ratios to roll back while the v1.13 chart still contains the legacy worker templates. ## Worker liveness probe The DB-queue worker pods (`reducto-worker`, `reducto-priority-worker`, `reducto-gpu-worker`) ship with a Kubernetes liveness probe that restarts a pod **only** when it is stuck mid-processing, not when it is idle waiting for work. ### How it works 1. An asyncio `WorkerWatchdog` task runs alongside the worker's job loops. 2. Every 5 seconds it writes a heartbeat file `/tmp/worker-state` containing ` `. Idle workers emit `-1` for the age. 3. The kubelet runs `bin/worker-liveness.sh` as an `exec` probe. The script fails (exit non-zero) and triggers a restart when **either**: * the heartbeat file itself is older than `WORKER_LIVENESS_WATCHDOG_STALE_SEC` (event loop is wedged so the watchdog can't tick); **or** * the oldest in-flight task age exceeds `WORKER_LIVENESS_STUCK_THRESHOLD_SEC` (a real job has hung beyond the threshold). 4. Idle workers always pass the probe. The watchdog ticks even with no work, and the age sentinel `-1` is always treated as healthy. A file-based heartbeat is used rather than an in-process HTTP `/health` endpoint because an HTTP server can keep returning `200` while the asyncio event loop is blocked on a syscall. The watchdog has to be alive to refresh the file, so the probe directly tests the thing we care about. ### Helm configuration Configure the probe via `worker.livenessProbe.*` in your Helm values: ```yaml theme={null} worker: livenessProbe: enabled: true # set to false to disable the probe entirely stuckTaskThresholdSec: 1800 # restart if any in-flight task runs longer than this watchdogStaleSec: 60 # restart if the watchdog heartbeat hasn't ticked in this long periodSeconds: 30 # how often kubelet runs the probe timeoutSeconds: 5 # exec probe timeout initialDelaySeconds: 120 # grace period after pod start before probing begins failureThreshold: 2 # consecutive probe failures before pod restart ``` The same Helm partial applies the probe to all three worker deployments, so a single block configures `reducto-worker`, `reducto-priority-worker`, and `reducto-gpu-worker` together. ### Defaults | Knob | Default | When to change | | - | - | - | | `stuckTaskThresholdSec` | `1800` (30 min) | Raise if your workload includes legitimately long single-task work (large multi-thousand-page documents, long extraction prompts). Lower if you'd rather fail fast and rely on client retries. | | `watchdogStaleSec` | `60` | Rarely needs tuning. Lower bounds how quickly an event-loop wedge is caught; should stay several times larger than the 5s tick interval to avoid false positives. | | `periodSeconds` | `30` | Lower for faster detection at the cost of more probe overhead. | | `initialDelaySeconds` | `120` | Raise if your pods take longer to come up (large image pulls, slow init containers). | | `failureThreshold` | `2` | Raise to make restart decisions more conservative. | ### Environment variables The Helm chart pipes the values above into env vars that the Python watchdog and shell probe both read, so the two stay in sync. You normally configure these via Helm, but you can override directly when running outside the chart: | Variable | Read by | Default | Purpose | | - | - | - | - | | `WORKER_STUCK_TASK_THRESHOLD_SEC` | Python watchdog | `1800` | Drives the per-task age comparison **and** the `logfire.warn` emitted when oldest age exceeds 80% of threshold (early breadcrumb for SRE before kubelet restarts the pod). | | `WORKER_LIVENESS_STUCK_THRESHOLD_SEC` | `bin/worker-liveness.sh` | `1800` | Per-task age limit used by the exec probe itself. Helm sets this from `worker.livenessProbe.stuckTaskThresholdSec`. | | `WORKER_LIVENESS_WATCHDOG_STALE_SEC` | `bin/worker-liveness.sh` | `60` | Maximum heartbeat-file age before the probe fails. Helm sets this from `worker.livenessProbe.watchdogStaleSec`. | | `WORKER_WATCHDOG_STATE_PATH` | Both | `/tmp/worker-state` | Heartbeat file location. Almost never needs to change. | ### Disabling the probe If you're operating in a constrained environment that can't run exec probes, or you'd rather rely on external orchestration, set: ```yaml theme={null} worker: livenessProbe: enabled: false ``` The Python watchdog still runs and emits `logfire.warn` when tasks exceed 80% of the threshold. The probe just doesn't trigger restarts. ### Verifying it's installed After deploy, confirm the probe is wired up: ```bash theme={null} kubectl describe pod -n reducto -l app=reducto-worker | grep -A 4 Liveness # Liveness: exec [bin/worker-liveness.sh] delay=120s timeout=5s period=30s ... ``` And confirm the watchdog file is being refreshed: ```bash theme={null} kubectl exec -n reducto -- cat /tmp/worker-state # 1778631873 -1 <- idle (age sentinel -1 is healthy) # 1778631878 42 <- busy with a 42-second-old task ``` ## Load shedding By default Reducto accepts every request and queues it. When clients send more sync `/parse` requests than the cpu workers can process, the queue grows and latency climbs until client timeouts fire. Reducto never returns an error in that state. Set a per-queue admission cap to return an error instead. When the destination queue's depth is at or above the cap, the HTTP pod returns `503` with a `Retry-After: 30` header and an error code of `QUEUE_FULL`. Reducto creates no job for a rejected request. | Variable | Default | Purpose | | - | - | - | | `STREAQ_CPU_MAX_QUEUE_DEPTH` | unset (off) | Cap for the cpu queue. Gates `/parse`, `/extract`, `/split` (including chained `jobid://` requests), and streaq `/edit`. | | `STREAQ_IO_MAX_QUEUE_DEPTH` | unset (off) | Cap for the io queue. Gates `/pipeline` and `/edit`. | The depth is the value `GET /streaq_metrics` reports as `streaq_cpu_active_queue_length` (or `streaq_io_active_queue_length`). Read that field under load to choose a cap. The depth counts queued and running jobs together, because a stream entry stays until the worker completes the job. A starting value for the cpu cap is 2 to 3 times the cpu pool size (`cpu replicas × CPU_WORKER_CONCURRENCY`). A lower cap rejects requests that would finish inside the client timeout. Requests with `queue_priority: batch` do not count and are not gated. The batch lane has its own cap, `BATCH_MAX_QUEUE_DEPTH`. The gate fails open. If a depth read fails or takes more than 1 second, the HTTP pod counts that Redis cluster as empty, accepts the request, logs `queue_depth_cap.read_failed`, and increments the `reducto.admission.depth_read_failed` counter. Watch that counter: a rising count means the cap is blind. Each rejection logs `queue_depth_cap.rejected` and increments the `reducto.admission.queue_full` counter. # Securing Reducto Source: https://docs.reducto.ai/onprem/securing_reducto Learn how to secure on-premise Reducto deployments ## Authentication Reducto on-premise should run behind your private network boundary and should be called only by trusted services. Authentication protects access to the deployment, but Reducto does not provide per-end-user authorization inside a single on-premise tenant. Review the [on-prem security model](/onprem/security_model) before exposing Reducto to additional services or networks. Reducto can read one or more tokens used for authenticating API calls from a file. On Kubernetes, a Secret can be mounted as a file in the `http` Pod - when this Secret is updated, Kubernetes automatically updates the mounted file using an eventually-consistent approach, allowing Reducto to reload the new value without requiring a restart. Multiple API keys can be specified by placing each key on a separate line. This enables progressive secret rotation: add the new key, roll your clients over, then remove the old key, all without downtime. > [!WARNING] > > Enable API authentication for production on-premise deployments. If you need different permissions for different users, applications, or business units, enforce those permissions in your gateway or run separate Reducto deployments. ### Overview When enabled, Reducto reads the authentication secret from a mounted file specified by the `AUTH_SECRET_PATH` environment variable. This provides better security isolation and follows Kubernetes best practices for secret management. ### Setup instructions You can set up authentication using either manual secret creation or secret creation via Helm Chart. #### Option 1: Manual secret creation ##### 1. Create a Kubernetes secret First, create a Kubernetes secret containing your API key(s). For a single key: ```bash theme={null} kubectl create secret generic reducto-auth-secret \ --from-literal=secret=your-api-key-here \ --namespace reducto ``` For multiple keys (e.g., during rotation), separate them with newlines: ```bash theme={null} kubectl create secret generic reducto-auth-secret \ --from-literal=secret=$'current-key\nnew-key' \ --namespace reducto ``` ##### 2. Configure Helm values Add the following configuration to your Helm values file: ```yaml theme={null} auth: secretPath: enabled: true secretName: "reducto-auth-secret" mountPath: "/etc/auth" filename: "secret" ``` #### Option 2: Secret creation via Helm Chart ##### 1. Configure values for creation via Helm Chart Add the following configuration to your Helm values file to let the Helm chart create the secret: ```yaml theme={null} auth: secretPath: enabled: true createSecret: true apiKey: "your-api-key-here" # Must specify when createSecret is true. secretName: "reducto-auth-secret" mountPath: "/etc/auth" filename: "secret" ``` **Note**: With `createSecret: true` you must specify `apiKey` #### 2. Deploy Reducto (for both options) Deploy or upgrade your Reducto installation: ```bash theme={null} helm upgrade --install reducto oci://registry.reducto.ai/reducto-api/reducto \ --namespace reducto \ --values your-values.yaml ``` ### Configuration options | Parameter | Description | Default | | - | - | - | | `auth.secretPath.enabled` | Enable file-based authentication | `false` | | `auth.secretPath.createSecret` | Create the Kubernetes secret | `false` | | `auth.secretPath.apiKey` | API key, must be specified when `createSecret: true` | - | | `auth.secretPath.secretName` | Name of the Kubernetes secret | `"reducto-auth-secret"` | | `auth.secretPath.mountPath` | Mount path for the secret file | `"/etc/auth"` | | `auth.secretPath.filename` | Filename within the secret | `"secret"` | ### How it works 1. When `auth.secretPath.enabled` is `true`, the Helm chart mounts the specified secret as a volume 2. The `AUTH_SECRET_PATH` environment variable is automatically set to the full file path 3. Reducto's authentication middleware reads all keys from the file (one per line) and authenticates API requests against any of them 4. The file is monitored for changes, supporting secret rotation without restarts 5. If the provided API key doesn't match any key in the file, a 401 error is returned ### Secret rotation To rotate keys without downtime: 1. Add the new key on a new line alongside the existing key 2. Update the Kubernetes secret (Reducto picks up the change automatically) 3. Migrate your clients to use the new key 4. Remove the old key from the secret ## Network access Do not expose Reducto directly to the public internet. Put the API behind an internal load balancer, private ingress, service mesh, or API gateway that matches your organization's access policy. Restrict access to: * Reducto API endpoints, including `/job`, `/jobs`, `/upload`, `/cancel`, `/configure_webhook`, and billing export endpoints * Metrics and Prometheus endpoints * PostgreSQL, Redis, object storage, model services, and observability UIs If users or browser clients need to initiate work, route them through your own application backend. That backend should authenticate the user, authorize the workflow, and then call Reducto as a trusted service. # On-prem security model Source: https://docs.reducto.ai/onprem/security_model Understand the shared security responsibilities and trust boundaries for Reducto on-premise deployments Reducto on-premise runs inside infrastructure that you control. Reducto provides the application, Helm chart, and deployment guidance. You control the network boundary, caller authentication, Kubernetes policy, storage policy, telemetry sinks, and access to the deployment. ## Trust boundary Reducto on-premise is designed for a single customer tenant. It should be deployed behind your private network boundary and called by trusted services inside your environment. Do not expose the Reducto API directly to the public internet or to untrusted browser clients. If users or applications outside your trusted network need access, place Reducto behind your own gateway, identity provider, firewall, and rate limiting controls. In the on-premise model, Reducto does not provide per-end-user authorization inside a single deployment. API keys authenticate access to the deployment, and resources such as jobs, uploaded files, webhook configuration, and usage data belong to the deployment's tenant. If you need isolation between business units, applications, or users, run separate deployments or enforce that isolation in your gateway before traffic reaches Reducto. ## Shared responsibility | Area | Reducto responsibility | Customer responsibility | | - | - | - | | Application code | Ship secure application code, fixes, and supported configuration options | Keep the deployment upgraded and apply security releases promptly | | Network exposure | Document intended network placement and internal endpoints | Keep Reducto APIs, metrics, databases, object storage, and observability UIs private unless intentionally exposed through your controls | | API authentication | Support bearer token authentication and file-based key rotation | Enable authentication, manage secrets, rotate keys, and restrict who can call the service | | Caller authorization | Enforce tenant-level access within the Reducto deployment | Enforce per-user or per-application authorization before requests reach Reducto | | Document input | Process documents and URLs provided by authenticated callers | Ensure callers only submit documents and URLs they are allowed to process | | URL fetching | Fetch customer-provided document URLs as part of document processing | Control egress, restrict metadata services, and use allowlists or network policy where needed | | Kubernetes hardening | Provide Helm values and supported runtime guidance | Apply pod security standards, network policies, ingress policy, secret management, and cluster logging policy | | Observability | Emit metadata-only logs, metrics, traces, and operational endpoints | Route telemetry to approved sinks, set retention, and restrict observability access to operators | ## API authentication Enable file-based API key authentication for production deployments. See [Securing Reducto](/onprem/securing_reducto) for setup and rotation steps. Use one or more long random bearer tokens stored in a Kubernetes Secret. Treat the token as a deployment-level credential. During rotation, configure both the current key and the new key, migrate clients, then remove the old key. If you operate multiple caller identities, do not rely on Reducto to distinguish those callers inside one on-premise deployment. Put an API gateway, service mesh, or internal service in front of Reducto to authenticate callers, authorize requested workflows, and attach audit context. ## Network placement Place Reducto services on private cluster networks. At minimum: * Expose the API only to trusted internal services or through a controlled ingress. * Keep PostgreSQL, Redis, object storage, and model services private. * Keep `/metrics`, `/prometheus`, and observability UIs private to monitoring and operations networks. * Block direct internet access to internal admin or scrape endpoints. * Use Kubernetes NetworkPolicy, cloud security groups, private endpoints, or equivalent controls to restrict east-west and egress traffic. ## Document URLs and uploads Reducto processes documents supplied by authenticated callers. This includes direct uploads, `reducto://` file IDs, presigned URLs, customer storage URLs, and internal URLs when your deployment allows access to them. Because URL fetching is part of the product, your network design decides which internal resources Reducto can reach. Treat document URL submission as a privileged capability. Only trusted services should be able to ask Reducto to fetch URLs. Recommended controls: * Prefer direct uploads or tightly scoped presigned URLs when possible. * Keep presigned URLs short-lived and limited to a single object. * Block cloud metadata endpoints and other sensitive infrastructure endpoints from worker egress. * Use egress allowlists for document source domains when your workflows allow it. * Avoid passing URLs that embed long-lived credentials. ## Internal endpoints Some endpoints are intended for tenant operators and internal automation, not public callers. Examples include job listing, webhook configuration, billing export, metrics, and Prometheus scraping endpoints. In a single-tenant deployment, these endpoints operate at the tenant boundary. They are not a substitute for per-end-user access control. Restrict them to trusted services and operators through your network and gateway policy. ## Logs, metrics, and traces Reducto emits application logs to stdout and can send traces, metrics, and logs through OpenTelemetry. See [Observability & Monitoring](/onprem/observability) for configuration. Reducto telemetry is metadata-only observability, not a data pipeline. Logs, traces, and metrics should not include document text, OCR output, extracted values, prompts, model responses, API tokens, access keys, secrets, or other customer content. File names and URLs can reveal customer identity and should be redacted or avoided in telemetry. You own the retention period, sink selection, access policy, and export policy for on-premise telemetry. Telemetry may include job IDs, counts, statuses, durations, sanitized error categories, and other debugging metadata. Route it only to systems approved by your organization. ## Deployment checklist * Enable API authentication with `auth.secretPath.enabled`. * Put Reducto behind a private ingress or internal gateway. * Document which services are allowed to call Reducto. * Restrict metrics, Prometheus, HyperDX, PostgreSQL, Redis, object storage, and model endpoints to internal networks. * Configure egress controls for URL fetching and block metadata services. * Use Kubernetes Secrets or your secret manager for API keys and provider credentials. * Configure telemetry retention, routing, and access controls. * Keep the Helm chart and Reducto image current with security releases. * Run separate deployments or gateway-level authorization if you need isolation between internal callers. # Air-gapped usage for billing Source: https://docs.reducto.ai/onprem/usage_for_billing Export usage data from air-gapped environments for billing purposes ## Overview Usage data from Reducto deployments in air-gapped environments can be exported as a compressed CSV file and sent to Reducto for billing purposes. When you invoke the `/billing-usage` API, usage data is exported from the database to a file and uploaded to the `BUCKET/usage_for_billing` prefix in object storage. A presigned URL is returned, which you can use to download the file and send it to Reducto. **Note**: To avoid data loss, ensure that the exported file is downloaded and shared before any lifecycle rules expire and delete the object. ## Enable usage persistence To export usage data, it must first be persisted in the database. This can be enabled by setting the following environment variable: ```bash theme={null} STORE_USAGE_FOR_BILLING="yes" ``` ## API reference ### POST `/billing-usage` Exports usage data to object storage and returns a presigned URL for download. #### Request parameters | Parameter | Type | Default | Description | | - | - | - | - | | `count` | integer | 1,000,000 | The number of records to fetch and export (minimum: 1) | | `delete` | boolean | false | Whether to delete the records after exporting | #### Request example ```bash theme={null} curl -X POST "https:///billing-usage" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "count": 100000, "delete": false }' ``` #### Response format ```json theme={null} { "presigned_url": "" } ``` If usage persistence is not enabled or no data is available, the response will be: ```json theme={null} { "presigned_url": null } ``` ## Exported data format The exported CSV file contains the following columns: * `idempotency_key`: Unique identifier for each usage record * `type`: Type of usage (e.g., "parse") * `num_pages`: Number of pages processed * `attributes`: Additional metadata as JSON * `timestamp`: When the usage occurred (ISO format) ## Usage workflow 1. **Enable persistence**: Set `STORE_USAGE_FOR_BILLING="yes"` in your environment 2. **Process documents**: Use Reducto normally; usage data will be automatically stored 3. **Export all data**: Call the `/billing-usage` API repeatedly with `"delete": true` until the presigned URL is null 4. **Download files**: Use each returned presigned URL to download the compressed CSV files 5. **Send to Reducto**: Share all downloaded files with Reducto for billing ### Complete data export process To ensure all usage data is exported, you must call the API repeatedly with `delete: true` until no more data remains: ```bash theme={null} set -eu REDUCTO_ENDPOINT="https://" REDUCTO_API_KEY="your-api-key" # Call this repeatedly until presigned_url is null while true; do echo "Exporting ..." response=$(curl -s -X POST "${REDUCTO_ENDPOINT}/billing-usage" \ -H "Authorization: Bearer ${REDUCTO_API_KEY}" \ -H "Content-Type: application/json" \ -d '{"count": 1000000, "delete": true}') presigned_url=$(echo "$response" | jq -r '.presigned_url') if [ "$presigned_url" = "null" ]; then echo "All data exported successfully" break fi echo "Downloading: $presigned_url" # Download the file using the presigned URL curl -OJ "$presigned_url" done ``` **Important**: Always use `"delete": true` when performing complete exports to avoid re-exporting the same data and to free up database storage. ## Important notes * Records are exported in chronological order (oldest first) * The presigned URL expires after 6 days or until lifecycle rule deletes the object * If `delete: true` is used, only successfully exported records are deleted from database * To regenerate presigned URL of already exported data visit `BUCKET/usage_for_billing` prefix on cloud console for Bucket # Overview Source: https://docs.reducto.ai/overview The agentic document platform for leading AI teams > [card] [Introducing Parse r-1 mini](/parse/r-1#r-1-mini) > > Industry-leading accuracy at \$1 per 1,000 pages. Reducto is the agentic document platform for AI teams who need production-grade document processing at enterprise scale. It provides a complete toolkit for the full document lifecycle, from classification and extraction to editing and workflow orchestration, powered by custom in-house and leading frontier models. **What you can build:** * **Automated intake pipelines** that classify, split, and route documents without manual triage * **Structured data extraction** from invoices, contracts, medical records, and any document type at scale * **Document generation and editing** that fills forms, modifies templates, and produces new documents programmatically * **RAG-ready content pipelines** with layout-aware chunking optimized for LLM consumption * **Multi-step workflows** that chain classification, parsing, extraction, and editing into single API calls *** ## Platform capabilities Reducto covers the full lifecycle of document work. Each capability is available as a standalone API endpoint or composed into pipelines. > [card] [Classify](/classify/overview) > > Route documents by type before processing. Define categories in natural language. > [card] [Parse](/parse/overview) > > Read every page into LLM-ready text, tables, and figures, preserving layout and reading order, with layout-aware chunking. > [card] [Extract](/extract/overview) > > Pull specific fields into structured JSON using a schema. Define what you need, get exactly that. > [card] [Split](/split) > > Divide documents into logical sections using natural language descriptions. > [card] [Edit](/editing/edit-overview) > > Fill PDF forms and modify DOCX files programmatically with natural language instructions. > [card] [Pipelines](/workflows/pipeline-basics) > > Chain multiple steps into reusable, single-call workflows deployed from Studio. Supports [30+ file types](/upload/overview#supported-file-types) including PDFs, images, spreadsheets, presentations, and scanned documents. *** ## How to use Reducto > [card] [API & SDKs](/quickstart) > > For developers building automated pipelines. Available in Python, Node.js, Go, and REST. > [card] [Studio](https://studio.reducto.ai) > > For visual pipeline building. Configure, test, and deploy document workflows in your browser. **Agent-ready tooling:** Reducto integrates directly into AI agent workflows via the [MCP server](/mcp-server), [CLI](/cli), and native SDKs. > [card] [Agent Guide](/agent-guide) > > One-page reference for coding agents using Reducto. > [card] [MCP Server](/mcp-server) > > Give agents Reducto tools directly in Claude Code, Codex, Cursor, and other MCP clients. > [card] [CLI](/cli) > > Parse local files and folders from a terminal with minimal setup. *** ## Performance Reducto's r-1 unified full-page model handles text, tables, figures, layout, formatting, and grounding in one pass, with customized agentic processing available as augmentation. The platform also uses specialized models for workflows that need additional processing. Every result links to the [Studio citation viewer](https://studio.reducto.ai) where you can inspect outputs against source documents at the bounding-box level. *** ## Built for production > [card] SOC 2 Type II > > Audited security controls. > [card] HIPAA > > Signed BAA available. > [card] Zero Data Retention > > Documents deleted within 24h. * **Deployment flexibility.** SaaS, hybrid VPC, full VPC, or air-gapped on-premises * **Scale.** 3B+ pages processed, with autoscaling for production workloads. * **Security.** Encryption at rest (AES-256) and in transit (TLS 1.2+). EU data residency. [Learn more →](/security/policies) * **Support.** Dedicated field engineering, custom model fine-tuning, and 24/7 oncall for Enterprise customers [See Enterprise readiness →](/enterprise/enterprise-readiness) *** ## Get started > [card] [API Quickstart](/quickstart) > > Parse your first document in 5 minutes with Python, Node.js, Go, or REST. > [card] [Studio Quickstart](/studio-quickstart) > > Build and deploy an extraction pipeline visually, no code required. # Parse Best Practices Source: https://docs.reducto.ai/parse/best-practices Key practices for getting the best results from Parse ## 1. Use Variable Chunking for RAG The default chunking mode (`disabled`) returns the entire document as one chunk. For RAG applications you need smaller chunks that can be embedded and retrieved independently. Variable chunking splits at semantic boundaries like section headers, tables, and figures, keeping related content together while creating chunks sized for embedding models. ```python Python theme={null} result = client.parse.run( input=upload.file_id, retrieval={ "chunking": {"chunk_mode": "variable"}, "embedding_optimized": True } ) # Use embed field for vector database, content for display for chunk in result.result.chunks: vector_db.insert( embedding=embed(chunk.embed), metadata={"content": chunk.content} ) ``` ```javascript Node.js theme={null} const result = await client.parse.run({ input: upload.file_id, retrieval: { chunking: { chunk_mode: 'variable' }, embedding_optimized: true } }); // Use embed field for vector database, content for display for (const chunk of result.result.chunks) { await vectorDb.insert({ embedding: embed(chunk.embed), metadata: { content: chunk.content } }); } ``` ```go Go theme={null} result, _ := client.Parse.Run(context.Background(), reducto.ParseRunParams{ ParseConfig: reducto.ParseConfigParam{ DocumentURL: reducto.F[reducto.ParseConfigDocumentURLUnionParam]( shared.UnionString(upload.FileID), ), }, Retrieval: reducto.F(reducto.RetrievalParam{ Chunking: reducto.F(reducto.RetrievalChunkingUnionParam{ OfVariableChunking: &reducto.VariableChunkingConfigParam{ ChunkMode: reducto.F(reducto.VariableChunkingConfigChunkModeVariable), }, }), EmbeddingOptimized: reducto.F(true), }), }) ``` ```bash cURL theme={null} curl -X POST https://platform.reducto.ai/parse \ -H "Authorization: Bearer $REDUCTO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "input": "reducto://your-file-id", "retrieval": { "chunking": {"chunk_mode": "variable"}, "embedding_optimized": true } }' ``` The `embed` field contains table and figure summaries as natural language, which embeds better than raw Markdown tables. The `content` field preserves the original formatting for display. *** ## 2. Augment r-1 Only for Customized Processing r-1 handles handwriting, difficult scans, complex tables, figures, and page layouts natively. It is not necessary to enable agentic processing as a general accuracy upgrade. Add agentic processing when your workflow needs: * A text-scoped custom prompt for form regions * A table-scoped custom prompt for domain-specific representation * A figure-scoped custom prompt for a use-case-specific description * The advanced chart agent for structured numerical extraction Customized agentic processing runs additional model steps and adds latency. It does not add cost to r-1 processing. ```python Python theme={null} result = client.parse.run( input=upload.file_id, enhance={ "agentic": [ { "scope": "table", "prompt": "Preserve domain-specific notation such as <0.01, ±, and N/A exactly as written." }, {"scope": "figure", "advanced_chart_agent": True} ] } ) ``` ```javascript Node.js theme={null} const result = await client.parse.run({ input: upload.file_id, enhance: { agentic: [ { scope: 'table', prompt: 'Preserve domain-specific notation such as <0.01, ±, and N/A exactly as written.' }, { scope: 'figure', advanced_chart_agent: true } ] } }); ``` ```bash cURL theme={null} curl -X POST https://platform.reducto.ai/parse \ -H "Authorization: Bearer $REDUCTO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "input": "reducto://your-file-id", "enhance": { "agentic": [ { "scope": "table", "prompt": "Preserve domain-specific notation such as <0.01, ±, and N/A exactly as written." }, {"scope": "figure", "advanced_chart_agent": true} ] } }' ``` Existing legacy Parse pipelines can continue using promptless agentic scopes as general-purpose accuracy enhancements. Do not carry those scopes into an r-1 configuration. See [Agentic Modes](/configs/parse/agentic-modes) for migration guidance and more examples. *** ## 3. Set Priority for Async Requests Parse has sync (`/parse`) and async (`/parse_async`) endpoints. **Async requests without `priority: true` enter a queue** and may experience delays during high traffic. If you're using async for latency-sensitive requests (user-facing features, real-time processing), always set priority. ```python Python theme={null} job = client.parse.run_job( input=upload.file_id, async_config={"priority": True} ) ``` ```javascript Node.js theme={null} const job = await client.parse.runJob({ input: upload.file_id, asyncConfig: { priority: true } }); ``` ```go Go theme={null} job, _ := client.Parse.RunJob(context.Background(), reducto.ParseRunJobParams{ ParseConfig: reducto.ParseConfigParam{ DocumentURL: reducto.F[reducto.ParseConfigDocumentURLUnionParam]( shared.UnionString(upload.FileID), ), }, Options: reducto.F(reducto.ParseRunJobParamsOptionsParam{ Priority: reducto.F(true), }), }) ``` ```bash cURL theme={null} curl -X POST https://platform.reducto.ai/parse_async \ -H "Authorization: Bearer $REDUCTO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "input": "reducto://your-file-id", "options": {"priority": true} }' ``` Use async with priority or sync for documents that need speed. Use async without priority for batch processing where latency doesn't matter. *** ## 4. Use HTML for Complex Tables The default table format (`dynamic`) auto-selects HTML or Markdown based on complexity. For documents with complex tables (merged cells, nested headers, multi-row cells), explicitly request HTML. ```python Python theme={null} result = client.parse.run( input=upload.file_id, formatting={ "table_output_format": "html" } ) ``` ```javascript Node.js theme={null} const result = await client.parse.run({ input: upload.file_id, formatting: { table_output_format: 'html' } }); ``` ```go Go theme={null} result, _ := client.Parse.Run(context.Background(), reducto.ParseRunParams{ ParseConfig: reducto.ParseConfigParam{ DocumentURL: reducto.F[reducto.ParseConfigDocumentURLUnionParam]( shared.UnionString(upload.FileID), ), }, Formatting: reducto.F(reducto.FormattingParam{ TableOutputFormat: reducto.F(reducto.FormattingTableOutputFormatHTML), }), }) ``` ```bash cURL theme={null} curl -X POST https://platform.reducto.ai/parse \ -H "Authorization: Bearer $REDUCTO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "input": "reducto://your-file-id", "formatting": { "table_output_format": "html" } }' ``` Markdown tables can't represent merged cells or complex structures. If your tables look broken, switching to HTML usually fixes it. For programmatic access to cell data, use `json` format instead. *** ## 5. Filter Headers and Footers for RAG Page headers, footers, and page numbers add noise to RAG retrieval. When a user asks about invoice totals, you don't want to retrieve chunks containing "Page 1 of 5" or "Confidential - Do Not Distribute". ```python Python theme={null} result = client.parse.run( input=upload.file_id, retrieval={ "filter_blocks": ["Header", "Footer", "Page Number"] } ) ``` ```javascript Node.js theme={null} const result = await client.parse.run({ input: upload.file_id, retrieval: { filter_blocks: ['Header', 'Footer', 'Page Number'] } }); ``` ```go Go theme={null} result, _ := client.Parse.Run(context.Background(), reducto.ParseRunParams{ ParseConfig: reducto.ParseConfigParam{ DocumentURL: reducto.F[reducto.ParseConfigDocumentURLUnionParam]( shared.UnionString(upload.FileID), ), }, Retrieval: reducto.F(reducto.RetrievalParam{ FilterBlocks: reducto.F([]reducto.RetrievalFilterBlock{ reducto.RetrievalFilterBlockHeader, reducto.RetrievalFilterBlockFooter, reducto.RetrievalFilterBlockPageNumber, }), }), }) ``` ```bash cURL theme={null} curl -X POST https://platform.reducto.ai/parse \ -H "Authorization: Bearer $REDUCTO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "input": "reducto://your-file-id", "retrieval": { "filter_blocks": ["Header", "Footer", "Page Number"] } }' ``` The filtered blocks still appear in `chunks[].blocks` metadata (so you can access them if needed), but they're excluded from `content` and `embed` fields. *** ## Common Pitfalls > [accordion] Requests taking too long > > If you're using agentic mode, it adds latency since it runs an LLM pass over the output. Disable it and only enable for document types that actually need correction. For async calls, make sure you have `priority: true` set. > [accordion] RAG retrieval quality is poor > > Check your chunking. If you're using `disabled` (default), the entire document is one chunk. Switch to `variable` for semantic chunking. > [accordion] Tables render incorrectly > > Switch from `dynamic` to `html` format. Markdown can't handle merged cells. > [accordion] Async jobs are slow even for small docs > > You forgot to set `priority: true`. Without it, jobs enter a queue. > [accordion] Document fails with 'corrupted' or 'invalid' error > > The PDF file may be malformed or use unsupported encryption. Try opening the file in a PDF viewer to verify it's valid. If the file opens but Reducto fails, the PDF may use non-standard formatting. Re-save it using a tool like Adobe Acrobat or a PDF printer, then retry. *** ## Configuration Reference For complete details on all options mentioned above, see the dedicated configuration pages: > [card] [Chunking Methods](/configs/parse/chunking-methods) > > All chunking modes and their use cases. > [card] [Agentic Mode](/configs/parse/agentic-modes) > > When and how to use LLM-assisted parsing. > [card] [Table Formats](/configs/parse/table-output-formats) > > HTML, Markdown, JSON, CSV options. > [card] [Configuration Overview](/configs/overview) > > Full reference of all configuration options. *** ## Related > [card] [Parse Overview](/parse/overview) > > Quick start and basic usage. > [card] [Response Format](/parse/response-format) > > Understanding chunks, blocks, and bounding boxes. # Parse overview Source: https://docs.reducto.ai/parse/overview Turn full documents into LLM-ready content: every text block, table, and figure with layout, formatting, and bounding boxes. Parse is Reducto's foundational endpoint. It reads every page and returns the full document content (text, tables, figures, layout, and formatting) with bounding boxes, then organizes the content into chunks for LLM consumption, RAG pipelines, or downstream extraction. Each element includes its type, page position, and confidence score. Parse handles multi-column text, nested tables, forms with handwriting, rotated pages, and documents mixing text with charts and images. For a higher-level view of supported use cases and how Parse fits with the rest of the platform, see the [Parse product overview](https://reducto.ai/parse?utm_source=docs\&utm_medium=referral\&utm_campaign=docs_product_bridge\&utm_content=parse_overview_top). > [!TIP] > > **Try it live:** See Parse in action with a [sample bank statement in Reducto Studio](https://studio.reducto.ai). **File size limits:** Upload files up to 100MB directly via the [Upload endpoint](/upload), or up to 5GB via [presigned URL](/upload/large-files). You can also pass public URLs or presigned S3/GCS/Azure URLs directly. *** ## Choose a Parse pipeline > [!NOTE] > > Starting December 7, 2026, Parse defaults to r-1. To keep legacy Parse, set `settings.model` to `"legacy"`. [Reducto parse r-1](/parse/r-1) is the main Parse model. It combines text, table, figure, layout, formatting, and grounding in one full-page model. Legacy Parse is the previous pipeline. | Pipeline | Recommended use | How it works | | - | - | - | | **r-1** | All new Parse pipelines and most existing workloads | One full-page model handles text, tables, figures, layout, formatting, and grounding together. | | **[r-1 mini](/parse/r-1#r-1-mini)** | Large, cost-sensitive workloads | High accuracy at low cost. Set `settings.model` to `"r-1-mini"`. | | **Legacy agentic pipeline** | Workflows that require custom prompts or specialized agentic processing | Additional vision-language model passes process relevant text, tables, or figures. This adds latency. | | **Legacy standard Parse** | Existing workflows that depend on legacy behavior or a configuration that r-1 does not support yet | OCR, layout detection, and post-processing run as separate stages. | r-1 replaces the general-purpose use of agentic text, table, and figure scopes. You can combine r-1 with the agentic pipeline when your workflow needs one or more custom prompts or a specialized augmentation such as advanced chart extraction. Reducto runs the relevant agentic components and augments the r-1 result. *** ## Quick Start ```python Python theme={null} from pathlib import Path from reducto import Reducto client = Reducto() upload = client.upload(file=Path("invoice.pdf")) result = client.parse.run(input=upload.file_id) for chunk in result.result.chunks: print(chunk.content) ``` ```javascript Node.js theme={null} import Reducto from 'reductoai'; import fs from 'fs'; const client = new Reducto(); const upload = await client.upload({ file: fs.createReadStream('invoice.pdf'), }); const result = await client.parse.run({ input: upload.file_id }); for (const chunk of result.result.chunks) { console.log(chunk.content); } ``` ```go Go theme={null} package main import ( "context" "fmt" "io" "os" reducto "github.com/reductoai/reducto-go-sdk" "github.com/reductoai/reducto-go-sdk/option" "github.com/reductoai/reducto-go-sdk/shared" ) func main() { client := reducto.NewClient(option.WithAPIKey(os.Getenv("REDUCTO_API_KEY"))) file, _ := os.Open("invoice.pdf") defer file.Close() upload, _ := client.Upload(context.Background(), reducto.UploadParams{ File: reducto.F[io.Reader](file), }) result, _ := client.Parse.Run(context.Background(), reducto.ParseRunParams{ ParseConfig: reducto.ParseConfigParam{ DocumentURL: reducto.F[reducto.ParseConfigDocumentURLUnionParam]( shared.UnionString(upload.FileID), ), }, }) if result.Result.Type == shared.ParseResponseResultTypeFull { chunks := result.Result.Chunks.([]shared.ParseResponseResultFullResultChunk) for _, chunk := range chunks { fmt.Println(chunk.Content) } } } ``` ```bash cURL theme={null} # Upload FILE_ID=$(curl -s -X POST https://platform.reducto.ai/upload \ -H "Authorization: Bearer $REDUCTO_API_KEY" \ -F "file=@invoice.pdf" | jq -r '.file_id') # Parse curl -X POST https://platform.reducto.ai/parse \ -H "Authorization: Bearer $REDUCTO_API_KEY" \ -H "Content-Type: application/json" \ -d "{\"input\": \"$FILE_ID\"}" ``` *** ## What You Get Back ```json theme={null} { "job_id": "7600c8c5-a52f-49d2-8a7d-d75d1b51e141", "duration": 3.89, "result": { "type": "full", "chunks": [ { "content": "# Invoice\n\nBill To: Acme Corp\n123 Main St...", "embed": "# Invoice\n\nBill To: Acme Corp...", "blocks": [ { "type": "Title", "content": "Invoice", "bbox": { "left": 0.1, "top": 0.05, "width": 0.3, "height": 0.04, "page": 1 }, "confidence": "high" } ] } ] }, "usage": { "num_pages": 1, "credits": 2.0 }, "studio_link": "https://studio.reducto.ai/job/7600c8c5-..." } ``` **Key fields:** | Field | What it is | | - | - | | `chunks[].content` | The extracted content, formatted as Markdown (headers become `#`, tables become Markdown/HTML tables). Ready to pass to an LLM. | | `chunks[].embed` | Same content but optimized for embeddings. When figure/table summaries are enabled, this field contains natural language descriptions instead of raw table markup. | | `chunks[].blocks` | The individual elements (paragraphs, tables, figures) with their positions and types. Useful for highlighting or linking back to source. | | `result.type` | Either `"full"` (content inline) or `"url"` (content at a URL). Large documents return `"url"` to avoid HTTP size limits. | > [card] [Response Format Details](/parse/response-format) > > Full breakdown of chunks, blocks, bounding boxes, and confidence scores. *** ## Input Options The `input` field accepts four formats: 1. **Upload response** (`reducto://...`): After uploading via `/upload`, use the returned `file_id`. This is the most common method for local files. 2. **Public URL**: Any publicly accessible URL. Reducto fetches the file directly. 3. **Presigned URL**: S3, GCS, or Azure Blob presigned URLs work. Useful when files are in your cloud storage. 4. **Previous job ID** (`jobid://...`): Reprocess a document from a previous parse job without re-uploading. Useful for testing different configurations. ```python Python theme={null} # From upload result = client.parse.run(input=upload.file_id) # Public URL result = client.parse.run(input="https://example.com/doc.pdf") # Presigned S3 URL result = client.parse.run(input="https://bucket.s3.amazonaws.com/doc.pdf?X-Amz-...") # Reprocess previous job result = client.parse.run(input="jobid://7600c8c5-a52f-49d2-8a7d-d75d1b51e141") ``` ```javascript Node.js theme={null} // From upload const result = await client.parse.run({ input: upload.file_id }); // Public URL const result = await client.parse.run({ input: 'https://example.com/doc.pdf' }); // Presigned S3 URL const result = await client.parse.run({ input: 'https://bucket.s3.amazonaws.com/doc.pdf?X-Amz-...' }); // Reprocess previous job const result = await client.parse.run({ input: 'jobid://7600c8c5-a52f-49d2-8a7d-d75d1b51e141' }); ``` ```go Go theme={null} // From upload result, _ := client.Parse.Run(context.Background(), reducto.ParseRunParams{ ParseConfig: reducto.ParseConfigParam{ DocumentURL: reducto.F[reducto.ParseConfigDocumentURLUnionParam]( shared.UnionString(upload.FileID), ), }, }) // Public URL result, _ := client.Parse.Run(context.Background(), reducto.ParseRunParams{ ParseConfig: reducto.ParseConfigParam{ DocumentURL: reducto.F[reducto.ParseConfigDocumentURLUnionParam]( shared.UnionString("https://example.com/doc.pdf"), ), }, }) ``` ```bash cURL theme={null} # From upload curl -X POST https://platform.reducto.ai/parse \ -H "Authorization: Bearer $REDUCTO_API_KEY" \ -H "Content-Type: application/json" \ -d '{"input": "reducto://your-file-id"}' # Public URL curl -X POST https://platform.reducto.ai/parse \ -H "Authorization: Bearer $REDUCTO_API_KEY" \ -H "Content-Type: application/json" \ -d '{"input": "https://example.com/doc.pdf"}' # Reprocess previous job curl -X POST https://platform.reducto.ai/parse \ -H "Authorization: Bearer $REDUCTO_API_KEY" \ -H "Content-Type: application/json" \ -d '{"input": "jobid://7600c8c5-a52f-49d2-8a7d-d75d1b51e141"}' ``` *** ## Sync vs Async Parse has both synchronous (`/parse`) and asynchronous (`/parse_async`) endpoints. Use async for large documents or when you need webhook delivery. > [card] [Sync vs Async Guide](/workflows/async-overview) > > When to use each, how priority works, webhook setup. *** ## Configuration Parse has several configuration groups. Here are the most commonly changed options: ### Chunking By default, Parse returns the entire document as one chunk. For RAG applications, you want smaller chunks that can be embedded and retrieved independently. ```python Python theme={null} result = client.parse.run( input=upload.file_id, retrieval={ "chunking": {"chunk_mode": "variable"} } ) ``` ```javascript Node.js theme={null} const result = await client.parse.run({ input: upload.file_id, retrieval: { chunking: { chunk_mode: 'variable' } } }); ``` ```go Go theme={null} result, _ := client.Parse.Run(context.Background(), reducto.ParseRunParams{ ParseConfig: reducto.ParseConfigParam{ DocumentURL: reducto.F[reducto.ParseConfigDocumentURLUnionParam]( shared.UnionString(upload.FileID), ), }, Retrieval: reducto.F(reducto.RetrievalParam{ Chunking: reducto.F(reducto.RetrievalChunkingUnionParam{ OfVariableChunking: &reducto.VariableChunkingConfigParam{ ChunkMode: reducto.F(reducto.VariableChunkingConfigChunkModeVariable), }, }), }), }) ``` ```bash cURL theme={null} curl -X POST https://platform.reducto.ai/parse \ -H "Authorization: Bearer $REDUCTO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "input": "reducto://your-file-id", "retrieval": { "chunking": {"chunk_mode": "variable"} } }' ``` | Mode | Behavior | | - | - | | `disabled` | One chunk for the whole document (default) | | `variable` | Splits at semantic boundaries (sections, tables, figures stay intact). Best for RAG. | | `page` | One chunk per page | | `section` | Splits at section headers | [Full chunking options →](/configs/parse/chunking-methods) ### Table Output Format Controls how tables appear in the output. ```python Python theme={null} result = client.parse.run( input=upload.file_id, formatting={ "table_output_format": "html" } ) ``` ```javascript Node.js theme={null} const result = await client.parse.run({ input: upload.file_id, formatting: { table_output_format: 'html' } }); ``` ```go Go theme={null} result, _ := client.Parse.Run(context.Background(), reducto.ParseRunParams{ ParseConfig: reducto.ParseConfigParam{ DocumentURL: reducto.F[reducto.ParseConfigDocumentURLUnionParam]( shared.UnionString(upload.FileID), ), }, Formatting: reducto.F(reducto.FormattingParam{ TableOutputFormat: reducto.F(reducto.FormattingTableOutputFormatHTML), }), }) ``` ```bash cURL theme={null} curl -X POST https://platform.reducto.ai/parse \ -H "Authorization: Bearer $REDUCTO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "input": "reducto://your-file-id", "formatting": { "table_output_format": "html" } }' ``` | Format | When to use | | - | - | | `dynamic` | Auto-selects HTML or Markdown based on table complexity (default) | | `html` | Complex tables with merged cells, nested headers | | `md` | Simple tables, Markdown-based workflows | | `json` | Programmatic processing, need cell-level access | | `csv` | Export to spreadsheets | [Full table format options →](/configs/parse/table-output-formats) ### Figure Summaries r-1 detects figures and generates a short description as part of the same full-page pass. When your workflow needs a specific interpretation, add a figure-scoped custom prompt. Reducto runs the specialized figure model for relevant figures. This adds latency. ```python Python theme={null} result = client.parse.run( input=upload.file_id, enhance={ "agentic": [ { "scope": "figure", "prompt": "Describe the relationships between components in this diagram." } ] } ) ``` ```javascript Node.js theme={null} const result = await client.parse.run({ input: upload.file_id, enhance: { agentic: [ { scope: "figure", prompt: "Describe the relationships between components in this diagram." } ] } }); ``` ```bash cURL theme={null} curl -X POST https://platform.reducto.ai/parse \ -H "Authorization: Bearer $REDUCTO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "input": "reducto://your-file-id", "enhance": { "agentic": [ { "scope": "figure", "prompt": "Describe the relationships between components in this diagram." } ] } }' ``` ### Agentic Mode r-1 handles difficult text, tables, figures, and page layouts. It is not necessary to enable agentic mode as a general accuracy upgrade. Use agentic mode when you need one or more custom prompts that change how Reducto interprets content for your workflow. For example, a table prompt can specify how to encode domain-specific content in the parsed output. ```python Python theme={null} result = client.parse.run( input=upload.file_id, enhance={ "agentic": [ { "scope": "table", "prompt": "Preserve domain-specific notation such as <0.01, ±, and N/A exactly as written." } ] } ) ``` ```javascript Node.js theme={null} const result = await client.parse.run({ input: upload.file_id, enhance: { agentic: [ { scope: "table", prompt: "Preserve domain-specific notation such as <0.01, ±, and N/A exactly as written." } ] } }); ``` ```bash cURL theme={null} curl -X POST https://platform.reducto.ai/parse \ -H "Authorization: Bearer $REDUCTO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "input": "reducto://your-file-id", "enhance": { "agentic": [ { "scope": "table", "prompt": "Preserve domain-specific notation such as <0.01, ±, and N/A exactly as written." } ] } }' ``` The `agentic` array can include custom prompts for more than one supported scope. Pages that need a custom prompt run through the matching agentic pipeline, while other pages continue through r-1. Agentic processing adds latency. For structured numerical data from charts, add [advanced chart extraction](/configs/parse/chart-extraction) to a figure-scoped agentic configuration. Reducto runs the separate chart extraction agent and augments the r-1 result. ### Filter Blocks Remove specific content types from the output. The blocks still appear in `blocks` metadata but are excluded from `content` and `embed`. ```python Python theme={null} result = client.parse.run( input=upload.file_id, retrieval={ "filter_blocks": ["Header", "Footer", "Page Number"] } ) ``` ```javascript Node.js theme={null} const result = await client.parse.run({ input: upload.file_id, retrieval: { filter_blocks: ['Header', 'Footer', 'Page Number'] } }); ``` ```go Go theme={null} result, _ := client.Parse.Run(context.Background(), reducto.ParseRunParams{ ParseConfig: reducto.ParseConfigParam{ DocumentURL: reducto.F[reducto.ParseConfigDocumentURLUnionParam]( shared.UnionString(upload.FileID), ), }, Retrieval: reducto.F(reducto.RetrievalParam{ FilterBlocks: reducto.F([]reducto.RetrievalFilterBlock{ reducto.RetrievalFilterBlockHeader, reducto.RetrievalFilterBlockFooter, reducto.RetrievalFilterBlockPageNumber, }), }), }) ``` ```bash cURL theme={null} curl -X POST https://platform.reducto.ai/parse \ -H "Authorization: Bearer $REDUCTO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "input": "reducto://your-file-id", "retrieval": { "filter_blocks": ["Header", "Footer", "Page Number"] } }' ``` Useful for RAG when headers/footers would pollute search results. ### Page Range Process only specific pages. ```python Python theme={null} result = client.parse.run( input=upload.file_id, settings={ "page_range": {"start": 1, "end": 10} } ) ``` ```javascript Node.js theme={null} const result = await client.parse.run({ input: upload.file_id, settings: { page_range: { start: 1, end: 10 } } }); ``` ```go Go theme={null} result, _ := client.Parse.Run(context.Background(), reducto.ParseRunParams{ ParseConfig: reducto.ParseConfigParam{ DocumentURL: reducto.F[reducto.ParseConfigDocumentURLUnionParam]( shared.UnionString(upload.FileID), ), }, Settings: reducto.F(reducto.SettingsParam{ PageRange: reducto.F(reducto.PageRangeParam{ Start: reducto.F(int64(1)), End: reducto.F(int64(10)), }), }), }) ``` ```bash cURL theme={null} curl -X POST https://platform.reducto.ai/parse \ -H "Authorization: Bearer $REDUCTO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "input": "reducto://your-file-id", "settings": { "page_range": {"start": 1, "end": 10} } }' ``` ### Return Images Get image URLs for figures and tables in the document. ```python Python theme={null} result = client.parse.run( input=upload.file_id, settings={ "return_images": ["figure", "table"] } ) # Access images from blocks for chunk in result.result.chunks: for block in chunk.blocks: if block.image_url: print(f"{block.type}: {block.image_url}") ``` ```javascript Node.js theme={null} const result = await client.parse.run({ input: upload.file_id, settings: { return_images: ['figure', 'table'] } }); // Access images from blocks for (const chunk of result.result.chunks) { for (const block of chunk.blocks) { if (block.image_url) { console.log(`${block.type}: ${block.image_url}`); } } } ``` ```bash cURL theme={null} curl -X POST https://platform.reducto.ai/parse \ -H "Authorization: Bearer $REDUCTO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "input": "reducto://your-file-id", "settings": { "return_images": ["figure", "table"] } }' ``` Options: `["figure"]`, `["table"]`, or `["figure", "table"]`. By default, no images are returned. ### Additional Settings | Setting | Default | Description | | - | - | - | | `persist_results` | `false` | Keep results indefinitely instead of expiring after 24 hours | | `timeout` | `null` | Custom timeout in seconds for processing | | `force_url_result` | `false` | Always return results as a URL (useful for consistent handling) | | `embed_pdf_metadata` | `false` | Embed OCR metadata into returned PDF | | `extract_document_properties` | `false` | Return properties embedded in the original pre-conversion document | Set `extract_document_properties` to `true` to include a top-level `document_properties` object in the response. The setting defaults to `false` and reads metadata from the original file before conversion. ```python Python theme={null} result = client.parse.run( input=upload.file_id, settings={"extract_document_properties": True} ) ``` ```javascript Node.js theme={null} const result = await client.parse.run({ input: upload.file_id, settings: { extract_document_properties: true } }); ``` ```bash cURL theme={null} curl -X POST https://platform.reducto.ai/parse \ -H "Authorization: Bearer $REDUCTO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "input": "reducto://your-file-id", "settings": {"extract_document_properties": true} }' ``` The response includes `document_properties` for supported files with embedded properties. It is `null` when no properties are found or the input format is unsupported. Supported formats are PDF, DOCX, XLSX, and PPTX. Legacy binary `.doc`, `.xls`, and `.ppt` files are not supported. All fields are nullable, and dates use timezone-aware ISO-8601 strings. PDF Info/XMP populates `title`, `author`, `subject`, `keywords`, `creator`, `producer`, `created_at`, and `modified_at`. `last_modified_by` is only available from OOXML core properties. DOCX, XLSX, and PPTX can populate `title`, `author`, `subject`, `keywords`, `creator`, `last_modified_by`, `created_at`, and `modified_at`; `producer` is PDF-only. ```json theme={null} { "document_properties": { "title": "Quarterly Report", "author": "Jane Smith", "subject": "Financial results", "keywords": "finance, quarterly", "creator": "Microsoft Word", "producer": null, "last_modified_by": "Alex Chen", "created_at": "2024-01-15T09:30:00+00:00", "modified_at": "2024-02-01T16:45:12+00:00" } } ``` ```python theme={null} result = client.parse.run( input=upload.file_id, settings={ "persist_results": True, "timeout": 120, "force_url_result": True } ) ``` > [!NOTE] > > For complete configuration reference including legacy OCR settings, spreadsheet options, and more, see the [Configuration section](/configs/parse/ocr-settings). *** ## Troubleshooting > [accordion] Tables look wrong > > Try `formatting.table_output_format: "html"`. HTML handles merged cells and complex headers better than Markdown. > > Still broken? Add a table-scoped custom prompt that describes how Reducto should represent the table for your use case. > [accordion] Response is slow > > Main causes: > > * Customized agentic processing adds latency > * On legacy Parse, `enhance.summarize_figures` adds latency for documents with figures > * Large documents take longer linearly > * `async_priority` should be True for faster priority processing > > For fastest processing, disable what you don't need. See [Best Practices](/parse/best-practices). > [accordion] Password-protected PDF > > ```python Python theme={null} > result = client.parse.run( > input=upload.file_id, > settings={"document_password": "your-password"} > ) > ``` > > ```javascript Node.js theme={null} > const result = await client.parse.run({ > input: upload.file_id, > settings: { document_password: 'your-password' } > }); > ``` > > ```bash cURL theme={null} > curl -X POST https://platform.reducto.ai/parse \ > -H "Authorization: Bearer $REDUCTO_API_KEY" \ > -H "Content-Type: application/json" \ > -d '{ > "input": "reducto://your-file-id", > "settings": {"document_password": "your-password"} > }' > ``` > [accordion] Response is a URL instead of content > > Large documents return `result.type: "url"` instead of inline content to avoid HTTP size limits. Fetch the content: > > ```python Python theme={null} > import requests > > if result.result.type == "url": > chunks = requests.get(result.result.url).json() > else: > chunks = result.result.chunks > ``` > > ```javascript Node.js theme={null} > let chunks; > if (result.result.type === 'url') { > const response = await fetch(result.result.url); > chunks = await response.json(); > } else { > chunks = result.result.chunks; > } > ``` > > ```bash cURL theme={null} > # If result.type is "url", fetch from the URL > curl -s "$RESULT_URL" | jq '.chunks' > ``` > > To always get a URL (consistent handling): `settings.force_url_result: true` *** ## Next Steps > [card] [Response Format](/parse/response-format) > > Full breakdown of chunks, blocks, and bounding boxes. > [card] [Best Practices](/parse/best-practices) > > Optimization by document type, latency tips. > [card] [Choosing a parser for RAG](https://reducto.ai/guides/best-document-parsers-rag) > > Reading order, tables, layout-aware chunks, and citations compared across parsers. > [card] [Comparing parsing accuracy](https://reducto.ai/guides/most-accurate-ocr-software) > > Which metrics to compare for character recognition, layout, and table extraction. # Parse r-1, r-1 mini Source: https://docs.reducto.ai/parse/r-1 Agentic-quality parsing with a unified, efficient full-page model. > [!NOTE] > > Starting December 7, 2026, Parse defaults to r-1. To keep legacy Parse, set `settings.model` to `"legacy"`. Reducto Parse r-1 and r-1 mini deliver agentic-quality parsing for text, tables, figures, layout, and formatting in one full-page pass, with bounding boxes that link each block to its position on the page. They are the recommended models for most existing workloads. The r-1 and r-1 mini sections below compare the two models. Elsewhere on this page, r-1 refers to both. Instead of splitting this work across OCR, layout detection, and optional agentic steps, r-1 uses a simpler, more efficient end-to-end architecture for both simple and complex documents. Before migrating an existing configuration, review [r-1 configuration compatibility](/configs/parse/r-1-configuration-compatibility). ## r-1 r-1 is the choice for frontier performance, with the highest accuracy and lower latency. ```python Python theme={null} result = client.parse.run( input=upload.file_id, settings={"model": "r-1"} ) ``` ```javascript Node.js theme={null} const result = await client.parse.run({ input: upload.file_id, settings: { model: 'r-1' } }); ``` ```bash cURL theme={null} curl -X POST https://platform.reducto.ai/parse \ -H "Authorization: Bearer $REDUCTO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "input": "reducto://your-file-id", "settings": {"model": "r-1"} }' ``` ## r-1 mini r-1 mini gives premium parsing without the premium cost. At \$1 per 1,000 pages, it breaks a new price barrier for high-accuracy document parsing. ```python Python theme={null} result = client.parse.run( input=upload.file_id, settings={"model": "r-1-mini"} ) ``` ```javascript Node.js theme={null} const result = await client.parse.run({ input: upload.file_id, settings: { model: 'r-1-mini' } }); ``` ```bash cURL theme={null} curl -X POST https://platform.reducto.ai/parse \ -H "Authorization: Bearer $REDUCTO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "input": "reducto://your-file-id", "settings": {"model": "r-1-mini"} }' ``` Use r-1 when accuracy or latency matters most, for example in synchronous use. Use r-1 mini for large, cost-sensitive workloads. ## How r-1 compares with legacy Parse | Pipeline | Recommended use | How it works | | - | - | - | | **r-1** | All new Parse pipelines and most existing workloads | One full-page model handles text, tables, figures, layout, formatting, and grounding together. | | **Legacy agentic pipeline** | Workflows that require custom prompts or specialized agentic processing | Additional vision-language model passes process relevant text, tables, or figures. This adds latency. | | **Legacy standard Parse** | Existing workflows that depend on legacy behavior or a configuration that r-1 does not support yet | OCR, layout detection, and post-processing run as separate stages. | r-1 replaces the general-purpose use of agentic text, table, and figure scopes. You can combine r-1 with agentic processing when your workflow needs one or more custom prompts or a specialized augmentation. Reducto runs the relevant agentic components and augments the r-1 result. ## What r-1 handles natively | Capability | r-1 behavior | | - | - | | **Text** | Reads digital text, scans, handwriting, and mixed formatting in the full-page pass. | | **Tables** | Interprets table structure with the surrounding page context, including merged cells and nested headers. | | **Layout and reading order** | Resolves columns, headers, footers, sidebars, and reading order together. | | **Figures** | Detects figures and generates a short description in the full-page pass. Use a figure-scoped custom prompt for specialized agentic interpretation. | | **Formatting** | Preserves meaningful formatting such as headings, lists, bold text, underlines, and strikethroughs. | | **Grounding** | Returns blocks with page-relative bounding boxes so you can trace content back to the source. | ## Augment r-1 with customized agentic processing r-1 handles the document structures that previously required general-purpose agentic processing. Add customized agentic processing when your workflow needs a custom prompt or advanced chart extraction. For example, a table prompt can tell Reducto how to encode domain-specific content in the parsed output. The `agentic` array can include custom prompts for more than one supported scope. Pages that need a custom prompt run through the matching agentic pipeline, while other pages continue through r-1. Agentic processing adds latency. For structured chart data, add [advanced chart extraction](/configs/parse/chart-extraction) to a figure-scoped agentic configuration. Reducto runs the separate chart extraction agent and augments the r-1 result. > [card] [Check configuration compatibility](/configs/parse/r-1-configuration-compatibility) > > Review supported, ignored, and unsupported settings before migrating an existing Parse configuration. > [card] [Parse overview](/parse/overview) > > Run Parse and review the main request and response structure. > [card] [Parse configurations](/configs/overview) > > Configure chunking, formatting, retrieval, and processing settings. # Parse Response Format Source: https://docs.reducto.ai/parse/response-format Understanding chunks, blocks, and bounding boxes Parse returns structured content in a format optimized for RAG and LLM applications. This page explains every field in the response. *** ## Response Structure ```json theme={null} { "job_id": "7600c8c5-a52f-49d2-8a7d-d75d1b51e141", "duration": 3.89, "result": { "type": "full", "chunks": [ ... ] }, "usage": { "num_pages": 1, "credits": 2.0 }, "pdf_url": "https://...", "studio_link": "https://studio.reducto.ai/job/..." } ``` ### Top-Level Fields | Field | Type | Description | | - | - | - | | `job_id` | string | Unique identifier for this job | | `duration` | number | Processing time in seconds | | `result` | object | Contains parsed content (see below) | | `usage.num_pages` | integer | Number of pages processed | | `usage.credits` | number | Credits consumed | | `usage.usage_breakdown` | object | Raw usage quantities. Present only for accounts on per-product pricing, where the credit fields are null. See [Pricing Migration Guide](/reference/pricing-migration#what-your-api-responses-look-like). | | `pdf_url` | string | Temporary URL to download the processed PDF | | `studio_link` | string | Link to view results in Reducto Studio | *** ## Result Types: Full vs URL Parse can return results in two ways: > [tab] Full (inline) > > ```json theme={null} > { > "result": { > "type": "full", > "chunks": [ > { "content": "...", "blocks": [...] } > ] > } > } > ``` > > **When returned:** Documents under \~6MB response size (most documents). > > **How to use:** Access `result.chunks` directly. > [tab] URL (external) > > ```json theme={null} > { > "result": { > "type": "url", > "url": "https://storage.reducto.ai/chunks/abc123.json" > } > } > ``` > > **When returned:** Large documents that would exceed HTTP response limits. > > **How to use:** Fetch the JSON from `result.url`. > > ```python Python theme={null} > import requests > > if result.result.type == "url": > chunks_data = requests.get(result.result.url).json() > else: > chunks_data = result.result.chunks > ``` > > ```javascript Node.js theme={null} > let chunksData; > if (result.result.type === 'url') { > const response = await fetch(result.result.url); > chunksData = await response.json(); > } else { > chunksData = result.result.chunks; > } > ``` > > ```go Go theme={null} > var chunksData interface{} > if result.Result.Type == shared.ParseResponseResultTypeURL { > resp, _ := http.Get(result.Result.URL) > defer resp.Body.Close() > json.NewDecoder(resp.Body).Decode(&chunksData) > } else { > chunksData = result.Result.Chunks > } > ``` > > ```bash cURL theme={null} > # Check if result.type is "url" and fetch if needed > RESULT_TYPE=$(echo $RESPONSE | jq -r '.result.type') > if [ "$RESULT_TYPE" = "url" ]; then > CHUNKS=$(curl -s $(echo $RESPONSE | jq -r '.result.url')) > else > CHUNKS=$(echo $RESPONSE | jq '.result.chunks') > fi > ``` > [!TIP] > > To always receive a URL (useful for consistent handling), set `force_url_result: true` in your request. > [!WARNING] > > **Result URLs expire after 12 hours.** For `pdf_url` and block `image_url`, the 12 hours start when the job completes. Reducto signs a new `result.url` each time you fetch the job. Download or process the content promptly after receiving the response. This applies to: > > * `result.url` (when `type: "url"`) > * `pdf_url` (processed PDF) > * `image_url` on blocks (when `return_images` is enabled) *** ## Understanding Chunks Chunks are the primary output unit, optimized for RAG and embedding workflows. ```json theme={null} { "content": "# Invoice\n\nBill To: Acme Corp\n123 Main St...", "embed": "# Invoice\n\nBill To: Acme Corp\n123 Main St...", "blocks": [ ... ], "enriched": null, "enrichment_success": false } ``` | Field | Description | | - | - | | `content` | Markdown-formatted content of this chunk | | `embed` | Embedding-optimized version (may include table/figure summaries) | | `blocks` | Array of individual content blocks with positions | | `enriched` | AI-enriched content (when enrich config enabled) | | `enrichment_success` | Whether enrichment completed successfully | ### content vs embed * **`content`**: Raw extracted content, preserves original text * **`embed`**: Optimized for vector embeddings, may include: * Table summaries (natural language descriptions) * Figure summaries (AI-generated descriptions) **For RAG:** Use `embed` for your vector database, `content` for display. *** ## Understanding Blocks Blocks are the atomic content elements within each chunk. Every paragraph, table, header, and figure is a separate block. ```json theme={null} { "type": "Table", "content": "...
DateAmount
", "bbox": { "left": 0.076, "top": 0.427, "width": 0.834, "height": 0.432, "page": 1, "original_page": 1 }, "confidence": "high", "granular_confidence": { "parse_confidence": 0.834, "extract_confidence": null }, "image_url": null } ``` ### Block Types | Type | Description | Example Content | | - | - | - | | `Title` | Document title | "Invoice #12345" | | `Section Header` | Section headings | "Payment Terms" | | `Header` | Page headers | "Page 1 of 5" | | `Footer` | Page footers | "Confidential" | | `Text` | Body paragraphs | "Thank you for your business..." | | `Table` | Tabular data | HTML/Markdown table | | `Figure` | Images and charts | Caption or AI description | | `Key Value` | Label-value pairs | "Total: \$1,234.56" | | `List Item` | Bulleted/numbered items | "• First item" | | `Checkbox` | Form checkboxes | "☑ Agree to terms" | ### Block Fields | Field | Type | Description | | - | - | - | | `type` | string | Block type (see table above) | | `content` | string | The actual content | | `bbox` | object | Position and size on the page | | `confidence` | string | "high" or "low" | | `granular_confidence` | object | Numeric confidence scores | | `image_url` | string\|null | URL to block image (if `return_images` enabled) | *** ## Bounding Box Coordinates Every block includes a `bbox` object describing its position on the page. ```json theme={null} { "left": 0.076, "top": 0.427, "width": 0.834, "height": 0.432, "page": 1, "original_page": 1 } ``` ### Coordinate System All coordinates are **normalized to \[0, 1]** relative to page dimensions: ``` (0,0) ─────────────────────────── (1,0) │ │ │ ┌─────────────────┐ │ │ │ Block │ │ │ │ left=0.1 │ │ │ │ top=0.2 │ │ │ │ width=0.5 │ │ │ │ height=0.3 │ │ │ └─────────────────┘ │ │ │ (0,1) ─────────────────────────── (1,1) ``` | Field | Description | | - | - | | `left` | Distance from left edge (0 = left edge, 1 = right edge) | | `top` | Distance from top edge (0 = top, 1 = bottom) | | `width` | Block width as fraction of page width | | `height` | Block height as fraction of page height | | `page` | Page number (1-indexed) in the processed output | | `original_page` | Page number in the source document | > [!NOTE] > > `page` and `original_page` differ when using `page_range` to process a subset of pages. For example, if you process pages 5-10, page 5 becomes `page: 1` but `original_page: 5`. *** ## Confidence Scores Parse provides confidence scores to help you identify potentially problematic extractions. ### String Confidence ```json theme={null} "confidence": "high" // or "low" ``` * **`high`**: Extraction is reliable * **`low`**: May need review; compare the extracted content with the source. If your workflow needs use-case-specific interpretation, add a scoped custom prompt. ### Granular Confidence ```json theme={null} "granular_confidence": { "parse_confidence": 0.834, "extract_confidence": null } ``` | Field | Description | | - | - | | `parse_confidence` | Numeric score (0-1) for parsing accuracy | | `extract_confidence` | Numeric score for extraction (when using Extract endpoint) | *** ## Complete Example Here's a complete example showing the full structure: > [accordion] Full Response Example > > ```json theme={null} > { > "job_id": "7600c8c5-a52f-49d2-8a7d-d75d1b51e141", > "duration": 3.89, > "result": { > "type": "full", > "chunks": [ > { > "content": "# Bank Statement\n\nAccount: 12345678\nPeriod: Jan 1 - Jan 31, 2024\n\n## Transactions\n\n| Date | Description | Amount |\n|------|-------------|--------|\n| 01/05 | Direct Deposit | $2,500.00 |\n| 01/10 | Grocery Store | -$85.32 |", > "embed": "# Bank Statement\n\nAccount: 12345678\nPeriod: Jan 1 - Jan 31, 2024\n\n## Transactions\n\nThis table shows transactions including a direct deposit of $2,500 and a grocery purchase of $85.32.", > "blocks": [ > { > "type": "Title", > "content": "Bank Statement", > "bbox": { > "left": 0.35, > "top": 0.02, > "width": 0.30, > "height": 0.03, > "page": 1, > "original_page": 1 > }, > "confidence": "high", > "granular_confidence": { > "parse_confidence": 0.95, > "extract_confidence": null > }, > "image_url": null > }, > { > "type": "Key Value", > "content": "Account: 12345678", > "bbox": { > "left": 0.10, > "top": 0.08, > "width": 0.25, > "height": 0.02, > "page": 1, > "original_page": 1 > }, > "confidence": "high", > "granular_confidence": { > "parse_confidence": 0.92, > "extract_confidence": null > }, > "image_url": null > }, > { > "type": "Section Header", > "content": "Transactions", > "bbox": { > "left": 0.10, > "top": 0.15, > "width": 0.20, > "height": 0.02, > "page": 1, > "original_page": 1 > }, > "confidence": "high", > "granular_confidence": { > "parse_confidence": 0.88, > "extract_confidence": null > }, > "image_url": null > }, > { > "type": "Table", > "content": "| Date | Description | Amount |\n|------|-------------|--------|\n| 01/05 | Direct Deposit | $2,500.00 |\n| 01/10 | Grocery Store | -$85.32 |", > "bbox": { > "left": 0.10, > "top": 0.20, > "width": 0.80, > "height": 0.25, > "page": 1, > "original_page": 1 > }, > "confidence": "high", > "granular_confidence": { > "parse_confidence": 0.78, > "extract_confidence": null > }, > "image_url": null > } > ], > "enriched": null, > "enrichment_success": false > } > ] > }, > "usage": { > "num_pages": 1, > "credits": 2.0 > }, > "pdf_url": "https://storage.reducto.ai/pdfs/7600c8c5.pdf?...", > "studio_link": "https://studio.reducto.ai/job/7600c8c5-a52f-49d2-8a7d-d75d1b51e141" > } > ``` *** ## Related > [card] [Parse Overview](/parse/overview) > > Quick start and basic usage. > [card] [Best Practices](/parse/best-practices) > > Optimize for your document types. > [card] [Chunking Methods](/configs/parse/chunking-methods) > > Control how content is split. > [card] [Table Output Formats](/configs/parse/table-output-formats) > > HTML, Markdown, JSON, CSV options. # API Quickstart Source: https://docs.reducto.ai/quickstart Parse your first document with Reducto in 5 minutes. This guide walks you through your first Reducto API call. You will parse a document and get back structured JSON ready for LLMs, downstream extraction, or any other processing step in your pipeline. *** ## Fastest path for coding agents If you are using Claude Code, Codex, Cursor, or another coding agent, start here. This path avoids Studio clicks and extra docs navigation. 1. Set `REDUCTO_API_KEY`. 2. Choose one interface: * Local file or folder: use the [Reducto CLI](/cli). * Agent tool calling: use the [Reducto MCP server](/mcp-server). * Application code: use the Python, Node.js, Go, or cURL examples below. 3. Parse the sample PDF first, then replace the URL or file path with your document. ```bash CLI theme={null} pip install reducto-cli reducto login curl -L -o fidelity-example.pdf https://cdn.reducto.ai/samples/fidelity-example.pdf reducto parse ./fidelity-example.pdf ``` ```python Python theme={null} from reducto import Reducto client = Reducto() result = client.parse.run(input="https://cdn.reducto.ai/samples/fidelity-example.pdf") print(result.job_id) print(result.result.chunks[0].content[:1000]) ``` ```javascript Node.js theme={null} import Reducto from "reductoai"; const client = new Reducto(); const result = await client.parse.run({ input: "https://cdn.reducto.ai/samples/fidelity-example.pdf", }); console.log(result.job_id); console.log(result.result.chunks[0].content.slice(0, 1000)); ``` ```bash cURL theme={null} curl -X POST "https://platform.reducto.ai/parse" \ -H "Authorization: Bearer $REDUCTO_API_KEY" \ -H "Content-Type: application/json" \ -d '{"input":"https://cdn.reducto.ai/samples/fidelity-example.pdf"}' ``` > [!TIP] > > For MCP, install once with `uvx mcp-server-reducto --login`, then ask the agent to call `parse_document(document_url="https://cdn.reducto.ai/samples/fidelity-example.pdf")`. *** ## What we're going to parse We'll use a financial statement PDF that contains multiple tables, headers, account summaries, and formatted text. This is the kind of complex document that's difficult to process manually but straightforward with Reducto. ![Finance Statement](https://mintcdn.com/reducto/VmlAHm6-E3eI_let/images/finance-statement.png?fit=max\&auto=format\&n=VmlAHm6-E3eI_let\&q=85\&s=8dcb72304454123172977d5b2556e0cf) [View the sample PDF in Studio](https://studio.reducto.ai/share/md726aw3w7mfs46659ttkqry0s7se3pd?processor=kh7c9e30evkfb5a4h80dq4xke17sfwck\&fileId=js7e4hrtnh2tsyjqdbz114ceyn7sf1v1) or [download it directly](https://cdn.reducto.ai/samples/fidelity-example.pdf) to follow along. **What we want to extract:** * The portfolio value table with beginning and ending values * Account information including account numbers and types * Income summary broken down by tax category * Top holdings with values and percentages By the end of this guide, you'll have all of this data in structured JSON that you can use in your application. For structured field extraction (e.g., extracting specific account numbers or values into typed fields), see the [/extract endpoint](/extract/overview) after completing this quickstart. *** ## Prerequisites **1. Create a Reducto account** Go to [studio.reducto.ai](https://studio.reducto.ai/) and sign up for a free account. **2. Get your API key** In the Studio sidebar, click **API Keys**, then **Create API Key**. Give it a name and copy the key. ![Reducto Studio sidebar showing API Keys option](https://mintcdn.com/reducto/R99njFX82lRP6p3f/images/reducto-studio.png?fit=max\&auto=format\&n=R99njFX82lRP6p3f\&q=85\&s=4a40c20b0ad36d87043dd4b2aa78aaad) Click API Keys in the sidebar to create a new key **3. Set your API key as an environment variable** This allows the SDK to authenticate automatically without hardcoding the key in your code. > [tab] macOS / Linux > > ```bash theme={null} > export REDUCTO_API_KEY="your_api_key_here" > ``` > [tab] Windows (PowerShell) > > ```powershell theme={null} > $env:REDUCTO_API_KEY="your_api_key_here" > ``` **4. Setup with AI** You can also copy the below snippet for your AI coding agent to connect to Reducto via the [MCP Server](/mcp-server). ````markdown theme={null} ## Add Reducto MCP Server ### 1. Authenticate (one-time) ```bash uvx mcp-server-reducto --login ``` This opens your browser to approve access. Your API key is saved to `~/.reducto/config.yaml`. ### 2. Add to your MCP client **Claude Code:** ```bash claude mcp add reducto -- uvx mcp-server-reducto ``` **Claude Desktop**: edit `~/Library/Application Support/Claude/claude_desktop_config.json`: ```json { "mcpServers": { "reducto": { "command": "uvx", "args": ["mcp-server-reducto"] } } } ``` **Cursor**: edit `.cursor/mcp.json`: ```json { "mcpServers": { "reducto": { "command": "uvx", "args": ["mcp-server-reducto"] } } } ``` **VS Code**: edit `.vscode/mcp.json`: ```json { "servers": { "reducto": { "command": "uvx", "args": ["mcp-server-reducto"] } } } ``` ### 3. Use it The server provides these tools: | Tool | What it does | |------|-------------| | `upload_file` | Upload a local file or URL to Reducto (returns `reducto://` URL) | | `parse_document` | Parse a document into structured text, tables, figures | | `extract_data` | Extract structured JSON from a document using a schema | | `split_document` | Segment a document into labeled sections | | `classify_document` | Categorize a document type | | `edit_document` | Fill forms or modify a PDF/DOCX | **Local files:** Use `upload_file` first, e.g. `upload_file("./report.pdf")`, then pass the returned `reducto://` URL to other tools. **Chain operations:** `parse_document` returns a `job_id`. Pass `jobid://` to `extract_data` or `split_document` to skip re-parsing. ```` *** ## Install the SDK Choose your language and install the Reducto SDK: > [tab] Python > > ```bash theme={null} > pip install reductoai > ``` > > Requires Python 3.8+. > [tab] Node.js > > ```bash theme={null} > npm install reductoai > ``` > [tab] Go > > ```bash theme={null} > go get github.com/reductoai/reducto-go-sdk > ``` *** ## Parse the document Now let's write the code to parse our financial statement. We'll go through each part step by step. > [tab] Python > > **1. Import the SDK and initialize the client** > > First, we import the Reducto client. When you create a `Reducto()` client without passing an API key, it automatically reads from the `REDUCTO_API_KEY` environment variable you set earlier. > > ```python theme={null} > from reducto import Reducto > > # The client reads REDUCTO_API_KEY from your environment > client = Reducto() > ``` > > **2. Upload your document** > > Before parsing, you need to upload the document to Reducto's servers. The `upload()` method accepts a file path (as a string) and returns a reference that you'll use in the next step. > > You can download the sample PDF from [here](https://cdn.reducto.ai/samples/fidelity-example.pdf). > > ```python theme={null} > from pathlib import Path > > # Upload the PDF file to Reducto > upload = client.upload(file=Path("fidelity-example.pdf")) > print(f"Uploaded: {upload.file_id}") > ``` > > > [!TIP] > > > > You can also pass a URL directly to the parse method if your document is already hosted somewhere accessible, like an S3 bucket: > > > > ```python theme={null} > > result = client.parse.run(input="https://cdn.reducto.ai/samples/fidelity-example.pdf") > > ``` > > **3. Parse the document** > > Now we call the `parse.run()` method with the uploaded file reference. This sends the document through Reducto's processing pipeline, which runs OCR, detects layout, extracts tables, and structures everything into chunks. > > ```python theme={null} > # Parse the uploaded document > result = client.parse.run(input=upload.file_id) > > # Check what we got back > print(f"Job ID: {result.job_id}") > print(f"Pages processed: {result.usage.num_pages}") > print(f"Credits used: {result.usage.credits}") > print(f"Number of chunks: {len(result.result.chunks)}") > ``` > > **4. Access the extracted content** > > The response contains `chunks`, which are logical sections of the document. Each chunk has a `content` field with the full text and a `blocks` field with individual elements like tables, headers, and paragraphs. > > ```python theme={null} > # Loop through each chunk > for i, chunk in enumerate(result.result.chunks): > print(f"\n=== Chunk {i + 1} ===") > print(chunk.content[:500]) # First 500 characters > > # Look at individual blocks within this chunk > for block in chunk.blocks: > print(f" [{block.type}] on page {block.bbox.page}") > > # Tables are returned as HTML by default > if block.type == "Table": > print(f" Table content: {block.content[:200]}...") > ``` > > Each block has a `type` that tells you what kind of content it is: `Title`, `Section Header`, `Text`, `Table`, `Figure`, `Key Value`, and others. The `bbox` field contains the bounding box coordinates so you know exactly where on the page this content came from. > > **Complete code:** > > ```python theme={null} > from pathlib import Path > from reducto import Reducto > > client = Reducto() > upload = client.upload(file=Path("fidelity-example.pdf")) > result = client.parse.run(input=upload.file_id) > > print(f"Processed {result.usage.num_pages} pages") > > for chunk in result.result.chunks: > print(chunk.content) > for block in chunk.blocks: > if block.type == "Table": > print(f"Found table on page {block.bbox.page}") > ``` > [tab] Node.js > > > [!NOTE] > > > > All Node.js examples use `await` and must be run inside an `async` function, or in a file with top-level await enabled (ES modules with Node.js 14.8+). > > **1. Import the SDK and initialize the client** > > Import the Reducto client and the `fs` module for reading files. The client automatically uses the `REDUCTO_API_KEY` environment variable for authentication. > > ```javascript theme={null} > import Reducto from 'reductoai'; > import fs from 'fs'; > > // The client reads REDUCTO_API_KEY from your environment > const client = new Reducto(); > ``` > > **2. Upload your document** > > Use `createReadStream` to upload the file to Reducto. This returns a reference you'll use when calling the parse endpoint. > > You can download the sample PDF from [here](https://cdn.reducto.ai/samples/fidelity-example.pdf). > > ```javascript theme={null} > // Upload the PDF file to Reducto > const upload = await client.upload({ > file: fs.createReadStream("fidelity-example.pdf") > }); > console.log(`Uploaded: ${upload.file_id}`); > ``` > > **3. Parse the document** > > Call `parse.run()` with the uploaded file reference. Reducto processes the document and returns structured content. > > ```javascript theme={null} > // Parse the uploaded document > const result = await client.parse.run({ input: upload.file_id }); > > console.log(`Job ID: ${result.job_id}`); > console.log(`Pages processed: ${result.usage.num_pages}`); > console.log(`Credits used: ${result.usage.credits}`); > console.log(`Number of chunks: ${result.result.chunks.length}`); > ``` > > **4. Access the extracted content** > > Loop through the chunks and blocks to access the extracted text, tables, and other elements. > > ```javascript theme={null} > // Loop through each chunk > for (let i = 0; i < result.result.chunks.length; i++) { > const chunk = result.result.chunks[i]; > console.log(`\n=== Chunk ${i + 1} ===`); > console.log(chunk.content.substring(0, 500)); > > // Look at individual blocks within this chunk > for (const block of chunk.blocks) { > console.log(` [${block.type}] on page ${block.bbox.page}`); > > if (block.type === "Table") { > console.log(` Table content: ${block.content.substring(0, 200)}...`); > } > } > } > ``` > > **Complete code:** > > ```javascript theme={null} > import Reducto from 'reductoai'; > import fs from 'fs'; > > const client = new Reducto(); > > async function main() { > const upload = await client.upload({ > file: fs.createReadStream("fidelity-example.pdf") > }); > const result = await client.parse.run({ input: upload.file_id }); > > console.log(`Processed ${result.usage.num_pages} pages`); > > for (const chunk of result.result.chunks) { > console.log(chunk.content); > for (const block of chunk.blocks) { > if (block.type === "Table") { > console.log(`Found table on page ${block.bbox.page}`); > } > } > } > } > > main(); > ``` > [tab] Go > > > [!NOTE] > > > > The Go SDK is currently in alpha (`v0.1.0-alpha.1`). The API may change in future releases. > > **1. Import the SDK and initialize the client** > > Import the Reducto client and the option package for configuration. The Go SDK requires you to pass the API key explicitly using `option.WithAPIKey()`. > > ```go theme={null} > package main > > import ( > "context" > "fmt" > "io" > "os" > > reducto "github.com/reductoai/reducto-go-sdk" > "github.com/reductoai/reducto-go-sdk/option" > "github.com/reductoai/reducto-go-sdk/shared" > ) > > func main() { > // Initialize client with API key from environment > client := reducto.NewClient(option.WithAPIKey(os.Getenv("REDUCTO_API_KEY"))) > } > ``` > > **2. Upload your document** > > Open the file and upload it to Reducto. The upload returns a file ID that you'll use for parsing. > > You can download the sample PDF from [here](https://cdn.reducto.ai/samples/fidelity-example.pdf). > > ```go theme={null} > file, err := os.Open("fidelity-example.pdf") > if err != nil { > fmt.Printf("Error opening file: %v\n", err) > return > } > defer file.Close() > > upload, err := client.Upload(context.Background(), reducto.UploadParams{ > File: reducto.F[io.Reader](file), > }) > if err != nil { > fmt.Printf("Upload error: %v\n", err) > return > } > fmt.Printf("Uploaded: %s\n", upload.FileID) > ``` > > **3. Parse the document** > > Call `Parse.Run()` with the file ID. The Go SDK requires you to wrap the file ID with `shared.UnionString()` and then with `reducto.F[...]()` because the SDK uses strongly-typed union parameters. > > ```go theme={null} > result, err := client.Parse.Run(context.Background(), reducto.ParseRunParams{ > ParseConfig: reducto.ParseConfigParam{ > // The file ID must be wrapped in shared.UnionString() and reducto.F[...]() > DocumentURL: reducto.F[reducto.ParseConfigDocumentURLUnionParam]( > shared.UnionString(upload.FileID), > ), > }, > }) > if err != nil { > fmt.Printf("Parse error: %v\n", err) > return > } > > fmt.Printf("Job ID: %s\n", result.JobID) > fmt.Printf("Pages: %d\n", result.Usage.NumPages) > // Note: To view in Studio, construct the URL: https://studio.reducto.ai/job/{job_id} > ``` > > **4. Access the extracted content** > > The result contains chunks with extracted content. The `Chunks` field is typed as `interface{}`, so you need to type assert it to `[]shared.ParseResponseResultFullResultChunk` before you can iterate over it. When checking block types, use the SDK constants instead of string comparisons. > > ```go theme={null} > if result.Result.Type == shared.ParseResponseResultTypeFull { > // Type assert Chunks from interface{} to the actual type > chunks, ok := result.Result.Chunks.([]shared.ParseResponseResultFullResultChunk) > if ok { > for _, chunk := range chunks { > fmt.Println(chunk.Content) > > for _, block := range chunk.Blocks { > // Use SDK constants for block type comparisons > if block.Type == shared.ParseResponseResultFullResultChunksBlocksTypeTable { > fmt.Printf("Found table on page %d\n", block.Bbox.Page) > } > } > } > } > } > ``` > > **Complete code:** > > ```go theme={null} > package main > > import ( > "context" > "fmt" > "io" > "os" > > reducto "github.com/reductoai/reducto-go-sdk" > "github.com/reductoai/reducto-go-sdk/option" > "github.com/reductoai/reducto-go-sdk/shared" > ) > > func main() { > client := reducto.NewClient(option.WithAPIKey(os.Getenv("REDUCTO_API_KEY"))) > > file, _ := os.Open("fidelity-example.pdf") > defer file.Close() > > upload, _ := client.Upload(context.Background(), reducto.UploadParams{ > File: reducto.F[io.Reader](file), > }) > > result, _ := client.Parse.Run(context.Background(), reducto.ParseRunParams{ > ParseConfig: reducto.ParseConfigParam{ > DocumentURL: reducto.F[reducto.ParseConfigDocumentURLUnionParam]( > shared.UnionString(upload.FileID), > ), > }, > }) > > fmt.Printf("Processed %d pages\n", result.Usage.NumPages) > > if result.Result.Type == shared.ParseResponseResultTypeFull { > chunks, _ := result.Result.Chunks.([]shared.ParseResponseResultFullResultChunk) > for _, chunk := range chunks { > fmt.Println(chunk.Content) > } > } > } > ``` > [tab] cURL > > If you prefer not to use an SDK, you can call the API directly with cURL or any HTTP client. > > **1. Upload your document** > > First, upload the file to get a file reference: > > ```bash theme={null} > curl -X POST "https://platform.reducto.ai/upload" \ > -H "Authorization: Bearer $REDUCTO_API_KEY" \ > -F "file=@fidelity-example.pdf" > ``` > > This returns a JSON response with a `file_id`: > > ```json theme={null} > {"file_id": "reducto://abc123def456.pdf"} > ``` > > **2. Parse the document** > > Use the `file_id` from the previous step as the `input` parameter: > > ```bash theme={null} > curl -X POST "https://platform.reducto.ai/parse" \ > -H "Authorization: Bearer $REDUCTO_API_KEY" \ > -H "Content-Type: application/json" \ > -d '{"input": "reducto://abc123def456.pdf"}' > ``` > > You can also skip the upload step if your document is already hosted at a public URL: > > ```bash theme={null} > curl -X POST "https://platform.reducto.ai/parse" \ > -H "Authorization: Bearer $REDUCTO_API_KEY" \ > -H "Content-Type: application/json" \ > -d '{"input": "https://cdn.reducto.ai/samples/fidelity-example.pdf"}' > ``` *** ## Understanding the response Here's what we got back from parsing our financial statement: ```json theme={null} { "job_id": "5df31070-8d98-4caa-9a5b-c5c511a03f71", "duration": 11.35, "usage": { "num_pages": 3, "credits": 4.0 }, "result": { "chunks": [ { "content": "# *** SAMPLE STATEMENT ***\nFor informational purposes only\n\nFidelity\nINVESTMENTS\n\n## Your Portfolio Value:\n\n$274,222.20\n\n| | This Period | Year-to-Date |\n|-|-|-|\n| Beginning Portfolio Value | $253,221.83 | $232,643.16 |\n| Additions | 59,269.64 | 121,433.55 |...", "blocks": [ { "type": "Title", "content": "*** SAMPLE STATEMENT ***\nFor informational purposes only", "bbox": {"page": 1, "left": 0.351, "top": 0.029, "width": 0.296, "height": 0.057}, "confidence": "high" }, { "type": "Section Header", "content": "Your Portfolio Value:", "bbox": {"page": 1, "left": 0.517, "top": 0.163, "width": 0.153, "height": 0.015}, "confidence": "high" }, { "type": "Table", "content": "| | This Period | Year-to-Date |\n|-|-|-|\n| Beginning Portfolio Value | $253,221.83 | $232,643.16 |\n| Additions | 59,269.64 | 121,433.55 |\n| Subtractions | -45,430.74 | -98,912.58 |\n| Transaction Costs, Fees & Charges | -139.77 | -625.87 |\n| Change in Investment Value* | 7,161.47 | 19,058.07 |\n| Ending Portfolio Value** | $274,222.20 | $274,222.20 |", "bbox": {"page": 1, "left": 0.516, "top": 0.261, "width": 0.444, "height": 0.158}, "confidence": "high" } ] } ] }, "studio_link": "https://studio.reducto.ai/job/5df31070-8d98-4caa-9a5b-c5c511a03f71" } ``` **Key fields:** | Field | What it contains | | - | - | | `job_id` | Unique identifier for this job. Use it to retrieve results later or debug in Studio. | | `usage.num_pages` | Number of pages that were processed. | | `usage.credits` | Credits consumed by this request. | | `chunks` | Logical sections of the document, optimized for feeding into LLMs. | | `chunks[].content` | The full text content of this chunk. | | `chunks[].blocks` | Individual elements (tables, headers, text) with their types and positions. | | `blocks[].type` | What kind of element this is: `Title`, `Table`, `Section Header`, `Text`, `Figure`, etc. | | `blocks[].bbox` | Bounding box with normalized coordinates (0-1) showing where this element appears on the page. | | `studio_link` | Direct link to view this job in Reducto Studio for visual debugging. | *** ## Customizing the output The default settings work well for most documents, but you can customize the parsing behavior for specific use cases. > [tab] Python > > You can pass configuration options as `TypedDict` imports from `reducto.types` or as plain dictionaries: > > ```python theme={null} > from reducto.types import EnhanceParam, FormattingParam, SettingsParam > > result = client.parse.run( > input=upload.file_id, > enhance=EnhanceParam( > agentic=[{ > "scope": "table", > "prompt": "Preserve domain-specific notation such as <0.01, ±, and N/A exactly as written." > }] > ), > formatting=FormattingParam( > # Get tables as HTML, md, json, or csv > table_output_format="md" > ), > settings=SettingsParam( > # Only process pages 1-5 > page_range={"start": 1, "end": 5} > ) > ) > ``` > > > [!TIP] > > > > You can also pass plain dictionaries instead of `TypedDict` imports. Both work identically. > [tab] Node.js > > ```javascript theme={null} > const result = await client.parse.run({ > input: upload.file_id, > enhance: { > agentic: [{ > scope: "table", > prompt: "Preserve domain-specific notation such as <0.01, ±, and N/A exactly as written." > }] > }, > formatting: { > table_output_format: "md" > }, > settings: { > page_range: {start: 1, end: 5} > } > }); > ``` > [tab] Go > > ```go theme={null} > result, err := client.Parse.Run(context.Background(), reducto.ParseRunParams{ > ParseConfig: reducto.ParseConfigParam{ > DocumentURL: reducto.F[reducto.ParseConfigDocumentURLUnionParam]( > shared.UnionString(upload.FileID), > ), > // Output formatting - use SDK constants for table format > AdvancedOptions: reducto.F(shared.AdvancedProcessingOptionsParam{ > TableOutputFormat: reducto.F(shared.AdvancedProcessingOptionsTableOutputFormatMd), > }), > // Chunking options > Options: reducto.F(shared.BaseProcessingOptionsParam{ > Chunking: reducto.F(shared.BaseProcessingOptionsChunkingParam{ > ChunkMode: reducto.F(shared.BaseProcessingOptionsChunkingChunkModeVariable), > }), > }), > }, > }) > ``` > > The Go SDK uses different parameter names than Python and Node.js: > > | Python/Node.js | Go SDK | > | - | - | > | `formatting.table_output_format` | `AdvancedOptions.TableOutputFormat` | > | `settings.page_range` | `AdvancedOptions.PageRange` | > | `retrieval.chunking.chunk_mode` | `Options.Chunking.ChunkMode` | > > Use SDK constants like `AdvancedProcessingOptionsTableOutputFormatMd` instead of strings, and wrap values with `reducto.F()`. > [tab] cURL > > ```bash theme={null} > curl -X POST "https://platform.reducto.ai/parse" \ > -H "Authorization: Bearer $REDUCTO_API_KEY" \ > -H "Content-Type: application/json" \ > -d '{ > "input": "reducto://abc123def456.pdf", > "enhance": { > "agentic": [{ > "scope": "table", > "prompt": "Preserve domain-specific notation such as <0.01, ±, and N/A exactly as written." > }] > }, > "formatting": { > "table_output_format": "md" > }, > "settings": { > "page_range": {"start": 1, "end": 5} > } > }' > ``` **What these options do:** * **`enhance.agentic`**: Runs customized agentic processing with a scoped custom prompt. This adds latency. * **`enhance.summarize_figures`**: Legacy Parse only. Generates descriptions of figures. r-1 handles figures natively and ignores this setting. * **`formatting.table_output_format`**: Controls how tables are returned. Options are `html`, `md` (markdown), `json`, `csv`, `dynamic` (default, returns markdown for simple tables and HTML for complex ones), or `jsonbbox`. * **`settings.page_range`**: Limits processing to specific pages. Useful for large documents where you only need certain sections. For the full list of options, see the [Parse configuration reference](/configs/overview). *** ## What's next Now that you can parse documents, explore the other Reducto endpoints: > [card] [/extract](/api-reference/extract) > > Define a JSON schema and extract specific fields from your documents. > [card] [/split](/api-reference/split) > > Divide long documents into sections based on content type. > [card] [/edit](/api-reference/edit) > > Fill PDF forms and modify DOCX documents programmatically. > [card] [/parse (async)](/api-reference/async-parse) > > Process documents asynchronously with webhooks for high-volume workloads. *** ## Troubleshooting > [accordion] 401 Unauthorized error > > This means your API key is missing or invalid. Check that the `REDUCTO_API_KEY` environment variable is set correctly and that the key hasn't expired in Studio. > [accordion] Tables aren't structured correctly > > Try `formatting.table_output_format` set to `"html"` or `"json"` for more structured output. If the table needs custom handling, add a table-scoped prompt to `enhance.agentic`, such as `{"scope": "table", "prompt": "Preserve domain-specific notation such as <0.01, ±, and N/A exactly as written."}`. > [accordion] Content is missing or garbled > > r-1 handles scanned documents and low-quality PDFs natively. If the document is password-protected, pass the password in `settings.document_password`. This may also be due to bad metadata polluting the output, in which case, reach out to Reducto support. > [!TIP] > > Every response includes a `studio_link` that opens the job in Reducto Studio. Use it to visually inspect what was extracted and debug any issues. # Checking API Health & Usage Source: https://docs.reducto.ai/reference/checking-api-health Check Reducto hosted API availability, usage, and throttling signals Use this page to check the availability of the Reducto API and monitor your usage and throttling signals. ## Check hosted API availability If you use the hosted Reducto API at `platform.reducto.ai`, check [status.reducto.ai](https://status.reducto.ai) for uptime and incident information. Subscribe to status updates there to receive notifications about incidents and maintenance. For a lightweight connectivity check, request `GET /version`. It returns the current API version string: ```bash theme={null} curl "https://platform.reducto.ai/version" \ -H "Authorization: Bearer $REDUCTO_API_KEY" ``` To also verify that your API key is valid, request `GET /jobs?limit=1`: ```bash theme={null} curl "https://platform.reducto.ai/jobs?limit=1" \ -H "Authorization: Bearer $REDUCTO_API_KEY" ``` A `200` response confirms the API is reachable and your key is valid. A `401` indicates an invalid or expired API key. A `403` with an HTML error page means the request was sent without an `Authorization` header. For transient failures, retry according to your client's retry policy. ## Self-hosted and on-premise deployments If you run Reducto in your own environment, reach out to the Reducto team at [support@reducto.ai](mailto:support@reducto.ai) for monitoring and observability guidance specific to your deployment. ## Check usage and throttling * Use the [Usage Export API](/reference/usage-export-api) for programmatic usage and credit data. * Use [Rate Limits](/reference/rate-limits) to identify request-rate `429` responses. * Use [Page Concurrency](/reference/rate-limits#page-concurrency) to understand queued work and latency on r-1 and r-1 mini. * Use [Concurrency Throttle (Legacy)](/reference/throttling) to understand queued work and latency on legacy Parse. # Usage and Pricing Source: https://docs.reducto.ai/reference/credit-usage Understanding how Reducto measures and bills API usage across products Reducto measures usage separately for each product. Your contract and rate card is the source of truth. **Contracted customers may have negotiated rates that differ from the list prices below.** These listed prices go into effect on September 1, 2026. You can view the [migration guide](/reference/pricing-migration) and our [legacy pricing chart](/reference/legacy-credit-usage). ## List pricing The following prices apply to standard list-rate accounts. Rates for Growth and Enterprise may vary based on volume. ### Parse Endpoint | Product | List price | Notes | | :- | :- | :- | | Parse, r-1 | \$10 / 1,000 pages | | | Parse, r-1 mini | \$1 / 1,000 pages | | | Legacy Parse, Standard Page | \$15 / 1,000 pages | 2x multiplier applies for agentic modes. | | Legacy Parse, Complex Page | \$30 / 1,000 pages | 2x multiplier applies for agentic modes. | | OCR data return, `embed_pdf_metadata`, and/or Parse citations | Included with r-1. \$1 / 1,000 pages with r-1 mini. | r-1 and legacy Parse include OCR at no extra charge. Optional add-on for r-1 mini. | | Prompted blocks and custom regions | \$5 / 1,000 blocks | Optional add-on for r-1 and r-1 mini. | | Advanced chart agent | \$0.06 per chart | Per processed chart; charged only if a chart is detected. | ### Batch processing Parse (r-1 and r-1 mini) and Extract jobs submitted through the batch queue receive a 20% usage discount. Deep Extract is not eligible for the batch queue or its discount. Batch jobs run asynchronously with a 12-hour completion guarantee. See [Batch Queue](/workflows/batch-queue). ### Extract Endpoint | Product | List price | Notes | | :- | :- | :- | | Extract | \$20 / 1,000 pages | Dense extractions (over 100 fields/page) may incur a per-field surcharge. No additional parse costs. | | Deep Extract | \$40 / 1,000 pages | Dense extractions (over 100 fields/page) may incur a per-field surcharge. No additional parse costs. | ### Split Endpoint | Product | List price | Notes | | :- | :- | :- | | Split | \$20 / 1,000 pages | No additional parse costs. | | Deep Split | \$40 / 1,000 pages | Agentic loop for near-perfect accuracy. See [Deep Split](/configs/split/deep-split). No additional parse costs. | ### Classify Endpoint | Product | List price | Notes | | :- | :- | :- | | Classify | \$7.50 / 1,000 pages standard | Standard uses the first five pages by default | | Deep Classify | \$15 / 1,000 pages (alpha pricing) | `model: "accurate"`. Higher accuracy on hard documents. Uses the first 5 pages by default. | ### Edit Endpoint | Product | List price | Notes | | :- | :- | :- | | Edit | \$60 / 1,000 pages; \$15 / 1,000 fully prefilled pages | | ## Spreadsheets Spreadsheets have no separate price. Reducto converts cells to pages and bills those pages at the per-page rate of the product you use. | Clustering mode | Cells per page | | :- | :- | | Accurate | 1,000 | | Fast | 5,000 | | Disabled | 5,000 | r-1 mini does not process spreadsheets. ## Monitoring usage Keeping track of how you're using Reducto is easy. You can: * View usage over time in the [Reducto Studio](https://studio.reducto.ai/) usage dashboard. * Inspect the per-product breakdown for individual jobs. * Configure usage alerts, either to notify with a dollar amount or credit amount threshold. * Export usage programmatically with the [Usage Export API](/reference/usage-export-api). ## Related > [card] [Pricing Migration Guide](/reference/pricing-migration) > > How the move to per-product pricing affects you. > [card] [Legacy Credit Usage](/reference/legacy-credit-usage) > > Credit rates that applied before September 1, 2026. # Error Codes Source: https://docs.reducto.ai/reference/error-codes Understanding and handling Reducto API errors When something goes wrong, Reducto returns an HTTP status code and error message. ## Error Response Format ```json theme={null} { "error": { "message": "Failed to download file from url. Please check the document url and try again", "type": "file_download_failure", "code": 400 } } ``` ## Client Errors (4xx) These errors indicate a problem with your request. | Code | Name | Description | Solution | | - | - | - | - | | 400 | JSON decoding error | LLM response too large to decode | Enable `array_extract: true` for large extraction schemas | | 400 | Invalid URL format | S3 URL malformed | Use format `s3://bucket-name/key` | | 400 | File download failure | Cannot fetch document from URL | Verify URL is accessible and not expired | | 400 | Invalid page range | Specified pages don't exist | Page range is 1-indexed. Check document has requested pages | | 403 | Permission error | URL access forbidden | Ensure presigned URL hasn't expired or credentials are valid | | 403 | PDF processing error | Cannot write output | Internal error, contact support | | 404 | File access error | File not found | Verify file exists at the specified path/URL | | 413 | Image too large | Image exceeds maximum dimensions | Resize image to under 50 megapixels total and 15,000 pixels per axis. See [image limits](/upload/overview#supported-file-types) | | 415 | File conversion error | Cannot convert to images | Check file is a valid, uncorrupted PDF | | 415 | PDF handling error | Cannot process PDF | File may be corrupted. Try re-saving with Adobe Acrobat | | 422 | Schema validation error | Invalid JSON schema | Validate your schema is proper JSON Schema format | | 422 | Table processing error | Table extraction failed | Retry, or enable agentic table mode for complex tables | | 442 | Document access error | Password protected | Provide password via `settings.document_password` | | 429 | Rate limit exceeded | Too many requests | Implement backoff, or switch to async endpoints | ## Server Errors (5xx) These indicate a problem on Reducto's side. Most are automatically retried by the SDK. | Code | Name | Description | Retriable? | | - | - | - | - | | 500 | Content extraction error | Extraction failed | ❌ No | | 500 | Citation extraction error | Citation extraction failed | ❌ No | | 500 | PDF metadata error | PDF metadata corrupted | ❌ No | | 502 | LLM service error | LLM provider error | ✅ Yes | | 503 | Service unavailable | Temporary overload | ✅ Yes | | 503 | Table processing error | Table service error | ✅ Yes | | 504 | LLM timeout error | LLM provider timed out | ✅ Yes | | 504 | Document conversion timeout | Conversion took too long | ✅ Yes | ## Automatic Retries The Reducto SDKs automatically retry requests that fail with these status codes: ``` 408, 409, 429, and all 5xx errors (500+) ``` Default retry behavior: * **Max retries**: 2 * **Backoff**: Exponential with jitter * **Timeout**: 1 hour per request (3600 seconds) You can customize retry behavior: ```python Python theme={null} from reducto import Reducto client = Reducto( max_retries=5, timeout=120.0 # seconds ) ``` ```javascript Node.js theme={null} import Reducto from 'reductoai'; const client = new Reducto({ maxRetries: 5, timeout: 120000 // milliseconds }); ``` ## Handling Errors ```python Python theme={null} from reducto import ( Reducto, APIError, BadRequestError, RateLimitError, APIConnectionError ) client = Reducto() try: result = client.parse.run(input=upload.file_id) except BadRequestError as e: # 400-level errors (your request has a problem) print(f"Bad request: {e.message}") except RateLimitError as e: # 429 - too many requests print("Rate limited, waiting...") time.sleep(60) except APIConnectionError as e: # Network issues print(f"Connection failed: {e}") except APIError as e: # Other API errors print(f"API error {e.status_code}: {e.message}") ``` ```javascript Node.js theme={null} import Reducto from 'reductoai'; const client = new Reducto(); try { const result = await client.parse.run({ input: upload.file_id }); } catch (error) { if (error.status === 400) { console.log('Bad request:', error.message); } else if (error.status === 429) { console.log('Rate limited, waiting...'); await new Promise(r => setTimeout(r, 60000)); } else if (error.status >= 500) { console.log('Server error, will retry automatically'); } } ``` ```bash cURL theme={null} # Check response status code RESPONSE=$(curl -s -w "\n%{http_code}" -X POST "https://platform.reducto.ai/parse" \ -H "Authorization: Bearer $REDUCTO_API_KEY" \ -d '{"input": "https://example.com/document.pdf"}') STATUS=$(echo "$RESPONSE" | tail -n 1) BODY=$(echo "$RESPONSE" | sed '$d') if [ "$STATUS" -eq 200 ]; then echo "Success: $BODY" elif [ "$STATUS" -eq 429 ]; then echo "Rate limited, waiting..." sleep 60 else echo "Error $STATUS: $BODY" fi ``` ## Common Issues > [accordion] 400: Invalid URL format > > **Cause**: The URL you provided isn't in a recognized format. > > **Solutions**: > > * For S3: Use `s3://bucket-name/key` format > * For presigned URLs: Ensure the full URL including query parameters is provided > * For reducto:// URLs: Use the exact string returned from `/upload` > [accordion] 403: Permission error > > **Cause**: Reducto cannot access the file at the URL you provided. > > **Solutions**: > > * Check that presigned URLs haven't expired > * Verify the URL is publicly accessible or properly authenticated > * For S3, ensure the bucket policy allows access from Reducto's IPs > [accordion] 429: Rate limit exceeded > > **Cause**: You've exceeded the request rate limit. > > **Solutions**: > > * Implement exponential backoff > * Switch to async endpoints (`/parse_async`) which have higher limits > * Use separate API keys for different applications > * Contact support for higher limits > [accordion] 413: Image too large > > **Cause**: An image in your document exceeds the maximum supported dimensions (50 megapixels total or 15,000 pixels on any single axis). > > **Solutions**: > > * Resize or re-export images to stay under 50 megapixels (e.g., under \~8200x6100) > * Ensure no single axis exceeds 15,000 pixels > * For scanned documents, reduce the scan DPI > [accordion] 504: Timeout errors > > **Cause**: The document took too long to process. > > **Solutions**: > > * Use async endpoints for large documents > * Process fewer pages using `page_range` > * Disable agentic modes if not needed > * Check if the document is unusually complex *** ## Related > [card] [Rate Limits](/reference/rate-limits) > > Understanding request limits. > [card] [Async Processing](/workflows/async-overview) > > Avoid timeouts with async endpoints. # Frequently Asked Questions Source: https://docs.reducto.ai/reference/faq Common questions about using Reducto ## Documents and Processing > [accordion] How do I return the whole document as plain Markdown? > > Set chunking to `disabled` (the default). The entire document will be returned as a single chunk with all content in the `content` field as Markdown. > > ```python theme={null} > result = client.parse.run( > input=upload.file_id, > retrieval={"chunking": {"chunk_mode": "disabled"}} > ) > > # Full document as markdown > markdown = result.result.chunks[0].content > ``` > > Tables will be formatted according to your `table_output_format` setting (default: `dynamic`, which uses Markdown for simple tables). > [accordion] What's the difference between content and embed fields? > > Both fields contain the chunk's text, but optimized for different purposes: > > * **`content`**: Raw extraction with original formatting. Tables appear as HTML or Markdown. Use for display. > * **`embed`**: Optimized for vector embeddings. When `embedding_optimized: true`, tables become natural language summaries like "This table shows quarterly revenue..." which embed better. > > **For RAG:** Use `embed` for your vector database, `content` for displaying results to users. > > See [Understanding Chunks](/parse/response-format#understanding-chunks) for details. > [accordion] How do I use Reducto for RAG? > > 1. **Parse with variable chunking** to get semantically meaningful segments > 2. **Enable embedding optimization** so tables become natural language > 3. **Filter noise** like headers and footers > 4. **Store chunks** in your vector database > > ```python theme={null} > result = client.parse.run( > input=upload.file_id, > retrieval={ > "chunking": {"chunk_mode": "variable", "chunk_size": 1000}, > "embedding_optimized": True, > "filter_blocks": ["Header", "Footer", "Page Number"] > } > ) > > for chunk in result.result.chunks: > your_vector_db.insert( > embedding=your_embedding_function(chunk.embed), > metadata={"content": chunk.content, "blocks": chunk.blocks} > ) > ``` > > See [Parse Best Practices](/parse/best-practices) for more. > [accordion] Which configurations increase latency? > > **Adds significant latency:** > > * Customized agentic processing with `enhance.agentic` and custom prompts > * `enhance.agentic[].advanced_chart_agent: true` (advanced chart extraction) > * On legacy Parse, promptless agentic scopes and `enhance.summarize_figures` > * Large documents with `embedding_optimized: true` > > **Moderate impact:** > > * `settings.return_images` (generates cropped images) > * `settings.embed_pdf_metadata` (modifies PDF) > > **Minimal impact:** > > * Chunking mode changes > * Table output format changes > * Block filtering > > r-1 handles general parsing accuracy natively, so promptless agentic scopes are not needed. For latency-sensitive applications, use customized agentic processing only when needed and use async with priority. > [accordion] Can I choose which LLM or model Reducto uses? > > No. Reducto manages model selection internally to optimize for accuracy, cost, and latency. The models used may change as we improve the system. > > For on-premise deployments, you can configure which LLM providers are available. See [LLM Configuration](/onprem/llm_options). ## URLs and Retention > [accordion] How long are result URLs valid? > > * **Image URLs** (`image_url` from `return_images`): Valid for 12 hours > * **PDF URLs** (`pdf_url`): Valid for 12 hours > * **Result URLs** (when `type: "url"`): Valid for 12 hours > > Reducto signs image and PDF URLs when the job completes, and fetching the job again returns the same URLs. Reducto signs a new result URL each time you fetch the job. Download or process any URLs promptly after receiving them. > [accordion] How long are job results retained? > > By default, job results are generally available for **12 hours**. Under Reducto's [zero data retention (ZDR) policy](/security/policies), all API processing artifacts for API-submitted jobs are typically purged within 24 hours for Growth and Enterprise tiers. > > To keep results longer: > > ```python theme={null} > result = client.parse.run( > input=upload.file_id, > settings={"persist_results": True} > ) > ``` > > With `persist_results: true`, results are stored indefinitely and can be retrieved anytime using the job ID. This requires opting in to Reducto Studio. > [accordion] Why am I getting a URL instead of inline results? > > When the response exceeds approximately 6MB, Reducto returns `result.type: "url"` instead of `result.type: "full"`. Fetch the content from `result.url`: > > ```python theme={null} > if result.result.type == "url": > import requests > chunks = requests.get(result.result.url).json() > else: > chunks = result.result.chunks > ``` > > To always get URL responses (for consistent handling): > > ```python theme={null} > result = client.parse.run( > input=upload.file_id, > settings={"force_url_result": True} > ) > ``` > [accordion] Job ID not found error > > Job IDs generally expire after 12 hours. Under Reducto's [zero data retention (ZDR) policy](/security/policies), all API processing artifacts for API-submitted jobs are typically purged within 24 hours for Growth and Enterprise tiers. Jobs can also be explicitly deleted via `DELETE /job/{job_id}`. > > A 410 response means the job was permanently deleted. A 409 response means deletion is in progress. > > To prevent data loss: > > * Process results immediately when you receive them > * Store results in your own database > * Use `persist_results: true` to keep results indefinitely > > See [Deleting Jobs & Uploads](/workflows/deletion) for more on manual deletion. ## API and Integration > [accordion] What's the maximum file size? > > * **Direct upload** (`/upload`): 100MB > * **Presigned URL upload**: 5GB > * **URL passthrough**: No limit (Reducto fetches the file) > > For files over 100MB, use the [presigned URL method](/upload/large-files). For files over 5GB, host them on S3 or another storage service and pass the URL directly. > [accordion] How do I check system status? > > Visit [status.reducto.ai](https://status.reducto.ai) for real-time status of all Reducto services, uptime history, and incident reports. > > Subscribe to updates to get notified of any service disruptions. > [accordion] How do I contact support? > > * **Email**: [support@reducto.ai](mailto:support@reducto.ai) > * **Slack**: Available for Growth and Enterprise customers > * **Teams**: Available for Growth and Enterprise customers > * **Studio**: Use the feedback button in [Reducto Studio](https://studio.reducto.ai/) *** ## Still Have Questions? > [card] [Error Codes](/reference/error-codes) > > Understand and resolve API errors. > [card] [Rate Limits](/reference/rate-limits) > > API request limits and optimization. > [card] [Credit Usage](/reference/credit-usage) > > How credits are calculated. > [card] [Glossary](/reference/glossary) > > Key terms and concepts. # Glossary Source: https://docs.reducto.ai/reference/glossary Key terms and concepts in Reducto ## A > [accordion] Agentic Mode > > Customized processing that runs additional vision-language model steps for a specific content type. With r-1, use agentic text, table, or figure scopes when your workflow needs a custom prompt, or use the advanced chart agent for structured numerical extraction. Agentic processing adds latency but does not add cost to r-1 processing. Existing legacy Parse pipelines can continue using promptless agentic scopes as general-purpose accuracy enhancements. See [Agentic Modes](/configs/parse/agentic-modes). > [accordion] Async Endpoint > > API endpoints that return immediately with a `job_id` instead of waiting for processing to complete. Examples: `/parse_async`, `/extract_async`. Use these for large documents, batch processing, or when you want webhook notifications. See [Async Processing](/workflows/async-overview). ## B > [accordion] Bounding Box (bbox) > > Coordinates describing where a block appears on a page. All values are normalized to \[0, 1] relative to page dimensions. Fields: `left`, `top`, `width`, `height`, `page`, `original_page`. See [Response Format](/parse/response-format#bounding-box-coordinates). > [accordion] Block > > The atomic content element in Reducto's output. Every paragraph, table, header, figure, list item, etc. is a separate block with its own type, content, bounding box, and confidence score. Blocks are grouped into chunks. ## C > [accordion] Chunk > > A group of related blocks returned by Parse. Chunking mode determines how blocks are grouped. Each chunk has `content` (raw Markdown), `embed` (optimized for vector embeddings), and `blocks` (individual elements with metadata). See [Chunking Methods](/configs/parse/chunking-methods). > [accordion] Confidence Score > > A measure of parsing accuracy. String confidence is `"high"` or `"low"`. Granular confidence provides numeric scores (0-1) in `parse_confidence` and `extract_confidence` fields. > [accordion] Content Field > > The `content` field in a chunk contains the raw Markdown representation of extracted text. Tables appear in their original format (HTML/Markdown). Use for display purposes. > [accordion] Credits > > The billing unit for Reducto API usage. Different endpoints and configurations consume different credit amounts. See [Credit Usage](/reference/credit-usage). ## E > [accordion] Embed Field > > The `embed` field in a chunk is optimized for vector embeddings. When `embedding_optimized: true`, tables are converted to natural language summaries that embed better than raw Markdown. Use for vector databases and semantic search. > [accordion] Extract > > The `/extract` endpoint pulls structured data from documents according to a JSON schema you define. Returns typed fields with values and citations. Different from Parse, which returns the full document content. ## F > [accordion] Force URL Result > > Setting `force_url_result: true` makes the API always return results as a URL pointing to JSON, rather than inline in the response. Useful for consistent handling or very large documents. See [Processing Settings](/configs/parse/ocr-settings#force-url-result). ## J > [accordion] Job ID > > A unique identifier returned when you submit an async request or complete any API call. Format: UUID like `7600c8c5-a52f-49d2-8a7d-d75d1b51e141`. Used to retrieve results, check status, or chain endpoints. > [accordion] jobid:// URL > > A special URL format (`jobid://abc123`) that lets you reference a previously parsed document without re-uploading or re-parsing it. Use with Extract or Split to avoid duplicate processing. ## O > [accordion] OCR System > > The configurable text extraction engine for legacy Parse pipelines. `standard` is the default legacy OCR system and supports multilingual documents. `legacy` supports Germanic languages and exists for backwards compatibility. r-1 uses native full-page processing and ignores this setting. See [Processing Settings](/configs/parse/ocr-settings). ## P > [accordion] Parse > > The `/parse` endpoint extracts text, tables, figures, and structure from documents. Returns chunks and blocks with positional metadata. The foundation for most Reducto workflows. > [accordion] Persist Results > > Setting `persist_results: true` stores job results indefinitely and exempts them from the 24-hour ZDR purge (requires Studio). Without this, job IDs generally expire after 12 hours, and results are no longer retrievable. > [accordion] Pipeline > > A saved configuration in Reducto Studio that bundles parsing, extraction, or other operations into a single API call. Call via `/pipeline` endpoint with a `pipeline_id`. > [accordion] Priority > > Async jobs can be submitted with `priority: true` to process ahead of non-priority async jobs. Sync requests are always prioritized over async. ## R > [accordion] r-1 > > Reducto's unified full-page Parse model for text, tables, figures, layout, formatting, and grounding. r-1 is the main Parse model, with the highest accuracy and lower latency than r-1 mini. See [Parse r-1](/parse/r-1). > [accordion] r-1 mini > > r-1 mini gives high accuracy at low cost. Select it with `settings.model: "r-1-mini"`. See [r-1 mini](/parse/r-1#r-1-mini). > [accordion] reducto:// URL > > A special URL format (`reducto://abc123`) returned by the `/upload` endpoint. Use this to reference uploaded files in subsequent API calls without re-uploading. ## S > [accordion] Schema > > A JSON Schema definition used with the Extract endpoint to specify what data to pull from documents. Defines field names, types, and structure. > [accordion] Split > > The `/split` endpoint divides documents into logical sections based on natural language descriptions you provide. Returns page ranges for each section with confidence scores. > [accordion] Studio > > [Reducto Studio](https://studio.reducto.ai/) is the web interface for configuring pipelines, viewing results, managing API keys, and monitoring usage. Pipelines created in Studio can be called via the API. > [accordion] Sync Endpoint > > API endpoints that block until processing completes and return the full result. Examples: `/parse`, `/extract`. Best for interactive applications with smaller documents. ## U > [accordion] Upload > > The `/upload` endpoint accepts direct file uploads and returns a `reducto://` URL for use in other API calls. Use for local files rather than URLs. > [accordion] URL Result Type > > When a Parse response is too large for inline delivery, `result.type` is `"url"` and you must fetch the content from `result.url`. The threshold is approximately 6MB response size. ## Z > [accordion] Zero Data Retention (ZDR) > > Reducto's data policy for Growth and Enterprise tiers. Job IDs and results are generally available for 12 hours by default, and all API processing artifacts for API-submitted jobs are typically purged within 24 hours. Studio jobs are exempt from the 24-hour purge. Results with `persist_results: true` are also exempt (requires Studio opt-in). # Per-Page Billing Breakdown Source: https://docs.reducto.ai/reference/page-billing-breakdown Understand exactly which billing features were applied to each page of your document Every Parse response includes a `page_billing_breakdown` field inside `usage` that tells you exactly which billable features were applied to each page. This gives you granular visibility into your credit consumption. ## Response Format The `page_billing_breakdown` is a map from **1-indexed page number** (as a string) to an array of billing feature tags applied to that page: ```json theme={null} { "usage": { "num_pages": 5, "credits": 15.0, "credit_breakdown": { "page": 5.0, "agentic": 5.0, "complex": 3.0 }, "page_billing_breakdown": { "1": ["agentic", "page"], "2": ["agentic", "complex", "page"], "3": ["agentic", "chart_agent", "page"], "4": ["agentic", "chart_agent", "complex", "page"], "5": ["agentic", "page"] } } } ``` ## Billing Features Each page can have one or more of the following feature tags: | Feature | Description | When Applied | | - | - | - | | `page` | Standard page processing | Every PDF/image page | | `html_page` | HTML document page | HTML and text file pages | | `docx_native_page` | Native DOCX page | Word documents processed natively | | `agentic` | Agentic extraction enabled | When any agentic mode is configured (text, tables, figures) | | `complex` | Complex page processing | Pages with complex tables, key-value regions, or figure summarization | | `chart_agent` | Advanced chart extraction | Pages where the chart agent ran | | `billable_spreadsheet_pages` | Spreadsheet page billing derived from cell count | Spreadsheet pages (XLSX, CSV, etc.) | ### Feature Collapsing Several internal work types are collapsed into simplified categories for clarity: * **`complex`** combines: `figure_summary`, `table_summary`, and `key_value` work types. If a page has multiple complex operations (e.g., both a complex table and a figure summary), it still appears as a single `complex` tag. * **`page`**, **`html_page`**, and **`docx_native_page`** are mutually exclusive base page types depending on the document format. * **`billable_spreadsheet_pages`** is the current spreadsheet billing tag. During rollout compatibility, older persisted responses may still show `spreadsheet_cells`. ## Related > [card] [Credit Usage](/reference/credit-usage) > > Full credit rates for all endpoints. > [card] [Parse Response Format](/parse/response-format) > > Complete parse response structure. # Pricing Migration Guide Source: https://docs.reducto.ai/reference/pricing-migration How Reducto's move from credit-based billing to per-product pricing affects you ## TL;DR * Effective September 1, 2026, Reducto is introducing a new pricing structure. See [Usage and Pricing](/reference/credit-usage) for the full list rates. * No existing customer will pay more in their current term as a result of these changes. Your pricing will either stay the same or decrease. * For many customers, we're decreasing the cost across Extract, Deep Extract, and Split. * Contracted customers do not need to take any action. Your current credit-based pricing and any applicable discounts remain in place until renewal. * For existing pay-as-you-go customers, the new pricing takes effect on September 1, 2026. No action is required to take advantage of these pricing improvements. ## Plan migration timeline | When | What changes | | :- | :- | | September 1, 2026 | Per-product pricing becomes the default for all new customers. Existing pay-as-you-go customers will automatically get the new list credit rates (see below). All credits will transfer over. Contracted customers will not see their rates change, aside from built-in savings (e.g., parsing being included in Extract). | | October 1, 2026 | New Standard tier pay-as-you-go customers move automatically to per-product rate cards. | | At renewal | Contracted customers move only when they sign a new contract or rate card. Until then, existing pricing remains in place. | ## Key pricing changes | Product | Old pricing | New pricing (effective 9/1/2026) | | :- | :- | :- | | \[New] r-1 Parse | N/A | \$10 / 1,000 pages | | \[New] r-1 mini Parse | N/A | \$1 / 1,000 pages | | Extract | \$20 / 1,000 pages. Parse billed separately, an additional \$10 / 1,000 pages up to \$40 / 1,000 pages. | \$20 / 1,000 pages, including parsing. Dense extractions (over 100 fields/page) may incur a per-field surcharge. | | Deep Extract | \$40 / 1,000 pages plus \$1 / 1,000 pages per field extracted. Minimum of 30 credits per document. Parse billed separately. | \$40 / 1,000 pages, including parsing. Dense extractions (over 100 fields/page) may incur a per-field surcharge. | | Split | \$20 / 1,000 pages. Parse billed separately. \$40 / 1,000 pages for Deep Split. Parse billed separately. | \$20 / 1,000 pages, including parsing. \$40 / 1,000 pages for Deep Split, including parsing. | For the rates that applied before this change, see [Legacy Credit Usage](/reference/legacy-credit-usage). ## Which pricing model am I on? Your response tells you which model you are on. Per-product accounts receive a `usage_breakdown` object, and their credit fields are `null`. Credit-based accounts receive a numeric `credits` value and no `usage_breakdown` key. Test for the presence of the key, not for `credits` being `null`. Your plan tells you when this changes: * Accounts created after September 1, 2026 are on the new rate cards. * Existing pay-as-you-go accounts move to per-product rate cards on October 1, 2026. The new list credit rates apply from September 1, 2026. * Contracted accounts change at their next renewal. ## What your API responses look like API responses use one of two usage shapes. Accounts on credit-based rate cards receive a numeric `credits` value. Parse, split, and edit responses also include `credit_breakdown` and `page_billing_breakdown`. Accounts on per-product pricing receive raw quantities in `usage_breakdown` and no credit total. Their credit fields are `null`. ### Credit-based accounts Credit-based accounts receive the response shape they see today. The `usage_breakdown` key is absent from the JSON. This example shows a parse response. ```json theme={null} { "usage": { "num_pages": 5, "credits": 15.0, "credit_breakdown": { "page": 5.0, "agentic": 5.0, "complex": 3.0 }, "page_billing_breakdown": { "1": ["agentic", "page"], "2": ["agentic", "complex", "page"], "3": ["agentic", "chart_agent", "page"], "4": ["agentic", "chart_agent", "complex", "page"], "5": ["agentic", "page"] } } } ``` ### Per-product accounts Per-product accounts receive raw quantities in `usage_breakdown`. The credit fields are `null`. The fields depend on the endpoint. This example shows a parse response. In a parse response, `parse_model` is `"R-1"`, `"R-1 Mini"`, or `"Legacy"`, and `parse_pages` counts the pages of that model. ```json theme={null} { "usage": { "num_pages": 5, "credits": null, "credit_breakdown": null, "page_billing_breakdown": null, "usage_breakdown": { "parse_model": "R-1", "tier": "Default", "parse_pages": 5, "parse_native_pages": 0, "legacy_parse_credits": 0.0, "ocr_pages": 0, "charts": 1, "prompted_blocks": 0 } } } ``` ## What existing customers need to do * Accounts moved to per-product pricing now receive `usage_breakdown` instead of the credit fields. It reports product-specific quantities. See [What your API responses look like](#what-your-api-responses-look-like). * Existing pay-as-you-go customers will have their changes take place on September 1, 2026. * Contracted customers will keep their current credit-based pricing, and any applicable discounts remain in place. Contact your CSM or support if you have any questions about how this change affects your individual plan. ## FAQ > [accordion] I just signed up for Reducto, does the new pricing apply to my account? > > If you signed up for Reducto after September 1, 2026, your account will automatically use the new pricing. > [accordion] Do I still get 15,000 free credits on the Standard pay-as-you-go plan? > > Yes. New customers will still have 15,000 credits to use. > [accordion] What happens if I have pre-paid credits? > > All pre-paid credits will carry over with no action required. > [accordion] Will the usage.credits field still be emitted? > > While your account stays on credit-based pricing, `usage.credits` keeps its current value. Once your account moves to a per-product rate card, `usage.credits` is `null`, and you should read quantities from `usage_breakdown` instead. See [What your API responses look like](#what-your-api-responses-look-like). > [accordion] Can I move to the new pricing earlier? > > All Standard pay-as-you-go customers will automatically move to the new pricing on September 1, 2026. All Growth and Enterprise customers retain their contracted pricing structure until renewal. ## Related > [card] [Usage and Pricing](/reference/credit-usage) > > Current list pricing across all Reducto products. > [card] [Legacy Credit Usage](/reference/legacy-credit-usage) > > Credit rates that applied before September 1, 2026. > [card] [Per-Page Billing Breakdown](/reference/page-billing-breakdown) > > Details about billing features applied to each page. # Rate Limits Source: https://docs.reducto.ai/reference/rate-limits > [!NOTE] > > Legacy Parse uses a different concurrency model. See [Concurrency Throttle (Legacy)](/reference/throttling). ## Page Concurrency Page concurrency is the number of pages of your r-1 and r-1 mini jobs that Reducto processes at the same time for your account. Pages above the limit wait in a queue, and Reducto processes them as soon as capacity is available. ### r-1 | Tier | US | EU | | - | - | - | | Standard | 500 | 250 | | Growth | 2,000 | 1,000 | | Enterprise | 4,000+ | 2,000+ | ### r-1 mini | Tier | US | EU | | - | - | - | | Standard | 50 | 25 | | Growth | 1,000 | 500 | | Enterprise | 2,000+ | 1,000+ | Enterprise accounts can get custom caps. [Contact sales](mailto:support@reducto.ai) to discuss. US is the default region. The EU column applies to requests that you send to the [EU region](/security/eu-data-residency). All values are in pages of your jobs in progress at the same time. ## Limit Mechanisms | Mechanism | Applies to | What it limits | When you exceed it | Returns | | - | - | - | - | - | | [Page concurrency](#page-concurrency) | r-1 and r-1 mini jobs | Pages in progress at the same time for your account | Work waits in a queue, then runs | `200` (after wait) | | [Edge rate limits](#edge-rate-limits) | Every request | Requests per second for each API key | Reducto rejects the request at the ingress | `429` | | [Sync connections](/workflows/batch-processing#concurrency-limits) | Sync requests (`/parse`, `/extract`, `/split`) | Open connections that wait for a result. Keep at most 50 to 200 at the same time, and [use async](/workflows/async-overview) for more. | The connection stays open until the job completes, up to the 15-minute (900s) timeout | `200`, or a timeout | | [Concurrency throttle (legacy)](/reference/throttling) | Legacy Parse jobs | Parse batches in progress at the same time for your account | Work waits in a queue, then runs | `200` (after wait) | ## How Page Concurrency Works * **Work above the cap waits.** Reducto queues the extra work and runs it when other work completes. You see added latency, not a `429`. * **Higher limits.** If your tail latency increases at high volume, the cause is work that waits in the queue. [Contact sales](mailto:support@reducto.ai) for higher limits. * **The cap is fixed.** The cap does not grow with your traffic. * **Each model has its own cap.** r-1 pages do not count against the r-1 mini cap, and r-1 mini pages do not count against the r-1 cap. * **Each region has its own cap.** Pages in the US region do not count against your EU cap. ## Edge Rate Limits Edge rate limits apply to every request at the ingress and protect against accidental floods. They cap **requests per second**, not concurrent work. Requests above the cap return `429` immediately. The SDKs retry `429` responses with exponential backoff automatically. ### Per API Key | Endpoint scope | Limit | 429 response body | | - | - | - | | All endpoints | 1,000 req/s sustained | `{"message": "[CODE 1000] rate limit exceeded, retry with exponential backoff"}` | | `GET /job/{job_id}` | 300 req/s sustained | `{"message": "[CODE 2000] rate limit exceeded, please use webhooks instead of polling"}` | If you hit `[CODE 2000]`, you're polling job status faster than necessary. Switch to [webhooks](/workflows/async-overview) so Reducto pushes results when ready. # Usage Export API Source: https://docs.reducto.ai/reference/usage-export-api Programmatically export usage data from your Reducto account The Usage Export API returns the same usage data available on the [Studio usage dashboard](/studio-account#usage), accessible programmatically via your PropelAuth API key. It covers the last 90 days of usage. The unit depends on your pricing model. Accounts on per-product pricing receive `pages`. Accounts on credit-based pricing receive `credits`. See [Response](#response) and the [Pricing Migration Guide](/reference/pricing-migration#which-pricing-model-am-i-on) to find out which model applies to you. ## Authentication Authenticate with a Bearer token using your PropelAuth API key from [Studio API Keys](https://studio.reducto.ai/): ```bash theme={null} curl -H "Authorization: Bearer YOUR_API_KEY" \ "https://studio.reducto.ai/api/v1/usage/export" ``` This is your personal API key from the Studio API Keys page, not the `REDUCTO_API_KEY` used for document processing. ## Endpoint ``` GET https://studio.reducto.ai/api/v1/usage/export ``` ## Query Parameters | Parameter | Type | Default | Description | | - | - | - | - | | `startDate` | `string` | 30 days ago | Start date in `yyyy-mm-dd` format. Must be within the last 90 days. | | `endDate` | `string` | Today | End date in `yyyy-mm-dd` format | | `groupBy` | `string` | `product` | Dimension to group results by. One of: `product`, `feature`, `api_key`, `file_type`, `metadata`. Accounts on per-product pricing can use `product` or `api_key` only. | | `metadataKey` | `string` | (none) | Async metadata key to group or filter by. Required when `groupBy=metadata` or when using `metadataValue`. Enable the key for your organization first. Credit-based pricing only. See [Grouping by async metadata](#grouping-by-async-metadata). | | `metadataValue` | `string` | (none) | Filter by an async metadata value. Repeatable for multiple values; requires `metadataKey`. | | `orgId` | `string` | API key's org | Target organization ID. Required if your account belongs to multiple organizations. | | `product` | `string` | (none) | Filter by product. Repeatable for multiple values. | | `feature` | `string` | (none) | Filter by feature. Repeatable for multiple values. | | `apiKey` | `string` | (none) | Filter by API key. Repeatable for multiple values. | | `fileType` | `string` | (none) | Filter by file type. Repeatable for multiple values. | ## Data retention The API serves the last 90 days of usage, the same range as the Studio "Last 90 days" view: today plus the 89 days before it. An earlier `startDate` returns a `400` error. ## Response The `metric` field tells you which unit the `data` entries carry: `pages` or `credits`. Each entry in `data` represents one day and one group value. ### Per-product pricing Reducto bills these accounts per page. Each entry carries `pages`. See [Usage and Pricing](/reference/credit-usage). ```json theme={null} { "orgId": "org_abc123", "startDate": "2026-09-01", "endDate": "2026-09-30", "groupBy": "product", "metric": "pages", "data": [ { "date": "2026-09-01", "group": "parse", "pages": 1200 }, { "date": "2026-09-01", "group": "extract", "pages": 340 } ] } ``` These accounts can group by `product` or `api_key`. Other `groupBy` values return a `400` error. ### Credit-based pricing Reducto bills these accounts in credits. Each entry carries `credits`, the total credits consumed. ```json theme={null} { "orgId": "org_abc123", "startDate": "2026-05-19", "endDate": "2026-06-18", "groupBy": "product", "metric": "credits", "data": [ { "date": "2026-05-19", "group": "parse", "credits": 150.0 }, { "date": "2026-05-19", "group": "extract", "credits": 80.0 } ] } ``` These accounts can use every `groupBy` value, including `metadata`. ### API key grouping With `groupBy=api_key`, `group` is the key prefix and `groupName` is the key's name in Studio. `groupName` is `null` for keys that no longer exist. ```json theme={null} { "date": "2026-09-01", "group": "rk_live_ab12", "groupName": "production", "pages": 1200 } ``` ## Examples ### Default: last 30 days grouped by product ```python Python theme={null} import requests response = requests.get( "https://studio.reducto.ai/api/v1/usage/export", headers={"Authorization": "Bearer YOUR_API_KEY"}, ) data = response.json() unit = data["metric"] for row in data["data"]: print(f"{row['date']} | {row['group']}: {row[unit]} {unit}") ``` ```javascript Node.js theme={null} const response = await fetch( "https://studio.reducto.ai/api/v1/usage/export", { headers: { Authorization: "Bearer YOUR_API_KEY" } } ); const data = await response.json(); const unit = data.metric; for (const row of data.data) { console.log(`${row.date} | ${row.group}: ${row[unit]} ${unit}`); } ``` ```bash cURL theme={null} curl -H "Authorization: Bearer YOUR_API_KEY" \ "https://studio.reducto.ai/api/v1/usage/export" ``` ### Custom date range grouped by file type Available on credit-based pricing. Accounts on per-product pricing group by `product` or `api_key`. ```python Python theme={null} import requests response = requests.get( "https://studio.reducto.ai/api/v1/usage/export", headers={"Authorization": "Bearer YOUR_API_KEY"}, params={ "startDate": "2026-06-01", "endDate": "2026-06-15", "groupBy": "file_type", }, ) data = response.json() ``` ```javascript Node.js theme={null} const params = new URLSearchParams({ startDate: "2026-06-01", endDate: "2026-06-15", groupBy: "file_type", }); const response = await fetch( `https://studio.reducto.ai/api/v1/usage/export?${params}`, { headers: { Authorization: "Bearer YOUR_API_KEY" } } ); const data = await response.json(); ``` ```bash cURL theme={null} curl -H "Authorization: Bearer YOUR_API_KEY" \ "https://studio.reducto.ai/api/v1/usage/export?startDate=2026-06-01&endDate=2026-06-15&groupBy=file_type" ``` ### Filter by specific products Use repeatable query parameters to filter results. This example groups by `feature`, which is available on credit-based pricing: ```python Python theme={null} import requests response = requests.get( "https://studio.reducto.ai/api/v1/usage/export", headers={"Authorization": "Bearer YOUR_API_KEY"}, params=[ ("groupBy", "feature"), ("product", "parse"), ("product", "extract"), ], ) data = response.json() ``` ```javascript Node.js theme={null} const params = new URLSearchParams(); params.append("groupBy", "feature"); params.append("product", "parse"); params.append("product", "extract"); const response = await fetch( `https://studio.reducto.ai/api/v1/usage/export?${params}`, { headers: { Authorization: "Bearer YOUR_API_KEY" } } ); const data = await response.json(); ``` ```bash cURL theme={null} curl -H "Authorization: Bearer YOUR_API_KEY" \ "https://studio.reducto.ai/api/v1/usage/export?groupBy=feature&product=parse&product=extract" ``` ## Grouping by async metadata Available on credit-based pricing. If you attach [metadata to async jobs](/workflows/async-overview), you can break usage down by it. Use this to attribute credits to your own end customers without issuing a separate API key per tenant: ```json theme={null} { "async": { "metadata": { "tenant_id": "acme" } } } ``` ```bash theme={null} curl -H "Authorization: Bearer YOUR_API_KEY" \ "https://studio.reducto.ai/api/v1/usage/export?groupBy=metadata&metadataKey=tenant_id" ``` Each returned `group` is one value of that key. You can also filter to specific values while grouping by something else. This returns per-product usage for a single tenant: ```bash theme={null} curl -H "Authorization: Bearer YOUR_API_KEY" \ "https://studio.reducto.ai/api/v1/usage/export?groupBy=product&metadataKey=tenant_id&metadataValue=acme" ``` > [!NOTE] > > Contact support with the key names you want enabled before using them as a usage dimension. Reducto gates this deliberately: async metadata is free-form and often carries webhook credentials or per-request identifiers, and neither belongs in usage reporting. Things to know: * **Only async jobs carry metadata.** Synchronous requests have no `async.metadata`, so their usage appears under `unknown`. * **Reducto groups jobs missing the key under `unknown`.** That includes jobs you submitted before you started sending the key, so historical totals show a large `unknown` bucket. * **Keep keys low-cardinality.** A key that labels tenants, customers, or departments works. Reducto rejects a key carrying a per-request identifier, such as a document id or a signature, because it produces one group per request. * **Reducto compares values as text** and truncates them to 256 characters. You cannot group by a nested object or an array. * **`credits` still reconciles.** For any single `metadataKey`, the per-group credits plus the `unknown` bucket equal your total credits for that period. * **`metadataKey` on its own narrows the results.** Passing it without `groupBy=metadata` restricts the response to jobs that carried that key, so totals cover a subset of your usage. `groupBy=file_type&metadataKey=tenant_id` returns the file-type breakdown of jobs that carried `tenant_id`, and adds no `unknown` bucket. ## Multi-Organization Access If your account belongs to multiple organizations, pass the `orgId` parameter to specify which organization's usage to retrieve. Without it, the API defaults to the organization associated with your API key. ```bash theme={null} curl -H "Authorization: Bearer YOUR_API_KEY" \ "https://studio.reducto.ai/api/v1/usage/export?orgId=org_abc123" ``` You can only query organizations you are a member of. Requesting an org you don't belong to returns a `403` error. ## Error Responses | Status | Meaning | | - | - | | `401` | Missing or invalid API key | | `400` | Invalid `groupBy` value, or a `groupBy` other than `product` or `api_key` on per-product pricing | | `400` | `metadataKey` or `metadataValue` on per-product pricing | | `400` | More than 100 values for one filter parameter | | `400` | `startDate` or `endDate` not in `yyyy-mm-dd` format, `endDate` before `startDate`, or `startDate` before the earliest retained date (today minus 89 days) | | `400` | `groupBy=metadata` or `metadataValue` without `metadataKey`, or a `metadataKey` not enabled for the organization | | `403` | Not a member of the requested organization | | `500` | Server error | ## Related > [card] [Pricing and Usage](/reference/credit-usage) > > How Reducto prices pages and credits per endpoint. > [card] [Account & Settings](/studio-account) > > Manage API keys, usage alerts, and billing in Studio. # Version Pinning Source: https://docs.reducto.ai/reference/version-pinning How Reducto model versioning works and how to pin versions Reducto continuously improves the models powering document processing. This page explains how versioning works and how to control which version you use. For the current status of each model version, see [Model Versions](/reference/model-versions). ## How Versioning Works Each model version progresses through four stages: 1. **Alpha**: New version available for opt-in testing via the `alpha` config 2. **Default**: Automatically used for all requests 3. **Deprecated**: Old default, still accessible via `alpha` config 4. **Removed**: No longer available Requesting an unknown or removed version returns an error listing the available versions. ## Rollout Process New model versions follow a 4-week rollout window: 1. **Weeks 1-2 (Alpha)**: The new version is available for opt-in testing. You can pin to the new version via the `alpha` config to evaluate it against your workloads before it becomes the default. 2. **Week 3 (Default)**: The new version becomes the default for all requests. The previous default is demoted to deprecated status. 3. **Weeks 3-4 (Deprecated)**: The previous default remains accessible via version pinning for 2 weeks after being replaced. Use this window to migrate any workflows that depend on the old version's behavior. This gives you a guaranteed 4-week window, 2 weeks before and 2 weeks after a version becomes the default, to test and transition between versions. > [!NOTE] > > Reducto is deployed as a monolith, so fully frozen snapshots of previous versions are not available outside of on-prem/VPC deployments. Version pinning during the rollout window is the supported mechanism for managing behavioral changes. ## Pinning a Version To pin a specific version, pass it in your request's `settings.alpha` config: ```python Python theme={null} # Layout version pinning (parse) result = client.parse.run( input="https://example.com/document.pdf", settings={ "alpha": { "layout_model": "v2" } } ) # Extract version pinning (requires citations enabled) result = client.extract.run( input="https://example.com/document.pdf", instructions={"schema": {...}}, settings={ "citations": {"enabled": True}, "alpha": { "extract_model": "v2" } } ) # Deep Extract version pinning (requires deep_extract enabled) result = client.extract.run( input="https://example.com/document.pdf", instructions={"schema": {...}}, settings={ "deep_extract": True, "alpha": { "deep_extract_model": "v3" } } ) # Deep Split version pinning (requires deep_split enabled) # For split, alpha is a top-level option, not nested under settings. result = client.split.run( input="https://example.com/document.pdf", split_description=[...], settings={ "deep_split": True }, alpha={ "deep_split_model": "v2" } ) ``` ```javascript Node.js theme={null} // Layout version pinning (parse) const result = await client.parse.run({ input: 'https://example.com/document.pdf', settings: { alpha: { layout_model: 'v2' } } }); // Extract version pinning (requires citations enabled) const extractResult = await client.extract.run({ input: 'https://example.com/document.pdf', instructions: { schema: {...} }, settings: { citations: { enabled: true }, alpha: { extract_model: 'v2' } } }); // Deep Extract version pinning (requires deep_extract enabled) const deepExtractResult = await client.extract.run({ input: 'https://example.com/document.pdf', instructions: { schema: {...} }, settings: { deep_extract: true, alpha: { deep_extract_model: 'v3' } } }); // Deep Split version pinning (requires deep_split enabled) // For split, alpha is a top-level option, not nested under settings. const deepSplitResult = await client.split.run({ input: 'https://example.com/document.pdf', split_description: [...], settings: { deep_split: true }, alpha: { deep_split_model: 'v2' } }); ``` ```bash cURL theme={null} # Layout version pinning (parse) curl -X POST https://platform.reducto.ai/parse \ -H "Authorization: Bearer $REDUCTO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "input": "https://example.com/document.pdf", "settings": { "alpha": { "layout_model": "v2" } } }' # Extract version pinning (requires citations enabled) curl -X POST https://platform.reducto.ai/extract \ -H "Authorization: Bearer $REDUCTO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "input": "https://example.com/document.pdf", "instructions": {"schema": {...}}, "settings": { "citations": {"enabled": true}, "alpha": { "extract_model": "v2" } } }' # Deep Extract version pinning (requires deep_extract enabled) curl -X POST https://platform.reducto.ai/extract \ -H "Authorization: Bearer $REDUCTO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "input": "https://example.com/document.pdf", "instructions": {"schema": {...}}, "settings": { "deep_extract": true, "alpha": { "deep_extract_model": "v3" } } }' # Deep Split version pinning (requires deep_split enabled) # For split, alpha is a top-level option, not nested under settings. curl -X POST https://platform.reducto.ai/split \ -H "Authorization: Bearer $REDUCTO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "input": "https://example.com/document.pdf", "split_description": [], "settings": { "deep_split": true }, "alpha": { "deep_split_model": "v2" } }' ``` > [!NOTE] > > Model versioning only changes processing behavior. It does not break the API contract. If a pinned version is unknown or has been removed, the request returns an error listing the available versions. # EU data residency & processing Source: https://docs.reducto.ai/security/eu-data-residency For customers requiring that all document data stays strictly within EU For customers who would like to utilize the EU region only, Reducto guarantees that *all customer document data is processed, stored, and deleted exclusively within the EU*, with no exceptions. This applies to ingestion, temporary storage, computation, and deletion workflows. ## EU regions utilized Reducto operates across multiple cloud providers with EU-only data processing: **AWS**: Frankfurt (eu-central-1), Ireland (eu-west-1), Paris (eu-west-3), Stockholm (eu-north-1) **GCP**: Belgium (europe-west1), Frankfurt (europe-west3), Netherlands (europe-west4) ## Data boundary guarantee No customer document data ever leaves the EU. Only aggregated operational metrics (containing no document content) may be processed outside the EU. ## Subprocessor transparency Reducto maintains a strict EU-only data boundary for all document content. ### Document-processing subprocessors (EU-only execution) | Company | Description | | - | - | | Amazon Web Services (AWS) | Compute, storage, networking (EU regions only) | | Google Cloud | Compute, storage, networking (EU regions only) | | Modal Labs | Compute infrastructure (EU regions only) | | OpenAI | Compute (ZDR, EU region only) | ### Vendors receiving only operational telemetry The following vendors receive only aggregated, anonymous service-level metrics and never receive document content: | Company | Description | | - | - | | Sentry | Error monitoring | | PostHog | Product analytics | ## Data retention, expiry & deletion Reducto enforces a strict 24-hour maximum retention window for all data submitted via API, except for Studio-run jobs and results saved with `persist_results: true`. ### Retention policy Job results are generally available for 12 hours. Under the ZDR policy, all documents, extracted text, and intermediate outputs expire within 24 hours. All S3 data is encrypted at rest (AES-256) and in transit (TLS 1.2+). ### Deletion guarantees API-submitted data is automatically and irreversibly deleted after the 24-hour window, except for Studio-run jobs and results saved with `persist_results: true`. No backups or long-term archives are maintained. Logs and cache entries containing customer data are never persisted. # Filing Complaints Source: https://docs.reducto.ai/security/filing-complaints At Reducto AI, we are committed to protecting your privacy and complying with HIPAA regulations. We have established a process for individuals to submit complaints regarding our HIPAA policies, procedures, or compliance. **How to Submit a Complaint** If you have a complaint related to our HIPAA policies, procedures, or compliance, please follow these steps: Compose an email with the following information: Subject line: "HIPAA Complaint" Your name and contact information A detailed description of your complaint Any relevant dates, times, or other specific information Send your email to: [support@reducto.ai](mailto:support@reducto.ai) **What Happens Next** Upon receiving your complaint: We will log and record your complaint in our system. You will receive an acknowledgment of your complaint within 3 business days. Our privacy officer will review your complaint and investigate as necessary. We will provide a response to your complaint within 30 days, unless additional time is required for a thorough investigation. We take all HIPAA-related complaints seriously and are committed to addressing them promptly and appropriately. For any questions about this process, please contact our privacy officer at [security@reducto.ai](mailto:privacy@reducto.ai). # Data policies & compliance Source: https://docs.reducto.ai/security/policies At Reducto, we take data security and privacy extremely seriously. We understand the importance of protecting our customers' sensitive information and have implemented robust measures to ensure the highest level of security. This report outlines our data storage practices, encryption protocols, and compliance adherence. ## Data storage 1. **Storage Location**: Reducto utilizes the cloud infrastructure providers listed in our [authorized subprocessors](https://trust.reducto.ai) for storing and processing data. All data is encrypted at rest and in transit. 2. **Access Permissions**: Access to stored data is strictly limited to Reducto's authorized processing services. This ensures that only authenticated processes can interact with the stored data, minimizing the risk of unauthorized access. 3. **Data Retention**: We have a Zero Data Retention policy (ZDR) for users on our "Growth" tier and above, meaning all data submitted via API is set to expire within 24 hours, except for Studio-run jobs and results saved with `persist_results: true`. This means that API-submitted data older than 24 hours is automatically deleted, reducing the amount of data we retain and minimizing the potential impact of any data breaches. 4. **Data Usage**: For customers on our Growth tier and above, your agreement with Reducto governs how we use your data, including for model training. We do not train models on the content of your documents or outputs. ## Encryption 1. **Encryption at Rest**: All stored data is encrypted at rest using industry-standard encryption algorithms. This means that even if unauthorized individuals were to gain access to the stored data, they would not be able to decipher it without the proper encryption keys. 2. **Encryption in Transit**: We employ encryption protocols to protect data in transit. All communication between our systems and data storage is conducted over secure channels using encryption mechanisms such as SSL/TLS. This ensures that data remains confidential and tamper-proof during transmission. ## Compliance 1. **SOC 2 Type 2**: We have completed our SOC 2 Type I and Type II process. Please reach out to receive the report. This rigorous certification demonstrates our commitment to maintaining a secure and reliable system. It involves a comprehensive audit of our security controls, policies, and procedures by an independent third party. 2. **HIPAA Compliance**: We currently offer a HIPAA compliant processing pipeline for Growth and Enterprise tier customers. By adhering to HIPAA regulations, we ensure that any PHI processed by our system is handled with the utmost care and in compliance with the stringent security and privacy standards set forth by HIPAA. Please reach out to us via email to sign a BAA with us. We continuously monitor and update our security measures to stay ahead of evolving threats and maintain the highest level of protection for our customers' data. Our dedicated security team regularly conducts assessments, penetration testing, and vulnerability scans to identify and address any potential weaknesses in our system. If you have any further questions or require additional information regarding our security practices, please don't hesitate to reach out to [support@reducto.ai](mailto:support@reducto.ai). ### List of authorized subprocessors For a current list of authorized subprocessors, please visit [trust.reducto.ai](https://trust.reducto.ai). # Single sign-on (SSO) FAQ Source: https://docs.reducto.ai/security/sso-saml-faq How to configure SAML or OIDC single sign-on for your Reducto organization. Reducto supports Enterprise SSO, letting your team log in with your identity provider (IdP) credentials. This page covers how setup works and answers common questions from IT and security teams. ## How SSO works at Reducto Users sign in at [accounts.reducto.ai](https://accounts.reducto.ai). Enterprise SSO is configured per organization through a guided setup page. Once SSO is enabled for your organization, members can no longer log in with other methods such as email/password or magic link. To get started, contact your Reducto account team or support. We will send you a shareable setup link that should guide you through the setup process. ## Setup process **1. Request SSO for your organization** Contact your Reducto account team to enable Enterprise SSO for your organization. We can also generate a setup link for the person configuring your IdP, even if they do not have a Reducto account. **2. Follow the guided setup for your IdP** The setup page provides step-by-step guides for Entra ID (Azure AD), Okta, Google, OneLogin, JumpCloud, Duo, Rippling, and Ping Identity, plus a generic guide for any other SAML 2.0 or OIDC provider. It supplies the values to enter into your IdP, including the ACS URL and SP Entity ID. **3. Provide your IdP details** For SAML, you enter your IdP SSO URL, IdP Entity ID, and the token signing certificate. The guide then walks through mapping user attributes and, optionally, roles. See [Mapping roles from your IdP](#mapping-roles-from-your-idp) below. **4. Finish and go live** After clicking Finish & Go Live, the setup page shows your organization's login URL in the form `https://auth.reducto.ai/saml/{org_slug}/login`. ## Mapping roles from your IdP Reducto gives every user it creates through SSO the **Member** role. To assign **Admin** or **Owner** automatically, send the role from your IdP in the SAML response. Reducto reads it in one of two ways: * A `role` attribute whose value is exactly `Owner`, `Admin`, or `Member`. * Group membership: a group named `role_{Role}` or `role_{label}_{Role}`, sent in a `groups` attribute. `role_Owner`, `role_Admin`, and `role_Member` are the simplest form. A user in `role_Owner` gets the Owner role. To tell the group apart from other applications' groups in your IdP, insert one label between the prefix and the role, for example `role_Reducto_Owner`. The label cannot contain underscores. `role_Reducto_Production_Owner` does not match. Values must match the [Reducto role names](/studio-roles-permissions) exactly. If the SAML response does not include a recognized role, the user gets the Member role. > [!NOTE] > > Role mapping is available for SAML connections only. OIDC connections provision every user as a Member; change roles afterwards from the organization settings page. The guided setup page repeats these instructions for your IdP. The provider-specific steps are summarized below. > [tab] Entra ID (Azure AD) > > **Send the role as a claim** > > 1. In your Reducto enterprise application, open **Single sign-on** and edit **Attributes & Claims**. > 2. Add a new claim with **Name** `role`, **Namespace** `http://schemas.xmlsoap.org/ws/2005/05/identity/claims`, **Source** `Attribute`, and **Source attribute** `user.assignedroles`. > > **Define the app roles** > > 1. Go to **App registrations**, select the Reducto app, and open **App roles**. > 2. Click **Create app role** once for each of `Owner`, `Admin`, and `Member`. Set both **Display name** and **Value** to the role name. > 3. Set **Allowed member types** to **Users/Groups**, add a description, leave the role enabled, and save. > > When you assign a user or group to the enterprise application, pick the app role to grant. Assigning a group gives every member of that group the selected Reducto role. > [tab] Okta > > 1. Go to **Directory > Profile Editor** and open the profile for the Reducto SAML app. > 2. Click **Add Attribute**. Set **Display name** and **Variable name** to `role`. Set **Attribute type** to **Group** so the value can be set per group. Alternatively, choose **Personal** and enable **Enum** with `Owner`, `Admin`, and `Member` as both display name and value. > 3. In the Reducto SAML app, open **General > SAML Settings > Attribute Statements** and add an attribute with **Name** `role` and **Value** `appuser.role`. > 4. When assigning a group (or user) to the app, set `role` to `Owner`, `Admin`, or `Member`. > [tab] Google Workspace > > Google sends group membership rather than a role attribute. > > 1. In Google Admin, create groups named `role_Owner`, `role_Admin`, and `role_Member` (or with a suffix such as `role_Reducto_Admin`) and add users to them. > 2. In the Reducto SAML app, open **Attribute mapping** and under **Group membership (optional)** select those groups and set **App attribute** to `groups`. > [tab] Other IdPs > > Any SAML 2.0 IdP works with either method: > > * Add an attribute statement named `role` whose value is `Owner`, `Admin`, or `Member`, populated from a user or group property in your directory. > * Or create groups named `role_Owner`, `role_Admin`, and `role_Member` and include the user's groups in the SAML response as a `groups` attribute. > > The guided setup page lists the exact steps for OneLogin, JumpCloud, Duo, Rippling, and Ping Identity. ## Frequently asked questions ### SAML support > [accordion] Do you support SAML 2.0? > > Yes. Reducto supports SAML 2.0, with both SP-initiated and IdP-initiated logins. OIDC is also supported. > [accordion] Which identity providers are supported? > > Guided setup is available for Entra ID (Azure AD), Okta, Google, OneLogin, JumpCloud, Duo, Rippling, and Ping Identity. Any other IdP that supports SAML 2.0 works through the generic SAML integration. > [accordion] What are the ACS URL and Entity ID? > > Both values are shown in the guided setup page for your organization. Enter them into your IdP when creating the application (in Entra ID, these are the Reply URL and Identifier fields in Basic SAML Configuration). > [accordion] Is the Sign on URL required? (Entra ID) > > No. In Entra ID's Basic SAML Configuration, only the Identifier (Entity ID) and Reply URL (ACS URL) are required. The Sign on URL is only used when starting login from a bookmark or the My Apps portal tile. You may leave it blank, or set it to your organization's login URL: `https://auth.reducto.ai/saml/{org_slug}/login`. > [accordion] Can you provide a Service Provider metadata file? > > Yes. After completing the setup guide and clicking Finish & Go Live, take your organization's login URL (`https://auth.reducto.ai/saml/{org_slug}/login`) and replace `/login` with `/metadata`. Navigating to that URL downloads the SP metadata XML, which can be used for a Relying Party Trust configuration. > [accordion] Do you support automatic token signing certificate rollover? > > No. The IdP signing certificate is provided during setup. When your IdP rotates its token signing certificate, update the SAML connection with the new certificate by revisiting the setup page. We recommend configuring certificate expiry notifications in your IdP. ### Attributes and provisioning > [accordion] Which attributes should the SAML response include? > > The SAML response should include: > > | Attribute | Required | Notes | > | - | - | - | > | Email | Yes | Also used as the NameID / subject | > | First name | Recommended | Mapped in the setup guide | > | Last name | Recommended | Mapped in the setup guide | > | Role | Optional | `role` attribute or `role_{Role}` group, see [Mapping roles from your IdP](#mapping-roles-from-your-idp) | > [accordion] Do you support mapping roles from our IdP? > > Yes, for SAML connections. Send a `role` attribute with the value `Owner`, `Admin`, or `Member`, or put users in groups named `role_Owner`, `role_Admin`, or `role_Member` and include group membership in the SAML response. Users without a recognized role become Members. See [Mapping roles from your IdP](#mapping-roles-from-your-idp) for per-IdP steps. > [accordion] Can we map roles over OIDC? > > Not yet. OIDC connections provision every user as a Member. An Owner or Admin can change roles afterwards from the organization settings page. If role mapping matters for your rollout, configure SAML instead of OIDC. > [accordion] Do you support Just-In-Time (JIT) provisioning? > > Yes. Users are provisioned automatically on their first SSO login. > [accordion] Do you support SCIM provisioning? > > SCIM is available for Okta, Entra ID, OneLogin, JumpCloud, and Ping Identity. Contact your Reducto account team to confirm availability for your organization. ### Login behavior > [accordion] Can members still log in with a password after SSO is enabled? > > No. Once Enterprise SSO is enabled for your organization, members (and users sharing your organization's email domain) can only log in through your IdP. > [accordion] Do you support IdP-initiated login? > > Yes. Both SP-initiated and IdP-initiated logins are supported. > [accordion] Who can configure SSO for our organization? > > Users with a role that includes the Enterprise SSO permission can configure it from the organization settings page. ## Need help? Contact [support@reducto.ai](mailto:support@reducto.ai) or your Reducto account team for SSO setup assistance. # Split Source: https://docs.reducto.ai/split Divide documents into logical sections for targeted processing Split identifies which pages contain which sections of a document. You describe sections in natural language, and Reducto returns the page numbers where each section lives. Use Split to route document segments into different processing pipelines or to target extraction at specific sections. Under the hood, Split runs [Parse](/parse/overview) to understand the document, then uses an LLM to classify pages against your descriptions. For a higher-level view of segmentation use cases and how Split fits into document workflows, see the [Split product overview](https://reducto.ai/split?utm_source=docs\&utm_medium=referral\&utm_campaign=docs_product_bridge\&utm_content=split_overview_top). > [!NOTE] > > **Split is not chunking.** Split returns page numbers telling you where sections live. Chunking (configured via Parse) breaks content into smaller pieces for embeddings or retrieval. They solve different problems: Split identifies locations, chunking divides content. ![Split endpoint workflow](https://cdn.reducto.ai/documentation_images/SplitGraphic.png) *** ## Quick Start ```python Python theme={null} from pathlib import Path from reducto import Reducto client = Reducto() upload = client.upload(file=Path("financial_report.pdf")) result = client.split.run( input=upload.file_id, split_description=[ { "name": "Executive Summary", "description": "High-level overview and key findings at the beginning of the report" }, { "name": "Financial Statements", "description": "Balance sheet, income statement, and cash flow tables" }, { "name": "Risk Factors", "description": "Section discussing business risks and uncertainties" } ] ) for split in result.result.splits: print(f"{split.name}: pages {split.pages}") ``` ```javascript Node.js theme={null} import Reducto from 'reductoai'; import fs from 'fs'; const client = new Reducto(); const upload = await client.upload({ file: fs.createReadStream('financial_report.pdf'), }); const result = await client.split.run({ input: upload.file_id, split_description: [ { name: 'Executive Summary', description: 'High-level overview and key findings at the beginning of the report' }, { name: 'Financial Statements', description: 'Balance sheet, income statement, and cash flow tables' }, { name: 'Risk Factors', description: 'Section discussing business risks and uncertainties' } ] }); for (const split of result.result.splits) { console.log(`${split.name}: pages ${split.pages}`); } ``` ```go Go theme={null} package main import ( "context" "encoding/json" "fmt" "io" "os" reducto "github.com/reductoai/reducto-go-sdk" "github.com/reductoai/reducto-go-sdk/option" "github.com/reductoai/reducto-go-sdk/shared" ) func main() { client := reducto.NewClient(option.WithAPIKey(os.Getenv("REDUCTO_API_KEY"))) file, _ := os.Open("financial_report.pdf") defer file.Close() upload, _ := client.Upload(context.Background(), reducto.UploadParams{ File: reducto.F[io.Reader](file), }) result, _ := client.Split.Run(context.Background(), reducto.SplitRunParams{ DocumentURL: reducto.F[reducto.SplitRunParamsDocumentURLUnion]( shared.UnionString(upload.FileID), ), SplitDescription: reducto.F([]shared.SplitCategoryParam{ { Name: reducto.F("Executive Summary"), Description: reducto.F("High-level overview and key findings"), }, { Name: reducto.F("Financial Statements"), Description: reducto.F("Balance sheet, income statement, and cash flow tables"), }, { Name: reducto.F("Risk Factors"), Description: reducto.F("Section discussing business risks and uncertainties"), }, }), }) // Access results via SectionMapping for name, pages := range result.Result.SectionMapping { fmt.Printf("%s: pages %v\n", name, pages) } // Or print full result as JSON resultJSON, _ := json.MarshalIndent(result, "", " ") fmt.Println(string(resultJSON)) } ``` ```bash cURL theme={null} # First upload the file FILE_ID=$(curl -s -X POST https://platform.reducto.ai/upload \ -H "Authorization: Bearer $REDUCTO_API_KEY" \ -F "file=@financial_report.pdf" | jq -r '.file_id') # Then split curl -X POST https://platform.reducto.ai/split \ -H "Authorization: Bearer $REDUCTO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "document_url": "'$FILE_ID'", "split_description": [ { "name": "Executive Summary", "description": "High-level overview and key findings at the beginning of the report" }, { "name": "Financial Statements", "description": "Balance sheet, income statement, and cash flow tables" }, { "name": "Risk Factors", "description": "Section discussing business risks and uncertainties" } ] }' ``` This request asks Split to find three sections in a financial report. Split returns the page numbers where each section appears, along with a confidence score for each match. ### Sample Response ```json theme={null} { "result": { "splits": [ {"name": "Executive Summary", "pages": [1, 2], "conf": "high", "partitions": null}, {"name": "Financial Statements", "pages": [15, 16, 17, 18], "conf": "high", "partitions": null}, {"name": "Risk Factors", "pages": [8, 9, 10, 11, 12], "conf": "high", "partitions": null} ], "section_mapping": { "Executive Summary": [1, 2], "Financial Statements": [15, 16, 17, 18], "Risk Factors": [8, 9, 10, 11, 12] } }, "usage": {"num_pages": 25, "credits": 50.0} } ``` Pages are 1-indexed, meaning the first page is page 1, not 0. *** ## Two Ways to Split Split handles two fundamentally different scenarios. ### Scenario 1: Different Sections Need Different Treatment Your document contains distinct sections that each need their own extraction schema or processing logic. A financial report has an executive summary (extract key metrics), financial tables (extract line items), and risk disclosures (extract risk categories). These are different types of content requiring different approaches. For this, you define multiple entries in `split_description`, each describing a different section: ```python Python theme={null} result = client.split.run( input=upload.file_id, split_description=[ { "name": "Account Summary", "description": "Overview section with account balances and totals" }, { "name": "Transaction History", "description": "Table listing individual transactions with dates and amounts" }, { "name": "Disclosures", "description": "Legal disclosures and terms at the end of the statement" } ] ) # Route each section to appropriate processing for split in result.result.splits: if split.name == "Transaction History": transactions = client.extract.run( input=f"jobid://{parse_job_id}", instructions={"schema": transaction_schema}, settings={"array_extract": True}, parsing={"settings": {"page_range": {"start": split.pages[0], "end": split.pages[-1]}}} ) ``` ```javascript Node.js theme={null} const result = await client.split.run({ input: upload.file_id, split_description: [ { name: 'Account Summary', description: 'Overview section with account balances and totals' }, { name: 'Transaction History', description: 'Table listing individual transactions with dates and amounts' }, { name: 'Disclosures', description: 'Legal disclosures and terms at the end of the statement' } ] }); // Route each section to appropriate processing for (const split of result.result.splits) { if (split.name === 'Transaction History') { const transactions = await client.extract.run({ input: `jobid://${parseJobId}`, instructions: { schema: transactionSchema }, settings: { array_extract: true }, parsing: { settings: { page_range: { start: split.pages[0], end: split.pages.at(-1) } } } }); } } ``` ```go Go theme={null} result, _ := client.Split.Run(context.Background(), reducto.SplitRunParams{ DocumentURL: reducto.F[reducto.SplitRunParamsDocumentURLUnion]( shared.UnionString(upload.FileID), ), SplitDescription: reducto.F([]shared.SplitCategoryParam{ { Name: reducto.F("Account Summary"), Description: reducto.F("Overview section with account balances and totals"), }, { Name: reducto.F("Transaction History"), Description: reducto.F("Table listing individual transactions with dates and amounts"), }, { Name: reducto.F("Disclosures"), Description: reducto.F("Legal disclosures and terms at the end of the statement"), }, }), }) // Route each section to appropriate processing for name, pages := range result.Result.SectionMapping { if name == "Transaction History" { fmt.Printf("Processing %s on pages %v\n", name, pages) // Extract with appropriate schema for this section } } ``` ```bash cURL theme={null} curl -X POST https://platform.reducto.ai/split \ -H "Authorization: Bearer $REDUCTO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "document_url": "reducto://your-file-id", "split_description": [ {"name": "Account Summary", "description": "Overview section with account balances and totals"}, {"name": "Transaction History", "description": "Table listing individual transactions with dates and amounts"}, {"name": "Disclosures", "description": "Legal disclosures and terms at the end of the statement"} ] }' ``` ### Scenario 2: Repeating Sections with Unknown Count Your document contains the same type of section repeated multiple times, but you don't know in advance how many. A consolidated financial statement might have holdings for 3 accounts or 30. A medical records packet might contain intake forms for 5 patients or 50. This is where `partition_key` becomes essential. Without a partition key, Split returns all pages containing "account holdings" as a single group. You'd then need to figure out where one account ends and the next begins. The partition key tells Split to look for a specific identifier within each section and group the pages by that identifier. ```python Python theme={null} result = client.split.run( input=upload.file_id, split_description=[ { "name": "Account Holdings", "description": "Investment holdings table for a specific account", "partition_key": "account_number" } ] ) ``` ```javascript Node.js theme={null} const result = await client.split.run({ input: upload.file_id, split_description: [ { name: 'Account Holdings', description: 'Investment holdings table for a specific account', partition_key: 'account_number' } ] }); ``` ```go Go theme={null} result, _ := client.Split.Run(context.Background(), reducto.SplitRunParams{ DocumentURL: reducto.F[reducto.SplitRunParamsDocumentURLUnion]( shared.UnionString(upload.FileID), ), SplitDescription: reducto.F([]shared.SplitCategoryParam{ { Name: reducto.F("Account Holdings"), Description: reducto.F("Investment holdings table for a specific account"), PartitionKey: reducto.F("account_number"), }, }), }) ``` ```bash cURL theme={null} curl -X POST https://platform.reducto.ai/split \ -H "Authorization: Bearer $REDUCTO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "document_url": "reducto://your-file-id", "split_description": [ { "name": "Account Holdings", "description": "Investment holdings table for a specific account", "partition_key": "account_number" } ] }' ``` The response now includes a `partitions` array that breaks down the section by the values Split found in the document: ```json theme={null} { "result": { "splits": [ { "name": "Account Holdings", "pages": [1, 2, 3, 7, 8, 9, 10, 11], "conf": "high", "partitions": [ {"name": "1234-5678", "pages": [1, 2, 3], "conf": "high"}, {"name": "8765-4321", "pages": [7, 8, 9, 10, 11], "conf": "high"} ] } ], "section_mapping": { "Account Holdings 1234-5678": [1, 2, 3], "Account Holdings 8765-4321": [7, 8, 9, 10, 11] } } } ``` The `name` in each partition is the actual value Split extracted from the document. If the document shows "Account #1234-5678" on pages 1-3 and "Account #8765-4321" on pages 7-11, those become your partition names. > [!WARNING] > > The partition key describes what to look for semantically, not an exact string to match. If you set `partition_key` to "account number" but the document says "Acct #1234", Split will still find it. *** ## Connecting Split to Parse and Extract Split is rarely used in isolation. The typical workflow is Parse → Split → Extract, where each step builds on the previous one. You can reuse a Parse result across multiple Split and Extract calls by passing the job ID. Since Parse is often the slowest step, this saves significant time and credits. ```python Python theme={null} # Step 1: Parse the document once parse_result = client.parse.run(input=upload.file_id) job_id = parse_result.job_id # Step 2: Split using the job ID (no re-parsing) split_result = client.split.run( input=f"jobid://{job_id}", split_description=[ {"name": "Summary", "description": "Account summary with balances"}, {"name": "Transactions", "description": "Transaction history table"} ] ) # Step 3: Extract from each section with the appropriate schema summary_schema = { "type": "object", "properties": { "account_number": {"type": "string"}, "current_balance": {"type": "number"}, "available_balance": {"type": "number"} } } transaction_schema = { "type": "object", "properties": { "transactions": { "type": "array", "items": { "type": "object", "properties": { "date": {"type": "string"}, "description": {"type": "string"}, "amount": {"type": "number"} } } } } } for split in split_result.result.splits: schema = summary_schema if split.name == "Summary" else transaction_schema extract_result = client.extract.run( input=f"jobid://{job_id}", instructions={"schema": schema}, parsing={"settings": {"page_range": {"start": split.pages[0], "end": split.pages[-1]}}} ) print(f"{split.name}: {extract_result.result}") ``` ```javascript Node.js theme={null} // Step 1: Parse the document once const parseResult = await client.parse.run({ input: upload.file_id }); const jobId = parseResult.job_id; // Step 2: Split using the job ID (no re-parsing) const splitResult = await client.split.run({ input: `jobid://${jobId}`, split_description: [ { name: 'Summary', description: 'Account summary with balances' }, { name: 'Transactions', description: 'Transaction history table' } ] }); // Step 3: Extract from each section with the appropriate schema const summarySchema = { type: 'object', properties: { account_number: { type: 'string' }, current_balance: { type: 'number' }, available_balance: { type: 'number' } } }; const transactionSchema = { type: 'object', properties: { transactions: { type: 'array', items: { type: 'object', properties: { date: { type: 'string' }, description: { type: 'string' }, amount: { type: 'number' } } } } } }; for (const split of splitResult.result.splits) { const schema = split.name === 'Summary' ? summarySchema : transactionSchema; const extractResult = await client.extract.run({ input: `jobid://${jobId}`, instructions: { schema }, parsing: { settings: { page_range: { start: split.pages[0], end: split.pages.at(-1) } } } }); console.log(`${split.name}:`, extractResult.result); } ``` ```go Go theme={null} // Step 1: Parse the document once parseResult, _ := client.Parse.Run(context.Background(), reducto.ParseRunParams{ DocumentURL: reducto.F[reducto.ParseRunParamsDocumentURLUnion]( shared.UnionString(upload.FileID), ), }) jobID := parseResult.JobID // Step 2: Split using the job ID (no re-parsing) splitResult, _ := client.Split.Run(context.Background(), reducto.SplitRunParams{ DocumentURL: reducto.F[reducto.SplitRunParamsDocumentURLUnion]( shared.UnionString("jobid://" + jobID), ), SplitDescription: reducto.F([]shared.SplitCategoryParam{ {Name: reducto.F("Summary"), Description: reducto.F("Account summary with balances")}, {Name: reducto.F("Transactions"), Description: reducto.F("Transaction history table")}, }), }) // Step 3: Extract from each section using SectionMapping for name, pages := range splitResult.Result.SectionMapping { fmt.Printf("%s: processing pages %v\n", name, pages) // Use pages to set page_range for extraction } ``` ```bash cURL theme={null} # Step 1: Parse the document once PARSE_RESPONSE=$(curl -s -X POST https://platform.reducto.ai/parse \ -H "Authorization: Bearer $REDUCTO_API_KEY" \ -H "Content-Type: application/json" \ -d '{"document_url": "reducto://your-file-id"}') JOB_ID=$(echo $PARSE_RESPONSE | jq -r '.job_id') # Step 2: Split using the job ID SPLIT_RESPONSE=$(curl -s -X POST https://platform.reducto.ai/split \ -H "Authorization: Bearer $REDUCTO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "document_url": "jobid://'$JOB_ID'", "split_description": [ {"name": "Summary", "description": "Account summary with balances"}, {"name": "Transactions", "description": "Transaction history table"} ] }') echo $SPLIT_RESPONSE | jq '.result.splits' ``` When you pass `jobid://` as input, the parsing step is skipped entirely. Any `parsing` options you include won't re-parse the document; they only affect how the already-parsed content is filtered (like limiting which pages to consider for extraction). *** ## Request Parameters ### input (required) The document to process. Accepts: | Format | Example | Description | | - | - | - | | Upload response | `upload.file_id` or `"reducto://abc123"` | File uploaded via `/upload` | | Public URL | `"https://example.com/doc.pdf"` | Publicly accessible document | | Presigned URL | `"https://bucket.s3.../doc.pdf?X-Amz-..."` | Cloud storage with temporary credentials | | Job ID | `"jobid://7600c8c5-..."` | Reuse a previous Parse result | ### split\_description (required) An array defining the sections to find. Each entry has: | Field | Required | Description | | - | - | - | | `name` | Yes | Identifier for this section in the response | | `description` | Yes | Natural language description of what the section contains | | `partition_key` | No | Identifier to look for when a section repeats (e.g., "account number", "patient ID") | Write descriptions that match how the content actually appears in the document. If the section has visual characteristics ("blue header", "signature line at bottom"), mention them. ### split\_rules A prompt that controls how Split handles page classification. The default is: ``` "Split the document into the applicable sections. Sections may only overlap at their first and last page if at all." ``` This default means a page can only belong to multiple sections if it's at the boundary between them. Page 5 can belong to both "Section A" and "Section B" only if it's the last page of A and the first page of B. You can customize this behavior for your use case: ```python Python theme={null} # Allow full overlap when content genuinely spans multiple categories result = client.split.run( input=upload.file_id, split_description=[...], split_rules="Pages can belong to multiple sections. A page with both summary information and transaction data should be included in both sections." ) # Force exclusive classification result = client.split.run( input=upload.file_id, split_description=[...], split_rules="Each page must belong to exactly one section. Choose the most relevant section for each page." ) ``` ```javascript Node.js theme={null} // Allow full overlap const result = await client.split.run({ input: upload.file_id, split_description: [...], split_rules: 'Pages can belong to multiple sections. A page with both summary information and transaction data should be included in both sections.' }); // Force exclusive classification const result = await client.split.run({ input: upload.file_id, split_description: [...], split_rules: 'Each page must belong to exactly one section. Choose the most relevant section for each page.' }); ``` ```go Go theme={null} // Allow full overlap result, _ := client.Split.Run(context.Background(), reducto.SplitRunParams{ DocumentURL: reducto.F[reducto.SplitRunParamsDocumentURLUnion]( shared.UnionString(upload.FileID), ), SplitDescription: reducto.F([]shared.SplitCategoryParam{...}), SplitRules: reducto.F("Pages can belong to multiple sections."), }) // Force exclusive classification result, _ := client.Split.Run(context.Background(), reducto.SplitRunParams{ DocumentURL: reducto.F[reducto.SplitRunParamsDocumentURLUnion]( shared.UnionString(upload.FileID), ), SplitDescription: reducto.F([]shared.SplitCategoryParam{...}), SplitRules: reducto.F("Each page must belong to exactly one section."), }) ``` ```bash cURL theme={null} # Allow full overlap curl -X POST https://platform.reducto.ai/split \ -H "Authorization: Bearer $REDUCTO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "document_url": "reducto://your-file-id", "split_description": [...], "split_rules": "Pages can belong to multiple sections." }' ``` The `split_rules` string is passed directly to the LLM as instructions, so write it as you would write instructions for a person doing the classification. ### parsing Configuration for how the document is parsed. These options are inherited from [Parse](/parse/overview) and are ignored if your `input` is a `jobid://` reference (since the document was already parsed). ```python Python theme={null} result = client.split.run( input=upload.file_id, split_description=[...], parsing={ "settings": { "page_range": {"start": 1, "end": 50} # Only analyze first 50 pages } } ) ``` ```javascript Node.js theme={null} const result = await client.split.run({ input: upload.file_id, split_description: [...], parsing: { settings: { page_range: { start: 1, end: 50 } // Only analyze first 50 pages } } }); ``` ```go Go theme={null} result, _ := client.Split.Run(context.Background(), reducto.SplitRunParams{ DocumentURL: reducto.F[reducto.SplitRunParamsDocumentURLUnion]( shared.UnionString(upload.FileID), ), SplitDescription: reducto.F([]shared.SplitCategoryParam{...}), Options: reducto.F(shared.BaseProcessingOptionsParam{ PageRange: reducto.F(shared.PageRangeParam{ Start: reducto.F(int64(1)), End: reducto.F(int64(50)), }), }), }) ``` ```bash cURL theme={null} curl -X POST https://platform.reducto.ai/split \ -H "Authorization: Bearer $REDUCTO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "document_url": "reducto://your-file-id", "split_description": [...], "options": { "page_range": {"start": 1, "end": 50} } }' ``` ### settings | Field | Values | Default | Description | | - | - | - | - | | `table_cutoff` | `"truncate"` or `"preserve"` | `"truncate"` | How to handle table content when classifying pages | When analyzing tables, Split truncates them by default to improve speed. This works fine for most cases, but if your `partition_key` values appear deep within tables (row 50 of a 200-row table), you need the full content: ```python Python theme={null} result = client.split.run( input=upload.file_id, split_description=[ { "name": "Holdings", "description": "Investment holdings table", "partition_key": "account_number" } ], settings={"table_cutoff": "preserve"} ) ``` ```javascript Node.js theme={null} const result = await client.split.run({ input: upload.file_id, split_description: [ { name: 'Holdings', description: 'Investment holdings table', partition_key: 'account_number' } ], settings: { table_cutoff: 'preserve' } }); ``` ```go Go theme={null} result, _ := client.Split.Run(context.Background(), reducto.SplitRunParams{ DocumentURL: reducto.F[reducto.SplitRunParamsDocumentURLUnion]( shared.UnionString(upload.FileID), ), SplitDescription: reducto.F([]shared.SplitCategoryParam{ { Name: reducto.F("Holdings"), Description: reducto.F("Investment holdings table"), PartitionKey: reducto.F("account_number"), }, }), // Note: table_cutoff setting may be in experimental options }) ``` ```bash cURL theme={null} curl -X POST https://platform.reducto.ai/split \ -H "Authorization: Bearer $REDUCTO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "document_url": "reducto://your-file-id", "split_description": [ { "name": "Holdings", "description": "Investment holdings table", "partition_key": "account_number" } ], "settings": {"table_cutoff": "preserve"} }' ``` The tradeoff is latency. Preserving tables means more content for the LLM to process. *** ## Sample Response ```json theme={null} { "result": { "splits": [ { "name": "Section Name", "pages": [1, 2, 3], "conf": "high", "partitions": null } ], "section_mapping": { "Section Name": [1, 2, 3] } }, "usage": { "num_pages": 10, "credits": 20.0 } } ``` | Field | Type | Description | | - | - | - | | `result.splits` | array | Array of found sections, one per entry in your `split_description` | | `result.splits[].name` | string | The name you provided | | `result.splits[].pages` | array | Page numbers where this section appears (1-indexed) | | `result.splits[].conf` | string | Either `"high"` or `"low"` indicating match confidence | | `result.splits[].partitions` | array \| null | When using `partition_key`, sub-sections with their own names, pages, and confidence | | `result.section_mapping` | object | Legacy format mapping section names to page arrays. Use `splits` for new code. | | `usage.num_pages` | number | Total pages in the document | | `usage.credits` | number | Credits consumed (2 per page, plus Parse credits if not using `jobid://`) | | `usage.usage_breakdown` | object | Raw usage quantities. Present only for accounts on per-product pricing, where the credit fields are null. See [Pricing Migration Guide](/reference/pricing-migration#what-your-api-responses-look-like). | A section that isn't found still appears in the response with an empty pages array. Always check that `pages` has content before processing: ```python Python theme={null} for split in result.result.splits: if not split.pages: print(f"Warning: {split.name} not found in document") continue # Process the section ``` ```javascript Node.js theme={null} for (const split of result.result.splits) { if (!split.pages || split.pages.length === 0) { console.log(`Warning: ${split.name} not found in document`); continue; } // Process the section } ``` ```go Go theme={null} for name, pages := range result.Result.SectionMapping { if len(pages) == 0 { fmt.Printf("Warning: %s not found in document\n", name) continue } fmt.Printf("Processing %s on pages %v\n", name, pages) } ``` *** ## Async Processing For large documents or batch processing, use the async pattern to avoid timeouts: ```python Python theme={null} import time submission = client.split.run_job( input=upload.file_id, split_description=[...] ) while True: job = client.job.get(submission.job_id) if job.status == "Completed": break if job.status == "Failed": raise Exception(f"Split failed: {job.reason}") time.sleep(2) for split in job.result.splits: print(f"{split.name}: {split.pages}") ``` ```javascript Node.js theme={null} const submission = await client.split.runJob({ input: upload.file_id, split_description: [...] }); let job; while (true) { job = await client.job.retrieve(submission.job_id); if (job.status === 'Completed') break; if (job.status === 'Failed') throw new Error(`Split failed: ${job.reason}`); await new Promise(resolve => setTimeout(resolve, 2000)); } for (const split of job.result.splits) { console.log(`${split.name}: ${split.pages}`); } ``` ```go Go theme={null} import "time" submission, _ := client.Split.RunJob(context.Background(), reducto.SplitRunJobParams{ DocumentURL: reducto.F[reducto.SplitRunJobParamsDocumentURLUnion]( shared.UnionString(upload.FileID), ), SplitDescription: reducto.F([]shared.SplitCategoryParam{...}), }) for { job, _ := client.Job.Get(context.Background(), submission.JobID) if job.Status == "Completed" { // Access results via SectionMapping resultJSON, _ := json.MarshalIndent(job, "", " ") fmt.Println(string(resultJSON)) break } if job.Status == "Failed" { fmt.Println("Job failed:", job.Reason) break } time.Sleep(2 * time.Second) } ``` ```bash cURL theme={null} # Submit async job JOB_ID=$(curl -s -X POST https://platform.reducto.ai/split_async \ -H "Authorization: Bearer $REDUCTO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "document_url": "reducto://your-file-id", "split_description": [...] }' | jq -r '.job_id') # Poll for completion while true; do STATUS=$(curl -s "https://platform.reducto.ai/job/$JOB_ID" \ -H "Authorization: Bearer $REDUCTO_API_KEY" | jq -r '.status') if [ "$STATUS" = "Completed" ]; then break; fi if [ "$STATUS" = "Failed" ]; then echo "Job failed"; exit 1; fi sleep 2 done # Get results curl -s "https://platform.reducto.ai/job/$JOB_ID" \ -H "Authorization: Bearer $REDUCTO_API_KEY" | jq '.result' ``` Documents over 100 pages should use async to avoid HTTP timeouts. The `.run_job()` method accepts the same parameters as `.run()`. *** ## Troubleshooting > [accordion] I don't know what sections my document has > > If you're unsure of the document structure, start with broad, generic descriptions: > > ```python Python theme={null} > result = client.split.run( > input=upload.file_id, > split_description=[ > {"name": "Introduction", "description": "Opening sections, executive summary, or overview"}, > {"name": "Main Content", "description": "Core content, analysis, or detailed information"}, > {"name": "Tables/Data", "description": "Tables, figures, numerical data, or structured information"}, > {"name": "Appendix", "description": "Supporting materials, references, or supplementary content"} > ] > ) > ``` > > ```javascript Node.js theme={null} > const result = await client.split.run({ > input: upload.file_id, > split_description: [ > { name: 'Introduction', description: 'Opening sections, executive summary, or overview' }, > { name: 'Main Content', description: 'Core content, analysis, or detailed information' }, > { name: 'Tables/Data', description: 'Tables, figures, numerical data, or structured information' }, > { name: 'Appendix', description: 'Supporting materials, references, or supplementary content' } > ] > }); > ``` > > ```go Go theme={null} > result, _ := client.Split.Run(context.Background(), reducto.SplitRunParams{ > DocumentURL: reducto.F[reducto.SplitRunParamsDocumentURLUnion]( > shared.UnionString(upload.FileID), > ), > SplitDescription: reducto.F([]shared.SplitCategoryParam{ > {Name: reducto.F("Introduction"), Description: reducto.F("Opening sections, executive summary, or overview")}, > {Name: reducto.F("Main Content"), Description: reducto.F("Core content, analysis, or detailed information")}, > {Name: reducto.F("Tables/Data"), Description: reducto.F("Tables, figures, numerical data, or structured information")}, > {Name: reducto.F("Appendix"), Description: reducto.F("Supporting materials, references, or supplementary content")}, > }), > }) > > for name, pages := range result.Result.SectionMapping { > fmt.Printf("%s: pages %v\n", name, pages) > } > ``` > > ```bash cURL theme={null} > curl -X POST https://platform.reducto.ai/split \ > -H "Authorization: Bearer $REDUCTO_API_KEY" \ > -H "Content-Type: application/json" \ > -d '{ > "document_url": "reducto://your-file-id", > "split_description": [ > {"name": "Introduction", "description": "Opening sections, executive summary, or overview"}, > {"name": "Main Content", "description": "Core content, analysis, or detailed information"}, > {"name": "Tables/Data", "description": "Tables, figures, numerical data, or structured information"}, > {"name": "Appendix", "description": "Supporting materials, references, or supplementary content"} > ] > }' > ``` > > Alternatively, Parse the document first and inspect the content to understand its structure, then create targeted split descriptions. > [accordion] Section not found (empty pages array) > > When a section returns with no pages: > > 1. **Check your description.** Is it specific enough? "Transaction table" might not match if the document calls it "Activity History". Include terms that appear in the actual document. > > 2. **Verify the section exists.** Run Parse first and inspect the content. If the section isn't visible to Parse, Split won't find it either. > > 3. **Broaden your description.** Start general ("any table with dates and amounts") and narrow down once you confirm Split can find it. > [accordion] Partitions not being detected > > When `partitions` is null despite setting `partition_key`: > > 1. **Check table\_cutoff.** If the partition key appears inside tables, set `settings.table_cutoff` to `"preserve"`. The default truncation might be hiding the values. > > 2. **Verify the key exists.** The partition key value must actually appear in the document. If you're looking for "account number" but the document uses "portfolio ID", adjust your partition key. > > 3. **Check for consistent structure.** Partition detection works best when repeating sections have similar layouts. Inconsistent formatting can confuse the classifier. > [accordion] Wrong pages returned > > When Split returns pages that don't contain the expected content: > > 1. **Make descriptions more specific.** If multiple sections have similar content, add distinguishing details: "the transaction table in the Account Activity section" rather than just "transaction table". > > 2. **Check confidence scores.** Low confidence suggests the match was ambiguous. The LLM made its best guess but wasn't certain. > > 3. **Adjust split\_rules.** The default overlap rules might be affecting page assignment. If a page legitimately belongs to multiple sections, customize `split_rules` to allow it. > [accordion] Request timeout on large documents > > Split can timeout on documents over 100 pages: > > 1. **Use async processing.** Replace `.run()` with `.run_job()` and poll for results. > > 2. **Parse first, then split.** If you're not already using `jobid://`, parse the document separately and pass the job ID. This isolates the slow parsing step. > > 3. **Limit page range.** If you know the sections you need are in a specific range, set `parsing.settings.page_range` to process only those pages. *** ## Next Steps > [card] [Parse](/parse/overview) > > Understand what Split is analyzing under the hood. > [card] [Extract](/extract/overview) > > Pull structured data from the sections Split identifies. > [card] [Page Ranges](/configs/parse/page-ranges) > > Control which pages get processed at each step. > [card] [Async Processing](/workflows/async-overview) > > Handle large documents without timeouts. > [card] [Document workflow automation](https://reducto.ai/guides/document-workflow-automation) > > Where splitting fits alongside classification, extraction, and validation. > [card] [Intelligent document processing](https://reducto.ai/guides/intelligent-document-processing) > > How segmentation fits an end to end document pipeline. # Studio Quickstart Source: https://docs.reducto.ai/studio-quickstart Build and deploy your first document workflow in Reducto Studio. [Studio](https://studio.reducto.ai/) is Reducto's visual interface. Build document workflows by configuring classification, parsing, extraction, and editing steps, test on real documents with the citation viewer, then deploy as a Pipeline ID callable from code. Studio also manages API keys, account settings, and team access. > [!NOTE] > > If you just want an API Key, visit your [account's API Keys page](https://studio.reducto.ai/api-keys) to create and manage your keys. Follow our [API quickstart here](https://docs.reducto.ai/quickstart). > [!NOTE] > > [Classify](/classify/overview) is available via the [API](/api-reference/classify) but is not yet integrated into Studio. You can try it interactively at [classify.reducto.ai](https://classify.reducto.ai). This guide walks you through your first structured extraction from a document of your choice. *** ## 1. Visit Studio Sign-up or log-in at [studio.reducto.ai](https://studio.reducto.ai/). If you're the first to sign-up from your organization, you'll be asked to create and set a name for the organization. You should then land on the Studio home page. New organizations see two starting points: **Build with Reducto**, which creates an API key for code or agent workflows, and **Start with a document**, which uploads a file or picks a sample. You can also click **Create pipeline** to configure one yourself. ![Studio Empty State](https://mintcdn.com/reducto/R99njFX82lRP6p3f/images/studio_empty_state.png?fit=max\&auto=format\&n=R99njFX82lRP6p3f\&q=85\&s=859751177559784be76cb5132ddb58ce) ## 2. Create an Extract Pipeline The goal here is to upload a document and see the structured output Reducto produces. Extract reads your document, and extracts the data you want in a schema shape you define. **1. Create a new pipeline** Click **Create pipeline**, and select **Extract**. ![Create Pipeline](https://mintcdn.com/reducto/R99njFX82lRP6p3f/images/create_pipeline.png?fit=max\&auto=format\&n=R99njFX82lRP6p3f\&q=85\&s=75beb5f5b346f5c4eb8c9de36c37da67) **2. Upload a document** Drag and drop a file or upload directly. ![Upload File](https://mintcdn.com/reducto/6gaOh-RCUb9JOUR4/images/upload_file.png?fit=max\&auto=format\&n=6gaOh-RCUb9JOUR4\&q=85\&s=7676e4c782690eaff636b2f63b1004f8) **3. Generate a JSON schema** Hit **Generate** and then choose between **Fast** and **Enhanced** mode. You can use natural language to describe what type of data you want to extract from your document. Reducto will automatically create a well formed schema for you. Alternatively, you can manually configure your schema by building one in the schema builder directly. ![Generate Schema](https://mintcdn.com/reducto/6gaOh-RCUb9JOUR4/images/generate_schema.png?fit=max\&auto=format\&n=6gaOh-RCUb9JOUR4\&q=85\&s=c7d2880f2b2eedffbc9bb9e98d61c406) **4. Run the pipeline** Hit **Run**. Once the pipeline is done, the **Results** tab will contain the Extract results. ![Extract Results](https://mintcdn.com/reducto/6gaOh-RCUb9JOUR4/images/extract_results.png?fit=max\&auto=format\&n=6gaOh-RCUb9JOUR4\&q=85\&s=30693f803b03a1e9fc153ddefc17d7f3) Review results and compare them to your original document. If you enable [citations](https://docs.reducto.ai/configs/extract/citations), bounding boxes will appear on the original document, linking them to where the data was extracted from. While most customers find value from our default configurations, you may need to adjust yours depending on your documents, schema, and goals. See details [here](https://docs.reducto.ai/configs/overview) on our endpoint configurations. ## 3. Create an API Key Once you're ready to move into production, go back to the homepage and hit **API Keys**. Here you'll be able to create, manage, and edit your account's API keys. ![Api Keys](https://mintcdn.com/reducto/R99njFX82lRP6p3f/images/api_keys.png?fit=max\&auto=format\&n=R99njFX82lRP6p3f\&q=85\&s=86512aebdf3d355d2cb1a2f7c63c3cdd) Once you have an API key, deploying your new pipeline into production is easy. In any pipeline, hit **Deploy** to get a small code snippet, easily copyable into your workflow. *Congrats, you've just created your first pipeline! 🎉* *** ## Next steps > [card] [API Quickstart](/quickstart) > > Learn how to use Reducto's SDKs to call your pipelines in production. > [card] [Extract Pipeline](/studio-extract) > > Define JSON schemas to pull structured data from documents, with automatic field detection and confidence scoring. > [card] [Reducto CLI Quickstart](/cli) > > Learn how to use Reducto's CLI to easily let agents use our endpoints. > [card] [Deploy to Production](/studio-deploy-pipeline) > > Learn how to Deploy your pipeline with a Pipeline ID. # Uploading Large Files Source: https://docs.reducto.ai/upload/large-files Upload files up to 5GB using presigned URLs For files larger than 100MB, use the presigned URL method. This uploads directly to cloud storage, bypassing the 100MB limit of the standard [Upload endpoint](/upload). | Method | Max Size | When to Use | | - | - | - | | [Direct upload](/upload) | 100MB | Most files | | Presigned URL (this page) | 5GB | Large PDFs, high-res scans, large spreadsheets | *** ## How It Works ```mermaid theme={null} sequenceDiagram participant You participant Reducto API participant Cloud Storage You->>Reducto API: 1. Request presigned URL Reducto API-->>You: file_id + presigned_url You->>Cloud Storage: 2. Upload file to presigned URL Cloud Storage-->>You: 200 OK You->>Reducto API: 3. Use file_id with Parse/Split/Extract ``` 1. **Request a presigned URL** from Reducto (no file attached) 2. **Upload your file** directly to cloud storage using the presigned URL 3. **Use the file\_id** with Parse, Split, or Extract endpoints *** ## Step 1: Request a Presigned URL Call the upload endpoint *without* attaching a file: ```python Python theme={null} import os import requests response = requests.post( "https://platform.reducto.ai/upload", headers={"Authorization": f"Bearer {os.environ.get('REDUCTO_API_KEY')}"} ) data = response.json() file_id = data["file_id"] presigned_url = data["presigned_url"] print(f"File ID: {file_id}") print(f"Presigned URL: {presigned_url[:80]}...") ``` ```javascript Node.js theme={null} const response = await fetch('https://platform.reducto.ai/upload', { method: 'POST', headers: { 'Authorization': `Bearer ${process.env.REDUCTO_API_KEY}`, }, }); const data = await response.json(); const fileId = data.file_id; const presignedUrl = data.presigned_url; console.log(`File ID: ${fileId}`); console.log(`Presigned URL: ${presignedUrl.slice(0, 80)}...`); ``` ```go Go theme={null} import ( "encoding/json" "net/http" "os" ) req, _ := http.NewRequest("POST", "https://platform.reducto.ai/upload", nil) req.Header.Set("Authorization", "Bearer "+os.Getenv("REDUCTO_API_KEY")) resp, _ := http.DefaultClient.Do(req) defer resp.Body.Close() var data struct { FileID string `json:"file_id"` PresignedURL string `json:"presigned_url"` } json.NewDecoder(resp.Body).Decode(&data) fmt.Printf("File ID: %s\n", data.FileID) ``` ```bash cURL theme={null} curl -X POST https://platform.reducto.ai/upload \ -H "Authorization: Bearer $REDUCTO_API_KEY" ``` **Response:** ```json theme={null} { "file_id": "reducto://50c07046-3bac-4844-8c4b-d1428ed9c8f4", "presigned_url": "https://prod-storage.s3.amazonaws.com/50c07046-3bac-4844-8c4b-d1428ed9c8f4?X-Amz-Algorithm=AWS4-HMAC-SHA256&..." } ``` > [!WARNING] > > **Save the `file_id` now.** You'll need it in Step 3. The presigned URL is only for uploading — you can't use it to process the document. *** ## Step 2: Upload to Presigned URL Upload your file using a PUT request to the presigned URL: ```python Python theme={null} import requests with open("large_document.pdf", "rb") as f: response = requests.put(presigned_url, data=f) if response.status_code == 200: print("Upload successful!") ``` ```javascript Node.js theme={null} import fs from 'fs'; const fileBuffer = fs.readFileSync('large_document.pdf'); const response = await fetch(presignedUrl, { method: 'PUT', body: fileBuffer, }); if (response.ok) { console.log('Upload successful!'); } ``` ```go Go theme={null} import ( "bytes" "io/ioutil" ) fileBytes, _ := ioutil.ReadFile("large_document.pdf") req, _ := http.NewRequest("PUT", presignedUrl, bytes.NewReader(fileBytes)) resp, _ := http.DefaultClient.Do(req) if resp.StatusCode == 200 { fmt.Println("Upload successful!") } ``` ```bash cURL theme={null} curl -X PUT "$PRESIGNED_URL" -T large_document.pdf ``` > [!TIP] > > **No Content-Type header needed.** When uploading to presigned URLs, you don't need to set a Content-Type header — the file will be accepted as-is. *** ## Step 3: Process with Parse, Split, or Extract Use the `file_id` from Step 1 (not the presigned URL) with any Reducto endpoint: ```python Python theme={null} from reducto import Reducto client = Reducto() # Use the file_id from Step 1 result = client.parse.run(input=file_id) print(f"Processed {result.usage.num_pages} pages") ``` ```javascript Node.js theme={null} import Reducto from 'reductoai'; const client = new Reducto(); // Use the fileId from Step 1 const result = await client.parse.run({ input: fileId }); console.log(`Processed ${result.usage.num_pages} pages`); ``` ```go Go theme={null} import ( reducto "github.com/reductoai/reducto-go-sdk" "github.com/reductoai/reducto-go-sdk/option" "github.com/reductoai/reducto-go-sdk/shared" ) client := reducto.NewClient(option.WithAPIKey(os.Getenv("REDUCTO_API_KEY"))) // Use the FileID from Step 1 result, _ := client.Parse.Run(context.Background(), reducto.ParseRunParams{ ParseConfig: reducto.ParseConfigParam{ DocumentURL: reducto.F[reducto.ParseConfigDocumentURLUnionParam]( shared.UnionString(data.FileID), // file_id from Step 1 ), }, }) fmt.Printf("Processed %d pages\n", result.Usage.NumPages) ``` ```bash cURL theme={null} # Use the FILE_ID from Step 1 curl -X POST https://platform.reducto.ai/parse \ -H "Authorization: Bearer $REDUCTO_API_KEY" \ -H "Content-Type: application/json" \ -d "{\"input\": \"$FILE_ID\"}" ``` *** ## Complete Example Here's the full workflow in one script: ```python Python theme={null} import os import requests from reducto import Reducto # Step 1: Get presigned URL response = requests.post( "https://platform.reducto.ai/upload", headers={"Authorization": f"Bearer {os.environ.get('REDUCTO_API_KEY')}"} ) data = response.json() file_id = data["file_id"] presigned_url = data["presigned_url"] # Step 2: Upload to presigned URL with open("large_document.pdf", "rb") as f: requests.put(presigned_url, data=f) # Step 3: Process with Reducto client = Reducto() result = client.parse.run(input=file_id) print(f"Successfully processed {result.usage.num_pages} pages") for chunk in result.result.chunks: print(chunk.content[:200]) ``` ```javascript Node.js theme={null} import Reducto from 'reductoai'; import fs from 'fs'; // Step 1: Get presigned URL const uploadResponse = await fetch('https://platform.reducto.ai/upload', { method: 'POST', headers: { 'Authorization': `Bearer ${process.env.REDUCTO_API_KEY}` }, }); const { file_id: fileId, presigned_url: presignedUrl } = await uploadResponse.json(); // Step 2: Upload to presigned URL await fetch(presignedUrl, { method: 'PUT', body: fs.readFileSync('large_document.pdf'), }); // Step 3: Process with Reducto const client = new Reducto(); const result = await client.parse.run({ input: fileId }); console.log(`Successfully processed ${result.usage.num_pages} pages`); ``` ```bash cURL theme={null} #!/bin/bash # Step 1: Get presigned URL UPLOAD_RESPONSE=$(curl -s -X POST https://platform.reducto.ai/upload \ -H "Authorization: Bearer $REDUCTO_API_KEY") FILE_ID=$(echo $UPLOAD_RESPONSE | jq -r '.file_id') PRESIGNED_URL=$(echo $UPLOAD_RESPONSE | jq -r '.presigned_url') # Step 2: Upload to presigned URL curl -X PUT "$PRESIGNED_URL" -T large_document.pdf # Step 3: Process with Reducto curl -X POST https://platform.reducto.ai/parse \ -H "Authorization: Bearer $REDUCTO_API_KEY" \ -H "Content-Type: application/json" \ -d "{\"input\": \"$FILE_ID\"}" ``` *** ## Troubleshooting > [accordion] 403 Forbidden on presigned URL > > **Cause:** The presigned URL has expired. > > **Fix:** Presigned URLs expire after a short time (typically 1 hour). Request a new presigned URL and try again. > [accordion] Upload succeeds but Parse fails > > **Cause:** You might be passing the `presigned_url` instead of the `file_id`. > > **Fix:** Always use the `file_id` (starts with `reducto://`) with Parse, Split, or Extract — not the presigned URL. > [accordion] Timeout during upload > > **Cause:** Large files on slow connections can timeout. > > **Fix:** > > * Use a wired connection if possible > * Consider chunked/multipart upload for files >1GB > * Implement retry logic with exponential backoff > [accordion] Unexpected upload errors > > **Cause:** Using incompatible upload methods or headers. > > **Fix:** > > * Don't include a Content-Type header — presigned URLs don't require it > * For cURL, use `-T filename` instead of `--data-binary @filename` > * In Go, use `bytes.NewReader()` to ensure proper Content-Length handling *** ## Related > [card] [Direct Upload](/upload) > > For files under 100MB — simpler, one-step upload. > [card] [Parse](/parse) > > Extract text, tables, and figures from uploaded documents. > [card] [Batch Processing](/workflows/batch-processing) > > Process many large files in parallel. > [card] [Async Processing](/workflows/async-overview) > > Use webhooks for long-running jobs. # Upload Source: https://docs.reducto.ai/upload/overview Upload documents to Reducto for processing The Upload endpoint transfers documents to Reducto, returning a unique `reducto://` identifier you can pass to any downstream endpoint: Classify, Parse, Split, Extract, or Edit. > [!NOTE] > > **Files over 100MB?** Use the [presigned URL method](/upload/large-files) which supports files up to 5GB. *** ## When to Use Upload | Your situation | What to do | | - | - | | Local file under 100MB | Use Upload (this page) | | Local file over 100MB | Use [Presigned URL Upload](/upload/large-files) | | File already hosted at a URL | [Skip upload entirely](#url-passthrough) — pass the URL directly | | Presigned S3/GCS/Azure URL | [Skip upload entirely](#url-passthrough) — pass the URL directly | ### Supported File Types | Category | Formats | | - | - | | **PDF** | PDF (Portable Document Format) | | **Documents** | DOCX (Word Open XML), DOC (Word Binary), DOTX (Word Template), RTF (Rich Text Format), TXT (Plain Text), WPD (WordPerfect) | | **Spreadsheets** | XLSX (Excel Open XML), XLSM (Excel Macro-Enabled), XLS (Excel Binary), XLTX (Excel Template), XLTM (Excel Macro-Enabled Template), CSV (Comma-Separated Values), QPW (Quattro Pro) | | **Presentations** | PPTX (PowerPoint Open XML), PPT (PowerPoint Binary) | | **Images** | PNG (Portable Network Graphics), JPEG/JPG (Joint Photographic Experts Group), GIF (Graphics Interchange Format), BMP (Bitmap), TIFF (Tagged Image File Format), HEIC (High Efficiency Image Codec), PSD (Adobe Photoshop), PCX (PC Paintbrush), PPM (Portable Pixmap), APNG (Animated PNG), CUR (Windows Cursor), DCX (Multi-page PCX), FTEX (3D Textures), PIXAR (Pixar Image) | > [!TIP] > > **Multi-page images:** TIFF files with multiple pages are processed as multi-page documents. > [!NOTE] > > **Image dimension limits:** The dimension limit for images is 50 megapixels total (e.g., \~8200×6100), with a per-axis maximum of 15,000 pixels. *** ## Quick Start ```python Python theme={null} from pathlib import Path from reducto import Reducto client = Reducto() # Reads REDUCTO_API_KEY from environment upload = client.upload(file=Path("document.pdf")) print(upload.file_id) # Output: reducto://a8f8ead1-e360-4ec6-9ccd-b3277421b9ef.pdf # Now use it with Parse, Split, or Extract result = client.parse.run(input=upload.file_id) ``` ```javascript Node.js theme={null} import Reducto from 'reductoai'; import fs from 'fs'; const client = new Reducto(); // Reads REDUCTO_API_KEY from environment const upload = await client.upload({ file: fs.createReadStream('document.pdf'), }); console.log(upload.file_id); // Output: reducto://a8f8ead1-e360-4ec6-9ccd-b3277421b9ef.pdf // Now use it with Parse, Split, or Extract const result = await client.parse.run({ input: upload.file_id }); ``` ```go Go theme={null} package main import ( "context" "fmt" "io" "os" reducto "github.com/reductoai/reducto-go-sdk" "github.com/reductoai/reducto-go-sdk/option" ) func main() { client := reducto.NewClient(option.WithAPIKey(os.Getenv("REDUCTO_API_KEY"))) file, _ := os.Open("document.pdf") defer file.Close() upload, _ := client.Upload(context.Background(), reducto.UploadParams{ File: reducto.F[io.Reader](file), }) fmt.Println(upload.FileID) // Output: reducto://a8f8ead1-e360-4ec6-9ccd-b3277421b9ef.pdf } ``` ```bash cURL theme={null} curl -X POST https://platform.reducto.ai/upload \ -H "Authorization: Bearer $REDUCTO_API_KEY" \ -F "file=@document.pdf" # Response: {"file_id": "reducto://a8f8ead1-e360-4ec6-9ccd-b3277421b9ef.pdf"} ``` *** ## URL Passthrough If your document is already accessible via URL, skip the upload step entirely: ```python Python theme={null} # No upload needed — pass URL directly result = client.parse.run(input="https://example.com/document.pdf") ``` ```javascript Node.js theme={null} // No upload needed — pass URL directly const result = await client.parse.run({ input: 'https://example.com/document.pdf', }); ``` ```go Go theme={null} // No upload needed — pass URL directly result, _ := client.Parse.Run(context.Background(), reducto.ParseRunParams{ ParseConfig: reducto.ParseConfigParam{ DocumentURL: reducto.F[reducto.ParseConfigDocumentURLUnionParam]( shared.UnionString("https://example.com/document.pdf"), ), }, }) ``` ```bash cURL theme={null} curl -X POST https://platform.reducto.ai/parse \ -H "Authorization: Bearer $REDUCTO_API_KEY" \ -H "Content-Type: application/json" \ -d '{"input": "https://example.com/document.pdf"}' ``` **This works with:** * Public URLs (https\://...) * Presigned S3, GCS, or Azure Blob URLs * Any URL that returns the file directly when accessed *** ## Response Format ```json theme={null} { "file_id": "reducto://a8f8ead1-e360-4ec6-9ccd-b3277421b9ef.pdf" } ``` | Field | Description | | - | - | | `file_id` | Unique identifier in `reducto://` format. Pass this to Parse, Split, or Extract. | > [!WARNING] > > **Files expire after 24 hours.** The `reducto://` URI becomes invalid after expiration. Re-upload if you need to process the file again. **Reuse the file\_id:** You can process the same document multiple times with different configurations without re-uploading — just use the same `file_id`. *** ## Common Questions > [accordion] What's the maximum upload file size? > > | Method | Max Size | > | - | - | > | Direct upload (this page) | 100MB | > | [Presigned URL upload](/upload/large-files) | 5GB | > > For files over 100MB, see [Uploading Large Files](/upload/large-files). > [accordion] How long are uploaded files stored? > > Files expire **24 hours** after upload. The `reducto://` URI becomes invalid after expiration. > > Need to process the same file again after 24 hours? Re-upload it. > [accordion] Can I upload multiple files at once? > > The Upload endpoint accepts one file per request. For batch uploads, make parallel requests: > > ```python theme={null} > from concurrent.futures import ThreadPoolExecutor > > files = ["doc1.pdf", "doc2.pdf", "doc3.pdf"] > > with ThreadPoolExecutor(max_workers=10) as executor: > uploads = list(executor.map( > lambda f: client.upload(file=Path(f)), > files > )) > ``` > > See our [batch processing guide](/workflows/batch-processing) for production patterns. > [accordion] Can I delete an uploaded file? > > Yes. Use `DELETE /upload/{file_id}` to remove an uploaded file immediately. Files are also automatically deleted within 24 hours under Reducto's [ZDR policy](/security/policies) for Growth tier and above. See [Deleting Jobs & Uploads](/workflows/deletion) for details. *** ## Troubleshooting > [accordion] Unsupported file format > > **Error:** `Unsupported file format` > > **Fix:** Check that your file extension matches the [supported file types](#supported-file-types). When uploading programmatically, ensure the filename includes the extension. > [accordion] File size exceeds maximum > > **Error:** `File size exceeds maximum allowed` > > **Fix:** Direct upload is limited to 100MB. For larger files, use the [presigned URL method](/upload/large-files) which supports up to 5GB. > [accordion] Request timeout > > **Error:** `Request timeout` > > **Fix:** > > * For files approaching 100MB, consider using [presigned URL upload](/upload/large-files) > * Check your network connection > * Implement retry logic with exponential backoff *** ## Next Steps > [card] [Parse](/parse) > > Convert documents into text, tables, and figures. > [card] [Extract](/extract) > > Pull specific fields into structured JSON using a schema. > [card] [Split](/split) > > Divide documents into sections for targeted processing. > [card] [Large Files](/upload/large-files) > > Upload files over 100MB using presigned URLs. # Async Processing Source: https://docs.reducto.ai/workflows/async-overview Process documents asynchronously with job queues, polling, and webhooks Reducto offers two processing modes: synchronous and asynchronous. The SDK provides `run()` and `run_job()` methods that map to different API endpoints: | SDK Method | API Endpoint | Returns | | - | - | - | | `client.parse.run()` | `POST /parse` | Full result (blocks until complete) | | `client.parse.run_job()` | `POST /parse_async` | Job ID (returns immediately) | The same pattern applies to all endpoints: `/extract` vs `/extract_async`, `/split` vs `/split_async`, and `/pipeline` vs `/pipeline_async`. > [!NOTE] > > The Go SDK is currently in alpha and has limited async support. Go users should use the REST API directly for async operations. See the cURL examples below. ## run() vs run\_job() | Method | Behavior | Best for | | - | - | - | | `run()` | Calls sync endpoint, blocks until complete | Interactive applications, smaller documents | | `run_job()` | Calls async endpoint, returns job ID | Large documents, high volume, background processing | Both methods produce the same results. The difference is whether you wait synchronously or retrieve results later. ### Synchronous: run() ```python Python theme={null} from reducto import Reducto client = Reducto() # Blocks until parsing completes (may take seconds to minutes) result = client.parse.run(input="https://example.com/document.pdf") print(result.result.chunks) ``` ```typescript TypeScript theme={null} import Reducto from "reductoai"; const client = new Reducto(); // Blocks until parsing completes const result = await client.parse.run({ input: "https://example.com/document.pdf" }); console.log(result.result.chunks); ``` ```go Go theme={null} package main import ( "context" "fmt" "os" reducto "github.com/reductoai/reducto-go-sdk" "github.com/reductoai/reducto-go-sdk/option" "github.com/reductoai/reducto-go-sdk/shared" ) func main() { client := reducto.NewClient(option.WithAPIKey(os.Getenv("REDUCTO_API_KEY"))) // Blocks until parsing completes result, err := client.Parse.Run(context.Background(), reducto.ParseRunParams{ ParseConfig: reducto.ParseConfigParam{ DocumentURL: reducto.F[reducto.ParseConfigDocumentURLUnionParam]( shared.UnionString("https://example.com/document.pdf"), ), }, }) if err != nil { panic(err) } fmt.Println(result.Result.Chunks) } ``` ```bash cURL theme={null} curl -X POST "https://platform.reducto.ai/parse" \ -H "Authorization: Bearer $REDUCTO_API_KEY" \ -H "Content-Type: application/json" \ -d '{"input": "https://example.com/document.pdf"}' ``` The `run()` method handles the job lifecycle internally. If the document takes too long, the request may time out. For documents over 50 pages or complex processing, consider using `run_job()` instead. ### Asynchronous: run\_job() ```python Python theme={null} from reducto import Reducto client = Reducto() # Returns immediately with job ID submission = client.parse.run_job(input="https://example.com/document.pdf") print(f"Job submitted: {submission.job_id}") # Retrieve results later via polling or webhook ``` ```typescript TypeScript theme={null} import Reducto from "reductoai"; const client = new Reducto(); // Returns immediately with job ID const submission = await client.parse.runJob({ input: "https://example.com/document.pdf" }); console.log(`Job submitted: ${submission.job_id}`); // Retrieve results later via polling or webhook ``` ```bash cURL theme={null} # Call the async endpoint directly curl -X POST "https://platform.reducto.ai/parse_async" \ -H "Authorization: Bearer $REDUCTO_API_KEY" \ -H "Content-Type: application/json" \ -d '{"input": "https://example.com/document.pdf"}' # Response: {"job_id": "abc123-def456"} ``` The `run_job()` method has no limit on concurrent submissions. You can queue thousands of documents and process them in parallel without managing connections or timeouts. ## Job lifecycle When you submit a job via the async endpoint, it moves through these states: | Status | Meaning | | - | - | | `Pending` | Job is queued, waiting for a worker | | `InProgress` | A worker is actively processing the document | | `Completing` | Processing finished, results being saved | | `Completed` | Results are ready to retrieve | | `Failed` | Processing failed (check error message) | Jobs typically spend most of their time in `Pending` (waiting for capacity) or `InProgress` (actual processing). ## Polling for results The simplest way to get results from an async job is to poll the job status: ```python Python theme={null} import time from reducto import Reducto client = Reducto() # Submit job submission = client.parse.run_job(input="https://example.com/document.pdf") # Poll until complete while True: job = client.job.get(submission.job_id) if job.status == "Completed": print("Success:", job.result) break elif job.status == "Failed": print("Failed:", job.error) break time.sleep(2) ``` ```typescript TypeScript theme={null} import Reducto from "reductoai"; const client = new Reducto(); // Submit job const submission = await client.parse.runJob({ input: "https://example.com/document.pdf" }); // Poll until complete while (true) { const job = await client.job.retrieve(submission.job_id); if (job.status === "Completed") { console.log("Success:", job.result); break; } else if (job.status === "Failed") { console.log("Failed:", job.error); break; } await new Promise(resolve => setTimeout(resolve, 2000)); } ``` ```bash cURL theme={null} # Submit job JOB_ID=$(curl -s -X POST "https://platform.reducto.ai/parse_async" \ -H "Authorization: Bearer $REDUCTO_API_KEY" \ -H "Content-Type: application/json" \ -d '{"input": "https://example.com/document.pdf"}' | jq -r '.job_id') echo "Job submitted: $JOB_ID" # Poll until complete while true; do RESPONSE=$(curl -s "https://platform.reducto.ai/job/$JOB_ID" \ -H "Authorization: Bearer $REDUCTO_API_KEY") STATUS=$(echo $RESPONSE | jq -r '.status') if [ "$STATUS" = "Completed" ]; then echo "Success" echo $RESPONSE | jq '.result' break elif [ "$STATUS" = "Failed" ]; then echo "Failed" break fi sleep 2 done ``` Polling is straightforward but requires keeping a process running. For production systems processing many documents, webhooks are more efficient. ## Priority processing By default, synchronous (`run()`) jobs are prioritized over asynchronous (`run_job()`) jobs. This ensures interactive requests get fast responses while background jobs process when capacity is available. You can request priority processing for async jobs with `priority=True`. Workers poll the priority queue ahead of the standard queue, so priority jobs start sooner when there is queued work: ```python Python theme={null} submission = client.parse.run_job( input="urgent-document.pdf", async_={ "priority": True } ) ``` ```typescript TypeScript theme={null} const submission = await client.parse.runJob({ input: "https://example.com/urgent-document.pdf", async: { priority: true } }); ``` ```bash cURL theme={null} curl -X POST "https://platform.reducto.ai/parse_async" \ -H "Authorization: Bearer $REDUCTO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "input": "https://example.com/urgent-document.pdf", "async": {"priority": true} }' ``` Priority jobs are processed before non-priority async jobs but may still queue behind synchronous requests. ## Async endpoints Every Reducto endpoint has a corresponding async variant: | Sync Endpoint | Async Endpoint | SDK Method | | - | - | - | | `POST /parse` | `POST /parse_async` | `client.parse.run_job()` | | `POST /extract` | `POST /extract_async` | `client.extract.run_job()` | | `POST /split` | `POST /split_async` | `client.split.run_job()` | | `POST /pipeline` | `POST /pipeline_async` | `client.pipeline.run_job()` | ```python Python theme={null} # Parse parse_job = client.parse.run_job(input="https://example.com/document.pdf") # Extract extract_job = client.extract.run_job( input="https://example.com/document.pdf", instructions={"schema": your_schema} ) # Split split_job = client.split.run_job( input="https://example.com/document.pdf", split_description=[{"name": "Section A", "description": "..."}] ) # Pipeline pipeline_job = client.pipeline.run_job( input="https://example.com/document.pdf", pipeline_id="your_pipeline_id" ) ``` ```typescript TypeScript theme={null} // Parse const parseJob = await client.parse.runJob({ input: "https://example.com/document.pdf" }); // Extract const extractJob = await client.extract.runJob({ input: "https://example.com/document.pdf", instructions: { schema: yourSchema } }); // Split const splitJob = await client.split.runJob({ input: "https://example.com/document.pdf", splitDescription: [{ name: "Section A", description: "..." }] }); // Pipeline const pipelineJob = await client.pipeline.runJob({ input: "document.pdf", pipelineId: "your_pipeline_id" }); ``` ```bash cURL theme={null} # Parse async curl -X POST "https://platform.reducto.ai/parse_async" \ -H "Authorization: Bearer $REDUCTO_API_KEY" \ -H "Content-Type: application/json" \ -d '{"input": "https://example.com/document.pdf"}' # Extract async curl -X POST "https://platform.reducto.ai/extract_async" \ -H "Authorization: Bearer $REDUCTO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "input": "https://example.com/document.pdf", "instructions": {"schema": {"field": "string"}} }' # Split async curl -X POST "https://platform.reducto.ai/split_async" \ -H "Authorization: Bearer $REDUCTO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "input": "https://example.com/document.pdf", "split_description": [{"name": "Section A", "description": "..."}] }' # Pipeline async curl -X POST "https://platform.reducto.ai/pipeline_async" \ -H "Authorization: Bearer $REDUCTO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "input": "https://example.com/document.pdf", "pipeline_id": "your_pipeline_id" }' ``` ## Using metadata Include metadata with your job submission to help identify and route results: ```python Python theme={null} submission = client.parse.run_job( input="https://example.com/document.pdf", async_={ "metadata": { "user_id": "user_123", "document_type": "invoice", "batch_id": "batch_456" } } ) ``` ```typescript TypeScript theme={null} const submission = await client.parse.runJob({ input: "https://example.com/document.pdf", async: { metadata: { userId: "user_123", documentType: "invoice", batchId: "batch_456" } } }); ``` ```bash cURL theme={null} curl -X POST "https://platform.reducto.ai/parse_async" \ -H "Authorization: Bearer $REDUCTO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "input": "https://example.com/document.pdf", "async": { "metadata": { "user_id": "user_123", "document_type": "invoice", "batch_id": "batch_456" } } }' ``` The metadata is included in webhook notifications, making it easy to match results back to your application context without maintaining a separate mapping. Metadata keys can also be enabled as a usage dimension, letting you break credit consumption down by tenant or customer in the [Usage Export API](/reference/usage-export-api#grouping-by-async-metadata). Because that data is republished through a usage API, avoid putting credentials or secrets in `metadata`. ## When to use async **Use `run()` when:** * Processing single documents interactively * Document size is small (under 20 pages) * You need results immediately in the same request * Testing and development **Use `run_job()` / async endpoints when:** * Processing many documents in parallel * Documents are large or complex * You want fire-and-forget with webhook notification * Building batch processing pipelines * Processing in background workers ## Job Retention > [!WARNING] > > **Job results are generally available for 12 hours.** Under Reducto's [zero data retention (ZDR) policy](/security/policies), all API processing artifacts for API-submitted jobs are typically purged within 24 hours for Growth and Enterprise tiers. **Default behavior:** Job results are generally available for 12 hours. **For longer retention:** Enable `persist_results` to keep results indefinitely: ```python Python theme={null} result = client.parse.run( input="document.pdf", settings={"persist_results": True} ) # This job's results will be stored indefinitely ``` ```typescript TypeScript theme={null} const result = await client.parse.run({ input: "document.pdf", settings: { persist_results: true } }); // This job's results will be stored indefinitely ``` ```bash cURL theme={null} curl -X POST "https://platform.reducto.ai/parse" \ -H "Authorization: Bearer $REDUCTO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "input": "https://example.com/document.pdf", "settings": {"persist_results": true} }' ``` > [!NOTE] > > `persist_results` requires opting in to Reducto Studio. Contact support to enable this feature for your organization. **Best practice:** Always store results in your own database when you receive them via polling or webhook, rather than relying on Reducto's retention. *** You can also delete job artifacts on demand using `DELETE /job/{job_id}`. This removes the stored output before the automatic retention window. See [Deleting Jobs & Uploads](/workflows/deletion) for details. ## API Reference See the full API documentation for async endpoints: * [Parse Async](/api-reference/async-parse) - `POST /parse_async` * [Extract Async](/api-reference/extract-async) - `POST /extract_async` * [Split Async](/api-reference/split-async) - `POST /split_async` * [Pipeline Async](/api-reference/pipeline-async) - `POST /pipeline_async` * [Get Job](/api-reference/get-jobs) - `GET /job/{job_id}` * [Delete Job](/api-reference/delete-job) - `DELETE /job/{job_id}` *** ## Related > [card] [Svix Webhooks](/workflows/svix-webhooks) > > Get notified when jobs complete instead of polling. > [card] [Batch Processing](/workflows/batch-processing) > > Process many documents in parallel with run(). > [card] [Chaining Endpoints](/workflows/chaining-endpoints) > > Reuse parsed documents across multiple calls. > [card] [Pipeline Basics](/workflows/pipeline-basics) > > Bundle workflows into a single API call. # Batch Processing Source: https://docs.reducto.ai/workflows/batch-processing Process multiple documents in parallel When you need to process many documents, batch processing lets you run multiple requests concurrently. This is faster than processing documents sequentially and more suitable for immediate results than webhooks. > [!NOTE] > > **Save 20% on bulk parsing:** for non-urgent workloads, submit jobs through the [batch queue](/workflows/batch-queue) (`queue_priority: "batch"`) for a 20% credit discount with a 12-hour completion guarantee. ## When to use batch processing | Approach | Best for | | - | - | | **Batch processing** (this page) | Processing many documents, need results immediately | | **[Webhooks](/workflows/svix-webhooks)** | Fire-and-forget, long documents, notification when done | | **Sequential** | Simple scripts, debugging, rate-limited scenarios | ## Async Python (recommended) Use `AsyncReducto` with `asyncio` for the best performance. The semaphore controls concurrency to avoid overwhelming the API. ### Processing URLs If your documents are already hosted (S3, web server, etc.), process URLs directly: ```python theme={null} import asyncio from reducto import AsyncReducto client = AsyncReducto() async def batch_parse_urls(urls: list[str], max_concurrency: int = 50): """Parse multiple URLs concurrently.""" sem = asyncio.Semaphore(max_concurrency) async def process(url: str): async with sem: result = await client.parse.run(input=url) return {"url": url, "pages": result.usage.num_pages} tasks = [process(url) for url in urls] return await asyncio.gather(*tasks) # Usage urls = [ "https://example.com/doc1.pdf", "https://example.com/doc2.pdf", "https://example.com/doc3.pdf", ] results = asyncio.run(batch_parse_urls(urls)) ``` ### Processing local files For local files, upload first then parse: ```python theme={null} import asyncio from pathlib import Path from reducto import AsyncReducto client = AsyncReducto() async def batch_parse_files(files: list[Path], max_concurrency: int = 50): """Parse multiple local files concurrently.""" sem = asyncio.Semaphore(max_concurrency) async def process(path: Path): async with sem: upload = await client.upload(file=path) result = await client.parse.run(input=upload) return {"file": path.name, "pages": result.usage.num_pages} tasks = [process(path) for path in files] return await asyncio.gather(*tasks) # Usage files = list(Path("documents").glob("*.pdf")) results = asyncio.run(batch_parse_files(files)) for r in results: print(f"{r['file']}: {r['pages']} pages") ``` ### With progress bar ```python theme={null} import asyncio from pathlib import Path from reducto import AsyncReducto from tqdm.asyncio import tqdm client = AsyncReducto() async def batch_parse_with_progress(files: list[Path], max_concurrency: int = 50): sem = asyncio.Semaphore(max_concurrency) async def process(path: Path): async with sem: upload = await client.upload(file=path) result = await client.parse.run(input=upload) return {"file": path.name, "pages": result.usage.num_pages} tasks = [process(path) for path in files] return await tqdm.gather(*tasks, desc="Processing documents") files = list(Path("documents").glob("*.pdf")) results = asyncio.run(batch_parse_with_progress(files)) ``` ### With error handling Some documents may fail (corrupt files, unsupported formats). Handle errors gracefully to avoid losing all results: ```python theme={null} import asyncio from pathlib import Path from reducto import AsyncReducto client = AsyncReducto() async def batch_parse_safe(files: list[Path], max_concurrency: int = 50): """Parse files with error handling.""" sem = asyncio.Semaphore(max_concurrency) async def process(path: Path): async with sem: try: upload = await client.upload(file=path) result = await client.parse.run(input=upload) return {"file": path.name, "success": True, "pages": result.usage.num_pages} except Exception as e: return {"file": path.name, "success": False, "error": str(e)} tasks = [process(path) for path in files] results = await asyncio.gather(*tasks) successes = [r for r in results if r["success"]] failures = [r for r in results if not r["success"]] print(f"Processed {len(successes)} successfully, {len(failures)} failed") return results files = list(Path("documents").glob("*.pdf")) results = asyncio.run(batch_parse_safe(files)) ``` ## Sync Python with threading If you can't use async, use `ThreadPoolExecutor` with the synchronous client: ```python theme={null} from concurrent.futures import ThreadPoolExecutor, as_completed from pathlib import Path from reducto import Reducto client = Reducto() def batch_parse_sync(files: list[Path], max_workers: int = 10): """Parse files using thread pool.""" def process(path: Path): upload = client.upload(file=path) result = client.parse.run(input=upload) return {"file": path.name, "pages": result.usage.num_pages} results = [] with ThreadPoolExecutor(max_workers=max_workers) as executor: futures = {executor.submit(process, f): f for f in files} for future in as_completed(futures): try: results.append(future.result()) except Exception as e: results.append({"file": futures[future].name, "error": str(e)}) return results files = list(Path("documents").glob("*.pdf")) results = batch_parse_sync(files) ``` ## Batch extraction The same patterns work for extraction. Define your schema once and apply it to all documents: ```python theme={null} import asyncio from reducto import AsyncReducto client = AsyncReducto() INVOICE_SCHEMA = { "type": "object", "properties": { "invoice_number": {"type": "string", "description": "Invoice number"}, "date": {"type": "string", "description": "Invoice date"}, "total": {"type": "number", "description": "Total amount"} } } async def batch_extract(urls: list[str], schema: dict, max_concurrency: int = 20): sem = asyncio.Semaphore(max_concurrency) async def extract(url: str): async with sem: result = await client.extract.run( input=url, instructions={"schema": schema} ) return {"url": url, "data": result.result} tasks = [extract(url) for url in urls] return await asyncio.gather(*tasks) urls = ["https://example.com/invoice1.pdf", "https://example.com/invoice2.pdf"] results = asyncio.run(batch_extract(urls, INVOICE_SCHEMA)) ``` ## JavaScript / TypeScript ```javascript theme={null} import Reducto from 'reductoai'; import fs from 'fs'; import { glob } from 'glob'; const client = new Reducto(); async function batchParse(files) { const results = await Promise.all( files.map(async (file) => { try { const upload = await client.upload({ file: fs.createReadStream(file) }); const result = await client.parse.run({ input: upload }); return { file, pages: result.usage.num_pages, success: true }; } catch (error) { return { file, error: error.message, success: false }; } }) ); return results; } const files = glob.sync('documents/*.pdf'); const results = await batchParse(files); console.log(results); ``` ## Saving results Save results as you process to avoid losing work: ```python theme={null} import asyncio import json from pathlib import Path from reducto import AsyncReducto client = AsyncReducto() async def batch_parse_and_save(files: list[Path], output_dir: Path, max_concurrency: int = 50): output_dir.mkdir(exist_ok=True) sem = asyncio.Semaphore(max_concurrency) async def process(path: Path): async with sem: upload = await client.upload(file=path) result = await client.parse.run(input=upload) # Save immediately output_path = output_dir / f"{path.stem}.json" output_path.write_text(result.model_dump_json(indent=2)) return {"file": path.name, "output": str(output_path)} tasks = [process(path) for path in files] return await asyncio.gather(*tasks) files = list(Path("documents").glob("*.pdf")) results = asyncio.run(batch_parse_and_save(files, Path("output"))) ``` ## Concurrency limits | Method | Recommended concurrency | | - | - | | `AsyncReducto` | 50-200 concurrent requests | | `ThreadPoolExecutor` | 10-50 workers | | `run_job()` (webhooks) | Unlimited | Higher concurrency means faster processing but may hit rate limits. Start with lower values and increase as needed. ## What about cURL? Batch processing requires programming constructs (loops, concurrency control, error handling) that aren't practical in cURL. For single-document processing via cURL, see the [API reference](/api-reference/parse). For batch workflows without writing code, consider: * [Reducto CLI](/cli) for scripting * [Studio pipelines](/studio-quickstart) for visual configuration ## Best practices 1. **Use async when possible**: `AsyncReducto` is more efficient than threading 2. **Handle errors gracefully**: Don't let one failure stop the entire batch 3. **Save incrementally**: Write results to disk as they complete 4. **Monitor progress**: Use `tqdm` or logging to track progress 5. **Set reasonable concurrency**: Start low (20-50) and increase if stable For very large batches or long-running documents, consider [webhooks](/workflows/svix-webhooks) instead. They're better suited for fire-and-forget processing where you don't need immediate results. # Batch Queue Source: https://docs.reducto.ai/workflows/batch-queue Discounted async processing for non-urgent workloads The batch queue is an opt-in lane for async jobs that don't need immediate turnaround. Jobs run when there's spare capacity and are guaranteed to complete within a published SLA. In return, they consume fewer credits than the standard lane. ## When to use the batch queue | Approach | Best for | | - | - | | **Batch queue** (this page) | Bulk uploads, backfills, overnight runs, anything where minutes-to-hours latency is acceptable | | **[Async parse](/workflows/async-overview)** | Standard async: immediate processing, normal pricing | | **[Sync parse](/parse)** | Small documents, interactive flows: get the result back in one request | Pick the batch queue when you can trade latency for cost. [Webhooks](/workflows/svix-webhooks) work with both async lanes (standard and batch), and we generally recommend them over polling `/job/{id}`. You get the result pushed to you as soon as it's ready instead of paying for the round-trips. ## How to opt in The batch queue works with **`/parse_async` and `/extract_async`**. Set `queue_priority: "batch"` on the request to use it. No other endpoint supports the batch queue today. The batch queue covers standard Parse and Extract only. Deep Extract (`deep_extract: true`) is not eligible for the batch queue. Reducto rejects a Deep Extract request with `queue_priority: "batch"` with HTTP 400. `queue_priority` has no effect on the synchronous endpoints or on `/split_async` and `/edit_async`. ```python Python theme={null} from reducto import AsyncReducto client = AsyncReducto() response = await client.parse.run_job( input="https://example.com/large-doc.pdf", queue_priority="batch", ) ``` ```javascript Node.js theme={null} import Reducto from "reductoai"; const client = new Reducto(); const response = await client.parse.runJob({ input: "https://example.com/large-doc.pdf", queue_priority: "batch", }); ``` ```bash cURL theme={null} curl -X POST https://platform.reducto.ai/parse_async \ -H "Authorization: Bearer $REDUCTO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "document_url": "https://example.com/large-doc.pdf", "queue_priority": "batch" }' ``` The same field works on `/extract_async`: ```python Python theme={null} response = await client.extract.run_job( input="https://example.com/large-doc.pdf", instructions={"schema": your_schema}, queue_priority="batch", ) ``` ```javascript Node.js theme={null} const response = await client.extract.runJob({ input: "https://example.com/large-doc.pdf", instructions: { schema: yourSchema }, queue_priority: "batch", }); ``` ```bash cURL theme={null} curl -X POST https://platform.reducto.ai/extract_async \ -H "Authorization: Bearer $REDUCTO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "input": "https://example.com/large-doc.pdf", "instructions": {"schema": {"type": "object", "properties": {"title": {"type": "string"}}}}, "queue_priority": "batch" }' ``` The response shape is identical to a regular `/parse_async` or `/extract_async` submission. You get back a `job_id` and poll `/job/{id}` for the result. ## SLA Batch jobs are guaranteed to complete within **12 hours** of submission. The 12-hour window is the firm commitment. ## Credit Consumption Batch jobs consume **20% fewer credits** than the same parse or extract on the standard lane. Deep Extract is not eligible for the batch queue, so it never gets the discount. The discount is applied to the number of credits used, not to the monetary cost of each credit. The credit usage returned in `/job/{id}` already reflects the reduced amount, so no separate invoicing step is needed. Because the reduction applies to credit consumption, it stacks on top of any existing per-organisation credit rate. Customers on legacy or volume pricing keep their existing per-credit rate and additionally consume 20% fewer credits when they use the batch queue. ## Back-off and capacity limits If the batch queue is saturated, `/parse_async` and `/extract_async` return HTTP 503 with a `Retry-After: 300` header. Well-behaved clients will pause for the suggested interval before retrying; SDKs handle this automatically. The cap is a safety valve, and under normal operation you should never see it. ## Out of scope today * The batch queue is supported on `/parse_async` and `/extract_async` only. It is not available on the synchronous endpoints or on `/split_async` and `/edit_async`; these do not honour `queue_priority` and run at standard priority and pricing. * Deep Extract (`deep_extract: true`) is not eligible for the batch queue. Reducto rejects the request with HTTP 400. Submit Deep Extract jobs on the standard lane. * Per-customer batch-discount tiers are not available; the 20% is global. # Chaining API Calls Source: https://docs.reducto.ai/workflows/chaining-endpoints Combine Classify, Parse, and Extract to build document processing workflows Reducto endpoints can be chained together to build multi-step document processing workflows. A common pattern is Classify first to determine document type, then Parse and Extract with the right configuration for that type. When you call Parse, Reducto returns a `job_id` that represents the parsed document. You can pass this job ID to subsequent Extract or Split calls using the `jobid://` prefix, which skips re-parsing and uses the cached result. This saves the latency of re-parsing a document when you need to run multiple operations on the same document. ## The jobid:// protocol After parsing a document, the response includes a `job_id`: ```python theme={null} from pathlib import Path upload = client.upload(file=Path("document.pdf")) parse_result = client.parse.run(input=upload) print(parse_result.job_id) # "7600c8c5-a52f-49d2-8a7d-d75d1b51e141" ``` To reuse this parsed content in Extract or Split, prefix the job ID with `jobid://`: ```python theme={null} # Extract using the parsed document (no re-parsing) extract_result = client.extract.run( input=f"jobid://{parse_result.job_id}", instructions={"schema": your_schema} ) ``` When Reducto sees `jobid://`, it retrieves the cached parse result instead of processing the document again. Any parsing options you include in the request are ignored since the document was already parsed. ## Common chaining patterns ### Parse → Extract The most common pattern. Parse once, then run one or more extractions with different schemas: ```python theme={null} from pathlib import Path from reducto import Reducto client = Reducto() # Step 1: Upload and parse the document upload = client.upload(file=Path("financial-report.pdf")) parse_result = client.parse.run(input=upload) job_id = parse_result.job_id # Step 2: Extract summary metrics summary = client.extract.run( input=f"jobid://{job_id}", instructions={"schema": { "type": "object", "properties": { "total_revenue": {"type": "number"}, "net_income": {"type": "number"} } }} ) # Step 3: Extract detailed line items (same parsed document) line_items = client.extract.run( input=f"jobid://{job_id}", instructions={"schema": { "type": "object", "properties": { "expenses": {"type": "array", "items": {"type": "object"}} } }}, settings={"deep_extract": True} ) ``` Without chaining, each Extract call would re-parse the document. ### Parse → Split → Extract For documents with distinct sections that need different extraction schemas: ```python theme={null} from pathlib import Path # Step 1: Upload and parse upload = client.upload(file=Path("contract.pdf")) parse_result = client.parse.run(input=upload) job_id = parse_result.job_id # Step 2: Split into sections split_result = client.split.run( input=f"jobid://{job_id}", split_description=[ {"name": "Terms", "description": "Terms and conditions section"}, {"name": "Pricing", "description": "Pricing and payment terms"}, {"name": "SLA", "description": "Service level agreement"} ] ) # Step 3: Extract from specific sections for section in split_result.result.splits: if section.pages: extract_result = client.extract.run( input=f"jobid://{job_id}", instructions={"schema": get_schema_for_section(section.name)}, parsing={"settings": {"page_range": { "start": section.pages[0], "end": section.pages[-1] }}} ) ``` ### Classify → Parse → Extract When you need to determine document type before choosing an extraction schema, use Classify first. ```python Python theme={null} from reducto import Reducto client = Reducto() # Step 1: Classify the document type classification = client.classify.run( input=document_url, classification_schema=[ { "category": "invoice", "criteria": ["billing information", "itemized charges", "payment details"], }, { "category": "receipt", "criteria": ["single transaction", "store or merchant name", "payment method"], }, { "category": "purchase_order", "criteria": ["order number", "requested items", "delivery instructions"], }, ], ) doc_type = classification.result.category # Step 2: Parse the document parse_result = client.parse.run(input=document_url) job_id = parse_result.job_id # Step 3: Extract with the right schema for this document type if doc_type == "invoice": schema = invoice_schema elif doc_type == "receipt": schema = receipt_schema else: schema = purchase_order_schema result = client.extract.run( input=f"jobid://{job_id}", instructions={"schema": schema} ) ``` ```javascript Node.js theme={null} import Reducto from 'reductoai'; const client = new Reducto(); // Step 1: Classify the document type const classification = await client.classify.run({ input: documentUrl, classification_schema: [ { category: 'invoice', criteria: ['billing information', 'itemized charges', 'payment details'], }, { category: 'receipt', criteria: ['single transaction', 'store or merchant name', 'payment method'], }, { category: 'purchase_order', criteria: ['order number', 'requested items', 'delivery instructions'], }, ], }); const docType = classification.result.category; // Step 2: Parse the document const parseResult = await client.parse.run({ input: documentUrl }); const jobId = parseResult.job_id; // Step 3: Extract with the right schema for this document type let schema; if (docType === 'invoice') { schema = invoiceSchema; } else if (docType === 'receipt') { schema = receiptSchema; } else { schema = purchaseOrderSchema; } const result = await client.extract.run({ input: `jobid://${jobId}`, instructions: { schema } }); ``` ```bash cURL theme={null} # Step 1: Classify the document type CLASSIFY_RESULT=$(curl -s -X POST https://platform.reducto.ai/classify \ -H "Authorization: Bearer $REDUCTO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "input": "https://example.com/document.pdf", "classification_schema": [ {"category": "invoice", "criteria": ["billing information", "itemized charges"]}, {"category": "receipt", "criteria": ["single transaction", "merchant name"]}, {"category": "purchase_order", "criteria": ["order number", "requested items"]} ] }') DOC_TYPE=$(echo $CLASSIFY_RESULT | jq -r '.result.category') echo "Document type: $DOC_TYPE" # Step 2: Parse the document PARSE_RESULT=$(curl -s -X POST https://platform.reducto.ai/parse \ -H "Authorization: Bearer $REDUCTO_API_KEY" \ -H "Content-Type: application/json" \ -d '{"input": "https://example.com/document.pdf"}') JOB_ID=$(echo $PARSE_RESULT | jq -r '.job_id') # Step 3: Extract with an appropriate schema based on classification # (choose your schema based on $DOC_TYPE) curl -X POST https://platform.reducto.ai/extract \ -H "Authorization: Bearer $REDUCTO_API_KEY" \ -H "Content-Type: application/json" \ -d "{ \"input\": \"jobid://$JOB_ID\", \"instructions\": { \"schema\": { \"type\": \"object\", \"properties\": { \"total\": {\"type\": \"number\", \"description\": \"Total amount\"} } } } }" ``` This pattern is useful when processing mixed document types from a single upload queue. Classify is purpose-built for document routing and costs only 0.5 credits per page of context, compared to using Extract as a workaround. ## Multiple job IDs Extract also accepts a list of job IDs, which combines the parsed content from multiple documents into a single extraction context: ```python theme={null} # Parse multiple documents (documents can be URLs or uploaded file IDs) job_ids = [] for doc in documents: result = client.parse.run(input=doc) job_ids.append(result.job_id) # Extract across all documents combined_result = client.extract.run( input=[f"jobid://{jid}" for jid in job_ids], instructions={"schema": aggregation_schema} ) ``` This behaves like [multi-document pipelines](/workflows/multi-document-pipelines): the extraction sees all documents together and returns a single result. Design your schema accordingly if you want data from each document. ## Supported endpoints | Endpoint | Accepts jobid:// | Notes | | - | - | - | | Parse | Yes | Reprocesses with different settings | | Extract | Yes | Single ID or list of IDs | | Split | Yes | Single ID only | | Classify | No | Accepts URLs and upload responses | | Edit | No | Requires actual document URL | ## Job ID retention Parse job IDs are retained for 12 hours by default. If you need to chain calls after this window, you'll need to re-parse the document. For workflows that span longer periods, consider storing the parsed content or using [pipelines](/workflows/pipeline-basics) which handle this automatically. *** ## Related > [card] [Classify](/classify/overview) > > Categorize documents by type before processing. > [card] [Pipeline Basics](/workflows/pipeline-basics) > > Bundle multi-step workflows into a single API call. > [card] [Split](/split) > > Divide documents into sections for targeted extraction. > [card] [Extract](/extract/overview) > > Pull structured data from parsed documents. # Deleting Jobs & Uploads Source: https://docs.reducto.ai/workflows/deletion Remove job artifacts and uploaded files before the end of the automatic retention window. Reducto lets you delete job artifacts and uploaded files on demand instead of waiting for the end of the automatic retention window. > [!NOTE] > > Organizations on Growth and Enterprise tiers already benefit from Reducto's [Zero Data Retention (ZDR) policy](/security/policies), which typically purges all API processing artifacts from API-submitted jobs within 24 hours; Studio-run jobs and results saved with `persist_results: true` are exempt. These endpoints are for cases where you need to delete artifacts before the 24-hour period. ## What gets deleted `DELETE /job/{job_id}` deletes the **API-layer artifacts** that Reducto stores in live systems when processing a job: | Artifact type | Examples | | - | - | | Job output | Parsed JSON, extracted data, split results | | Intermediate files | Converted images, page renders | Uploaded files are managed separately. To remove a file you uploaded via `POST /upload`, use `DELETE /upload/{file_id}`. When cleanup finishes, Reducto tombstones the job record. Tombstoning marks the record as deleted so Reducto no longer serves it through job retrieval endpoints. **What is not affected:** * **Studio data.** Jobs created through Studio pipelines are managed separately. These endpoints only delete artifacts stored by the Reducto API. * **Your source documents.** If you provided a URL to an externally hosted file, that file is untouched. * **Completed downstream results.** If another job already consumed this job's output (e.g. chained extract after parse), the downstream job's own artifacts remain until you delete them separately. * **Child jobs of a pipeline.** Each pipeline step (parse, split, extract, edit) runs as its own job with its own ID and its own artifacts. Deleting the parent pipeline job does not delete them. *** ## Delete a job `DELETE /job/{job_id}` is asynchronous. It validates your request, marks the job as pending deletion, enqueues a background cleanup task, and returns `202 Accepted`. Artifact cleanup runs in the background. ```python Python theme={null} import os import requests api_key = os.environ["REDUCTO_API_KEY"] base_url = "https://platform.reducto.ai" response = requests.delete( f"{base_url}/job/{job_id}", headers={"Authorization": f"Bearer {api_key}"}, ) print(response.status_code) # 202 print(response.json()) # {"job_id": "abc123"} ``` ```javascript Node.js theme={null} const apiKey = process.env.REDUCTO_API_KEY; const baseUrl = "https://platform.reducto.ai"; const response = await fetch(`${baseUrl}/job/${jobId}`, { method: "DELETE", headers: { Authorization: `Bearer ${apiKey}` }, }); console.log(response.status); // 202 console.log(await response.json()); // { job_id: "abc123" } ``` ```bash cURL theme={null} curl -X DELETE "https://platform.reducto.ai/job/{job_id}" \ -H "Authorization: Bearer $REDUCTO_API_KEY" \ -w "\nHTTP status: %{http_code}\n" # HTTP status: 202 ``` ### HTTP status codes Job deletion is asynchronous, so the status codes reflect the lifecycle of the deletion process. | Code | Meaning | When | | - | - | - | | **202 Accepted** | Deletion request accepted | Returned by `DELETE /job/{job_id}`. Cleanup is running in the background. | | **409 Conflict** | Deletion in progress | Returned by `GET /job/{job_id}` while background cleanup is still running. | | **410 Gone** | Job deleted | Returned by `GET /job/{job_id}` after cleanup finishes and the job is tombstoned. | | **404 Not Found** | Job does not exist | The job ID is invalid or belongs to a different organization. | | **400 Bad Request** | Job not in a terminal state | The job is still pending, in progress, or completing. Only completed or failed jobs can be deleted. | | **422 Not applicable** | Deletion not available for your tier | On-demand deletion is only available to Growth and Enterprise organizations. The response body carries error code `NOT_APPLICABLE`. | ### Deleting persisted artifacts If you used `persist_results: true` when creating the job, persisted result artifacts are kept by default even after deletion according to your account settings or agreement with Reducto. Pass `include_persisted=true` to also delete those long-retention artifacts: ```python Python theme={null} response = requests.delete( f"{base_url}/job/{job_id}", headers={"Authorization": f"Bearer {api_key}"}, params={"include_persisted": True}, ) ``` ```javascript Node.js theme={null} const response = await fetch( `${baseUrl}/job/${jobId}?include_persisted=true`, { method: "DELETE", headers: { Authorization: `Bearer ${apiKey}` }, } ); ``` ```bash cURL theme={null} curl -X DELETE "https://platform.reducto.ai/job/{job_id}?include_persisted=true" \ -H "Authorization: Bearer $REDUCTO_API_KEY" ``` ### Response | Field | Type | Description | | - | - | - | | `job_id` | `string` | The ID of the job being deleted | Store the deletion response with your own request logs if you need evidence that Reducto accepted the deletion request. ### Failure recovery If the background cleanup task fails, the deletion marker is rolled back automatically. The job becomes retrievable again via `GET /job/{job_id}` and you can retry the `DELETE` request. *** ## Delete an uploaded file `DELETE /upload/{file_id}` removes the file you uploaded via `POST /upload`. This operation is synchronous and returns immediately. Pass the full `reducto://` file ID returned by `POST /upload`. ```python Python theme={null} import os import requests api_key = os.environ["REDUCTO_API_KEY"] base_url = "https://platform.reducto.ai" response = requests.delete( f"{base_url}/upload/{file_id}", headers={"Authorization": f"Bearer {api_key}"}, ) print(response.json()) # {"file_id": "reducto://abc-123-def"} ``` ```javascript Node.js theme={null} const apiKey = process.env.REDUCTO_API_KEY; const baseUrl = "https://platform.reducto.ai"; const response = await fetch(`${baseUrl}/upload/${fileId}`, { method: "DELETE", headers: { Authorization: `Bearer ${apiKey}` }, }); console.log(await response.json()); // { file_id: "reducto://abc-123-def" } ``` ```bash cURL theme={null} curl -X DELETE "https://platform.reducto.ai/upload/{file_id}" \ -H "Authorization: Bearer $REDUCTO_API_KEY" ``` ### Response | Field | Type | Description | | - | - | - | | `file_id` | `string` | The normalized `reducto://` file ID | > [!NOTE] > > Jobs that already processed this file are not affected. Delete each job separately if needed. *** ## Relationship to data retention These deletion endpoints complement, not replace, Reducto's automatic data retention policies. For the full scope of Reducto's data handling obligations, please refer to Reducto's Data Processing Agreement (DPA). | Tier | Automatic retention | Manual deletion | | - | - | - | | Growth and Enterprise | ZDR: API-submitted jobs' processing artifacts typically purged within 24 hours | Job deletion via `DELETE /job` | | With `persist_results` | Results stored for the period specified in your account settings or as agreed with Reducto | Pass `include_persisted=true` to also delete | For full details on data retention and security policies, see [Data policies & compliance](/security/policies). *** ## Troubleshooting > [accordion] Do I need to delete child jobs separately? > > Yes. Deleting a pipeline job does not cascade to the jobs its steps created. > > A pipeline run creates one parent job plus one child job per step. Each child job stores its own artifacts under its own job ID. `DELETE /job/{job_id}` only deletes the artifacts belonging to the job ID you pass, so deleting the parent leaves every child's artifacts in place. > > Collect the child job IDs from the pipeline response payload and delete each one, then delete the parent: > > ```python Python theme={null} > import os > > import requests > from reducto import Reducto > > api_key = os.environ["REDUCTO_API_KEY"] > base_url = "https://platform.reducto.ai" > client = Reducto(api_key=api_key) > > result = client.pipeline.run(pipeline_id=pipeline_id, input=file_id) > > child_job_ids = [] > parse = result.result.parse > if isinstance(parse, list): > child_job_ids.extend(item.job_id for item in parse) > elif parse: > child_job_ids.append(parse.job_id) > extract = result.result.extract > if isinstance(extract, list): > child_job_ids.extend(split.result.job_id for split in extract) > elif extract: > child_job_ids.append(extract.job_id) > > for job_id in [*child_job_ids, result.job_id]: > response = requests.delete( > f"{base_url}/job/{job_id}", > headers={"Authorization": f"Bearer {api_key}"}, > ) > print(job_id, response.status_code) # 202 per job > ``` > > ```javascript Node.js theme={null} > import Reducto from "reductoai"; > > const apiKey = process.env.REDUCTO_API_KEY; > const baseUrl = "https://platform.reducto.ai"; > const client = new Reducto({ apiKey }); > > const result = await client.pipeline.run({ pipeline_id: pipelineId, input: fileId }); > > const childJobIds = []; > const parse = result.result.parse; > if (Array.isArray(parse)) { > childJobIds.push(...parse.map((item) => item.job_id)); > } else if (parse) { > childJobIds.push(parse.job_id); > } > const extract = result.result.extract; > if (Array.isArray(extract)) { > childJobIds.push(...extract.map((split) => split.result.job_id)); > } else if (extract) { > childJobIds.push(extract.job_id); > } > > for (const jobId of [...childJobIds, result.job_id]) { > const response = await fetch(`${baseUrl}/job/${jobId}`, { > method: "DELETE", > headers: { Authorization: `Bearer ${apiKey}` }, > }); > console.log(jobId, response.status); // 202 per job > } > ``` > > ```bash cURL theme={null} > # Delete each child job ID from the pipeline response, then the parent job ID. > for job_id in parse-456 extract-789 pipeline-abc123; do > curl -X DELETE "https://platform.reducto.ai/job/$job_id" \ > -H "Authorization: Bearer $REDUCTO_API_KEY" \ > -w "\n$job_id: %{http_code}\n" > done > ``` > > Check the status code for every request. A job you skip keeps its artifacts until the automatic retention window ends. Deletion is asynchronous, so a `202` means cleanup was accepted, not finished. Poll `GET /job/{job_id}` for each ID until it returns `410 Gone` if you need confirmation that cleanup completed. > [accordion] Does deleting a job affect Reducto's ability to debug it? > > Yes, deleting a job impacts Reducto's ability to provide support for a specific job because most indicators of job success (i.e. results) are deleted from our storage. > [accordion] Can I delete Studio jobs? > > No. Jobs created through Studio pipelines are managed separately. These endpoints only delete artifacts stored by the Reducto API. > [accordion] Can I recover a deleted job? > > No. Deleted jobs are not recoverable through the API. Back up results separately before deleting them in Reducto's database if you need them later. > [accordion] 409: Deletion in progress > > The job's artifacts are being cleaned up in the background. Wait and retry your `GET /job/{job_id}` request. Once cleanup finishes, the status changes to `410 Gone`. > [accordion] 410: Job has been deleted > > The job was deleted from live systems (either manually via this endpoint or by automatic retention). The job record is tombstoned and is no longer retrievable through the API. > [accordion] 400: Job not in a terminal state > > Only completed or failed jobs can be deleted. If the job is still processing, wait for it to finish or cancel it first via `POST /cancel/{job_id}`. > [accordion] What happens if deletion fails? > > The deletion marker is rolled back automatically. The job becomes retrievable again and you can retry the `DELETE` request. # Direct Webhooks Source: https://docs.reducto.ai/workflows/direct-webhooks Simple HTTP POST webhooks for prototyping and basic integrations > [!WARNING] > > For production applications, use [Svix webhooks](/workflows/svix-webhooks) instead. Svix provides cryptographic signing, advanced retries, and a debugging dashboard. Direct webhooks are best for prototyping or simple internal integrations. Direct webhooks send HTTP POST requests directly to your endpoint when jobs complete. Reducto retries failed deliveries up to 3 times with exponential backoff. ## Testing with webhook.site For quick testing, use [webhook.site](https://webhook.site) to get a temporary endpoint URL. It shows you the exact payload Reducto sends. ## Submitting jobs Include your webhook URL in the async configuration: ```python Python theme={null} from reducto import Reducto client = Reducto() job = client.parse.run_job( input="https://example.com/document.pdf", async_={ "webhook": { "mode": "direct", "url": "https://your-app.com/webhook" }, "metadata": { "user_id": "123", "document_type": "invoice" } } ) print(f"Job ID: {job.job_id}") ``` ```typescript TypeScript theme={null} import Reducto from "reductoai"; const client = new Reducto(); const job = await client.parse.runJob({ input: "https://example.com/document.pdf", async: { webhook: { mode: "direct", url: "https://your-app.com/webhook" }, metadata: { userId: "123", documentType: "invoice" } } }); ``` ```bash cURL theme={null} curl -X POST https://platform.reducto.ai/parse_async \ -H "Authorization: Bearer $REDUCTO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "input": "https://example.com/document.pdf", "async": { "webhook": {"mode": "direct", "url": "https://your-app.com/webhook"}, "metadata": {"user_id": "123"} } }' ``` Works with all async endpoints: `/parse_async`, `/extract_async`, `/split_async`, `/pipeline_async`. ## Webhook payload When the job completes, your endpoint receives: ```json theme={null} { "status": "Completed", "job_id": "204a39e4-dd10-4c83-a978-0cee4af8cde2", "metadata": { "user_id": "123", "document_type": "invoice" } } ``` The `status` is either `Completed` or `Failed`. Use `job_id` to retrieve results with `client.job.get()`. ## Handling webhooks ```python Python theme={null} from flask import Flask, request, jsonify from reducto import Reducto app = Flask(__name__) client = Reducto() @app.route('/webhook', methods=['POST']) def handle_webhook(): payload = request.json if payload['status'] == "Completed": job = client.job.get(payload['job_id']) # Process job.result print(f"Processed job: {payload['job_id']}") return jsonify({"received": True}), 200 ``` ```typescript TypeScript theme={null} import express from 'express'; import Reducto from "reductoai"; const app = express(); const client = new Reducto(); app.use(express.json()); app.post('/webhook', async (req, res) => { const { job_id, status } = req.body; if (status === "Completed") { const job = await client.job.retrieve(job_id); // Process job.result console.log(`Processed job: ${job_id}`); } res.status(200).json({ received: true }); }); ``` ## Validating requests Since direct webhooks lack cryptographic signing, validate requests using a secret token in metadata: ```python Python theme={null} import os from flask import Flask, request, jsonify, abort from reducto import Reducto app = Flask(__name__) client = Reducto() WEBHOOK_SECRET = os.environ["WEBHOOK_SECRET"] # When submitting jobs, include the secret def submit_job(): return client.parse.run_job( input="https://example.com/document.pdf", async_={ "webhook": {"mode": "direct", "url": "https://your-app.com/webhook"}, "metadata": {"secret": WEBHOOK_SECRET, "user_id": "123"} } ) # When handling webhooks, verify the secret @app.route('/webhook', methods=['POST']) def handle_webhook(): payload = request.json # Validate secret if payload.get('metadata', {}).get('secret') != WEBHOOK_SECRET: abort(401) if payload['status'] == "Completed": job = client.job.get(payload['job_id']) # Process job.result return jsonify({"received": True}), 200 ``` ```typescript TypeScript theme={null} import express from 'express'; import Reducto from "reductoai"; const app = express(); const client = new Reducto(); const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET!; app.use(express.json()); // When submitting jobs, include the secret async function submitJob() { return client.parse.runJob({ input: "document.pdf", async: { webhook: { mode: "direct", url: "https://your-app.com/webhook" }, metadata: { secret: WEBHOOK_SECRET, userId: "123" } } }); } // When handling webhooks, verify the secret app.post('/webhook', async (req, res) => { // Validate secret if (req.body.metadata?.secret !== WEBHOOK_SECRET) { return res.status(401).json({ error: "Unauthorized" }); } if (req.body.status === "Completed") { const job = await client.job.retrieve(req.body.job_id); // Process job.result } res.status(200).json({ received: true }); }); ``` ## Troubleshooting > [accordion] Webhook not received > > 1. Verify your endpoint URL is publicly accessible (not localhost) > 2. Ensure your endpoint returns 2xx status codes > 3. Check your server logs for incoming requests > [accordion] Webhook received but job retrieval fails > > Job IDs expire after 12 hours. Retrieve results promptly after receiving the webhook. > [accordion] Duplicate webhooks received > > Reducto retries failed deliveries. Make your handler idempotent by tracking processed job IDs. ## Best practices 1. **Use HTTPS** for your webhook endpoint 2. **Validate requests** using the token-in-metadata pattern 3. **Return quickly**: Return 2xx immediately, process results asynchronously 4. **Be idempotent**: Handle duplicate deliveries gracefully 5. **Log everything**: Direct webhooks have no dashboard, so log for debugging For production applications with reliability requirements, use [Svix webhooks](/workflows/svix-webhooks). # Multi-document Pipelines Source: https://docs.reducto.ai/workflows/multi-document-pipelines Process related documents together with shared context for extraction > [!WARNING] > > The `/pipeline` endpoint is deprecated. Call `/parse`, `/extract`, `/split`, or `/edit` directly instead. Multi-document pipelines let you pass several documents to one pipeline call. Reducto parses each document in parallel, combines the parsed content, then runs a single extraction across all of them. This is useful when related information spans multiple files, such as a contract split across several PDFs or supporting documents that reference each other. ## How it works Instead of passing a single document URL, you pass a list: ```python Python theme={null} result = client.pipeline.run( input=[upload1.file_id, upload2.file_id, upload3.file_id], pipeline_id="your_pipeline" ) ``` ```typescript TypeScript theme={null} const result = await client.pipeline.run({ input: [upload1.file_id, upload2.file_id, upload3.file_id], pipeline_id: "your_pipeline" }); ``` ```bash cURL theme={null} curl -X POST "https://platform.reducto.ai/pipeline" \ -H "Authorization: Bearer $REDUCTO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "input": [ "reducto://file1.pdf", "reducto://file2.pdf", "reducto://file3.pdf" ], "pipeline_id": "your_pipeline" }' ``` ```go Go theme={null} // Go SDK does not yet support pipelines; use HTTP directly payload := map[string]interface{}{ "input": []string{"reducto://file1.pdf", "reducto://file2.pdf", "reducto://file3.pdf"}, "pipeline_id": "your_pipeline", } body, _ := json.Marshal(payload) req, _ := http.NewRequest("POST", "https://platform.reducto.ai/pipeline", bytes.NewReader(body)) req.Header.Set("Authorization", "Bearer "+os.Getenv("REDUCTO_API_KEY")) req.Header.Set("Content-Type", "application/json") resp, _ := http.DefaultClient.Do(req) defer resp.Body.Close() ``` Reducto then: 1. Parses all documents in parallel 2. Combines the parsed content into a single context 3. Runs extraction once across the combined content 4. Returns parse results as an array (one per document) and extract as a single result ## Understanding the response The key difference from single-document pipelines is that `result.parse` becomes an array: ```json theme={null} { "job_id": "pipeline-abc123", "usage": {"num_pages": 3, "credits": 8.0}, "result": { "parse": [ {"job_id": "parse-001", "result": {...}, "usage": {"num_pages": 1}}, {"job_id": "parse-002", "result": {...}, "usage": {"num_pages": 1}}, {"job_id": "parse-003", "result": {...}, "usage": {"num_pages": 1}} ], "extract": { "job_id": "extract-combined", "result": { "fieldName": {"value": "...", "citations": [...]} } } } } ``` The parse array maintains the same order as your input documents. The extract result is a single object representing one extraction across all combined content. ## When to use multi-document pipelines Multi-document pipelines work best when your documents are related and the extraction benefits from seeing them together: | Good use cases | Why it works | | - | - | | Contract with exhibits split into separate PDFs | Extract can reference terms from main contract when processing exhibits | | Multi-page report scanned as individual files | Reassemble logical document from physical pages | | Application with supporting documents | Extract can cross-reference between application form and attachments | > [!WARNING] > > Multi-document pipelines are not the same as batch processing. If you have independent documents that don't relate to each other (like 100 different customer invoices), use [batch processing](/workflows/batch-processing) instead. Batch processing runs your pipeline independently on each document. ## Schema design matters Because extraction runs once across combined content, your schema determines what you get back. Consider three invoices with amounts $1,000, $2,000, and \$3,000: **Singular field schema:** ```json theme={null} {"total_amount": "number"} ``` Result: `{"total_amount": 1000}` — picks one value, not a sum **Aggregate field schema:** ```json theme={null} {"total_spend": "The sum of all invoice amounts across all documents"} ``` Result: `{"total_spend": 6000}` — LLM computes the aggregate **Array field schema:** ```json theme={null} {"invoices": [{"invoice_number": "string", "amount": "number"}]} ``` Result: `{"invoices": [{"invoice_number": "A001", "amount": 1000}, ...]}` — extracts from each The LLM sees all documents together, so it can answer questions that span them. But you need to design your schema to ask the right questions. ## Requirements and limitations **Requirements:** * Pipeline must include an Extract step * At least one document required * All documents must be accessible URLs or uploaded files **Limitations:** * Split is not supported with multi-document pipelines * Only one Extract step is supported * Edit pipelines don't support multi-document input ## Credits Multi-document pipelines bill for: * Parse credits for each document (based on page count) * Extract credits once for the combined extraction If you need to run the same extraction independently on many documents, batch processing is more appropriate and gives you separate results per document. *** ## Related > [card] [Pipeline Basics](/workflows/pipeline-basics) > > Understanding pipeline patterns and response structures. > [card] [Batch Processing](/workflows/batch-processing) > > Process many independent documents in parallel. > [card] [Array Extraction](/configs/extract/array-extraction) > > Extract arrays of items from documents. > [card] [Pipeline API Reference](/api-reference/pipeline) > > Full API documentation for pipeline calls. # Pipeline Basics Source: https://docs.reducto.ai/workflows/pipeline-basics Compose Reducto capabilities into reusable, single-call workflows > [!WARNING] > > The `/pipeline` endpoint is deprecated. Call `/parse`, `/extract`, `/split`, or `/edit` directly instead. A pipeline composes multiple Reducto steps into a single workflow. Design it visually in [Studio](/studio-quickstart), deploy it to get a `pipeline_id`, then call it from your code with one API request. The pipeline orchestrates Classify, Parse, Extract, Split, and Edit steps behind the scenes, giving you a complete document workflow in a single call. ![Deploy Pipeline Modal](https://mintcdn.com/reducto/HIMlsEXojApe7G_x/images/deploy_pipeline_modal.png?fit=max\&auto=format\&n=HIMlsEXojApe7G_x\&q=85\&s=86921da7565caa3f2ea0778c7273b9e5) ## Why pipelines? Without pipelines, building a multi-step document workflow means writing separate API calls for each step. You call Parse, wait for the result, feed that into Extract, handle errors at each stage, and manage all the configuration in your application code. This works, but it couples your code tightly to Reducto's API structure and makes configuration changes require code deployments. Pipelines solve this by moving the workflow definition out of your code and into Studio. You configure the steps visually, test with real documents, and deploy. Your code then reduces to a single call: ```python theme={null} result = client.pipeline.run( input=upload, pipeline_id="k9798h9mwt0wmq5qz5e45qxbfx7yj4bq" ) ``` When you need to adjust extraction logic or add a processing step, you update the pipeline in Studio and redeploy. Your application code stays unchanged. ## Creating a pipeline in Studio Build your pipeline in [Studio](/studio-quickstart) by adding steps and configuring each one. The Studio guides cover each step type in detail: * [Parse](/studio-parse) for document conversion * [Extract](/studio-extract) for structured data extraction * [Split](/studio-split) for document sectioning * [Edit](/studio-edit) for form filling Once you're satisfied with the results, click **Deploy** in the top right. Select **Pipeline** as the deployment type and optionally provide a version name to track your changes. ![Deploy Pipeline 1](https://mintcdn.com/reducto/HIMlsEXojApe7G_x/images/deploy_pipeline_1.png?fit=max\&auto=format\&n=HIMlsEXojApe7G_x\&q=85\&s=65a37e52de2a006ac77e298a9201f6a2) Deploy dialog showing Pipeline option with version naming Studio generates a `pipeline_id` that you can copy directly into your code. This ID points to your exact configuration, so API calls always match what you tested in Studio. > [!NOTE] > > Changes made in Studio don't affect production until you deploy. This lets you iterate and test without impacting live systems. ## Updating a pipeline When you need to modify a deployed pipeline, make your changes in Studio and test with sample documents. Then click **Deploy**, select **Pipeline**, update the version name, and click **Redeploy**. The update takes effect immediately, and all API calls using that `pipeline_id` will use the new configuration. The **Activity** option shows all previous versions of your pipeline, letting you track what changed and when: ![Pipeline Activity Logs](https://mintcdn.com/reducto/HIMlsEXojApe7G_x/images/pipeline_activity_logs.png?fit=max\&auto=format\&n=HIMlsEXojApe7G_x\&q=85\&s=d46abd28fbbdb4fa8fa7d78c96bab3c2) Activity history showing pipeline versions The **Execution** **Logs** tab shows logs for when an API call hits your pipeline ID. For more details on pipeline management, see [Deploy to Production](/studio-deploy-pipeline). ## Pipeline types Studio determines the pipeline type based on which steps you add: | Type | Steps | Use case | | - | - | - | | **Parse** | Parse only | Convert documents to markdown, chunk for RAG | | **Parse → Extract** | Parse + Extract | Pull specific fields as JSON | | **Parse → Split → Extract** | Parse + Split + Extract(s) | Different schemas per document section | | **Edit** | Edit only | Fill forms, modify documents | ## Basic usage ```python Python theme={null} from pathlib import Path from reducto import Reducto client = Reducto() # Upload and run pipeline in one flow upload = client.upload(file=Path("document.pdf")) result = client.pipeline.run( input=upload.file_id, pipeline_id="your_pipeline_id" ) # Access results based on pipeline type if result.result.extract: # For Parse→Split→Extract pipelines, extract is a list if isinstance(result.result.extract, list): for section in result.result.extract: print(f"{section.split_name}: {section.result}") else: # For Parse→Extract pipelines, extract is an object print(result.result.extract.result) elif result.result.parse: for chunk in result.result.parse.result.chunks: print(chunk.content) ``` ```typescript TypeScript theme={null} import Reducto from "reductoai"; import fs from "fs"; const client = new Reducto(); const upload = await client.upload({ file: fs.createReadStream("document.pdf") }); const result = await client.pipeline.run({ input: upload.file_id, pipeline_id: "your_pipeline_id" }); if (result.result.extract) { // For Parse→Split→Extract pipelines, extract is an array if (Array.isArray(result.result.extract)) { result.result.extract.forEach(section => { console.log(`${section.split_name}: ${JSON.stringify(section.result)}`); }); } else { // For Parse→Extract pipelines, extract is an object console.log(result.result.extract.result); } } else if (result.result.parse) { result.result.parse.result.chunks.forEach(chunk => { console.log(chunk.content); }); } ``` ```bash cURL theme={null} curl -X POST "https://platform.reducto.ai/pipeline" \ -H "Authorization: Bearer $REDUCTO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "input": "reducto://your-uploaded-file.pdf", "pipeline_id": "your_pipeline_id" }' ``` ```go Go theme={null} package main import ( "bytes" "encoding/json" "fmt" "io" "net/http" "os" ) func main() { // Go SDK does not yet support pipelines; use HTTP directly payload := map[string]string{ "input": "reducto://your-uploaded-file.pdf", "pipeline_id": "your_pipeline_id", } body, _ := json.Marshal(payload) req, _ := http.NewRequest("POST", "https://platform.reducto.ai/pipeline", bytes.NewReader(body)) req.Header.Set("Authorization", "Bearer "+os.Getenv("REDUCTO_API_KEY")) req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) if err != nil { fmt.Printf("Error: %v\n", err) return } defer resp.Body.Close() result, _ := io.ReadAll(resp.Body) fmt.Println(string(result)) } ``` > [!NOTE] > > The Go SDK does not yet support the Pipeline endpoint. Use the HTTP example above or the cURL snippet as a reference for Go implementations. ## Response structure Every pipeline returns a `PipelineResponse` with the same shape. Which fields are populated depends on the pipeline type you configured in Studio. ```json theme={null} { "job_id": "pipeline-abc123", "usage": {"num_pages": 3, "credits": 6.0}, "result": { "parse": {...}, "extract": {...}, "split": {...}, "edit": {...} } } ``` The `parse` field is present for Parse, Parse→Extract, and Parse→Split→Extract pipelines. The `extract` field appears as an object for Parse→Extract pipelines, or as an array for Parse→Split→Extract pipelines where each entry corresponds to a section. The `split` field only appears when Split is configured. The `edit` field only appears for Edit pipelines. > [accordion] Parse pipeline response > > ```json theme={null} > { > "job_id": "abc123", > "usage": {"num_pages": 3, "credits": 4.0}, > "result": { > "parse": { > "job_id": "parse-456", > "result": { > "chunks": [ > {"content": "# Title\n\nParagraph text...", "blocks": [...]} > ] > }, > "usage": {"num_pages": 3, "credits": 4.0} > }, > "extract": null, > "split": null > } > } > ``` > [accordion] Parse → Extract pipeline response > > ```json theme={null} > { > "job_id": "abc123", > "usage": {"num_pages": 3, "credits": 6.0}, > "result": { > "parse": {"job_id": "parse-456", "result": {...}, "usage": {...}}, > "extract": { > "job_id": "extract-789", > "result": { > "invoiceNumber": {"value": "INV-001", "citations": [...]}, > "totalAmount": {"value": "$1,500.00", "citations": [...]} > }, > "usage": {"credits": 2.0} > }, > "split": null > } > } > ``` > [accordion] Parse → Split → Extract pipeline response > > When Split is involved, `extract` becomes an array with one entry per section: > > ```json theme={null} > { > "job_id": "abc123", > "usage": {"num_pages": 10, "credits": 12.0}, > "result": { > "parse": {...}, > "split": { > "result": { > "splits": [ > {"name": "Summary", "pages": [1, 2]}, > {"name": "Details", "pages": [3, 4, 5]} > ] > } > }, > "extract": [ > { > "split_name": "Summary", > "page_range": [1, 2], > "result": {"totalValue": {"value": "$274,222", "citations": [...]}} > }, > { > "split_name": "Details", > "page_range": [3, 4, 5], > "result": {"holdings": [...]} > } > ] > } > } > ``` > [accordion] Edit pipeline response > > Edit pipelines return a URL to the modified document. Note that for edit pipelines, the `input` parameter contains the edit instructions rather than a document URL. The document to edit is configured in Studio as part of the pipeline. > > ```json theme={null} > { > "job_id": "abc123", > "usage": {"num_pages": 0, "credits": null}, > "result": { > "parse": null, > "extract": null, > "split": null, > "edit": { > "job_id": "edit-999", > "result": {"file_url": "https://storage.reducto.ai/edited-doc.pdf"} > } > } > } > ``` ## Next steps > [card] [Studio Quickstart](/studio-quickstart) > > Build and deploy your first pipeline in Studio. > [card] [Multi-document Pipelines](/workflows/multi-document-pipelines) > > Process multiple documents in a single call. > [card] [Deploy to Production](/studio-deploy-pipeline) > > Manage pipeline versions and view execution logs. > [card] [Async Processing](/workflows/async-overview) > > Run pipelines asynchronously with webhooks. # Svix Webhooks Source: https://docs.reducto.ai/workflows/svix-webhooks Enterprise-grade webhook delivery with signing, retries, and dashboard Svix webhooks provide cryptographic request signing, automatic retries with exponential backoff, and a delivery dashboard for debugging. Use Svix for production applications. ## Accessing the Svix dashboard You can access your Svix webhook dashboard in two ways: > [tab] Via Studio > > In [Reducto Studio](https://studio.reducto.ai), go to **Webhooks** in the sidebar. This opens your Svix dashboard directly. > [tab] Via API > > Call the `/configure_webhook` endpoint to get a dashboard URL: > > ```python Python theme={null} > import requests > > response = requests.post( > "https://platform.reducto.ai/configure_webhook", > headers={"Authorization": "Bearer YOUR_API_KEY"}, > ) > print(response.text) # Returns Svix dashboard URL > ``` > > ```typescript TypeScript theme={null} > const response = await fetch("https://platform.reducto.ai/configure_webhook", { > method: "POST", > headers: { "Authorization": "Bearer YOUR_API_KEY" } > }); > console.log(await response.text()); > ``` > > ```bash cURL theme={null} > curl -X POST https://platform.reducto.ai/configure_webhook \ > -H "Authorization: Bearer $REDUCTO_API_KEY" > ``` ## Adding an endpoint In the Svix dashboard, click **+ Add Endpoint** to configure where webhooks are delivered: ![Add endpoint dialog in Svix](https://mintcdn.com/reducto/Ya0_HQxb9L1990IV/images/svix-add-endpoint.png?fit=max\&auto=format\&n=Ya0_HQxb9L1990IV\&q=85\&s=6b254e98f34be9f81628a59e271c3634) Enter your endpoint URL (must be HTTPS for production). You can use [webhook.site](https://webhook.site) for testing. Once added, you'll see your endpoint in the list: ![Svix endpoints list](https://mintcdn.com/reducto/Ya0_HQxb9L1990IV/images/svix-endpoints.png?fit=max\&auto=format\&n=Ya0_HQxb9L1990IV\&q=85\&s=5b80ff423defbe53c7a7393537652d4c) ## Submitting jobs with webhooks When submitting async jobs, include the webhook configuration: ```python Python theme={null} from reducto import Reducto client = Reducto() job = client.parse.run_job( input="https://example.com/document.pdf", async_={ "webhook": { "mode": "svix", "channels": [] # Optional: route to specific endpoints }, "metadata": { "user_id": "123", "document_type": "invoice" } } ) print(f"Job ID: {job.job_id}") ``` ```typescript TypeScript theme={null} import Reducto from "reductoai"; const client = new Reducto(); const job = await client.parse.runJob({ input: "https://example.com/document.pdf", async: { webhook: { mode: "svix", channels: [] }, metadata: { userId: "123", documentType: "invoice" } } }); ``` ```bash cURL theme={null} curl -X POST https://platform.reducto.ai/parse_async \ -H "Authorization: Bearer $REDUCTO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "input": "https://example.com/document.pdf", "async": { "webhook": {"mode": "svix", "channels": []}, "metadata": {"user_id": "123"} } }' ``` Works with all async endpoints: `/parse_async`, `/extract_async`, `/split_async`, `/pipeline_async`. ## Viewing deliveries When a job completes, Reducto sends an `async.update` event to Svix, which delivers it to your endpoint. You can monitor deliveries in the dashboard: ![Successful webhook delivery in Svix](https://mintcdn.com/reducto/Ya0_HQxb9L1990IV/images/svix-webhook-success.png?fit=max\&auto=format\&n=Ya0_HQxb9L1990IV\&q=85\&s=94f788a985333e5972627d2cbb74227e) The dashboard shows: * **Delivery stats**: Success/failure rates * **Message attempts**: Each delivery with timestamp and status * **Signing secret**: For verifying webhooks in your handler ## Webhook payload Your endpoint receives: ```json theme={null} { "status": "Completed", "job_id": "a1b9090e-c9ae-420b-9726-f658afbbe338", "metadata": { "user_id": "123", "document_type": "invoice" } } ``` The `status` is either `Completed` or `Failed`. Use `job_id` to retrieve results. ## Handling webhooks with signature verification Always verify webhook signatures in production. Get your signing secret from the Svix dashboard (starts with `whsec_`): ```python Python theme={null} from flask import Flask, request, jsonify from reducto import Reducto from svix.webhooks import Webhook, WebhookVerificationError app = Flask(__name__) client = Reducto() WEBHOOK_SECRET = "whsec_your_secret" # From Svix dashboard @app.route('/webhook', methods=['POST']) def handle_webhook(): # Verify signature wh = Webhook(WEBHOOK_SECRET) try: payload = wh.verify(request.data, { 'svix-id': request.headers.get('svix-id'), 'svix-timestamp': request.headers.get('svix-timestamp'), 'svix-signature': request.headers.get('svix-signature') }) except WebhookVerificationError: return jsonify({"error": "Invalid signature"}), 401 # Process webhook if payload['status'] == "Completed": job = client.job.get(payload['job_id']) # Use job.result return jsonify({"received": True}), 200 ``` ```typescript TypeScript theme={null} import express from 'express'; import Reducto from "reductoai"; import { Webhook } from "svix"; const app = express(); const client = new Reducto(); const WEBHOOK_SECRET = "whsec_your_secret"; // From Svix dashboard app.post('/webhook', express.raw({ type: 'application/json' }), async (req, res) => { const wh = new Webhook(WEBHOOK_SECRET); let payload; try { payload = wh.verify(req.body, { 'svix-id': req.headers['svix-id'] as string, 'svix-timestamp': req.headers['svix-timestamp'] as string, 'svix-signature': req.headers['svix-signature'] as string }); } catch { return res.status(401).json({ error: "Invalid signature" }); } if (payload.status === "Completed") { const job = await client.job.retrieve(payload.job_id); // Use job.result } res.status(200).json({ received: true }); }); ``` ## Channel routing Use channels to route webhooks to different endpoints (e.g., production vs development): ```python theme={null} # Production endpoint receives this client.parse.run_job( input="https://example.com/document.pdf", async_={"webhook": {"mode": "svix", "channels": ["production"]}} ) # Development endpoint receives this client.parse.run_job( input="https://example.com/test.pdf", async_={"webhook": {"mode": "svix", "channels": ["development"]}} ) ``` Configure which channels each endpoint listens to in the Svix dashboard under endpoint settings. ## Troubleshooting > [accordion] Webhooks not received > > 1. Check the Svix dashboard "Message Attempts" for delivery status > 2. Verify your endpoint URL is publicly accessible > 3. Ensure your endpoint returns 2xx status codes within 15 seconds > [accordion] Signature verification failing > > 1. Verify you're using the correct secret from the Svix dashboard > 2. Pass the raw request body (not parsed JSON) to verification > 3. Check all three headers are present: `svix-id`, `svix-timestamp`, `svix-signature` > [accordion] Webhook timeouts > > Return 2xx immediately and process results asynchronously. Svix expects responses within 15 seconds. > [accordion] How do I replay a failed webhook? > > In the Svix dashboard, click on a failed message attempt and use the "Resend" button to replay it. ## Best practices 1. **Always verify signatures** in production 2. **Return quickly** (within 15 seconds), process results asynchronously 3. **Be idempotent**: Svix may retry, use `svix-id` header as idempotency key 4. **Use HTTPS** for your webhook endpoint For simpler use cases or prototyping, see [Direct Webhooks](/workflows/direct-webhooks). # Cancel Job Source: https://docs.reducto.ai/api-reference/cancel-job openapi.json post /cancel/{job_id} # Delete Job Source: https://docs.reducto.ai/api-reference/delete-job openapi.json delete /job/{job_id} Asynchronously delete a job's stored artifacts. Tags the job with the deletion marker. Retrieval returns 409 until artifact cleanup finishes, then 410 once the deletion has completed. # Delete Upload Source: https://docs.reducto.ai/api-reference/delete-upload openapi.json delete /upload/{file_id} Delete a previously uploaded file. Removes the stored file for the given `reducto://` file ID. Deletion is immediate and permanent, and returns 404 if the file does not exist or was not uploaded by your organization. Jobs that already ran against the file are unaffected; delete those separately with `DELETE /job/{job_id}`. # Get Jobs Source: https://docs.reducto.ai/api-reference/get-jobs openapi.json get /jobs # Get Version Source: https://docs.reducto.ai/api-reference/get-version openapi.json get /version # Retrieve Job Source: https://docs.reducto.ai/api-reference/retrieve-parse openapi.json get /job/{job_id} # Webhook Portal Source: https://docs.reducto.ai/api-reference/webhook-portal openapi.json post /configure_webhook # Parallel Document Processing Source: https://docs.reducto.ai/cookbooks/batch-processing Process hundreds of documents concurrently using AsyncReducto with progress tracking and error handling Process a real invoice dataset from Hugging Face using `AsyncReducto` with progress tracking and error handling. *** ## Sample Dataset We'll use the [Northwind Invoices dataset](https://huggingface.co/datasets/AyoubChLin/northwind_invocies) from Hugging Face, which contains 831 PDF invoices. Each invoice includes customer information, line items, and totals. ![Sample Northwind invoice showing customer details, line items, and totals](https://mintcdn.com/reducto/9Avr4qdsIoNo7JLQ/images/northwind-invoices.png?fit=max\&auto=format\&n=9Avr4qdsIoNo7JLQ\&q=85\&s=6b113127e133e16b7ccc00ecf41a5d6c) *** ## Create API Key **1. Open Studio** Go to [studio.reducto.ai](https://studio.reducto.ai) and sign in. From the home page, click **API Keys** in the left sidebar. ![Studio home page with API Keys in sidebar](https://mintcdn.com/reducto/9Avr4qdsIoNo7JLQ/cookbooks/dummy-docs/screenshots/api-1.png?fit=max\&auto=format\&n=9Avr4qdsIoNo7JLQ\&q=85\&s=6fda1435e042681807741c7743273da2) **2. View API Keys** The API Keys page shows your existing keys. Click **Create API Key** in the top right corner. ![API Keys page with Create button](https://mintcdn.com/reducto/9Avr4qdsIoNo7JLQ/cookbooks/dummy-docs/screenshots/api-2.png?fit=max\&auto=format\&n=9Avr4qdsIoNo7JLQ\&q=85\&s=10db7406c2ac7217e4b1d75e028b58e1) **3. Configure Key** In the modal, enter a name for your key and set an expiration policy (or select "Never" for no expiration). Click **Create**. ![New API Key modal with name and expiration fields](https://mintcdn.com/reducto/9Avr4qdsIoNo7JLQ/cookbooks/dummy-docs/screenshots/api-3.png?fit=max\&auto=format\&n=9Avr4qdsIoNo7JLQ\&q=85\&s=afb60f6cfb4d33940669d534dd007343) **4. Copy Your Key** Copy your new API key and store it securely. You won't be able to see it again after closing this dialog. ![Copy API key dialog](https://mintcdn.com/reducto/9Avr4qdsIoNo7JLQ/cookbooks/dummy-docs/screenshots/api-4.png?fit=max\&auto=format\&n=9Avr4qdsIoNo7JLQ\&q=85\&s=c861b1c2f593244957cf15c6fd717f60) Set the key as an environment variable: ```bash theme={null} export REDUCTO_API_KEY="your-api-key-here" ``` *** ## Download the Dataset First, download the Northwind invoices dataset using the Hugging Face libraries: ```bash Python theme={null} pip install datasets ``` ```bash JavaScript theme={null} npm install @huggingface/hub ``` ```python Python theme={null} from datasets import load_dataset from pathlib import Path # Load the dataset dataset = load_dataset("AyoubChLin/northwind_invocies") # Create output directory output_dir = Path("./northwind_invoices") output_dir.mkdir(exist_ok=True) # Save PDFs to disk for i, sample in enumerate(dataset["train"]): pdf_path = output_dir / f"invoice_{i:04d}.pdf" pdf = sample["pdf"] pdf.stream.seek(0) with open(pdf_path, "wb") as f: f.write(pdf.stream.read()) print(f"Downloaded {len(dataset['train'])} invoices to {output_dir}") ``` ```javascript JavaScript theme={null} import { listFiles, downloadFile } from "@huggingface/hub"; import fs from "fs"; import path from "path"; // Load the dataset const repo = { type: "dataset", name: "AyoubChLin/northwind_invocies" }; // Create output directory const outputDir = "./northwind_invoices"; fs.mkdirSync(outputDir, { recursive: true }); // List and download PDF files let count = 0; for await (const fileInfo of listFiles({ repo })) { if (fileInfo.path.endsWith(".pdf")) { const response = await downloadFile({ repo, path: fileInfo.path }); const buffer = Buffer.from(await response.arrayBuffer()); const outputPath = path.join(outputDir, `invoice_${String(count).padStart(4, "0")}.pdf`); fs.writeFileSync(outputPath, buffer); count++; if (count % 100 === 0) console.log(`Downloaded ${count} invoices...`); } } console.log(`Downloaded ${count} invoices to ${outputDir}`); ``` ``` Downloaded 831 invoices to ./northwind_invoices ``` You now have 831 PDF invoices ready to process. *** ## Process the Batch Document processing is network-bound, not CPU-bound. While your code waits for one API response, it could be uploading and processing other documents. Python uses `AsyncReducto` with `asyncio`, while JavaScript uses `Promise.all()` with the `p-limit` package for concurrency control. ```python Python theme={null} import asyncio import json from pathlib import Path from reducto import AsyncReducto from tqdm.asyncio import tqdm client = AsyncReducto() async def process_invoices( input_dir: Path, output_dir: Path, max_concurrency: int = 50 ): """Process all invoices concurrently with progress tracking.""" output_dir.mkdir(exist_ok=True) files = list(input_dir.glob("*.pdf")) print(f"Found {len(files)} invoices to process") # Semaphore limits concurrent requests sem = asyncio.Semaphore(max_concurrency) async def process(path: Path): async with sem: try: upload = await client.upload(file=path) result = await client.parse.run(input=upload.file_id) # Save result immediately output_path = output_dir / f"{path.stem}.json" output_path.write_text(json.dumps({ "source": path.name, "pages": result.usage.num_pages, "content": [c.content for c in result.result.chunks] }, indent=2)) return {"file": path.name, "success": True, "pages": result.usage.num_pages} except Exception as e: return {"file": path.name, "success": False, "error": str(e)} # Process all files with progress bar tasks = [process(f) for f in files] results = await tqdm.gather(*tasks, desc="Processing invoices") # Summary successes = [r for r in results if r["success"]] failures = [r for r in results if not r["success"]] total_pages = sum(r["pages"] for r in successes) print(f"\nCompleted: {len(successes)} succeeded, {len(failures)} failed") print(f"Total pages processed: {total_pages}") if failures: print("\nFailed files:") for f in failures[:5]: # Show first 5 failures print(f" - {f['file']}: {f['error']}") return results # Run the batch results = asyncio.run(process_invoices( input_dir=Path("./northwind_invoices"), output_dir=Path("./parsed_invoices") )) ``` ```javascript JavaScript theme={null} import Reducto from "reductoai"; import fs from "fs"; import path from "path"; import pLimit from "p-limit"; // npm install p-limit const client = new Reducto(); async function processInvoices(inputDir, outputDir, maxConcurrency = 50) { fs.mkdirSync(outputDir, { recursive: true }); const files = fs.readdirSync(inputDir) .filter(f => f.endsWith(".pdf")) .map(f => path.join(inputDir, f)); console.log(`Found ${files.length} invoices to process`); // p-limit controls concurrency (like Python's Semaphore) const limit = pLimit(maxConcurrency); async function processFile(filePath) { try { const upload = await client.upload({ file: fs.createReadStream(filePath) }); const result = await client.parse.run({ input: upload.file_id }); // Save result immediately const outputPath = path.join(outputDir, `${path.basename(filePath, ".pdf")}.json`); fs.writeFileSync(outputPath, JSON.stringify({ source: path.basename(filePath), pages: result.usage.num_pages, content: result.result.chunks.map(c => c.content) }, null, 2)); return { file: path.basename(filePath), success: true, pages: result.usage.num_pages }; } catch (e) { return { file: path.basename(filePath), success: false, error: e.message }; } } // Process all files with concurrency limit const results = await Promise.all(files.map(f => limit(() => processFile(f)))); // Summary const successes = results.filter(r => r.success); const failures = results.filter(r => !r.success); const totalPages = successes.reduce((sum, r) => sum + r.pages, 0); console.log(`\nCompleted: ${successes.length} succeeded, ${failures.length} failed`); console.log(`Total pages processed: ${totalPages}`); if (failures.length > 0) { console.log("\nFailed files:"); failures.slice(0, 5).forEach(f => console.log(` - ${f.file}: ${f.error}`)); } return results; } // Run the batch const results = await processInvoices("./northwind_invoices", "./parsed_invoices"); ``` **Output:** ``` Found 831 invoices to process Processing invoices: 100%|██████████| 831/831 [03:42<00:00, 3.73it/s] Completed: 831 succeeded, 0 failed Total pages processed: 831 ``` *** ## Extract Structured Data To extract specific fields like invoice numbers, totals, and line items, use the Extract API with a schema: ```python Python theme={null} import asyncio import json from pathlib import Path from reducto import AsyncReducto from tqdm.asyncio import tqdm client = AsyncReducto() # Schema for Northwind invoices invoice_schema = { "type": "object", "properties": { "order_id": {"type": "string", "description": "Order ID at top of invoice"}, "customer_name": {"type": "string", "description": "Ship To customer name"}, "order_date": {"type": "string", "description": "Order date"}, "shipped_date": {"type": "string", "description": "Shipped date"}, "ship_address": {"type": "string", "description": "Full shipping address"}, "line_items": { "type": "array", "description": "Products ordered", "items": { "type": "object", "properties": { "product": {"type": "string"}, "quantity": {"type": "number"}, "unit_price": {"type": "number"}, "discount": {"type": "number"}, "extended_price": {"type": "number"} } } }, "subtotal": {"type": "number"}, "freight": {"type": "number"}, "total": {"type": "number"} } } async def extract_invoices( input_dir: Path, output_dir: Path, max_concurrency: int = 30 ): """Extract structured data from invoices.""" output_dir.mkdir(exist_ok=True) files = list(input_dir.glob("*.pdf"))[:100] # Process first 100 for demo print(f"Extracting from {len(files)} invoices") sem = asyncio.Semaphore(max_concurrency) async def extract(path: Path): async with sem: try: upload = await client.upload(file=path) result = await client.extract.run( input=upload.file_id, instructions={"schema": invoice_schema} ) output_path = output_dir / f"{path.stem}.json" output_path.write_text(json.dumps(result.result, indent=2)) return {"file": path.name, "success": True, "data": result.result} except Exception as e: return {"file": path.name, "success": False, "error": str(e)} tasks = [extract(f) for f in files] results = await tqdm.gather(*tasks, desc="Extracting") successes = [r for r in results if r["success"]] print(f"\nExtracted {len(successes)} invoices") # Calculate totals across all invoices grand_total = sum(r["data"].get("total", 0) or 0 for r in successes) print(f"Grand total across invoices: ${grand_total:,.2f}") return results results = asyncio.run(extract_invoices( input_dir=Path("./northwind_invoices"), output_dir=Path("./extracted_invoices") )) ``` ```javascript JavaScript theme={null} import Reducto from "reductoai"; import fs from "fs"; import path from "path"; import pLimit from "p-limit"; const client = new Reducto(); // Schema for Northwind invoices const invoiceSchema = { type: "object", properties: { order_id: { type: "string", description: "Order ID at top of invoice" }, customer_name: { type: "string", description: "Ship To customer name" }, order_date: { type: "string", description: "Order date" }, shipped_date: { type: "string", description: "Shipped date" }, ship_address: { type: "string", description: "Full shipping address" }, line_items: { type: "array", description: "Products ordered", items: { type: "object", properties: { product: { type: "string" }, quantity: { type: "number" }, unit_price: { type: "number" }, discount: { type: "number" }, extended_price: { type: "number" } } } }, subtotal: { type: "number" }, freight: { type: "number" }, total: { type: "number" } } }; async function extractInvoices(inputDir, outputDir, maxConcurrency = 30) { fs.mkdirSync(outputDir, { recursive: true }); const files = fs.readdirSync(inputDir) .filter(f => f.endsWith(".pdf")) .slice(0, 100); // Process first 100 for demo console.log(`Extracting from ${files.length} invoices`); const limit = pLimit(maxConcurrency); async function extractFile(fileName) { const filePath = path.join(inputDir, fileName); try { const upload = await client.upload({ file: fs.createReadStream(filePath) }); const result = await client.extract.run({ input: upload.file_id, instructions: { schema: invoiceSchema } }); const outputPath = path.join(outputDir, `${path.basename(fileName, ".pdf")}.json`); fs.writeFileSync(outputPath, JSON.stringify(result.result, null, 2)); return { file: fileName, success: true, data: result.result[0] }; } catch (e) { return { file: fileName, success: false, error: e.message }; } } const results = await Promise.all(files.map(f => limit(() => extractFile(f)))); const successes = results.filter(r => r.success); console.log(`\nExtracted ${successes.length} invoices`); // Calculate totals across all invoices const grandTotal = successes.reduce((sum, r) => sum + (r.data?.total || 0), 0); console.log(`Grand total across invoices: $${grandTotal.toFixed(2)}`); return results; } const results = await extractInvoices("./northwind_invoices", "./extracted_invoices"); ``` **Output:** ``` Extracting from 100 invoices Extracting: 100%|██████████| 100/100 [01:45<00:00, 1.05s/it] Extracted 100 invoices Grand total across invoices: $128,347.52 ``` *** ## Cost Optimization with Job Chaining Parse once, extract multiple times. When you need different extractions from the same documents, reuse the parse job ID to avoid re-parsing: ```python Python theme={null} import asyncio from pathlib import Path from reducto import AsyncReducto client = AsyncReducto() async def extract_multiple_schemas(files: list[Path]): """Parse once, extract with multiple schemas.""" results = [] for path in files: # Parse once upload = await client.upload(file=path) parse_result = await client.parse.run(input=upload.file_id) job_id = parse_result.job_id # Extract headers (reuses parse) header_result = await client.extract.run( input=f"jobid://{job_id}", instructions={"schema": { "type": "object", "properties": { "invoice_number": {"type": "string"}, "customer_name": {"type": "string"}, "date": {"type": "string"} } }} ) # Extract line items (still reuses parse - no extra parse cost!) items_result = await client.extract.run( input=f"jobid://{job_id}", instructions={"schema": { "type": "object", "properties": { "line_items": { "type": "array", "items": { "type": "object", "properties": { "product": {"type": "string"}, "quantity": {"type": "number"}, "price": {"type": "number"} } } }, "total": {"type": "number"} } }} ) results.append({ "file": path.name, "header": header_result.result, "items": items_result.result }) return results ``` ```javascript JavaScript theme={null} import Reducto from "reductoai"; import fs from "fs"; const client = new Reducto(); async function extractMultipleSchemas(files) { const results = []; for (const filePath of files) { // Parse once const upload = await client.upload({ file: fs.createReadStream(filePath) }); const parseResult = await client.parse.run({ input: upload.file_id }); const jobId = parseResult.job_id; // Extract headers (reuses parse) const headerResult = await client.extract.run({ input: `jobid://${jobId}`, instructions: { schema: { type: "object", properties: { invoice_number: { type: "string" }, customer_name: { type: "string" }, date: { type: "string" } } } } }); // Extract line items (still reuses parse - no extra parse cost!) const itemsResult = await client.extract.run({ input: `jobid://${jobId}`, instructions: { schema: { type: "object", properties: { line_items: { type: "array", items: { type: "object", properties: { product: { type: "string" }, quantity: { type: "number" }, price: { type: "number" } } } }, total: { type: "number" } } } } }); results.push({ file: filePath, header: headerResult.result[0], items: itemsResult.result[0] }); } return results; } ``` > [!TIP] > > The `jobid://` prefix tells Reducto to reuse an existing parse result. You only pay for parsing once, regardless of how many extractions you run. *** ## Handling Failures Add retry logic with exponential backoff to handle transient network errors: ```python Python theme={null} import asyncio import random from pathlib import Path from reducto import AsyncReducto client = AsyncReducto() async def process_with_retry(path: Path, max_retries: int = 3): """Process a document with exponential backoff on failures.""" for attempt in range(max_retries): try: upload = await client.upload(file=path) result = await client.parse.run(input=upload.file_id) return {"file": path.name, "success": True, "pages": result.usage.num_pages} except Exception as e: if attempt == max_retries - 1: return {"file": path.name, "success": False, "error": str(e)} wait_time = (2 ** attempt) + random.uniform(0, 1) await asyncio.sleep(wait_time) async def process_batch_with_retries(input_dir: Path, max_concurrency: int = 50): """Process a batch with automatic retries.""" files = list(input_dir.glob("*.pdf")) sem = asyncio.Semaphore(max_concurrency) async def process(path): async with sem: return await process_with_retry(path) tasks = [process(f) for f in files] results = await asyncio.gather(*tasks) successes = [r for r in results if r["success"]] failures = [r for r in results if not r["success"]] print(f"Completed: {len(successes)} succeeded, {len(failures)} failed") if failures: print("Failed files:") for f in failures: print(f" - {f['file']}: {f['error']}") return results ``` ```javascript JavaScript theme={null} import Reducto from "reductoai"; import fs from "fs"; import pLimit from "p-limit"; const client = new Reducto(); async function processWithRetry(filePath, maxRetries = 3) { for (let attempt = 0; attempt < maxRetries; attempt++) { try { const upload = await client.upload({ file: fs.createReadStream(filePath) }); const result = await client.parse.run({ input: upload.file_id }); return { file: filePath, success: true, pages: result.usage.num_pages }; } catch (e) { if (attempt === maxRetries - 1) { return { file: filePath, success: false, error: e.message }; } const waitTime = Math.pow(2, attempt) * 1000 + Math.random() * 1000; await new Promise(r => setTimeout(r, waitTime)); } } } async function processBatchWithRetries(inputDir, maxConcurrency = 50) { const files = fs.readdirSync(inputDir) .filter(f => f.endsWith(".pdf")) .map(f => `${inputDir}/${f}`); const limit = pLimit(maxConcurrency); const results = await Promise.all(files.map(f => limit(() => processWithRetry(f)))); const successes = results.filter(r => r.success); const failures = results.filter(r => !r.success); console.log(`Completed: ${successes.length} succeeded, ${failures.length} failed`); if (failures.length > 0) { console.log("Failed files:"); failures.forEach(f => console.log(` - ${f.file}: ${f.error}`)); } return results; } ``` *** ## Monitoring Job Status When using webhooks or async jobs, you can poll for job status: ```python Python theme={null} import asyncio from reducto import AsyncReducto client = AsyncReducto() async def wait_for_job(job_id: str, timeout: int = 300): """Poll until job completes or times out.""" for _ in range(timeout): status = await client.job.get(job_id) if status.status == "Completed": return {"success": True, "status": status} elif status.status == "Failed": return {"success": False, "status": status} await asyncio.sleep(1) return {"success": False, "error": "Timeout"} # Example: Submit job and wait async def submit_and_wait(path): upload = await client.upload(file=path) job = await client.parse.run_job(input=upload.file_id) print(f"Submitted job {job.job_id}, waiting...") result = await wait_for_job(job.job_id) if result["success"]: print(f"Job completed!") else: print(f"Job failed or timed out") return result ``` ```javascript JavaScript theme={null} import Reducto from "reductoai"; import fs from "fs"; const client = new Reducto(); async function waitForJob(jobId, timeout = 300) { for (let i = 0; i < timeout; i++) { const status = await client.job.retrieve(jobId); if (status.status === "Completed") { return { success: true, status }; } else if (status.status === "Failed") { return { success: false, status }; } await new Promise(r => setTimeout(r, 1000)); } return { success: false, error: "Timeout" }; } // Example: Submit job and wait async function submitAndWait(filePath) { const upload = await client.upload({ file: fs.createReadStream(filePath) }); const job = await client.parse.runJob({ input: upload.file_id }); console.log(`Submitted job ${job.job_id}, waiting...`); const result = await waitForJob(job.job_id); if (result.success) { console.log("Job completed!"); } else { console.log("Job failed or timed out"); } return result; } ``` > [!NOTE] > > For production workloads, prefer webhooks over polling. Webhooks are more efficient and don't require keeping connections open. *** ## Why Concurrency Control? The semaphore (`asyncio.Semaphore` in Python, `p-limit` in JavaScript) limits how many requests run simultaneously. Without it, submitting 831 files would create 831 concurrent connections, overwhelming both your system and the API. The concurrency limiter acts as a queue, letting only `max_concurrency` requests proceed at once. | File Size | Recommended Concurrency | | - | - | | Small (\< 5 pages) | 50-100 | | Medium (5-50 pages) | 20-50 | | Large (50+ pages) | 10-20 | Start conservative and increase if stable. Larger files consume more memory per request, so lower concurrency prevents memory issues. *** ## Fire-and-Forget with Webhooks For very large batches where you don't want to wait for results, use webhooks. Submit all jobs immediately and receive results as they complete via HTTP callbacks. ```python Python theme={null} import asyncio from pathlib import Path from reducto import AsyncReducto client = AsyncReducto() async def submit_with_webhooks( input_dir: Path, webhook_url: str, max_concurrency: int = 100 ): """Submit documents for processing with webhook notifications.""" files = list(input_dir.glob("*.pdf")) sem = asyncio.Semaphore(max_concurrency) async def submit(path: Path): async with sem: upload = await client.upload(file=path) job = await client.parse.run_job( input=upload.file_id, async_={ "webhook": {"mode": "direct", "url": webhook_url}, "metadata": {"filename": path.name} } ) return {"file": path.name, "job_id": job.job_id} tasks = [submit(f) for f in files] submissions = await asyncio.gather(*tasks) print(f"Submitted {len(submissions)} jobs - results will arrive via webhook") return submissions # Submit batch asyncio.run(submit_with_webhooks( input_dir=Path("./northwind_invoices"), webhook_url="https://your-app.com/webhook/reducto" )) ``` ```javascript JavaScript theme={null} import Reducto from "reductoai"; import fs from "fs"; import path from "path"; import pLimit from "p-limit"; const client = new Reducto(); async function submitWithWebhooks(inputDir, webhookUrl, maxConcurrency = 100) { const files = fs.readdirSync(inputDir) .filter(f => f.endsWith(".pdf")) .map(f => path.join(inputDir, f)); const limit = pLimit(maxConcurrency); async function submitFile(filePath) { const upload = await client.upload({ file: fs.createReadStream(filePath) }); const job = await client.parse.runJob({ input: upload.file_id, async_: { webhook: { mode: "direct", url: webhookUrl }, metadata: { filename: path.basename(filePath) } } }); return { file: path.basename(filePath), job_id: job.job_id }; } const submissions = await Promise.all(files.map(f => limit(() => submitFile(f)))); console.log(`Submitted ${submissions.length} jobs - results will arrive via webhook`); return submissions; } // Submit batch await submitWithWebhooks("./northwind_invoices", "https://your-app.com/webhook/reducto"); ``` Your webhook receives a payload when each job completes: ```json theme={null} { "status": "Completed", "job_id": "abc123", "metadata": {"filename": "invoice_0001.pdf"} } ``` Fetch the result in your handler with `client.job.get(job_id)`. *** ## Sync Alternative (Python) If you can't use async in Python, use threading with the synchronous `Reducto` client. > [!NOTE] > > This section is Python-specific. The JavaScript SDK is async by default—all methods return Promises and work naturally with `async/await` and `Promise.all()`. No threading is needed in JavaScript. ```python theme={null} from concurrent.futures import ThreadPoolExecutor, as_completed from pathlib import Path from reducto import Reducto client = Reducto() def process_sync(input_dir: Path, max_workers: int = 10): files = list(input_dir.glob("*.pdf")) def process(path: Path): upload = client.upload(file=path) result = client.parse.run(input=upload.file_id) return {"file": path.name, "pages": result.usage.num_pages} results = [] with ThreadPoolExecutor(max_workers=max_workers) as executor: futures = {executor.submit(process, f): f for f in files} for future in as_completed(futures): try: results.append(future.result()) except Exception as e: results.append({"file": futures[future].name, "error": str(e)}) return results ``` Threading works but is less efficient than async for I/O-bound work. Use lower concurrency (10-20 workers) to avoid thread overhead. *** ## Next Steps > [card] [Async Processing](/workflows/async-overview) > > Deep dive into async jobs and job lifecycle > [card] [Webhooks](/workflows/svix-webhooks) > > Production webhook setup with Svix > [card] [Rate Limits](/reference/rate-limits) > > Understand per-account concurrency limits for batch sizing > [card] [Job Management](/sdk/python/job-management) > > Monitor and manage async jobs # Layout & Table Extraction to Analytics Source: https://docs.reducto.ai/cookbooks/financial-analysis Extract and analyze financial data from 10-K annual reports using Parse, Extract, and table extraction Extract structured financial data from SEC 10-K filings for investment analysis, competitive intelligence, or compliance automation. *** ## Sample Document [https://cdn.reducto.ai/samples/10k-apple.pdf](https://cdn.reducto.ai/samples/10k-apple.pdf) > [!NOTE] > > Download the sample: [10k-apple.pdf](https://cdn.reducto.ai/samples/10k-apple.pdf) *** ## Create API Key **1. Open Studio** Go to [studio.reducto.ai](https://studio.reducto.ai) and sign in. From the home page, click **API Keys** in the left sidebar. ![Studio home page with API Keys in sidebar](https://mintcdn.com/reducto/9Avr4qdsIoNo7JLQ/cookbooks/dummy-docs/screenshots/api-1.png?fit=max\&auto=format\&n=9Avr4qdsIoNo7JLQ\&q=85\&s=6fda1435e042681807741c7743273da2) **2. View API Keys** The API Keys page shows your existing keys. Click **Create API Key** in the top right corner. ![API Keys page with Create button](https://mintcdn.com/reducto/9Avr4qdsIoNo7JLQ/cookbooks/dummy-docs/screenshots/api-2.png?fit=max\&auto=format\&n=9Avr4qdsIoNo7JLQ\&q=85\&s=10db7406c2ac7217e4b1d75e028b58e1) **3. Configure Key** In the modal, enter a name for your key and set an expiration policy (or select "Never" for no expiration). Click **Create**. ![New API Key modal with name and expiration fields](https://mintcdn.com/reducto/9Avr4qdsIoNo7JLQ/cookbooks/dummy-docs/screenshots/api-3.png?fit=max\&auto=format\&n=9Avr4qdsIoNo7JLQ\&q=85\&s=afb60f6cfb4d33940669d534dd007343) **4. Copy Your Key** Copy your new API key and store it securely. You won't be able to see it again after closing this dialog. ![Copy API key dialog](https://mintcdn.com/reducto/9Avr4qdsIoNo7JLQ/cookbooks/dummy-docs/screenshots/api-4.png?fit=max\&auto=format\&n=9Avr4qdsIoNo7JLQ\&q=85\&s=c861b1c2f593244957cf15c6fd717f60) Set the key as an environment variable: ```bash theme={null} export REDUCTO_API_KEY="your-api-key-here" ``` *** ## Studio Walkthrough **1. Create an Extract Pipeline** Go to [studio.reducto.ai](https://studio.reducto.ai) and create an Extract pipeline. Upload the 10-K PDF. Extract runs Parse under the hood, then uses an LLM to locate and return only the specific fields you define. This is ideal for pulling financial metrics from 10-K filings. **2. Configure Parse Settings** Before defining your schema, check the Parse settings that Extract will use. Open the **Configurations** tab to access settings. ![Parse view with Configurations showing settings options](https://mintcdn.com/reducto/9Avr4qdsIoNo7JLQ/cookbooks/dummy-docs/screenshots/apple-10k-1.png?fit=max\&auto=format\&n=9Avr4qdsIoNo7JLQ\&q=85\&s=f4bee0232c029486d9cf6fb3f388859b) Key settings for financial documents: * **Enable AI Summarization** — Generate summaries of figures and charts * **Return Figure/Table Images** — Include extracted images of charts and tables **3. View Parse Results** Click **Run** to see the parsed content. Reducto parses financial tables with their structure preserved, including multi-year comparative data. ![Parse results showing extracted financial table with gross margin data](https://mintcdn.com/reducto/9Avr4qdsIoNo7JLQ/cookbooks/dummy-docs/screenshots/apple-10k-parse-result-2.png?fit=max\&auto=format\&n=9Avr4qdsIoNo7JLQ\&q=85\&s=05faf892bba9c0358f09a6bf4ef4086e) Notice how the Products and Services gross margin table keeps proper row/column structure with data for 2023, 2022, and 2021. If a value doesn't appear in Parse output, Extract can't find it either. **4. Switch to Extract and Import Schema** Click **Extract** to switch views. While Parse gives you the full document content, Extract lets you define a schema to pull only the specific fields you need as structured JSON. For 10-K analysis, we want to extract two categories of data: * **Company info** — Name, ticker symbol, fiscal year end, and SEC CIK number from the cover page * **Income statement** — Key metrics like total revenue, cost of sales, gross profit, operating expenses, and operating income from the financial statements Click **Import** in the Schema Builder to paste this pre-defined JSON schema: ```json theme={null} { "type": "object", "properties": { "company_info": { "type": "object", "properties": { "name": { "type": "string", "description": "Company name from cover page" }, "ticker": { "type": "string", "description": "Stock ticker symbol" }, "fiscal_year_end": { "type": "string", "description": "Fiscal year end date" }, "cik": { "type": "string", "description": "SEC CIK number" } } }, "income_statement": { "type": "object", "description": "Data from Consolidated Statements of Operations", "properties": { "total_revenue": { "type": "number", "description": "Total net sales/revenue in millions" }, "cost_of_sales": { "type": "number", "description": "Cost of goods sold in millions" }, "gross_profit": { "type": "number", "description": "Gross profit (revenue minus COGS)" }, "operating_expenses": { "type": "number", "description": "Total operating expenses" }, "operating_income": { "type": "number", "description": "Operating income" } } } } } ``` ![Paste JSON Schema modal showing company\_info and income\_statement schema](https://mintcdn.com/reducto/9Avr4qdsIoNo7JLQ/cookbooks/dummy-docs/screenshots/apple-10k-upload-schema-json-3.png?fit=max\&auto=format\&n=9Avr4qdsIoNo7JLQ\&q=85\&s=a0701d31835ed5286a3ccfdfab64e9c5) **5. Run and View Results** The Schema Builder shows your imported schema with nested structure. Click **Run** to execute the extraction. The Results tab shows your extracted data matching your schema. Use the toolbar to copy or download the results or switch to JSON. ![Extraction results showing company\_info and income\_statement data](https://mintcdn.com/reducto/9Avr4qdsIoNo7JLQ/images/apple-10k-result.png?fit=max\&auto=format\&n=9Avr4qdsIoNo7JLQ\&q=85\&s=eed06cf32fa79ecf63b2b3e27777295e) *** ## Using the API To run this extraction programmatically, click **Deploy** in Studio. ![Deploy Pipeline dialog showing Pipeline and Direct API Call options](https://mintcdn.com/reducto/9Avr4qdsIoNo7JLQ/images/apple-10k-deployment.png?fit=max\&auto=format\&n=9Avr4qdsIoNo7JLQ\&q=85\&s=b5cc24a8166c11642b55c4805beaa55f) **Pipeline ID** creates a stable endpoint with a single identifier. Your code stays simple regardless of workflow complexity. Update settings in Studio and redeploy without changing code. ```python Python theme={null} from pathlib import Path from reducto import Reducto client = Reducto() upload = client.upload(file=Path("10k-apple.pdf")) result = client.pipeline.run( input=upload, pipeline_id="k97c67gd5jahhs5anj3zr1gqd97zadrg" ) ``` ```typescript TypeScript theme={null} import Reducto from "reductoai"; import fs from "fs"; const client = new Reducto(); const upload = await client.upload({ file: fs.createReadStream("10k-apple.pdf"), }); const result = await client.pipeline.run({ input: upload, pipelineId: "k97c67gd5jahhs5anj3zr1gqd97zadrg", }); ``` ```bash cURL theme={null} # First, upload your file curl -X POST "https://platform.reducto.ai/upload" \ -H "Authorization: Bearer $REDUCTO_API_KEY" \ -F "file=@10k-apple.pdf" # This returns a response like: {"upload_id": "abc123"} # Use the upload_id in the next request # Run the pipeline curl -X POST "https://platform.reducto.ai/pipeline" \ -H "Authorization: Bearer $REDUCTO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "input": "reducto://abc123", "pipeline_id": "k97c67gd5jahhs5anj3zr1gqd97zadrg" }' ``` **Direct API Call** exports the raw configuration as code. Use this when you need runtime flexibility or want configuration in version control. ```python Python theme={null} from pathlib import Path from reducto import Reducto client = Reducto() upload_response = client.upload(file=Path("10k-apple.pdf")) instructions = { "schema": { "type": "object", "properties": { "company_info": { "type": "object", "properties": { "name": {"type": "string", "description": "Company name from cover page"}, "ticker": {"type": "string", "description": "Stock ticker symbol"}, "fiscal_year_end": {"type": "string", "description": "Fiscal year end date"}, "cik": {"type": "string", "description": "SEC CIK number"} }, "required": ["name", "ticker", "fiscal_year_end", "cik"] }, "income_statement": { "type": "object", "description": "Data from Consolidated Statements of Operations", "properties": { "total_revenue": {"type": "number", "description": "Total net sales/revenue in millions"}, "cost_of_sales": {"type": "number", "description": "Cost of goods sold in millions"}, "gross_profit": {"type": "number", "description": "Gross profit (revenue minus COGS)"}, "operating_expenses": {"type": "number", "description": "Total operating expenses"}, "operating_income": {"type": "number", "description": "Operating income"} }, "required": ["total_revenue", "cost_of_sales", "gross_profit", "operating_expenses", "operating_income"] } }, "required": ["company_info", "income_statement"] } } settings = { "citations": {"enabled": True, "numerical_confidence": False} } result = client.extract.run( input=upload_response, instructions=instructions, settings=settings ) print(result) ``` ```typescript TypeScript theme={null} import Reducto from 'reductoai'; import fs from 'fs'; const client = new Reducto(); const uploadResponse = await client.upload({ file: fs.createReadStream("10k-apple.pdf") }); const instructions = { "schema": { "type": "object", "properties": { "company_info": { "type": "object", "properties": { "name": {"type": "string", "description": "Company name from cover page"}, "ticker": {"type": "string", "description": "Stock ticker symbol"}, "fiscal_year_end": {"type": "string", "description": "Fiscal year end date"}, "cik": {"type": "string", "description": "SEC CIK number"} }, "required": ["name", "ticker", "fiscal_year_end", "cik"] }, "income_statement": { "type": "object", "description": "Data from Consolidated Statements of Operations", "properties": { "total_revenue": {"type": "number", "description": "Total net sales/revenue in millions"}, "cost_of_sales": {"type": "number", "description": "Cost of goods sold in millions"}, "gross_profit": {"type": "number", "description": "Gross profit (revenue minus COGS)"}, "operating_expenses": {"type": "number", "description": "Total operating expenses"}, "operating_income": {"type": "number", "description": "Operating income"} }, "required": ["total_revenue", "cost_of_sales", "gross_profit", "operating_expenses", "operating_income"] } }, "required": ["company_info", "income_statement"] } }; const settings = { "citations": {"enabled": true, "numerical_confidence": false} }; const result = await client.extract.run({ input: uploadResponse, instructions, settings }); ``` ```bash cURL theme={null} # First, upload your file curl -X POST "https://platform.reducto.ai/upload" \ -H "Authorization: Bearer $REDUCTO_API_KEY" \ -F "file=@10k-apple.pdf" # This returns a response like: {"upload_id": "abc123"} # Use the upload_id in the next request # Extract the document curl -X POST "https://platform.reducto.ai/extract" \ -H "Authorization: Bearer $REDUCTO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "input": "abc123", "instructions": { "schema": { "type": "object", "properties": { "company_info": { "type": "object", "properties": { "name": {"type": "string", "description": "Company name from cover page"}, "ticker": {"type": "string", "description": "Stock ticker symbol"}, "fiscal_year_end": {"type": "string", "description": "Fiscal year end date"}, "cik": {"type": "string", "description": "SEC CIK number"} }, "required": ["name", "ticker", "fiscal_year_end", "cik"] }, "income_statement": { "type": "object", "description": "Data from Consolidated Statements of Operations", "properties": { "total_revenue": {"type": "number", "description": "Total net sales/revenue in millions"}, "cost_of_sales": {"type": "number", "description": "Cost of goods sold in millions"}, "gross_profit": {"type": "number", "description": "Gross profit (revenue minus COGS)"}, "operating_expenses": {"type": "number", "description": "Total operating expenses"}, "operating_income": {"type": "number", "description": "Operating income"} }, "required": ["total_revenue", "cost_of_sales", "gross_profit", "operating_expenses", "operating_income"] } }, "required": ["company_info", "income_statement"] } }, "settings": { "citations": {"enabled": true, "numerical_confidence": false} } }' ``` *** ## Next Steps > [card] [Table Formats](/configs/parse/table-output-formats) > > Configure table extraction > [card] [Chunking Methods](/configs/parse/chunking-methods) > > Optimize document sectioning > [card] [Array Extraction](/configs/extract/array-extraction) > > Extract multi-year data > [card] [Pipeline Basics](/workflows/pipeline-basics) > > Chain multiple operations # ACORD Insurance Forms Source: https://docs.reducto.ai/cookbooks/form-filling Extract policy data from declarations pages and fill ACORD 25 certificates ACORD 25 is the standard certificate of liability insurance in the US. Agencies issue hundreds daily, pulling data from policy declarations pages. This cookbook extracts declarations data and maps it to ACORD 25 fields. *** ## Sample Documents ### ACORD 25 Certificate This is a blank ACORD 25 Certificate of Liability Insurance. We'll extract data from a declarations page and use it to fill this form. ![](https://mintcdn.com/reducto/9Avr4qdsIoNo7JLQ/images/accord-25-sample.png?fit=max\&auto=format\&n=9Avr4qdsIoNo7JLQ\&q=85\&s=1f7592b9be590e083bf908289348fc88) > [!NOTE] > > View the sample form: [ACORD 25 Certificate of Liability Insurance (PDF)](https://mykraftlake.btisinc.com/Portals/0/Users/011/11/11/25%20Certificate%20of%20Liability%20Insurance.pdf) The ACORD 25 is a standardized form with specific field locations for policy numbers, coverage limits, and dates. The Edit API detects these fields automatically. ### Declarations Page (Source Data) The declarations page contains all policy details: insured name, policy numbers, effective dates, and coverage limits. This is the source data we'll extract. [https://r-ranchca.com/wp-content/uploads/2021/06/2021-Umbrella-Declaration-Pages.pdf](https://r-ranchca.com/wp-content/uploads/2021/06/2021-Umbrella-Declaration-Pages.pdf) *** ## Create API Key **1. Open Studio** Go to [studio.reducto.ai](https://studio.reducto.ai) and sign in. From the home page, click **API Keys** in the left sidebar. ![Studio home page with API Keys in sidebar](https://mintcdn.com/reducto/9Avr4qdsIoNo7JLQ/cookbooks/dummy-docs/screenshots/api-1.png?fit=max\&auto=format\&n=9Avr4qdsIoNo7JLQ\&q=85\&s=6fda1435e042681807741c7743273da2) **2. View API Keys** The API Keys page shows your existing keys. Click **Create API Key** in the top right corner. ![API Keys page with Create button](https://mintcdn.com/reducto/9Avr4qdsIoNo7JLQ/cookbooks/dummy-docs/screenshots/api-2.png?fit=max\&auto=format\&n=9Avr4qdsIoNo7JLQ\&q=85\&s=10db7406c2ac7217e4b1d75e028b58e1) **3. Configure Key** In the modal, enter a name for your key and set an expiration policy (or select "Never" for no expiration). Click **Create**. ![New API Key modal with name and expiration fields](https://mintcdn.com/reducto/9Avr4qdsIoNo7JLQ/cookbooks/dummy-docs/screenshots/api-3.png?fit=max\&auto=format\&n=9Avr4qdsIoNo7JLQ\&q=85\&s=afb60f6cfb4d33940669d534dd007343) **4. Copy Your Key** Copy your new API key and store it securely. You won't be able to see it again after closing this dialog. ![Copy API key dialog](https://mintcdn.com/reducto/9Avr4qdsIoNo7JLQ/cookbooks/dummy-docs/screenshots/api-4.png?fit=max\&auto=format\&n=9Avr4qdsIoNo7JLQ\&q=85\&s=c861b1c2f593244957cf15c6fd717f60) Set the key as an environment variable: ```bash theme={null} export REDUCTO_API_KEY="your-api-key-here" ``` *** ## Part 1: Extract from Declarations Page The declarations page contains all the policy details needed to fill an ACORD 25: insured name, policy number, effective/expiration dates, and coverage limits. ### Studio Walkthrough **1. Upload Declarations PDF** Go to [studio.reducto.ai](https://studio.reducto.ai) and create an **Extract** pipeline. Upload the declarations page PDF. Extract reads the document and lets you define a schema to pull specific fields as structured JSON. **2. Build the Schema** In the Schema Builder, define fields matching the declarations page structure. Click **Add Field** for each: * `policy_number` (text) - "Policy number from the declarations page" * `named_insured` (object) - "Policyholder information" * `name` (text) - "Insured name" * `address` (text) - "Mailing address" * `effective_date` (text) - "Policy effective date" * `expiration_date` (text) - "Policy expiration date" * `insurer_name` (text) - "Insurance company name" * `umbrella_occurrence_limit` (number) - "Each occurrence limit" * `umbrella_aggregate_limit` (number) - "Aggregate limit" ![Schema Builder showing policy fields](https://mintcdn.com/reducto/9Avr4qdsIoNo7JLQ/images/declarations-schema.png?fit=max\&auto=format\&n=9Avr4qdsIoNo7JLQ\&q=85\&s=f049c7570f1ca4b7080d3b824e7c6573) Schema Builder with declarations page fields defined Field descriptions help the LLM locate the right values. Be specific about where each field appears. See the [Complete Schema](#complete-schema) below for a copy-paste version. **3. Run Extraction** Click **Run**. The Results tab shows extracted data as JSON: ![Extraction results showing policy data](https://mintcdn.com/reducto/9Avr4qdsIoNo7JLQ/images/declarations-result.png?fit=max\&auto=format\&n=9Avr4qdsIoNo7JLQ\&q=85\&s=0f4055809a2b89a24daab335183622b4) Extraction results from the declarations page ```json theme={null} { "policy_number": "PHUB754850", "named_insured": { "name": "R Ranch Property Owners Association", "address": "PO Box 71, Hornbrook, CA 96044-0071" }, "effective_date": "02/01/2021", "expiration_date": "02/01/2022", "insurer_name": "Philadelphia Indemnity Insurance Company", "umbrella_occurrence_limit": 2000000, "umbrella_aggregate_limit": 2000000 } ``` ### Using the API Let's walk through extracting policy data step by step. #### Step 1: Initialize the client Start by importing Reducto and creating a client. The client reads your API key from the `REDUCTO_API_KEY` environment variable automatically. ```python Python theme={null} from reducto import Reducto client = Reducto() ``` ```javascript JavaScript theme={null} import Reducto from "reductoai"; const client = new Reducto(); ``` ```bash cURL theme={null} # Set your API key as an environment variable export REDUCTO_API_KEY="your-api-key-here" ``` #### Step 2: Upload your document Upload the declarations page PDF. This returns a `file_id` that you'll use for extraction. Reducto stores the file temporarily so you can process it without re-uploading. ```python Python theme={null} from pathlib import Path upload = client.upload(file=Path("declarations.pdf")) print(f"Uploaded: {upload.file_id}") ``` ```javascript JavaScript theme={null} import fs from "fs"; const upload = await client.upload({ file: fs.createReadStream("declarations.pdf"), }); console.log(`Uploaded: ${upload.file_id}`); ``` ```bash cURL theme={null} UPLOAD=$(curl -s -X POST "https://platform.reducto.ai/upload" \ -H "Authorization: Bearer $REDUCTO_API_KEY" \ -F "file=@declarations.pdf") FILE_ID=$(echo $UPLOAD | jq -r '.file_id') echo "Uploaded: $FILE_ID" ``` #### Step 3: Define the schema structure The schema tells Reducto what fields to extract. Start with the basic structure: ```python Python theme={null} declarations_schema = { "type": "object", "properties": { # We'll add fields here } } ``` ```javascript JavaScript theme={null} const declarationsSchema = { type: "object", properties: { // We'll add fields here } }; ``` ```json JSON Schema theme={null} { "type": "object", "properties": { } } ``` Every extraction schema is an object with properties. Each property becomes a key in your JSON output. #### Step 4: Add simple fields Add the basic policy fields. Each field needs a `type` and a `description`. The description is important because it tells the LLM where to find the value. ```python Python theme={null} declarations_schema = { "type": "object", "properties": { "policy_number": { "type": "string", "description": "Policy number from declarations page header" }, "effective_date": { "type": "string", "description": "Policy effective date (MM/DD/YYYY)" }, "expiration_date": { "type": "string", "description": "Policy expiration date (MM/DD/YYYY)" }, "insurer_name": { "type": "string", "description": "Insurance company name" } } } ``` ```javascript JavaScript theme={null} const declarationsSchema = { type: "object", properties: { policy_number: { type: "string", description: "Policy number from declarations page header" }, effective_date: { type: "string", description: "Policy effective date (MM/DD/YYYY)" }, expiration_date: { type: "string", description: "Policy expiration date (MM/DD/YYYY)" }, insurer_name: { type: "string", description: "Insurance company name" } } }; ``` ```json JSON Schema theme={null} { "type": "object", "properties": { "policy_number": { "type": "string", "description": "Policy number from declarations page header" }, "effective_date": { "type": "string", "description": "Policy effective date (MM/DD/YYYY)" }, "expiration_date": { "type": "string", "description": "Policy expiration date (MM/DD/YYYY)" }, "insurer_name": { "type": "string", "description": "Insurance company name" } } } ``` Why descriptions matter: "Policy number" alone is ambiguous if the page has multiple numbers. Adding "from declarations page header" helps the LLM find the right one. #### Step 5: Add nested objects For grouped information like the insured's details, use a nested object: ```python Python theme={null} "named_insured": { "type": "object", "properties": { "name": { "type": "string", "description": "Insured name" }, "address": { "type": "string", "description": "Mailing address" } } } ``` ```javascript JavaScript theme={null} named_insured: { type: "object", properties: { name: { type: "string", description: "Insured name" }, address: { type: "string", description: "Mailing address" } } } ``` ```json JSON Schema theme={null} "named_insured": { "type": "object", "properties": { "name": { "type": "string", "description": "Insured name" }, "address": { "type": "string", "description": "Mailing address" } } } ``` Nested objects keep related fields together in your output, making it easier to work with the data. #### Step 6: Add coverage limits For numeric values like coverage limits, use `type: "number"`. Reducto will return these as integers or floats, not strings. ```python Python theme={null} "umbrella_occurrence_limit": { "type": "number", "description": "Each occurrence limit in dollars" }, "umbrella_aggregate_limit": { "type": "number", "description": "Aggregate limit in dollars" } ``` ```javascript JavaScript theme={null} umbrella_occurrence_limit: { type: "number", description: "Each occurrence limit in dollars" }, umbrella_aggregate_limit: { type: "number", description: "Aggregate limit in dollars" } ``` ```json JSON Schema theme={null} "umbrella_occurrence_limit": { "type": "number", "description": "Each occurrence limit in dollars" }, "umbrella_aggregate_limit": { "type": "number", "description": "Aggregate limit in dollars" } ``` ### Complete Schema Here's the complete schema you can copy and paste: ```json JSON Schema theme={null} { "type": "object", "properties": { "policy_number": { "type": "string", "description": "Policy number from declarations page header" }, "named_insured": { "type": "object", "properties": { "name": { "type": "string", "description": "Insured name" }, "address": { "type": "string", "description": "Mailing address" } } }, "effective_date": { "type": "string", "description": "Policy effective date (MM/DD/YYYY)" }, "expiration_date": { "type": "string", "description": "Policy expiration date (MM/DD/YYYY)" }, "insurer_name": { "type": "string", "description": "Insurance company name" }, "umbrella_occurrence_limit": { "type": "number", "description": "Each occurrence limit in dollars" }, "umbrella_aggregate_limit": { "type": "number", "description": "Aggregate limit in dollars" } } } ``` ```python Python theme={null} declarations_schema = { "type": "object", "properties": { "policy_number": { "type": "string", "description": "Policy number from declarations page header" }, "named_insured": { "type": "object", "properties": { "name": {"type": "string", "description": "Insured name"}, "address": {"type": "string", "description": "Mailing address"} } }, "effective_date": { "type": "string", "description": "Policy effective date (MM/DD/YYYY)" }, "expiration_date": { "type": "string", "description": "Policy expiration date (MM/DD/YYYY)" }, "insurer_name": { "type": "string", "description": "Insurance company name" }, "umbrella_occurrence_limit": { "type": "number", "description": "Each occurrence limit in dollars" }, "umbrella_aggregate_limit": { "type": "number", "description": "Aggregate limit in dollars" } } } ``` ```javascript JavaScript theme={null} const declarationsSchema = { type: "object", properties: { policy_number: { type: "string", description: "Policy number from declarations page header" }, named_insured: { type: "object", properties: { name: { type: "string", description: "Insured name" }, address: { type: "string", description: "Mailing address" } } }, effective_date: { type: "string", description: "Policy effective date (MM/DD/YYYY)" }, expiration_date: { type: "string", description: "Policy expiration date (MM/DD/YYYY)" }, insurer_name: { type: "string", description: "Insurance company name" }, umbrella_occurrence_limit: { type: "number", description: "Each occurrence limit in dollars" }, umbrella_aggregate_limit: { type: "number", description: "Aggregate limit in dollars" } } }; ``` #### Step 7: Run the extraction Pass your schema to the Extract API using the `instructions` parameter: ```python Python theme={null} result = client.extract.run( input=upload.file_id, instructions={"schema": declarations_schema} ) ``` ```javascript JavaScript theme={null} const result = await client.extract.run({ input: upload.file_id, instructions: { schema: declarationsSchema } }); ``` ```bash cURL theme={null} curl -X POST "https://platform.reducto.ai/extract" \ -H "Authorization: Bearer $REDUCTO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "input": "'$FILE_ID'", "instructions": { "schema": { "type": "object", "properties": { "policy_number": {"type": "string", "description": "Policy number"}, "named_insured": { "type": "object", "properties": { "name": {"type": "string"}, "address": {"type": "string"} } }, "effective_date": {"type": "string"}, "expiration_date": {"type": "string"}, "insurer_name": {"type": "string"}, "umbrella_occurrence_limit": {"type": "number"}, "umbrella_aggregate_limit": {"type": "number"} } } } }' ``` The `instructions` parameter wraps your schema. This structure allows for additional options like system prompts. #### Step 8: Access the results The extracted data is in `result.result[0]` (the API returns an array for multi-document support). Each field from your schema becomes a key in the response: ```python Python theme={null} policy_data = result.result[0] print(f"Policy: {policy_data['policy_number']}") print(f"Insured: {policy_data['named_insured']['name']}") print(f"Coverage: ${policy_data['umbrella_occurrence_limit']:,}") ``` ```javascript JavaScript theme={null} const policyData = result.result[0]; console.log(`Policy: ${policyData.policy_number}`); console.log(`Insured: ${policyData.named_insured.name}`); console.log(`Coverage: $${policyData.umbrella_occurrence_limit.toLocaleString()}`); ``` ```bash cURL theme={null} # The response JSON contains the result field (an array): # { # "result": [{ # "policy_number": "PHUB754850", # "named_insured": { # "name": "R Ranch Property Owners Association", # "address": "PO Box 71, Hornbrook, CA 96044-0071" # }, # ... # }] # } # Use jq to extract values: echo $RESULT | jq '.result[0].policy_number' echo $RESULT | jq '.result[0].named_insured.name' ``` **Output:** ``` Policy: PHUB754850 Insured: R Ranch Property Owners Association Coverage: $2,000,000 ``` *** ## Part 2: Fill ACORD 25 Certificate Use the extracted policy data to fill a blank ACORD 25 certificate. The Edit API detects form fields and fills them based on natural language instructions. ### Studio Walkthrough **1. Upload Blank ACORD 25** Create an **Edit** pipeline in Studio. Upload a blank ACORD 25 PDF. Edit detects all fillable fields in the form: text boxes, checkboxes, and dropdowns. **2. Write Fill Instructions** In the Edit Instructions panel, describe what values to fill. Reference the data you extracted: ``` Fill this ACORD 25 Certificate with: INSURED: - Name: R Ranch Property Owners Association - Address: PO Box 71, Hornbrook, CA 96044-0071 INSURER A: - Company: Philadelphia Indemnity Insurance Company UMBRELLA LIABILITY: - Policy Number: PHUB754850 - Effective: 02/01/2021 - Expiration: 02/01/2022 - Each Occurrence: $2,000,000 - Aggregate: $2,000,000 - Check "Occurrence" for policy type ``` Be explicit about field locations when the form has similar fields (e.g., multiple policy number boxes). **3. Run and Download** Click **Run**. Edit fills the form and returns a download link. The filled PDF has all your values in the correct fields. ![Edit results showing filled ACORD 25 form](https://mintcdn.com/reducto/9Avr4qdsIoNo7JLQ/images/accord-result-studio.png?fit=max\&auto=format\&n=9Avr4qdsIoNo7JLQ\&q=85\&s=ac71c0f2fc83464fe624966767f3bca0) Filled ACORD 25 certificate ready for download ### Using the API Now let's fill the ACORD 25 with the extracted data. #### Step 1: Upload the blank form Upload the blank ACORD 25 PDF. Edit uses the same upload mechanism as Extract. ```python Python theme={null} from pathlib import Path upload = client.upload(file=Path("acord-25-blank.pdf")) ``` ```javascript JavaScript theme={null} const upload = await client.upload({ file: fs.createReadStream("acord-25-blank.pdf"), }); ``` ```bash cURL theme={null} UPLOAD=$(curl -s -X POST "https://platform.reducto.ai/upload" \ -H "Authorization: Bearer $REDUCTO_API_KEY" \ -F "file=@acord-25-blank.pdf") FILE_ID=$(echo $UPLOAD | jq -r '.file_id') ``` > [!NOTE] > > Edit uses `document_url` instead of `input`. This is because Edit modifies documents rather than reading them. #### Step 2: Write fill instructions Edit uses natural language instructions instead of a schema. Describe what values should go where. Reference the section names on the form to help Edit find the right fields. ```python Python theme={null} instructions = f""" Fill this ACORD 25 Certificate of Liability Insurance with: INSURED: - Name: {policy_data['named_insured']['name']} - Address: {policy_data['named_insured']['address']} INSURER A: - Company: {policy_data['insurer_name']} UMBRELLA LIABILITY SECTION: - Policy Number: {policy_data['policy_number']} - Effective Date: {policy_data['effective_date']} - Expiration Date: {policy_data['expiration_date']} - Each Occurrence limit: ${policy_data['umbrella_occurrence_limit']:,} - Aggregate limit: ${policy_data['umbrella_aggregate_limit']:,} - Check the "Occurrence" checkbox for umbrella policy type """ ``` ```javascript JavaScript theme={null} const instructions = ` Fill this ACORD 25 Certificate of Liability Insurance with: INSURED: - Name: ${policyData.named_insured.name} - Address: ${policyData.named_insured.address} INSURER A: - Company: ${policyData.insurer_name} UMBRELLA LIABILITY SECTION: - Policy Number: ${policyData.policy_number} - Effective Date: ${policyData.effective_date} - Expiration Date: ${policyData.expiration_date} - Each Occurrence limit: $${policyData.umbrella_occurrence_limit.toLocaleString()} - Aggregate limit: $${policyData.umbrella_aggregate_limit.toLocaleString()} - Check the "Occurrence" checkbox for umbrella policy type `; ``` ```bash cURL theme={null} # Build the instructions string with extracted values INSTRUCTIONS="Fill this ACORD 25 with: INSURED Name: R Ranch Property Owners Association, Address: PO Box 71, Hornbrook, CA 96044-0071. INSURER A: Philadelphia Indemnity Insurance Company. UMBRELLA LIABILITY: Policy Number PHUB754850, Effective 02/01/2021, Expiration 02/01/2022, Each Occurrence \$2,000,000, Aggregate \$2,000,000, check Occurrence checkbox." ``` Why natural language? ACORD forms have cryptic field names like `topmostSubform[0].Page1[0].f1_1[0]`. Edit uses AI to understand context, so "Insured Name" works even if the PDF field is named something obscure. #### Step 3: Run the Edit API Pass your instructions to the Edit API: ```python Python theme={null} result = client.edit.run( document_url=upload.file_id, edit_instructions=instructions ) ``` ```javascript JavaScript theme={null} const result = await client.edit.run({ document_url: upload.file_id, edit_instructions: instructions }); ``` ```bash cURL theme={null} curl -X POST "https://platform.reducto.ai/edit" \ -H "Authorization: Bearer $REDUCTO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "document_url": "'$FILE_ID'", "edit_instructions": "'"$INSTRUCTIONS"'" }' ``` Edit: 1. Detects all fillable fields in the PDF 2. Reads surrounding context (labels, headers) to understand each field 3. Maps your instructions to the appropriate fields 4. Fills the values and returns the modified document #### Step 4: Download the result The response includes a URL to download the filled form: ```python Python theme={null} print(f"Filled certificate: {result.document_url}") ``` ```javascript JavaScript theme={null} console.log(`Filled certificate: ${result.document_url}`); ``` ```bash cURL theme={null} # The response contains the document_url field: # { # "document_url": "https://storage.reducto.ai/filled-form.pdf?..." # } echo $RESULT | jq -r '.document_url' ``` This URL is a presigned link valid for 24 hours. Download or share it with the certificate recipient. *** ## Complete Workflow The full pipeline extracts data from a declarations page and fills an ACORD 25: ``` Declarations PDF → Extract → policy_data → Edit → Filled ACORD 25 ``` ```python Python theme={null} from pathlib import Path from reducto import Reducto client = Reducto() # Schema for declarations extraction declarations_schema = { "type": "object", "properties": { "policy_number": {"type": "string", "description": "Policy number"}, "named_insured": { "type": "object", "properties": { "name": {"type": "string"}, "address": {"type": "string"} } }, "effective_date": {"type": "string"}, "expiration_date": {"type": "string"}, "insurer_name": {"type": "string"}, "umbrella_occurrence_limit": {"type": "number"}, "umbrella_aggregate_limit": {"type": "number"} } } # 1. Extract from declarations dec_upload = client.upload(file=Path("declarations.pdf")) extraction = client.extract.run( input=dec_upload.file_id, instructions={"schema": declarations_schema} ) policy = extraction.result[0] # 2. Fill ACORD 25 acord_upload = client.upload(file=Path("acord-25-blank.pdf")) filled = client.edit.run( document_url=acord_upload.file_id, edit_instructions=f""" Fill this ACORD 25 Certificate of Liability Insurance: INSURED: - Name: {policy['named_insured']['name']} - Address: {policy['named_insured']['address']} INSURER A: {policy['insurer_name']} UMBRELLA LIABILITY: - Policy Number: {policy['policy_number']} - Effective: {policy['effective_date']} - Expiration: {policy['expiration_date']} - Each Occurrence: ${policy['umbrella_occurrence_limit']:,} - Aggregate: ${policy['umbrella_aggregate_limit']:,} - Check "Occurrence" for policy type """ ) # 3. Get the result print(f"Certificate ready: {filled.document_url}") ``` ```javascript JavaScript theme={null} import Reducto from "reductoai"; import fs from "fs"; const client = new Reducto(); // Schema for declarations extraction const declarationsSchema = { type: "object", properties: { policy_number: { type: "string", description: "Policy number" }, named_insured: { type: "object", properties: { name: { type: "string" }, address: { type: "string" } } }, effective_date: { type: "string" }, expiration_date: { type: "string" }, insurer_name: { type: "string" }, umbrella_occurrence_limit: { type: "number" }, umbrella_aggregate_limit: { type: "number" } } }; async function generateCertificate() { // 1. Extract from declarations const decUpload = await client.upload({ file: fs.createReadStream("declarations.pdf"), }); const extraction = await client.extract.run({ input: decUpload.file_id, instructions: { schema: declarationsSchema } }); const policy = extraction.result[0]; // 2. Fill ACORD 25 const acordUpload = await client.upload({ file: fs.createReadStream("acord-25-blank.pdf"), }); const filled = await client.edit.run({ document_url: acordUpload.file_id, edit_instructions: ` Fill this ACORD 25 Certificate of Liability Insurance: INSURED: - Name: ${policy.named_insured.name} - Address: ${policy.named_insured.address} INSURER A: ${policy.insurer_name} UMBRELLA LIABILITY: - Policy Number: ${policy.policy_number} - Effective: ${policy.effective_date} - Expiration: ${policy.expiration_date} - Each Occurrence: $${policy.umbrella_occurrence_limit.toLocaleString()} - Aggregate: $${policy.umbrella_aggregate_limit.toLocaleString()} - Check "Occurrence" for policy type ` }); // 3. Get the result console.log(`Certificate ready: ${filled.document_url}`); } generateCertificate(); ``` ```bash cURL theme={null} #!/bin/bash # 1. Upload declarations page DEC_UPLOAD=$(curl -s -X POST "https://platform.reducto.ai/upload" \ -H "Authorization: Bearer $REDUCTO_API_KEY" \ -F "file=@declarations.pdf") DEC_FILE_ID=$(echo $DEC_UPLOAD | jq -r '.file_id') # 2. Extract policy data EXTRACTION=$(curl -s -X POST "https://platform.reducto.ai/extract" \ -H "Authorization: Bearer $REDUCTO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "input": "'$DEC_FILE_ID'", "instructions": { "schema": { "type": "object", "properties": { "policy_number": {"type": "string"}, "named_insured": { "type": "object", "properties": { "name": {"type": "string"}, "address": {"type": "string"} } }, "effective_date": {"type": "string"}, "expiration_date": {"type": "string"}, "insurer_name": {"type": "string"}, "umbrella_occurrence_limit": {"type": "number"}, "umbrella_aggregate_limit": {"type": "number"} } } } }') # 3. Upload blank ACORD 25 ACORD_UPLOAD=$(curl -s -X POST "https://platform.reducto.ai/upload" \ -H "Authorization: Bearer $REDUCTO_API_KEY" \ -F "file=@acord-25-blank.pdf") ACORD_FILE_ID=$(echo $ACORD_UPLOAD | jq -r '.file_id') # 4. Fill the form (using extracted values) POLICY_NUM=$(echo $EXTRACTION | jq -r '.result[0].policy_number') INSURED_NAME=$(echo $EXTRACTION | jq -r '.result[0].named_insured.name') INSURED_ADDR=$(echo $EXTRACTION | jq -r '.result[0].named_insured.address') INSURER=$(echo $EXTRACTION | jq -r '.result[0].insurer_name') EFF_DATE=$(echo $EXTRACTION | jq -r '.result[0].effective_date') EXP_DATE=$(echo $EXTRACTION | jq -r '.result[0].expiration_date') OCC_LIMIT=$(echo $EXTRACTION | jq -r '.result[0].umbrella_occurrence_limit') AGG_LIMIT=$(echo $EXTRACTION | jq -r '.result[0].umbrella_aggregate_limit') FILLED=$(curl -s -X POST "https://platform.reducto.ai/edit" \ -H "Authorization: Bearer $REDUCTO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "document_url": "'$ACORD_FILE_ID'", "edit_instructions": "Fill ACORD 25: INSURED Name: '"$INSURED_NAME"', Address: '"$INSURED_ADDR"'. INSURER A: '"$INSURER"'. UMBRELLA: Policy '"$POLICY_NUM"', Effective '"$EFF_DATE"', Expiration '"$EXP_DATE"', Each Occurrence $'"$OCC_LIMIT"', Aggregate $'"$AGG_LIMIT"', check Occurrence." }') # 5. Get the result echo "Certificate ready: $(echo $FILLED | jq -r '.document_url')" ``` *** ## Tips ### Handling checkboxes ACORD 25 has multiple checkbox fields (policy types, coverage indicators). Be explicit in your instructions: ```python theme={null} edit_instructions = """ Check the "Occurrence" checkbox in the Umbrella Liability section. Check "Any Auto" in the Auto Liability section. Leave all other checkboxes unchecked. """ ``` ### Form schema for production For high-volume certificate generation, save the `form_schema` from your first Edit response. This skips field detection on subsequent calls. ```python Python theme={null} # First call - Edit detects fields and returns schema result = client.edit.run(document_url=upload.file_id, edit_instructions="...") saved_schema = result.form_schema # Save this # Subsequent calls - use saved schema (faster) result = client.edit.run( document_url=upload.file_id, edit_instructions="...", form_schema=saved_schema ) ``` ```javascript JavaScript theme={null} // First call - Edit detects fields and returns schema const result = await client.edit.run({ document_url: upload.file_id, edit_instructions: "..." }); const savedSchema = result.form_schema; // Save this // Subsequent calls - use saved schema (faster) const result2 = await client.edit.run({ document_url: upload.file_id, edit_instructions: "...", form_schema: savedSchema }); ``` ```bash cURL theme={null} # First call returns form_schema in response # Save it to a file: echo $RESULT | jq '.form_schema' > saved_schema.json # Subsequent calls - include form_schema parameter curl -X POST "https://platform.reducto.ai/edit" \ -H "Authorization: Bearer $REDUCTO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "document_url": "'$FILE_ID'", "edit_instructions": "...", "form_schema": '"$(cat saved_schema.json)"' }' ``` See [Form Schema](/configs/edit/form-schema) for details. *** ## Next Steps > [card] [Edit API Overview](/editing/edit-overview) > > Full Edit API documentation > [card] [Form Schema](/configs/edit/form-schema) > > Pre-define field locations for faster fills > [card] [Extract API](/extract/overview) > > Structured data extraction > [card] [Batch Processing](/cookbooks/batch-processing) > > Process many certificates at scale # ID & Onboarding Documents Source: https://docs.reducto.ai/cookbooks/identity-verification Verify user identity by extracting and cross-matching data from ID cards, utility bills, and tax forms KYC verification requires cross-checking identity across multiple documents: government IDs, utility bills, tax forms. Names appear differently ("IMA" vs "Ima"), addresses vary ("Street" vs "St"). This cookbook extracts identity fields from mixed document formats and builds verification logic to match them. *** ## Sample Documents > [tab] ID Card > > ![California Driver License](https://cdn.reducto.ai/samples/id-card.png) > > California Driver License showing: > > * Name: IMA CARDHOLDER > * Address: 2570 24TH STREET, ANYTOWN, CA 95818 > * DOB: 08/31/1977 > * DL Number: 11234568 > [tab] Utility Bill > > [https://cdn.reducto.ai/samples/sample-utility-bill.pdf](https://cdn.reducto.ai/samples/sample-utility-bill.pdf) > > PG\&E Energy Statement showing: > > * Name: IMA CARDHOLDER > * Address: 2570 24th Street, Andytown, CA 95818 > * Account: 0123456789-1 > [tab] W-9 Form > > [https://cdn.reducto.ai/samples/w9-sample.pdf](https://cdn.reducto.ai/samples/w9-sample.pdf) > > IRS Form W-9 showing: > > * Name: Ima Cardholder > * Address: 2570 24th Street, Andytown, CA 95818 > * SSN: 012-34-5678 > [!NOTE] > > Download samples: [id-card.png](https://cdn.reducto.ai/samples/id-card.png) | [utility-bill.pdf](https://cdn.reducto.ai/samples/sample-utility-bill.pdf) | [w9-form.pdf](https://cdn.reducto.ai/samples/w9-sample.pdf) Notice the variations already visible: * **Name case**: "IMA CARDHOLDER" (ID) vs "Ima Cardholder" (W-9) * **City spelling**: "ANYTOWN" (ID) vs "Andytown" (utility bill) * **Street format**: "24TH STREET" vs "24th Street" These are the same person, same address. Our verification code needs to handle these variations. *** ## Create API Key **1. Open Studio** Go to [studio.reducto.ai](https://studio.reducto.ai) and sign in. From the home page, click **API Keys** in the left sidebar. ![Studio home page with API Keys in sidebar](https://mintcdn.com/reducto/9Avr4qdsIoNo7JLQ/cookbooks/dummy-docs/screenshots/api-1.png?fit=max\&auto=format\&n=9Avr4qdsIoNo7JLQ\&q=85\&s=6fda1435e042681807741c7743273da2) **2. View API Keys** The API Keys page shows your existing keys. Click **Create API Key** in the top right corner. ![API Keys page with Create button](https://mintcdn.com/reducto/9Avr4qdsIoNo7JLQ/cookbooks/dummy-docs/screenshots/api-2.png?fit=max\&auto=format\&n=9Avr4qdsIoNo7JLQ\&q=85\&s=10db7406c2ac7217e4b1d75e028b58e1) **3. Configure Key** In the modal, enter a name for your key and set an expiration policy (or select "Never" for no expiration). Click **Create**. ![New API Key modal with name and expiration fields](https://mintcdn.com/reducto/9Avr4qdsIoNo7JLQ/cookbooks/dummy-docs/screenshots/api-3.png?fit=max\&auto=format\&n=9Avr4qdsIoNo7JLQ\&q=85\&s=afb60f6cfb4d33940669d534dd007343) **4. Copy Your Key** Copy your new API key and store it securely. You won't be able to see it again after closing this dialog. ![Copy API key dialog](https://mintcdn.com/reducto/9Avr4qdsIoNo7JLQ/cookbooks/dummy-docs/screenshots/api-4.png?fit=max\&auto=format\&n=9Avr4qdsIoNo7JLQ\&q=85\&s=c861b1c2f593244957cf15c6fd717f60) Set the key as an environment variable: ```bash theme={null} export REDUCTO_API_KEY="your-api-key-here" ``` *** ## Verification Workflow **1. Upload Documents** User submits ID card, utility bill, and W-9 form **2. Extract Data** Reducto extracts name, address, and identifiers from each document **3. Normalize Fields** Standardize names and addresses for comparison **4. Cross-Match** Compare fields across documents to verify consistency **5. Return Result** Pass or fail based on matching criteria *** ## Step 1: Define Extraction Schemas Each document type needs a tailored schema. The key is writing good field descriptions that tell the LLM where to find each value. ### ID Card Schema Government IDs have structured layouts with clear field labels. We extract both identity fields and the ID's validity period. ```json JSON Schema theme={null} { "type": "object", "properties": { "document_type": { "type": "string", "description": "Type of ID: driver_license, passport, state_id" }, "full_name": { "type": "string", "description": "Full legal name as shown on ID" }, "first_name": { "type": "string", "description": "First name" }, "last_name": { "type": "string", "description": "Last name / surname" }, "address": { "type": "string", "description": "Street address on ID" }, "city": { "type": "string", "description": "City" }, "state": { "type": "string", "description": "State abbreviation (e.g., CA, NY)" }, "zip_code": { "type": "string", "description": "ZIP code" }, "date_of_birth": { "type": "string", "description": "Date of birth in YYYY-MM-DD format" }, "id_number": { "type": "string", "description": "License or ID number" }, "expiration_date": { "type": "string", "description": "ID expiration date in YYYY-MM-DD format" } } } ``` ```python Python theme={null} id_card_schema = { "type": "object", "properties": { "document_type": { "type": "string", "description": "Type of ID: driver_license, passport, state_id" }, "full_name": { "type": "string", "description": "Full legal name as shown on ID" }, "first_name": { "type": "string", "description": "First name" }, "last_name": { "type": "string", "description": "Last name / surname" }, "address": { "type": "string", "description": "Street address on ID" }, "city": { "type": "string", "description": "City" }, "state": { "type": "string", "description": "State abbreviation (e.g., CA, NY)" }, "zip_code": { "type": "string", "description": "ZIP code" }, "date_of_birth": { "type": "string", "description": "Date of birth in YYYY-MM-DD format" }, "id_number": { "type": "string", "description": "License or ID number" }, "expiration_date": { "type": "string", "description": "ID expiration date in YYYY-MM-DD format" } } } ``` ```javascript JavaScript theme={null} const idCardSchema = { type: "object", properties: { document_type: { type: "string", description: "Type of ID: driver_license, passport, state_id" }, full_name: { type: "string", description: "Full legal name as shown on ID" }, first_name: { type: "string", description: "First name" }, last_name: { type: "string", description: "Last name / surname" }, address: { type: "string", description: "Street address on ID" }, city: { type: "string", description: "City" }, state: { type: "string", description: "State abbreviation (e.g., CA, NY)" }, zip_code: { type: "string", description: "ZIP code" }, date_of_birth: { type: "string", description: "Date of birth in YYYY-MM-DD format" }, id_number: { type: "string", description: "License or ID number" }, expiration_date: { type: "string", description: "ID expiration date in YYYY-MM-DD format" } } }; ``` **Design decisions:** * `full_name` **and** `first_name`/`last_name`: Extract both because other documents may format names differently * `date_of_birth` format: Request YYYY-MM-DD for consistent date handling in code * `expiration_date`: Critical for checking if the ID is still valid ### Utility Bill Schema Utility bills prove current address. They vary more in layout than IDs, so field descriptions need to be more specific about what to extract. ```json JSON Schema theme={null} { "type": "object", "properties": { "provider": { "type": "string", "description": "Utility company name" }, "account_holder": { "type": "string", "description": "Name on the account" }, "service_address": { "type": "string", "description": "Full service address including street" }, "city": { "type": "string", "description": "City" }, "state": { "type": "string", "description": "State" }, "zip_code": { "type": "string", "description": "ZIP code" }, "account_number": { "type": "string", "description": "Account number" }, "statement_date": { "type": "string", "description": "Statement date in YYYY-MM-DD format" }, "amount_due": { "type": "number", "description": "Total amount due" } } } ``` ```python Python theme={null} utility_bill_schema = { "type": "object", "properties": { "provider": { "type": "string", "description": "Utility company name" }, "account_holder": { "type": "string", "description": "Name on the account" }, "service_address": { "type": "string", "description": "Full service address including street" }, "city": { "type": "string", "description": "City" }, "state": { "type": "string", "description": "State" }, "zip_code": { "type": "string", "description": "ZIP code" }, "account_number": { "type": "string", "description": "Account number" }, "statement_date": { "type": "string", "description": "Statement date in YYYY-MM-DD format" }, "amount_due": { "type": "number", "description": "Total amount due" } } } ``` ```javascript JavaScript theme={null} const utilityBillSchema = { type: "object", properties: { provider: { type: "string", description: "Utility company name" }, account_holder: { type: "string", description: "Name on the account" }, service_address: { type: "string", description: "Full service address including street" }, city: { type: "string", description: "City" }, state: { type: "string", description: "State" }, zip_code: { type: "string", description: "ZIP code" }, account_number: { type: "string", description: "Account number" }, statement_date: { type: "string", description: "Statement date in YYYY-MM-DD format" }, amount_due: { type: "number", description: "Total amount due" } } }; ``` **Design decisions:** * `account_holder`: This is what we match against the ID name * `service_address` (not mailing address): The service address proves residence * `statement_date`: Bills must be recent (typically within 90 days) ### W-9 Tax Form Schema W-9s have a fixed IRS layout. Field descriptions reference specific line numbers to help the LLM locate values. ```json JSON Schema theme={null} { "type": "object", "properties": { "name": { "type": "string", "description": "Name on Line 1" }, "business_name": { "type": "string", "description": "Business name on Line 2, if any" }, "address": { "type": "string", "description": "Street address from Line 5" }, "city_state_zip": { "type": "string", "description": "City, state, ZIP from Line 6" }, "ssn": { "type": "string", "description": "Social Security Number (XXX-XX-XXXX)" }, "ein": { "type": "string", "description": "Employer Identification Number" }, "tax_classification": { "type": "string", "description": "Federal tax classification" } } } ``` ```python Python theme={null} w9_schema = { "type": "object", "properties": { "name": { "type": "string", "description": "Name on Line 1" }, "business_name": { "type": "string", "description": "Business name on Line 2, if any" }, "address": { "type": "string", "description": "Street address from Line 5" }, "city_state_zip": { "type": "string", "description": "City, state, ZIP from Line 6" }, "ssn": { "type": "string", "description": "Social Security Number (XXX-XX-XXXX)" }, "ein": { "type": "string", "description": "Employer Identification Number" }, "tax_classification": { "type": "string", "description": "Federal tax classification" } } } ``` ```javascript JavaScript theme={null} const w9Schema = { type: "object", properties: { name: { type: "string", description: "Name on Line 1" }, business_name: { type: "string", description: "Business name on Line 2, if any" }, address: { type: "string", description: "Street address from Line 5" }, city_state_zip: { type: "string", description: "City, state, ZIP from Line 6" }, ssn: { type: "string", description: "Social Security Number (XXX-XX-XXXX)" }, ein: { type: "string", description: "Employer Identification Number" }, tax_classification: { type: "string", description: "Federal tax classification" } } }; ``` **Design decisions:** * `city_state_zip` as one field: W-9 Line 6 combines these, so we extract them together and parse later * Line number references: "Line 1", "Line 5", "Line 6" help the LLM find the right fields on the standardized IRS form *** ## Step 2: Extract from All Documents Upload each document and run extraction with the appropriate schema. Reducto handles both image files (ID card) and PDFs (utility bill, W-9) with the same API. ```python Python theme={null} from pathlib import Path from reducto import Reducto client = Reducto() def extract_from_documents(id_card_path, utility_bill_path, w9_path): """Extract data from all three verification documents.""" results = {} # Extract from ID card (supports images!) id_upload = client.upload(file=Path(id_card_path)) id_result = client.extract.run( input=id_upload.file_id, instructions={"schema": id_card_schema} ) results["id_card"] = id_result.result # Extract from utility bill bill_upload = client.upload(file=Path(utility_bill_path)) bill_result = client.extract.run( input=bill_upload.file_id, instructions={"schema": utility_bill_schema} ) results["utility_bill"] = bill_result.result # Extract from W-9 w9_upload = client.upload(file=Path(w9_path)) w9_result = client.extract.run( input=w9_upload.file_id, instructions={"schema": w9_schema} ) results["w9"] = w9_result.result return results # Run extraction extracted = extract_from_documents( "id-card.png", "sample-utility-bill.pdf", "w9-sample.pdf" ) ``` ```javascript JavaScript theme={null} import Reducto from "reductoai"; import fs from "fs"; const client = new Reducto(); async function extractFromDocuments(idCardPath, utilityBillPath, w9Path) { const results = {}; // Extract from ID card (images supported) const idUpload = await client.upload({ file: fs.createReadStream(idCardPath), }); const idResult = await client.extract.run({ input: idUpload.file_id, instructions: { schema: idCardSchema }, }); results.idCard = idResult.result; // Extract from utility bill const billUpload = await client.upload({ file: fs.createReadStream(utilityBillPath), }); const billResult = await client.extract.run({ input: billUpload.file_id, instructions: { schema: utilityBillSchema }, }); results.utilityBill = billResult.result; // Extract from W-9 const w9Upload = await client.upload({ file: fs.createReadStream(w9Path), }); const w9Result = await client.extract.run({ input: w9Upload.file_id, instructions: { schema: w9Schema }, }); results.w9 = w9Result.result; return results; } // Run extraction const extracted = await extractFromDocuments( "id-card.png", "sample-utility-bill.pdf", "w9-sample.pdf" ); ``` ### Extraction Results From our sample documents: ```json theme={null} { "id_card": { "document_type": "driver_license", "full_name": "IMA CARDHOLDER", "first_name": "IMA", "last_name": "CARDHOLDER", "address": "2570 24TH STREET", "city": "ANYTOWN", "state": "CA", "zip_code": "95818", "date_of_birth": "1977-08-31", "id_number": "11234568", "expiration_date": "2014-08-31" }, "utility_bill": { "provider": "PG&E", "account_holder": "IMA CARDHOLDER", "service_address": "2570 24th Street", "city": "Andytown", "state": "CA", "zip_code": "95818", "account_number": "0123456789-1", "statement_date": "2025-02-24", "amount_due": 4158.74 }, "w9": { "name": "Ima Cardholder", "business_name": null, "address": "2570 24th Street", "city_state_zip": "Andytown, CA 95818", "ssn": "012-34-5678", "ein": null, "tax_classification": "Individual/sole proprietor" } } ``` Look at the variations: * **Name**: "IMA CARDHOLDER" vs "Ima Cardholder" (case difference) * **City**: "ANYTOWN" vs "Andytown" (case + typo) * **Street**: "24TH STREET" vs "24th Street" (case + abbreviation) An exact string match would fail. We need normalization. *** ## Step 3: Normalize and Compare Extracted data won't match exactly across documents. Here's what we see: | Field | ID Card | Utility Bill | W-9 | | - | - | - | - | | Name | IMA CARDHOLDER | IMA CARDHOLDER | Ima Cardholder | | City | ANYTOWN | Andytown | Andytown | | Street | 2570 24TH STREET | 2570 24th Street | 2570 24th Street | These are clearly the same person at the same address, but string comparison would fail. ### Normalization Functions Normalization standardizes these variations: * Uppercase everything * Convert abbreviations ("STREET" → "ST") * Remove punctuation * Collapse extra whitespace ```python Python theme={null} import re def normalize_name(name): """Normalize name for comparison.""" if not name: return "" # Uppercase, remove extra spaces, remove punctuation name = name.upper().strip() name = re.sub(r'[^\w\s]', '', name) name = re.sub(r'\s+', ' ', name) return name def normalize_address(address): """Normalize address for comparison.""" if not address: return "" address = address.upper().strip() # Standardize common abbreviations replacements = { 'STREET': 'ST', 'AVENUE': 'AVE', 'BOULEVARD': 'BLVD', 'DRIVE': 'DR', 'ROAD': 'RD', 'LANE': 'LN', 'COURT': 'CT', } for full, abbrev in replacements.items(): address = address.replace(full, abbrev) address = re.sub(r'[^\w\s]', '', address) address = re.sub(r'\s+', ' ', address) return address def parse_city_state_zip(city_state_zip): """Parse 'City, ST 12345' format into components.""" if not city_state_zip: return "", "", "" # Pattern: City, State ZIP match = re.match(r'(.+),\s*([A-Z]{2})\s*(\d{5})', city_state_zip.upper()) if match: return match.group(1).strip(), match.group(2), match.group(3) return city_state_zip, "", "" ``` ```javascript JavaScript theme={null} function normalizeName(name) { if (!name) return ""; // Uppercase, remove extra spaces, remove punctuation return name.toUpperCase().trim() .replace(/[^\w\s]/g, '') .replace(/\s+/g, ' '); } function normalizeAddress(address) { if (!address) return ""; address = address.toUpperCase().trim(); // Standardize common abbreviations const replacements = { 'STREET': 'ST', 'AVENUE': 'AVE', 'BOULEVARD': 'BLVD', 'DRIVE': 'DR', 'ROAD': 'RD', 'LANE': 'LN', 'COURT': 'CT', }; for (const [full, abbrev] of Object.entries(replacements)) { address = address.replace(new RegExp(full, 'g'), abbrev); } return address.replace(/[^\w\s]/g, '').replace(/\s+/g, ' '); } function parseCityStateZip(cityStateZip) { if (!cityStateZip) return ["", "", ""]; // Pattern: City, State ZIP const match = cityStateZip.toUpperCase().match(/(.+),\s*([A-Z]{2})\s*(\d{5})/); if (match) { return [match[1].trim(), match[2], match[3]]; } return [cityStateZip, "", ""]; } ``` After normalization: * "IMA CARDHOLDER" → "IMA CARDHOLDER" * "Ima Cardholder" → "IMA CARDHOLDER" ✓ Match! * "2570 24TH STREET" → "2570 24TH ST" * "2570 24th Street" → "2570 24TH ST" ✓ Match! ### Why Fuzzy Matching? Even after normalization, OCR errors and typos happen. "ANYTOWN" vs "ANDYTOWN" is a single character difference. It's likely the same city, not a fraudulent mismatch. Fuzzy matching with an 85% similarity threshold catches these while rejecting genuine mismatches: ```python Python theme={null} def fuzzy_match(str1, str2, threshold=0.85): """Check if two strings match above threshold.""" if not str1 or not str2: return False str1, str2 = str1.upper(), str2.upper() if str1 == str2: return True # Simple character-level similarity matches = sum(c1 == c2 for c1, c2 in zip(str1, str2)) max_len = max(len(str1), len(str2)) similarity = matches / max_len if max_len > 0 else 0 return similarity >= threshold ``` ```javascript JavaScript theme={null} function fuzzyMatch(str1, str2, threshold = 0.85) { if (!str1 || !str2) return false; str1 = str1.toUpperCase(); str2 = str2.toUpperCase(); if (str1 === str2) return true; // Simple character-level similarity const minLen = Math.min(str1.length, str2.length); let matches = 0; for (let i = 0; i < minLen; i++) { if (str1[i] === str2[i]) matches++; } const maxLen = Math.max(str1.length, str2.length); const similarity = maxLen > 0 ? matches / maxLen : 0; return similarity >= threshold; } ``` *** ## Step 4: Verification Strategy Our verification uses two tiers of checks: **Critical checks (must pass):** 1. **Name match** - Name must match across all three documents 2. **Address match** - Address must match (street, state, ZIP) **Warning checks (informational):** 3\. **ID not expired** - Government ID should be valid 4\. **Recent bill** - Utility bill should be within 90 days If critical checks pass, verification succeeds even with warnings. This matches real-world KYC where an expired ID triggers re-verification but doesn't necessarily block the user. ### Implementing Name Matching Compare normalized names across all document pairs. All three must match: ```python Python theme={null} def check_name_match(extracted): """Check if names match across all documents.""" id_name = normalize_name(extracted["id_card"].get("full_name", "")) bill_name = normalize_name(extracted["utility_bill"].get("account_holder", "")) w9_name = normalize_name(extracted["w9"].get("name", "")) id_vs_bill = fuzzy_match(id_name, bill_name) id_vs_w9 = fuzzy_match(id_name, w9_name) bill_vs_w9 = fuzzy_match(bill_name, w9_name) passed = id_vs_bill and id_vs_w9 and bill_vs_w9 return { "check": "name_match", "passed": passed, "details": { "id_card": id_name, "utility_bill": bill_name, "w9": w9_name, "id_vs_bill": id_vs_bill, "id_vs_w9": id_vs_w9, "bill_vs_w9": bill_vs_w9 } } ``` ```javascript JavaScript theme={null} function checkNameMatch(extracted) { const idName = normalizeName(extracted.idCard?.full_name || ""); const billName = normalizeName(extracted.utilityBill?.account_holder || ""); const w9Name = normalizeName(extracted.w9?.name || ""); const idVsBill = fuzzyMatch(idName, billName); const idVsW9 = fuzzyMatch(idName, w9Name); const billVsW9 = fuzzyMatch(billName, w9Name); const passed = idVsBill && idVsW9 && billVsW9; return { check: "name_match", passed, details: { id_card: idName, utility_bill: billName, w9: w9Name, id_vs_bill: idVsBill, id_vs_w9: idVsW9, bill_vs_w9: billVsW9 } }; } ``` ### Implementing Address Matching Address matching is trickier. We check street, state, and ZIP separately. The W-9 combines city/state/zip into one field, so we parse it first. ```python Python theme={null} def check_address_match(extracted): """Check if addresses match across all documents.""" # ID card address id_address = normalize_address(extracted["id_card"].get("address", "")) id_state = extracted["id_card"].get("state", "").upper() id_zip = extracted["id_card"].get("zip_code", "") # Utility bill address bill_address = normalize_address(extracted["utility_bill"].get("service_address", "")) bill_state = extracted["utility_bill"].get("state", "").upper() bill_zip = extracted["utility_bill"].get("zip_code", "") # W-9 address (parse city_state_zip) w9_address = normalize_address(extracted["w9"].get("address", "")) w9_city, w9_state, w9_zip = parse_city_state_zip( extracted["w9"].get("city_state_zip", "") ) # Compare components street_match = fuzzy_match(id_address, bill_address) and fuzzy_match(id_address, w9_address) state_match = id_state == bill_state == w9_state zip_match = id_zip == bill_zip == w9_zip passed = street_match and state_match and zip_match return { "check": "address_match", "passed": passed, "details": { "street_match": street_match, "state_match": state_match, "zip_match": zip_match, "id_card": f"{id_address}, {id_state} {id_zip}", "utility_bill": f"{bill_address}, {bill_state} {bill_zip}", "w9": f"{w9_address}, {w9_state} {w9_zip}" } } ``` ```javascript JavaScript theme={null} function checkAddressMatch(extracted) { // ID card address const idAddress = normalizeAddress(extracted.idCard?.address || ""); const idState = (extracted.idCard?.state || "").toUpperCase(); const idZip = extracted.idCard?.zip_code || ""; // Utility bill address const billAddress = normalizeAddress(extracted.utilityBill?.service_address || ""); const billState = (extracted.utilityBill?.state || "").toUpperCase(); const billZip = extracted.utilityBill?.zip_code || ""; // W-9 address (parse city_state_zip) const w9Address = normalizeAddress(extracted.w9?.address || ""); const [w9City, w9State, w9Zip] = parseCityStateZip(extracted.w9?.city_state_zip || ""); // Compare components const streetMatch = fuzzyMatch(idAddress, billAddress) && fuzzyMatch(idAddress, w9Address); const stateMatch = idState === billState && billState === w9State; const zipMatch = idZip === billZip && billZip === w9Zip; const passed = streetMatch && stateMatch && zipMatch; return { check: "address_match", passed, details: { street_match: streetMatch, state_match: stateMatch, zip_match: zipMatch, id_card: `${idAddress}, ${idState} ${idZip}`, utility_bill: `${billAddress}, ${billState} ${billZip}`, w9: `${w9Address}, ${w9State} ${w9Zip}` } }; } ``` ### Document Validity Checks These are warnings, not blockers. An expired ID or old utility bill should be flagged but may not fail verification outright. ```python Python theme={null} from datetime import datetime def check_id_not_expired(extracted): """Check if the ID card is still valid.""" exp_date_str = extracted["id_card"].get("expiration_date", "") is_valid = False if exp_date_str: try: exp_date = datetime.strptime(exp_date_str, "%Y-%m-%d") is_valid = exp_date > datetime.now() except ValueError: pass return { "check": "id_not_expired", "passed": is_valid, "details": { "expiration_date": exp_date_str, "is_valid": is_valid } } def check_recent_utility_bill(extracted, max_days=90): """Check if the utility bill is recent (within max_days).""" statement_date_str = extracted["utility_bill"].get("statement_date", "") is_recent = False if statement_date_str: try: statement_date = datetime.strptime(statement_date_str, "%Y-%m-%d") days_old = (datetime.now() - statement_date).days is_recent = days_old <= max_days except ValueError: pass return { "check": "recent_utility_bill", "passed": is_recent, "details": { "statement_date": statement_date_str, "is_recent": is_recent } } ``` ```javascript JavaScript theme={null} function checkIdNotExpired(extracted) { const expDateStr = extracted.idCard?.expiration_date || ""; let isValid = false; if (expDateStr) { const expDate = new Date(expDateStr); isValid = expDate > new Date(); } return { check: "id_not_expired", passed: isValid, details: { expiration_date: expDateStr, is_valid: isValid } }; } function checkRecentUtilityBill(extracted, maxDays = 90) { const statementDateStr = extracted.utilityBill?.statement_date || ""; let isRecent = false; if (statementDateStr) { const statementDate = new Date(statementDateStr); const daysOld = Math.floor((Date.now() - statementDate) / (1000 * 60 * 60 * 24)); isRecent = daysOld <= maxDays; } return { check: "recent_utility_bill", passed: isRecent, details: { statement_date: statementDateStr, is_recent: isRecent } }; } ``` ### Complete Verification Function Combine all checks and calculate the result: ```python Python theme={null} def verify_identity(extracted): """ Run all verification checks and return result. Returns success if critical checks (name + address) pass. Warning checks (expiry, recency) are informational. """ checks = [] errors = [] # Critical checks name_check = check_name_match(extracted) checks.append(name_check) if not name_check["passed"]: errors.append("Name mismatch detected across documents") address_check = check_address_match(extracted) checks.append(address_check) if not address_check["passed"]: errors.append("Address mismatch detected across documents") # Warning checks id_check = check_id_not_expired(extracted) checks.append(id_check) if not id_check["passed"]: errors.append("ID card is expired") bill_check = check_recent_utility_bill(extracted) checks.append(bill_check) if not bill_check["passed"]: errors.append("Utility bill is older than 90 days") # Calculate result critical_passed = name_check["passed"] and address_check["passed"] passed_count = sum(1 for check in checks if check["passed"]) confidence = passed_count / len(checks) return { "success": critical_passed, "confidence": confidence, "checks": checks, "errors": errors } ``` ```javascript JavaScript theme={null} function verifyIdentity(extracted) { const checks = []; const errors = []; // Critical checks const nameCheck = checkNameMatch(extracted); checks.push(nameCheck); if (!nameCheck.passed) { errors.push("Name mismatch detected across documents"); } const addressCheck = checkAddressMatch(extracted); checks.push(addressCheck); if (!addressCheck.passed) { errors.push("Address mismatch detected across documents"); } // Warning checks const idCheck = checkIdNotExpired(extracted); checks.push(idCheck); if (!idCheck.passed) { errors.push("ID card is expired"); } const billCheck = checkRecentUtilityBill(extracted); checks.push(billCheck); if (!billCheck.passed) { errors.push("Utility bill is older than 90 days"); } // Calculate result const criticalPassed = nameCheck.passed && addressCheck.passed; const passedCount = checks.filter(c => c.passed).length; const confidence = passedCount / checks.length; return { success: criticalPassed, confidence, checks, errors }; } ``` *** ## Step 5: Run Verification ```python Python theme={null} # Complete verification flow extracted = extract_from_documents( "id-card.png", "sample-utility-bill.pdf", "w9-sample.pdf" ) result = verify_identity(extracted) print("=" * 50) print(f"VERIFICATION {'PASSED ✓' if result['success'] else 'FAILED ✗'}") print(f"Confidence: {result['confidence']:.0%}") print("=" * 50) for check in result["checks"]: status = "✓" if check["passed"] else "✗" print(f"\n{status} {check['check']}") for key, value in check["details"].items(): print(f" {key}: {value}") if result["errors"]: print("\n⚠ Issues Found:") for error in result["errors"]: print(f" - {error}") ``` ```javascript JavaScript theme={null} // Complete verification flow const extracted = await extractFromDocuments( "id-card.png", "sample-utility-bill.pdf", "w9-sample.pdf" ); const result = verifyIdentity(extracted); console.log("=".repeat(50)); console.log(`VERIFICATION ${result.success ? 'PASSED ✓' : 'FAILED ✗'}`); console.log(`Confidence: ${Math.round(result.confidence * 100)}%`); console.log("=".repeat(50)); for (const check of result.checks) { const status = check.passed ? "✓" : "✗"; console.log(`\n${status} ${check.check}`); for (const [key, value] of Object.entries(check.details)) { console.log(` ${key}: ${value}`); } } if (result.errors.length > 0) { console.log("\n⚠ Issues Found:"); for (const error of result.errors) { console.log(` - ${error}`); } } ``` ### Verification Output (Sample Documents) ``` ================================================== VERIFICATION PASSED ✓ Confidence: 50% ================================================== ✓ name_match id_card: IMA CARDHOLDER utility_bill: IMA CARDHOLDER w9: IMA CARDHOLDER id_vs_bill: True id_vs_w9: True bill_vs_w9: True ✓ address_match street_match: True state_match: True zip_match: True id_card: 2570 24TH ST, CA 95818 utility_bill: 2570 24TH ST, CA 95818 w9: 2570 24TH ST, CA 95818 ✗ id_not_expired expiration_date: 2014-08-31 is_valid: False ✗ recent_utility_bill statement_date: 2025-02-24 is_recent: False ⚠ Issues Found: - ID card is expired - Utility bill is older than 90 days ``` > [!TIP] > > The **name and address match** across all documents (critical checks pass), but the ID is expired and the bill date doesn't pass the recency check. In production, you'd decide which checks are blocking vs. warnings based on your compliance requirements. *** ## Complete Example ```python Python theme={null} from reducto import Reducto client = Reducto() def run_kyc_verification(id_path, bill_path, w9_path): """ Complete KYC verification workflow. Returns verification result with detailed checks. """ # Step 1: Extract from all documents extracted = extract_from_documents(id_path, bill_path, w9_path) # Step 2: Verify identity result = verify_identity(extracted) # Step 3: Return structured result return { "verified": result["success"], "confidence": result["confidence"], "extracted_data": { "name": extracted["id_card"].get("full_name"), "address": extracted["utility_bill"].get("service_address"), "ssn_last_four": extracted["w9"].get("ssn", "")[-4:] if extracted["w9"].get("ssn") else None }, "checks": result["checks"], "errors": result["errors"] } # Run verification kyc_result = run_kyc_verification( "id-card.png", "sample-utility-bill.pdf", "w9-sample.pdf" ) if kyc_result["verified"]: print(f"✓ Identity verified for {kyc_result['extracted_data']['name']}") else: print(f"✗ Verification failed: {kyc_result['errors']}") ``` ```javascript JavaScript theme={null} import Reducto from "reductoai"; import fs from "fs"; const client = new Reducto(); async function runKycVerification(idPath, billPath, w9Path) { // Step 1: Extract from all documents const extracted = await extractFromDocuments(idPath, billPath, w9Path); // Step 2: Verify identity const result = verifyIdentity(extracted); // Step 3: Return structured result return { verified: result.success, confidence: result.confidence, extractedData: { name: extracted.idCard?.full_name, address: extracted.utilityBill?.service_address, ssnLastFour: extracted.w9?.ssn?.slice(-4) || null }, checks: result.checks, errors: result.errors }; } // Run verification const kycResult = await runKycVerification( "id-card.png", "sample-utility-bill.pdf", "w9-sample.pdf" ); if (kycResult.verified) { console.log(`✓ Identity verified for ${kycResult.extractedData.name}`); } else { console.log(`✗ Verification failed: ${kycResult.errors.join(", ")}`); } ``` *** ## Tips ### Handling verification failures Build user-friendly error messages that tell users exactly what to fix: ```python Python theme={null} ERROR_MESSAGES = { "name_match": "The name on your documents doesn't match. Please ensure all documents show the same legal name.", "address_match": "Your address doesn't match across documents. Please provide documents with your current address.", "id_not_expired": "Your ID has expired. Please provide a valid, non-expired government ID.", "recent_utility_bill": "Your utility bill is too old. Please provide a bill from the last 90 days." } def get_user_friendly_errors(result): return [ ERROR_MESSAGES.get(check["check"], "Verification check failed") for check in result["checks"] if not check["passed"] ] ``` ```javascript JavaScript theme={null} const ERROR_MESSAGES = { name_match: "The name on your documents doesn't match. Please ensure all documents show the same legal name.", address_match: "Your address doesn't match across documents. Please provide documents with your current address.", id_not_expired: "Your ID has expired. Please provide a valid, non-expired government ID.", recent_utility_bill: "Your utility bill is too old. Please provide a bill from the last 90 days." }; function getUserFriendlyErrors(result) { return result.checks .filter(check => !check.passed) .map(check => ERROR_MESSAGES[check.check] || "Verification check failed"); } ``` ### Async processing for scale For high-volume verification, use async extraction to process documents in parallel: ```python Python theme={null} import asyncio from pathlib import Path from reducto import AsyncReducto async_client = AsyncReducto() async def extract_all_async(id_path, bill_path, w9_path): """Extract from all documents concurrently.""" async def extract_one(path, schema): upload = await async_client.upload(file=Path(path)) result = await async_client.extract.run( input=upload.file_id, instructions={"schema": schema} ) return result.result results = await asyncio.gather( extract_one(id_path, id_card_schema), extract_one(bill_path, utility_bill_schema), extract_one(w9_path, w9_schema) ) return { "id_card": results[0], "utility_bill": results[1], "w9": results[2] } ``` ```javascript JavaScript theme={null} import Reducto from "reductoai"; import fs from "fs"; const client = new Reducto(); async function extractAllAsync(idPath, billPath, w9Path) { // Extract from all documents concurrently const [idResult, billResult, w9Result] = await Promise.all([ (async () => { const upload = await client.upload({ file: fs.createReadStream(idPath) }); const result = await client.extract.run({ input: upload.file_id, instructions: { schema: idCardSchema } }); return result.result; })(), (async () => { const upload = await client.upload({ file: fs.createReadStream(billPath) }); const result = await client.extract.run({ input: upload.file_id, instructions: { schema: utilityBillSchema } }); return result.result; })(), (async () => { const upload = await client.upload({ file: fs.createReadStream(w9Path) }); const result = await client.extract.run({ input: upload.file_id, instructions: { schema: w9Schema } }); return result.result; })() ]); return { idCard: idResult, utilityBill: billResult, w9: w9Result }; } ``` ### Compliance considerations > [!WARNING] > > **Data Privacy**: Identity documents contain sensitive PII. Ensure your implementation: > > * Encrypts data in transit and at rest > * Follows data retention policies > * Complies with regulations (GDPR, CCPA, KYC/AML) > * Logs access for audit purposes *** ## Next Steps > [card] [Extract Overview](/extract/overview) > > Learn about structured extraction > [card] [Image Processing](/parse/overview) > > Reducto supports images and PDFs > [card] [Async Processing](/workflows/async-overview) > > Scale to high volumes > [card] [Batch Processing](/cookbooks/batch-processing) > > Process many verifications # Invoice & Accounts Payable Automation Source: https://docs.reducto.ai/cookbooks/invoice-extraction Extract structured data from freight invoices using Reducto's Extract API with array extraction and citations 11 Freight invoices combine fixed fields (invoice number, dates, addresses) with variable-length data (charges). One invoice might have 3 line items, another might have 12. Defining `charge_1`, `charge_2`, `charge_3` fails when the count varies. Reducto's Extract API handles both patterns: fixed fields use schema properties, variable charges use array extraction. This cookbook shows how to extract a freight invoice into structured JSON. *** ## Sample Document [https://cdn.reducto.ai/samples/freight-invoice.pdf](https://cdn.reducto.ai/samples/freight-invoice.pdf) > [!NOTE] > > Download the sample: [freight-invoice.pdf](https://cdn.reducto.ai/samples/freight-invoice.pdf) This Atlas National Freight invoice contains: * Invoice header (number, date, payment terms) * Bill-to and ship-to addresses * Shipment details table (items, weight, NMFC class) * Freight charges breakdown (linehaul, fuel surcharge, accessorials) * Payment instructions *** ## Create API Key **1. Open Studio** Go to [studio.reducto.ai](https://studio.reducto.ai) and sign in. From the home page, click **API Keys** in the left sidebar. ![Studio home page with API Keys in sidebar](https://mintcdn.com/reducto/9Avr4qdsIoNo7JLQ/cookbooks/dummy-docs/screenshots/api-1.png?fit=max\&auto=format\&n=9Avr4qdsIoNo7JLQ\&q=85\&s=6fda1435e042681807741c7743273da2) **2. View API Keys** The API Keys page shows your existing keys. Click **Create API Key** in the top right corner. ![API Keys page with Create button](https://mintcdn.com/reducto/9Avr4qdsIoNo7JLQ/cookbooks/dummy-docs/screenshots/api-2.png?fit=max\&auto=format\&n=9Avr4qdsIoNo7JLQ\&q=85\&s=10db7406c2ac7217e4b1d75e028b58e1) **3. Configure Key** In the modal, enter a name for your key and set an expiration policy (or select "Never" for no expiration). Click **Create**. ![New API Key modal with name and expiration fields](https://mintcdn.com/reducto/9Avr4qdsIoNo7JLQ/cookbooks/dummy-docs/screenshots/api-3.png?fit=max\&auto=format\&n=9Avr4qdsIoNo7JLQ\&q=85\&s=afb60f6cfb4d33940669d534dd007343) **4. Copy Your Key** Copy your new API key and store it securely. You won't be able to see it again after closing this dialog. ![Copy API key dialog](https://mintcdn.com/reducto/9Avr4qdsIoNo7JLQ/cookbooks/dummy-docs/screenshots/api-4.png?fit=max\&auto=format\&n=9Avr4qdsIoNo7JLQ\&q=85\&s=c861b1c2f593244957cf15c6fd717f60) Set the key as an environment variable: ```bash theme={null} export REDUCTO_API_KEY="your-api-key-here" ``` *** ## Studio Walkthrough **1. Start Extract Workflow** Go to [studio.reducto.ai](https://studio.reducto.ai) and click **Extract** on the homepage to start a new extraction workflow. Upload the freight invoice PDF. **2. Review Parse Results** After upload, you'll see the **Parse** view showing how Reducto structures the document. The right panel displays extracted text content. ![Parse view showing freight invoice with extracted text](https://mintcdn.com/reducto/9Avr4qdsIoNo7JLQ/cookbooks/dummy-docs/screenshots/freight-invoice-studio-parse-1.png?fit=max\&auto=format\&n=9Avr4qdsIoNo7JLQ\&q=85\&s=c14fac0ec85403d71e78bb3dfdf8e3be) This confirms Reducto can read the invoice header, addresses, shipment details, and freight charges. **3. Switch to Extract View** Click **Extract** in the top navigation to switch views. In the **Schema Builder**, add fields by entering a name, selecting a type, and providing a description. Start with `invoice_number`. ![Extract view with Schema Builder showing invoice\_number field](https://mintcdn.com/reducto/9Avr4qdsIoNo7JLQ/cookbooks/dummy-docs/screenshots/freight-invoice-studio-extract-2.png?fit=max\&auto=format\&n=9Avr4qdsIoNo7JLQ\&q=85\&s=64b3c130b9219b80e94c731b469bce15) You can add a **System Prompt** in the Instructions section for context like "This is a freight invoice with shipment information." **4. Build Nested Schema with Arrays** Add more fields including nested objects and arrays. For shipment information, create an `array` type field called `shipment_info` with nested fields like `bill_of_lading`, `service_level`, and `equipment_type`. ![Schema Builder showing nested shipment\_info array with fields](https://mintcdn.com/reducto/9Avr4qdsIoNo7JLQ/cookbooks/dummy-docs/screenshots/freight-invoice-studio-extract-3.png?fit=max\&auto=format\&n=9Avr4qdsIoNo7JLQ\&q=85\&s=31e19656d6bcee3afff86662a2d8a449) The Schema Builder supports nested fields - click the arrow to expand and define child fields within objects and arrays. **5. Run Extraction and View Results** Click **Run** to execute the extraction. Switch to the **Results** tab to see extracted values. The document highlights where each value was found. ![Extraction results showing highlighted values on document](https://mintcdn.com/reducto/9Avr4qdsIoNo7JLQ/cookbooks/dummy-docs/screenshots/freight-invoice-studio-extract-4.png?fit=max\&auto=format\&n=9Avr4qdsIoNo7JLQ\&q=85\&s=40ad0d55f84b657dc32b04d275071c28) Notice how the extracted values (ANF-INV-2026-004918, February 11, 2026, Net 30) are highlighted directly on the document, showing exactly where Reducto found each piece of data. *** ## API Implementation ### Building the Schema A good extraction schema mirrors the invoice structure. We'll build it incrementally, explaining design decisions along the way. #### Invoice Header Every invoice has fixed header fields. Request dates in ISO format (YYYY-MM-DD) for consistent parsing and date math. ```python Python theme={null} header_schema = { "invoice_number": { "type": "string", "description": "Invoice number from the header" }, "invoice_date": { "type": "string", "description": "Invoice date in YYYY-MM-DD format" }, "due_date": { "type": "string", "description": "Payment due date in YYYY-MM-DD format" }, "payment_terms": { "type": "string", "description": "Payment terms like Net 30" } } ``` ```javascript JavaScript theme={null} const headerSchema = { invoice_number: { type: "string", description: "Invoice number from the header" }, invoice_date: { type: "string", description: "Invoice date in YYYY-MM-DD format" }, due_date: { type: "string", description: "Payment due date in YYYY-MM-DD format" }, payment_terms: { type: "string", description: "Payment terms like Net 30" } }; ``` **Design decisions:** * `invoice_date` and `due_date`: ISO format enables date calculations (days until due, overdue checks) * `payment_terms`: Keep as string, not parsed days. Terms vary widely ("Net 30", "2/10 Net 30", "Due on Receipt") #### Parties (Vendor and Bill-To) Invoices involve two parties: the vendor (carrier) and the customer (bill-to). Nested objects keep fields organized and separate. ```python Python theme={null} parties_schema = { "vendor": { "type": "object", "properties": { "name": {"type": "string"}, "address": {"type": "string"}, "phone": {"type": "string"}, "email": {"type": "string"} } }, "bill_to": { "type": "object", "properties": { "company": {"type": "string"}, "department": {"type": "string"}, "address": {"type": "string"}, "city_state_zip": {"type": "string"}, "account_number": {"type": "string"} } } } ``` ```javascript JavaScript theme={null} const partiesSchema = { vendor: { type: "object", properties: { name: { type: "string" }, address: { type: "string" }, phone: { type: "string" }, email: { type: "string" } } }, bill_to: { type: "object", properties: { company: { type: "string" }, department: { type: "string" }, address: { type: "string" }, city_state_zip: { type: "string" }, account_number: { type: "string" } } } }; ``` #### Shipment Details Shipment metadata links the invoice to physical freight movement. Use number types for fields you'll calculate with. ```python Python theme={null} shipment_schema = { "shipment": { "type": "object", "properties": { "pro_number": {"type": "string"}, "bol_number": {"type": "string"}, "pickup_date": {"type": "string"}, "delivery_date": {"type": "string"}, "service_level": {"type": "string"}, "total_weight_lbs": {"type": "number"} } } } ``` ```javascript JavaScript theme={null} const shipmentSchema = { shipment: { type: "object", properties: { pro_number: { type: "string" }, bol_number: { type: "string" }, pickup_date: { type: "string" }, delivery_date: { type: "string" }, service_level: { type: "string" }, total_weight_lbs: { type: "number" } } } }; ``` **Why these fields:** * `pro_number`: Carrier's tracking number, the primary identifier for freight * `bol_number`: Customer's bill of lading, for matching invoices to purchase orders * `total_weight_lbs`: Number type enables rate-per-pound calculations #### Charges Array Here's the key insight for invoice extraction: charges are a variable-length array. One invoice might have 3 charges (linehaul, fuel, one accessorial), another might have 12 (multiple accessorials, adjustments, fees). ```python Python theme={null} charges_schema = { "charges": { "type": "array", "description": "All freight charges including linehaul, fuel, and accessorials", "items": { "type": "object", "properties": { "charge_type": {"type": "string"}, "description": {"type": "string"}, "amount": {"type": "number"} } } }, "subtotal": {"type": "number"}, "tax": {"type": "number"}, "total_amount": {"type": "number"} } ``` ```javascript JavaScript theme={null} const chargesSchema = { charges: { type: "array", description: "All freight charges including linehaul, fuel, and accessorials", items: { type: "object", properties: { charge_type: { type: "string" }, description: { type: "string" }, amount: { type: "number" } } } }, subtotal: { type: "number" }, tax: { type: "number" }, total_amount: { type: "number" } }; ``` **Why a variable-length array matters:** Without an array field, you'd define `charge_1`, `charge_2`, `charge_3` and hope you have enough fields. If the invoice has 5 charges, you miss two. If it has 2 charges, you have empty fields. An `array` field lets Reducto capture every charge row regardless of count. > [!NOTE] > > For long or complex documents where results look truncated, enable `deep_extract: True` in settings. Deep Extract is an agentic mode that iteratively refines its output to capture every item. It costs more and runs slower, so reserve it for cases that need it. (The older `array_extract` setting is deprecated in favor of `deep_extract`.) ### Complete Extraction Combine all schema sections and run extraction: ```python Python theme={null} from pathlib import Path from reducto import Reducto client = Reducto() # Upload the invoice upload = client.upload(file=Path("freight-invoice.pdf")) # Complete schema combining all sections invoice_schema = { "type": "object", "properties": { "invoice_number": { "type": "string", "description": "Invoice number from the header" }, "invoice_date": { "type": "string", "description": "Invoice date in YYYY-MM-DD format" }, "due_date": { "type": "string", "description": "Payment due date" }, "payment_terms": { "type": "string", "description": "Payment terms like Net 30" }, "vendor": { "type": "object", "properties": { "name": {"type": "string"}, "address": {"type": "string"}, "phone": {"type": "string"}, "email": {"type": "string"} } }, "bill_to": { "type": "object", "properties": { "company": {"type": "string"}, "department": {"type": "string"}, "address": {"type": "string"}, "city_state_zip": {"type": "string"}, "account_number": {"type": "string"} } }, "shipment": { "type": "object", "properties": { "pro_number": {"type": "string"}, "bol_number": {"type": "string"}, "pickup_date": {"type": "string"}, "delivery_date": {"type": "string"}, "service_level": {"type": "string"}, "total_weight_lbs": {"type": "number"} } }, "charges": { "type": "array", "description": "All freight charges including linehaul, fuel, and accessorials", "items": { "type": "object", "properties": { "charge_type": {"type": "string"}, "description": {"type": "string"}, "amount": {"type": "number"} } } }, "subtotal": {"type": "number"}, "tax": {"type": "number"}, "total_amount": {"type": "number"} } } # Deep Extract improves accuracy on long or complex invoices (higher cost/latency) result = client.extract.run( input=upload.file_id, instructions={"schema": invoice_schema}, settings={"deep_extract": True} ) invoice_data = result.result[0] print(f"Invoice: {invoice_data['invoice_number']}") print(f"Total: ${invoice_data['total_amount']}") ``` ```javascript JavaScript theme={null} import Reducto from "reductoai"; import fs from "fs"; const client = new Reducto(); async function extractInvoice() { // Upload const upload = await client.upload({ file: fs.createReadStream("freight-invoice.pdf"), }); // Complete schema const invoiceSchema = { type: "object", properties: { invoice_number: { type: "string", description: "Invoice number from the header" }, invoice_date: { type: "string", description: "Invoice date in YYYY-MM-DD format" }, due_date: { type: "string", description: "Payment due date" }, payment_terms: { type: "string", description: "Payment terms like Net 30" }, vendor: { type: "object", properties: { name: { type: "string" }, address: { type: "string" }, phone: { type: "string" }, email: { type: "string" } } }, bill_to: { type: "object", properties: { company: { type: "string" }, department: { type: "string" }, address: { type: "string" }, city_state_zip: { type: "string" }, account_number: { type: "string" } } }, shipment: { type: "object", properties: { pro_number: { type: "string" }, bol_number: { type: "string" }, pickup_date: { type: "string" }, delivery_date: { type: "string" }, service_level: { type: "string" }, total_weight_lbs: { type: "number" } } }, charges: { type: "array", description: "All freight charges including linehaul, fuel, and accessorials", items: { type: "object", properties: { charge_type: { type: "string" }, description: { type: "string" }, amount: { type: "number" } } } }, subtotal: { type: "number" }, tax: { type: "number" }, total_amount: { type: "number" } } }; // Deep Extract improves accuracy on long or complex invoices (higher cost/latency) const result = await client.extract.run({ input: upload.file_id, instructions: { schema: invoiceSchema }, settings: { deep_extract: true } }); console.log(JSON.stringify(result.result[0], null, 2)); } extractInvoice(); ``` > [!TIP] > > The `deep_extract: True` setting optimizes extraction for documents with repeating structures like invoice line items. It improves accuracy and helps ensure all charges are captured regardless of how many appear, at higher cost and latency. ### Extraction Results When you run extraction on the sample invoice, you get: ```json theme={null} { "invoice_number": "ANF-INV-2026-004918", "invoice_date": "2026-01-12", "due_date": "2026-02-11", "payment_terms": "Net 30", "vendor": { "name": "Atlas National Freight", "address": "4500 Logistics Parkway, Memphis, TN 38118", "phone": "(901) 555-7421", "email": "billing@atlasnationalfreight.com" }, "bill_to": { "company": "Acme Manufacturing, Inc.", "department": "Accounts Payable Dept.", "address": "1234 Industrial Parkway", "city_state_zip": "Columbus, OH 43215", "account_number": "ACME-44721" }, "shipment": { "pro_number": "778345921", "bol_number": "FCC-2026-001234", "pickup_date": "2026-01-08", "delivery_date": "2026-01-10", "service_level": "LTL – Standard", "total_weight_lbs": 5200 }, "charges": [ { "charge_type": "Linehaul", "description": "LTL Freight – OH → TX", "amount": 1425.00 }, { "charge_type": "Fuel Surcharge", "description": "Based on DOE Index (18%)", "amount": 256.50 }, { "charge_type": "Accessorial", "description": "Liftgate Pickup", "amount": 75.00 }, { "charge_type": "Accessorial", "description": "Appointment Delivery", "amount": 45.00 }, { "charge_type": "Accessorial", "description": "Limited Access – Warehouse", "amount": 85.00 } ], "subtotal": 1886.50, "tax": 0.00, "total_amount": 1886.50 } ``` *** ## Tracing Values with Citations In accounts payable, disputes arise: "Where did this \$256.50 fuel surcharge come from?" Citations let you point to the exact location in the source document. Enable citations via the `settings` parameter: ```python Python theme={null} result = client.extract.run( input=upload.file_id, instructions={"schema": invoice_schema}, settings={ "deep_extract": True, "citations": {"enabled": True} } ) # Each field includes citation info invoice_data = result.result[0] for field, data in invoice_data.items(): if isinstance(data, dict) and "citation" in data: print(f"{field}: {data['value']} (page {data['citation']['page']})") ``` ```javascript JavaScript theme={null} const result = await client.extract.run({ input: upload.file_id, instructions: { schema: invoiceSchema }, settings: { deep_extract: true, citations: { enabled: true } } }); // Each field includes citation info const invoiceData = result.result[0]; for (const [field, data] of Object.entries(invoiceData)) { if (data && typeof data === 'object' && 'citation' in data) { console.log(`${field}: ${data.value} (page ${data.citation.page})`); } } ``` Each extracted value includes its source location: ```json theme={null} { "invoice_number": { "value": "ANF-INV-2026-004918", "citation": { "page": 1, "bbox": [0.12, 0.08, 0.35, 0.11] } } } ``` **When to use citations:** * **Audit trails**: Show auditors exactly where each charge originated * **Dispute resolution**: Link a questioned fee back to its source location * **Quality validation**: Spot-check extractions by comparing values to their highlighted source *** ## Best Practices ### Handle Multiple Invoice Formats Different carriers use different invoice layouts. Make your schema robust with descriptive field hints: ```python Python theme={null} # Use descriptions that guide extraction across formats schema = { "type": "object", "properties": { "total_amount": { "type": "number", "description": "Total amount due, usually at bottom of invoice near payment instructions" }, "invoice_number": { "type": "string", "description": "Invoice number or invoice ID from the header area" } } } # Add a system prompt for extra context result = client.extract.run( input=upload.file_id, instructions={ "schema": schema, "system_prompt": "This is a freight invoice. Extract billing and shipment details." } ) ``` ```javascript JavaScript theme={null} // Use descriptions that guide extraction across formats const schema = { type: "object", properties: { total_amount: { type: "number", description: "Total amount due, usually at bottom of invoice near payment instructions" }, invoice_number: { type: "string", description: "Invoice number or invoice ID from the header area" } } }; // Add a system prompt for extra context const result = await client.extract.run({ input: upload.file_id, instructions: { schema, system_prompt: "This is a freight invoice. Extract billing and shipment details." } }); ``` ### Validate Extracted Totals Invoices have internal consistency: line items should sum to the subtotal. Use this for quality checks: ```python Python theme={null} invoice_data = result.result[0] # Sum all charges calculated_total = sum(charge["amount"] for charge in invoice_data["charges"]) # Compare to extracted subtotal if abs(calculated_total - invoice_data["subtotal"]) > 0.01: print(f"Warning: Charges sum to ${calculated_total:.2f}, but subtotal is ${invoice_data['subtotal']:.2f}") else: print("Validation passed: charges match subtotal") ``` ```javascript JavaScript theme={null} const invoiceData = result.result[0]; // Sum all charges const calculatedTotal = invoiceData.charges.reduce((sum, charge) => sum + charge.amount, 0); // Compare to extracted subtotal if (Math.abs(calculatedTotal - invoiceData.subtotal) > 0.01) { console.log(`Warning: Charges sum to $${calculatedTotal.toFixed(2)}, but subtotal is $${invoiceData.subtotal.toFixed(2)}`); } else { console.log("Validation passed: charges match subtotal"); } ``` A mismatch might indicate: * Missing charges in extraction (increase specificity in descriptions) * OCR errors on amounts (check the source document) * Hidden fees not in the main charges table *** ## Next Steps > [card] [Array Extraction](/configs/extract/array-extraction) > > Configure extraction for repeating items > [card] [Citations](/configs/extract/citations) > > Track where extracted values come from > [card] [Extract Best Practices](/extraction/best-practices-extract) > > Schema design tips > [card] [Batch Processing](/workflows/batch-processing) > > Process thousands of invoices # Multi-lingual Document Processing Source: https://docs.reducto.ai/cookbooks/multilingual-processing Process documents in 60+ languages with automatic language detection and OCR configuration Process documents in 60+ languages with automatic language detection. No configuration required. *** ## Sample Document [https://cdn.reducto.ai/samples/un-document-spanish.pdf](https://cdn.reducto.ai/samples/un-document-spanish.pdf) > [!NOTE] > > Download the sample: [un-document-spanish.pdf](https://cdn.reducto.ai/samples/un-document-spanish.pdf) *** ## Supported Languages Reducto automatically detects and processes these languages: > [accordion] View all 60+ supported languages > > | Region | Languages | > | - | - | > | **European** | English, German, French, Spanish, Portuguese, Italian, Dutch, Polish, Romanian, Czech, Greek, Hungarian, Swedish, Danish, Finnish, Norwegian, Bulgarian, Croatian, Slovak, Slovenian, Lithuanian, Latvian, Estonian, Albanian, Icelandic, Catalan, Serbian, Macedonian, Belarusian, Ukrainian | > | **Asian** | Chinese, Japanese, Korean, Hindi, Bengali, Tamil, Telugu, Marathi, Gujarati, Kannada, Malayalam, Punjabi, Thai, Vietnamese, Indonesian, Malay, Filipino/Tagalog, Khmer, Lao, Nepali | > | **Middle Eastern** | Arabic, Hebrew, Persian, Turkish | > | **Other** | Russian, Armenian, Yiddish, Afrikaans | > [!TIP] > > The standard OCR handles **mixed-language documents** automatically. A single document can contain text in multiple languages without any special configuration. *** ## Create API Key **1. Open Studio** Go to [studio.reducto.ai](https://studio.reducto.ai) and sign in. From the home page, click **API Keys** in the left sidebar. ![Studio home page with API Keys in sidebar](https://mintcdn.com/reducto/9Avr4qdsIoNo7JLQ/cookbooks/dummy-docs/screenshots/api-1.png?fit=max\&auto=format\&n=9Avr4qdsIoNo7JLQ\&q=85\&s=6fda1435e042681807741c7743273da2) **2. View API Keys** The API Keys page shows your existing keys. Click **Create API Key** in the top right corner. ![API Keys page with Create button](https://mintcdn.com/reducto/9Avr4qdsIoNo7JLQ/cookbooks/dummy-docs/screenshots/api-2.png?fit=max\&auto=format\&n=9Avr4qdsIoNo7JLQ\&q=85\&s=10db7406c2ac7217e4b1d75e028b58e1) **3. Configure Key** In the modal, enter a name for your key and set an expiration policy (or select "Never" for no expiration). Click **Create**. ![New API Key modal with name and expiration fields](https://mintcdn.com/reducto/9Avr4qdsIoNo7JLQ/cookbooks/dummy-docs/screenshots/api-3.png?fit=max\&auto=format\&n=9Avr4qdsIoNo7JLQ\&q=85\&s=afb60f6cfb4d33940669d534dd007343) **4. Copy Your Key** Copy your new API key and store it securely. You won't be able to see it again after closing this dialog. ![Copy API key dialog](https://mintcdn.com/reducto/9Avr4qdsIoNo7JLQ/cookbooks/dummy-docs/screenshots/api-4.png?fit=max\&auto=format\&n=9Avr4qdsIoNo7JLQ\&q=85\&s=c861b1c2f593244957cf15c6fd717f60) Set the key as an environment variable: ```bash theme={null} export REDUCTO_API_KEY="your-api-key-here" ``` *** ## Studio Walkthrough **1. Upload and Configure OCR** Upload your multilingual document to [studio.reducto.ai](https://studio.reducto.ai). In the **Parse** view, open the **Configurations** tab to see OCR settings. ![Parse view with Spanish UN document showing OCR settings](https://mintcdn.com/reducto/9Avr4qdsIoNo7JLQ/cookbooks/dummy-docs/screenshots/multilingual-parse-1.png?fit=max\&auto=format\&n=9Avr4qdsIoNo7JLQ\&q=85\&s=5002520a0cadcad74e687d65a6dd7c0d) Key settings: * **Extraction Mode**: Use `ocr` for scanned documents where text is embedded as images. Use `hybrid` (default) for mixed documents where some pages are native text and others are scans. * **OCR System**: Keep `standard` (default) for 60+ language support. The `legacy` system only supports Germanic languages. **2. View Extracted Text** Click **Run** and switch to the **Results** tab. Reducto extracts text in the original language with proper character encoding. ![Parse results showing extracted Spanish text from UN document](https://mintcdn.com/reducto/9Avr4qdsIoNo7JLQ/cookbooks/dummy-docs/screenshots/multilingual-parse-result.png?fit=max\&auto=format\&n=9Avr4qdsIoNo7JLQ\&q=85\&s=28c3f779b2be7a6905ac90bb8f6221d4) Notice how the Spanish text is extracted accurately, including accented characters (á, é, í, ó, ú, ñ) and proper formatting. *** ## Processing Non-English Documents ### Basic Usage No special configuration needed - just parse as usual: ```python Python theme={null} from pathlib import Path from reducto import Reducto client = Reducto() # Upload Spanish document upload = client.upload(file=Path("documento_español.pdf")) # Parse - language is detected automatically result = client.parse.run(input=upload.file_id) # Access extracted text for chunk in result.result.chunks: print(chunk.content) ``` ```javascript JavaScript theme={null} import Reducto from "reductoai"; import fs from "fs"; const client = new Reducto(); // Upload Spanish document const upload = await client.upload({ file: fs.createReadStream("documento_español.pdf"), }); // Parse - language is detected automatically const result = await client.parse.run({ input: upload.file_id }); // Access extracted text for (const chunk of result.result.chunks) { console.log(chunk.content); } ``` ### Output Example From a Spanish UN Security Council document: ``` Naciones Unidas S/2025/856 Consejo de Seguridad Distr. general 30 de diciembre de 2025 Español Original: inglés Carta de fecha 29 de diciembre de 2025 dirigida a la Presidencia del Consejo de Seguridad por el Secretario General Tengo el honor de referirme a la resolución 2719 (2023) del Consejo de Seguridad, por la que el Consejo estableció el marco para financiar las operaciones de paz... ``` *** ## OCR Configuration Options ### Extraction Modes Choose the right mode for your document type: ```python Python theme={null} # For scanned documents (images, old PDFs) result = client.parse.run( input=upload.file_id, settings={ "extraction_mode": "ocr" # Force OCR, ignore embedded text } ) # For native PDFs with embedded text result = client.parse.run( input=upload.file_id, settings={ "extraction_mode": "metadata" # Use embedded text only } ) # For mixed documents (default) result = client.parse.run( input=upload.file_id, settings={ "extraction_mode": "hybrid" # Use metadata first, OCR as fallback } ) ``` ```javascript JavaScript theme={null} // For scanned documents (images, old PDFs) const resultOcr = await client.parse.run({ input: upload.file_id, settings: { extraction_mode: "ocr" // Force OCR, ignore embedded text } }); // For native PDFs with embedded text const resultMetadata = await client.parse.run({ input: upload.file_id, settings: { extraction_mode: "metadata" // Use embedded text only } }); // For mixed documents (default) const resultHybrid = await client.parse.run({ input: upload.file_id, settings: { extraction_mode: "hybrid" // Use metadata first, OCR as fallback } }); ``` | Mode | Best For | Speed | Accuracy | | - | - | - | - | | `hybrid` | Mixed document sets | Fast | High | | `ocr` | Scanned documents | Slower | High | | `metadata` | Native PDFs | Fastest | Depends on PDF quality | ### OCR System Selection Always use `standard` for multilingual support: ```python Python theme={null} result = client.parse.run( input=upload.file_id, settings={ "ocr_system": "standard" # 60+ languages (default) } ) ``` ```javascript JavaScript theme={null} const result = await client.parse.run({ input: upload.file_id, settings: { ocr_system: "standard" // 60+ languages (default) } }); ``` > [!WARNING] > > The `legacy` OCR system only supports Germanic languages (English, German, Dutch, etc.). Always use `standard` for non-Germanic languages. *** ## Mixed-Language Documents Documents containing multiple languages are handled automatically: ```python Python theme={null} # A document with English headers and Spanish content result = client.parse.run(input=upload.file_id) # Both languages are extracted correctly # No configuration needed ``` ```javascript JavaScript theme={null} // A document with English headers and Spanish content const result = await client.parse.run({ input: upload.file_id }); // Both languages are extracted correctly // No configuration needed ``` ### Example: Bilingual Contract ``` AGREEMENT / ACUERDO This agreement ("Agreement") is entered into between... Este acuerdo ("Acuerdo") se celebra entre... TERMS AND CONDITIONS / TÉRMINOS Y CONDICIONES 1. Definitions / Definiciones The following terms shall have the meanings set forth below... Los siguientes términos tendrán los significados establecidos a continuación... ``` Reducto extracts both English and Spanish text accurately. *** ## Agentic Mode for Difficult Text Standard OCR works well for clean, printed documents. For challenging documents like handwriting, faded text, or unusual fonts, agentic mode uses a vision language model to verify and correct OCR output. ```python Python theme={null} result = client.parse.run( input=upload.file_id, enhance={ "agentic": [{"scope": "text"}] } ) ``` ```javascript JavaScript theme={null} const result = await client.parse.run({ input: upload.file_id, enhance: { agentic: [{ scope: "text" }] } }); ``` Use agentic mode when: * Text is handwritten or uses decorative fonts * Document is faded, stained, or low quality * OCR produces garbled output on first pass > [!NOTE] > > Agentic mode costs approximately 2x credits. Use it selectively for documents where standard OCR struggles. *** ## Extracting Structured Data Extract structured data from non-English documents using schemas with descriptive field hints: ```python Python theme={null} # Schema for Spanish invoice spanish_invoice_schema = { "type": "object", "properties": { "numero_factura": { "type": "string", "description": "Número de factura / Invoice number" }, "fecha": { "type": "string", "description": "Fecha de la factura / Invoice date" }, "proveedor": { "type": "object", "description": "Información del proveedor / Vendor information", "properties": { "nombre": {"type": "string"}, "direccion": {"type": "string"}, "nif": {"type": "string", "description": "NIF/CIF fiscal ID"} } }, "cliente": { "type": "object", "description": "Información del cliente / Customer information", "properties": { "nombre": {"type": "string"}, "direccion": {"type": "string"} } }, "lineas": { "type": "array", "description": "Líneas de factura / Line items", "items": { "type": "object", "properties": { "descripcion": {"type": "string"}, "cantidad": {"type": "number"}, "precio_unitario": {"type": "number"}, "importe": {"type": "number"} } } }, "subtotal": {"type": "number"}, "iva": {"type": "number", "description": "IVA / VAT amount"}, "total": {"type": "number"} } } result = client.extract.run( input=upload.file_id, instructions={"schema": spanish_invoice_schema} ) print(f"Factura: {result.result['numero_factura']}") print(f"Total: €{result.result['total']}") ``` ```javascript JavaScript theme={null} // Schema for Spanish invoice const spanishInvoiceSchema = { type: "object", properties: { numero_factura: { type: "string", description: "Número de factura / Invoice number" }, fecha: { type: "string", description: "Fecha de la factura / Invoice date" }, proveedor: { type: "object", description: "Información del proveedor / Vendor information", properties: { nombre: { type: "string" }, direccion: { type: "string" }, nif: { type: "string", description: "NIF/CIF fiscal ID" } } }, cliente: { type: "object", description: "Información del cliente / Customer information", properties: { nombre: { type: "string" }, direccion: { type: "string" } } }, lineas: { type: "array", description: "Líneas de factura / Line items", items: { type: "object", properties: { descripcion: { type: "string" }, cantidad: { type: "number" }, precio_unitario: { type: "number" }, importe: { type: "number" } } } }, subtotal: { type: "number" }, iva: { type: "number", description: "IVA / VAT amount" }, total: { type: "number" } } }; const result = await client.extract.run({ input: upload.file_id, instructions: { schema: spanishInvoiceSchema } }); const data = result.result[0]; // Note: result.result is an array console.log(`Factura: ${data.numero_factura}`); console.log(`Total: €${data.total}`); ``` > [!TIP] > > Include field descriptions in both the source language and English to improve extraction accuracy. *** ## Tips For best results with non-English documents: 1. **Use high-quality scans** (300 DPI minimum) for better OCR accuracy 2. **Enable agentic mode** for handwritten or degraded text 3. **Provide bilingual field descriptions** in extraction schemas to improve accuracy 4. **Use `extraction_mode: "ocr"`** for scanned documents instead of relying on embedded text *** ## Next Steps > [card] [OCR Settings](/configs/parse/ocr-settings) > > Full OCR configuration reference > [card] [Agentic Modes](/configs/parse/agentic-modes) > > AI-enhanced text correction > [card] [Batch Processing](/cookbooks/batch-processing) > > Process many documents at scale > [card] [Extract API](/extract/overview) > > Structured data extraction # Multimodal RAG with Image Results Source: https://docs.reducto.ai/cookbooks/multimodal-rag-image-results Build a RAG system that retrieves and reasons over both text and images from documents Standard RAG extracts text and misses charts, figures, and tables. Multimodal RAG indexes both text and images, then passes relevant visuals to a vision LLM at query time. ``` PDF → Reducto Parse → S3 (images) → Pinecone (embeddings) → Vision LLM ``` This cookbook builds a pipeline that answers questions using both text context and document images. *** ## Create Reducto API Key **1. Open Studio** Go to [studio.reducto.ai](https://studio.reducto.ai) and sign in. From the home page, click **API Keys** in the left sidebar. ![Studio home page with API Keys in sidebar](https://mintcdn.com/reducto/9Avr4qdsIoNo7JLQ/cookbooks/dummy-docs/screenshots/api-1.png?fit=max\&auto=format\&n=9Avr4qdsIoNo7JLQ\&q=85\&s=6fda1435e042681807741c7743273da2) **2. View API Keys** The API Keys page shows your existing keys. Click **Create API Key** in the top right corner. ![API Keys page with Create button](https://mintcdn.com/reducto/9Avr4qdsIoNo7JLQ/cookbooks/dummy-docs/screenshots/api-2.png?fit=max\&auto=format\&n=9Avr4qdsIoNo7JLQ\&q=85\&s=10db7406c2ac7217e4b1d75e028b58e1) **3. Configure Key** In the modal, enter a name for your key and set an expiration policy (or select "Never" for no expiration). Click **Create**. ![New API Key modal with name and expiration fields](https://mintcdn.com/reducto/9Avr4qdsIoNo7JLQ/cookbooks/dummy-docs/screenshots/api-3.png?fit=max\&auto=format\&n=9Avr4qdsIoNo7JLQ\&q=85\&s=afb60f6cfb4d33940669d534dd007343) **4. Copy Your Key** Copy your new API key and store it securely. You won't be able to see it again after closing this dialog. ![Copy API key dialog](https://mintcdn.com/reducto/9Avr4qdsIoNo7JLQ/cookbooks/dummy-docs/screenshots/api-4.png?fit=max\&auto=format\&n=9Avr4qdsIoNo7JLQ\&q=85\&s=c861b1c2f593244957cf15c6fd717f60) Set the key as an environment variable: ```bash theme={null} export REDUCTO_API_KEY="your-api-key-here" ``` *** ## Prerequisites You'll also need accounts and API keys from these services: | Service | Purpose | Sign up | | - | - | - | | AWS S3 | Permanent image storage | [aws.amazon.com](https://aws.amazon.com) | | Pinecone | Vector database for semantic search | [pinecone.io](https://www.pinecone.io) | | VoyageAI | Text embeddings | [voyageai.com](https://www.voyageai.com) | > [!WARNING] > > VoyageAI's free tier has a rate limit of 3 requests per minute. For production use, add delays between embedding calls or upgrade to a paid plan. You'll also need a vision-capable LLM (Claude, GPT-4V, Gemini, etc.) for the final generation step. Install the required packages: ```bash Python theme={null} pip install reductoai pinecone voyageai requests boto3 ``` ```bash JavaScript theme={null} npm install reductoai @aws-sdk/client-s3 @aws-sdk/s3-request-presigner @pinecone-database/pinecone voyageai ``` *** ## Setup: AWS S3 bucket Reducto image URLs expire after 12 hours, so upload extracted images to your own S3 bucket for durable storage. Keep the bucket private. When an image needs to be read, your application mints a short-lived presigned URL. **1. Create an IAM user** Never use your AWS root account for applications. Instead, create a dedicated IAM user with limited permissions. Go to [IAM Console](https://console.aws.amazon.com/iam) → **Users** → **Create user**. ![create aws user reducto demo](https://mintcdn.com/reducto/9Avr4qdsIoNo7JLQ/images/aws-create-user.png?fit=max\&auto=format\&n=9Avr4qdsIoNo7JLQ\&q=85\&s=5682b3a0893f6993802f764a950d17d3) **2. Attach least-privilege S3 permissions** Select **Attach policies directly** → **Create policy**, switch to the JSON editor, and paste a policy that grants access only to your bucket's `multimodal-rag/` prefix: ```json theme={null} { "Version": "2012-10-17", "Statement": [{ "Effect": "Allow", "Action": ["s3:PutObject", "s3:GetObject"], "Resource": "arn:aws:s3:::YOUR-BUCKET-NAME/multimodal-rag/*" }] } ``` Replace `YOUR-BUCKET-NAME` with the bucket name you plan to use in the next step. Click **Next**, name the policy (e.g., `multimodal-rag-s3`), and click **Create policy**. Back in the user wizard, refresh the policy list, select the policy you just created, then click **Next** → **Create user**. When the pipeline runs on AWS, such as EC2, ECS, or Lambda, use an IAM role or other temporary credentials instead of long-lived access keys. Use access keys for local development only. See [AWS IAM best practices](https://docs.aws.amazon.com/IAM/latest/UserGuide/best-practices.html). **3. Create access keys** Click on your new user → **Security credentials** → **Create access key**. Select "Command Line Interface (CLI)", confirm, and create. **Save both keys now** - you won't see the secret again. You'll need these for the `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` environment variables. **4. Create an S3 bucket** Go to [S3 Console](https://s3.console.aws.amazon.com) → **Create bucket**. ![make bucket](https://mintcdn.com/reducto/9Avr4qdsIoNo7JLQ/images/s3-make-bucket.png?fit=max\&auto=format\&n=9Avr4qdsIoNo7JLQ\&q=85\&s=5a81837ccd0313df2f71ed808e667f96) Choose a unique name (e.g., `my-multimodal-rag-images`) and select a region close to you for lower latency. **5. Keep public access blocked** Leave S3 Block Public Access enabled, which is the default, and do not add a bucket policy. Your application reads objects through presigned URLs created with its IAM permissions. See [S3 Block Public Access](https://docs.aws.amazon.com/AmazonS3/latest/userguide/access-control-block-public-access.html). *** ## Setup: Pinecone index We need a vector index to store text embeddings. Each vector will also include the S3 bucket and object key, so we can mint a presigned URL when we need the corresponding image. **1. Create index** ![make pinecone index](https://mintcdn.com/reducto/9Avr4qdsIoNo7JLQ/images/pinecone-index.png?fit=max\&auto=format\&n=9Avr4qdsIoNo7JLQ\&q=85\&s=b6bb6246e096b454c97b9fe80c108b15) Go to [Pinecone Console](https://app.pinecone.io) → **Create Index**. * **Name**: `multimodal-rag` * **Dimensions**: `1024` (the default dimension for VoyageAI's `voyage-4` model) * **Metric**: `cosine` The dimension setting must match the embedding model. VoyageAI's `voyage-4` model defaults to 1024 dimensions. If you use a different embedding model, check its output dimensions. *** ## Set environment variables Before running any code, set these environment variables in your terminal: ```bash theme={null} export REDUCTO_API_KEY="your-reducto-key" export PINECONE_API_KEY="your-pinecone-key" export VOYAGEAI_API_KEY="your-voyageai-key" export AWS_ACCESS_KEY_ID="your-aws-access-key" export AWS_SECRET_ACCESS_KEY="your-aws-secret-key" export S3_BUCKET_NAME="your-bucket-name" export AWS_REGION="your-bucket-region" ``` *** ## Sample document For this cookbook, we'll use Deng et al.'s 2024 PLOS ONE paper, "Visual scanning patterns of a talking face when evaluating phonetic information in a native and non-native language." The paper has 24 pages, 8 figures, and 6 tables, making it suitable for demonstrating multimodal RAG. ![pdf sample used](https://mintcdn.com/reducto/afTnzq3qfLo_wqCN/images/multimodal-pdf-sample.png?fit=max\&auto=format\&n=afTnzq3qfLo_wqCN\&q=85\&s=7032a4cf067a807815ce5d786cb7cf86) **Download the sample PDF:** ``` https://cdn.reducto.ai/samples/multimodal-rag-research-paper.pdf ``` Save it as `research-paper.pdf` in your working directory. **Attribution:** Deng, Xizi, Elise McClay, Erin Jastrzebski, Yue Wang, and H. Henny Yeung. PLOS ONE, 2024. Licensed under [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/). > [!TIP] > > You can use any PDF with charts or figures. Annual reports, scientific papers, and technical documentation work well for multimodal RAG. *** ## Step 1: Parse the document with image extraction ### Initialize the client First, we create a Reducto client using our API key: ```python Python theme={null} import os from reducto import Reducto client = Reducto(api_key=os.environ["REDUCTO_API_KEY"]) ``` ```javascript JavaScript theme={null} import Reducto from "reductoai"; const client = new Reducto({ apiKey: process.env.REDUCTO_API_KEY }); ``` ### Upload the document Before parsing, we need to upload the file to Reducto's servers. The `upload()` method returns a file ID that we'll use in the parse call: ```python Python theme={null} from pathlib import Path upload = client.upload(file=Path("research-paper.pdf")) print(f"File ID: {upload.file_id}") ``` ```javascript JavaScript theme={null} import fs from "fs"; const upload = await client.upload({ file: fs.createReadStream("research-paper.pdf"), }); console.log(`File ID: ${upload.file_id}`); ``` ``` File ID: reducto://c0584170-17a0-44f5-baf4-727467b71b84.pdf ``` ### Parse with image extraction Now we parse the document. The key settings here are: * **`return_images`**: Tells Reducto to crop and return images for figures and tables * **`chunk_mode`**: Controls how text is grouped into chunks ```python Python theme={null} result = client.parse.run( input=upload.file_id, settings={ "return_images": ["figure", "table"] }, retrieval={ "chunking": { "chunk_mode": "section" } } ) print(f"Parsed {result.usage.num_pages} pages") print(f"Got {len(result.result.chunks)} chunks") ``` ```javascript JavaScript theme={null} const result = await client.parse.run({ input: upload.file_id, settings: { return_images: ["figure", "table"] }, retrieval: { chunking: { chunk_mode: "section" } } }); console.log(`Parsed ${result.usage.num_pages} pages`); console.log(`Got ${result.result.chunks.length} chunks`); ``` ``` Parsed 24 pages Got 33 chunks ``` **Why `chunk_mode: "section"`?** We use section-based chunking because it keeps figures together with their surrounding explanatory text. If a figure appears in the "Results" section, the chunk will include both the figure and the text that explains it. This improves retrieval quality because the embedding captures the full context. Other options: * `page`: One chunk per page. Simpler but may split related content. * `variable`: Adaptive chunking based on content density. *** ## Step 2: Understand the response structure Before we start uploading images, let's look at what Reducto actually returns. This helps us understand what data we're working with. ### Exploring chunks and blocks Each chunk contains multiple blocks. A block can be text, a figure, a table, or other content types: ```python Python theme={null} # Skip the front matter and look at a content chunk chunks = result.result.chunks chunk = chunks[2] if len(chunks) > 2 else chunks[0] print(f"Chunk has {len(chunk.blocks)} blocks") print(f"Embed text preview: {chunk.embed[:200]}...") ``` ```javascript JavaScript theme={null} // Skip the front matter and look at a content chunk const chunks = result.result.chunks; const chunk = chunks[2] ?? chunks[0]; console.log(`Chunk has ${chunk.blocks.length} blocks`); console.log(`Embed text preview: ${chunk.embed.slice(0, 200)}...`); ``` ``` Chunk has 5 blocks Embed text preview: # Visual scanning patterns of a talking face when evaluating phonetic information in a native and non-native language Xizi DengⓇ*, Elise McClay, Erin Jastrzebski, Yue Wang, H. Henny Yeung Department... ``` ### Finding figures and tables Let's find all figures and tables in the document: ```python Python theme={null} figure_count = 0 table_count = 0 for chunk in result.result.chunks: for block in chunk.blocks: if block.type == "Figure": figure_count += 1 elif block.type == "Table": table_count += 1 print(f"Found {figure_count} figures and {table_count} tables") ``` ```javascript JavaScript theme={null} let figureCount = 0; let tableCount = 0; for (const chunk of result.result.chunks) { for (const block of chunk.blocks) { if (block.type === "Figure") { figureCount++; } else if (block.type === "Table") { tableCount++; } } } console.log(`Found ${figureCount} figures and ${tableCount} tables`); ``` ``` Found 8 figures and 6 tables ``` ### Examining a figure block Each figure block has several important fields: ```python Python theme={null} # The first figures in this paper are the journal masthead and a badge, # so inspect a later one figure_blocks = [ block for chunk in result.result.chunks for block in chunk.blocks if block.type == "Figure" ] figure = figure_blocks[2] if len(figure_blocks) > 2 else figure_blocks[0] print(f"Type: {figure.type}") print(f"Page: {figure.bbox.page}") print(f"Image URL: {figure.image_url[:80]}...") print(f"Content: {figure.content[:150]}...") ``` ```javascript JavaScript theme={null} // The first figures in this paper are the journal masthead and a badge, // so inspect a later one const figureBlocks = result.result.chunks .flatMap(chunk => chunk.blocks) .filter(block => block.type === "Figure"); const figure = figureBlocks[2] ?? figureBlocks[0]; console.log(`Type: ${figure.type}`); console.log(`Page: ${figure.bbox.page}`); console.log(`Image URL: ${figure.image_url.slice(0, 80)}...`); console.log(`Content: ${figure.content.slice(0, 150)}...`); ``` ``` Type: Figure Page: 6 Image URL: https://prod-storage20241010144745140900000001.s3.amazonaws.com/org/25e89aa6... Content: Trial-structure diagram for a sentence-matching task: Sentence A (~3-6 s) is followed by a 300 ms pause, Sentence B (~3-6 s), a 500 ms pause, a silent... ``` **Key fields explained:** | Field | What it contains | | - | - | | `block.type` | "Figure", "Table", "Text", etc. | | `block.bbox.page` | Page number (1-indexed) | | `block.image_url` | Temporary URL to the cropped image. **Expires in 12 hours.** | | `block.content` | AI-generated description of the figure | | `chunk.embed` | Full text optimized for embedding, includes figure descriptions | > [!NOTE] > > The `content` field contains Reducto's AI-generated description of the figure. This is incredibly useful, as it means your vector search can find figures based on what they show, not just the surrounding text. *** ## Step 3: Upload images to S3 Now we need to save these images permanently. Reducto's URLs expire in 12 hours, so we'll upload each image to S3 immediately. ### Initialize the S3 client ```python Python theme={null} import boto3 s3 = boto3.client("s3", region_name=os.environ["AWS_REGION"]) bucket_name = os.environ["S3_BUCKET_NAME"] ``` ```javascript JavaScript theme={null} import { S3Client, PutObjectCommand, HeadObjectCommand } from "@aws-sdk/client-s3"; const s3 = new S3Client({ region: process.env.AWS_REGION }); const bucketName = process.env.S3_BUCKET_NAME; ``` ### Create the upload function This function downloads an image from Reducto and uploads it to S3. It returns the private object key, not a public URL. ```python Python theme={null} import requests def upload_image_to_s3(image_url, s3_key): """Download image from Reducto and upload to S3.""" # Download from Reducto response = requests.get(image_url) response.raise_for_status() # Upload to S3 s3.put_object( Bucket=bucket_name, Key=s3_key, Body=response.content, ContentType="image/png", ServerSideEncryption="AES256" ) return s3_key ``` ```javascript JavaScript theme={null} async function uploadImageToS3(imageUrl, s3Key) { // Download from Reducto const response = await fetch(imageUrl); if (!response.ok) throw new Error(`Failed to fetch: ${response.status}`); const imageBuffer = Buffer.from(await response.arrayBuffer()); // Upload to S3 await s3.send(new PutObjectCommand({ Bucket: bucketName, Key: s3Key, Body: imageBuffer, ContentType: "image/png", ServerSideEncryption: "AES256" })); return s3Key; } ``` ### Upload all images Now we loop through all chunks. For each chunk, we check if it contains any figures or tables. If it does, we upload those images to S3 and store the object key. If it doesn't, we still index the chunk without image metadata. This is the key difference from image-only indexing: **we index everything**, both text-only chunks and chunks with figures. ```python Python theme={null} all_items = [] for chunk_idx, chunk in enumerate(result.result.chunks): # Find any figures/tables in this chunk images_in_chunk = [] for block in chunk.blocks: if block.type in ["Figure", "Table"] and block.image_url: image_id = f"chunk-{chunk_idx}-{block.type.lower()}-page{block.bbox.page}" s3_key = f"multimodal-rag/{image_id}.png" object_key = upload_image_to_s3(block.image_url, s3_key) images_in_chunk.append({ "s3_key": object_key, "block_type": block.type, "page": block.bbox.page }) # Get the page number from the first block page = chunk.blocks[0].bbox.page if chunk.blocks else 1 # Index this chunk (with or without images) item = { "id": f"chunk-{chunk_idx}", "text": chunk.embed, "page": page, "has_images": len(images_in_chunk) > 0 } # If this chunk has images, include the first one # (for chunks with multiple figures, you could store all keys) if images_in_chunk: item["s3_key"] = images_in_chunk[0]["s3_key"] item["block_type"] = images_in_chunk[0]["block_type"] all_items.append(item) # Count what we have chunks_with_images = sum(1 for item in all_items if item.get("has_images")) chunks_text_only = len(all_items) - chunks_with_images print(f"Total chunks: {len(all_items)}") print(f" - With images: {chunks_with_images}") print(f" - Text only: {chunks_text_only}") ``` ```javascript JavaScript theme={null} const allItems = []; for (let chunkIdx = 0; chunkIdx < result.result.chunks.length; chunkIdx++) { const chunk = result.result.chunks[chunkIdx]; // Find any figures/tables in this chunk const imagesInChunk = []; for (const block of chunk.blocks) { if (["Figure", "Table"].includes(block.type) && block.image_url) { const imageId = `chunk-${chunkIdx}-${block.type.toLowerCase()}-page${block.bbox.page}`; const s3Key = `multimodal-rag/${imageId}.png`; const objectKey = await uploadImageToS3(block.image_url, s3Key); imagesInChunk.push({ s3_key: objectKey, block_type: block.type, page: block.bbox.page }); } } // Get the page number from the first block const page = chunk.blocks.length > 0 ? chunk.blocks[0].bbox.page : 1; // Index this chunk (with or without images) const item = { id: `chunk-${chunkIdx}`, text: chunk.embed, page: page, has_images: imagesInChunk.length > 0 }; // If this chunk has images, include the first one if (imagesInChunk.length > 0) { item.s3_key = imagesInChunk[0].s3_key; item.block_type = imagesInChunk[0].block_type; } allItems.push(item); } // Count what we have const chunksWithImages = allItems.filter(item => item.has_images).length; const chunksTextOnly = allItems.length - chunksWithImages; console.log(`Total chunks: ${allItems.length}`); console.log(` - With images: ${chunksWithImages}`); console.log(` - Text only: ${chunksTextOnly}`); ``` ``` Total chunks: 33 - With images: 11 - Text only: 22 ``` Now our index will contain the entire document. Text-only queries will find relevant text chunks, while queries about figures will find chunks that have associated images. ### Verify an image is accessible Use the S3 API to verify that the object exists in your private bucket: ```python Python theme={null} # Find a chunk that has an image test_item = next(item for item in all_items if item.get("s3_key")) test_key = test_item["s3_key"] response = s3.head_object(Bucket=bucket_name, Key=test_key) print(f"Testing: s3://{bucket_name}/{test_key}") print(f"Content length: {response['ContentLength']}") ``` ```javascript JavaScript theme={null} // Find a chunk that has an image const testItem = allItems.find(item => item.s3_key); const testKey = testItem.s3_key; const response = await s3.send( new HeadObjectCommand({ Bucket: bucketName, Key: testKey }) ); console.log(`Testing: s3://${bucketName}/${testKey}`); console.log(`Content length: ${response.ContentLength}`); ``` ``` Testing: s3://reducto-multimodal-rag-demo/multimodal-rag/chunk-0-figure-page1.png Content length: 48231 ``` > [!WARNING] > > If you get `AccessDenied`, check that the IAM policy covers the exact bucket and the `multimodal-rag/*` prefix. Keep S3 Block Public Access enabled and do not add a public bucket policy. *** ## Step 4: Index into Pinecone With images safely stored in S3, we can now create vector embeddings and store them in Pinecone. ### Initialize the clients ```python Python theme={null} from pinecone import Pinecone import voyageai pc = Pinecone(api_key=os.environ["PINECONE_API_KEY"]) index = pc.Index("multimodal-rag") vo = voyageai.Client(api_key=os.environ["VOYAGEAI_API_KEY"]) ``` ```javascript JavaScript theme={null} import { Pinecone } from "@pinecone-database/pinecone"; import { VoyageAIClient } from "voyageai"; const pc = new Pinecone({ apiKey: process.env.PINECONE_API_KEY }); const index = pc.index("multimodal-rag"); const vo = new VoyageAIClient({ apiKey: process.env.VOYAGEAI_API_KEY }); ``` ### Understanding what we're indexing For each chunk, we store: * **Vector**: Embedding of the text (from `chunk.embed`) * **Metadata**: Text preview, page number, and optionally the S3 bucket and object key The vector enables semantic search across the entire document. When a chunk has an associated image, the metadata includes its S3 bucket and object key. Never persist presigned URLs in Pinecone because they expire. ### Create embeddings and upsert ```python Python theme={null} for item in all_items: # Create embedding from the chunk's embed text embedding_response = vo.embed( [item["text"][:8000]], # VoyageAI has input limits model="voyage-4" ) embedding = embedding_response.embeddings[0] # Build metadata (S3 location only included if chunk has images) metadata = { "text": item["text"][:1000], # Preview for display "page": item["page"], "has_images": item["has_images"] } if item.get("s3_key"): metadata["s3_bucket"] = bucket_name metadata["s3_key"] = item["s3_key"] metadata["block_type"] = item.get("block_type", "Figure") # Upsert to Pinecone index.upsert(vectors=[{ "id": item["id"], "values": embedding, "metadata": metadata }]) print(f"Indexed {len(all_items)} items") ``` ```javascript JavaScript theme={null} for (const item of allItems) { // Create embedding from the chunk's embed text const embeddingResponse = await vo.embed({ input: [item.text.slice(0, 8000)], // VoyageAI has input limits model: "voyage-4" }); const embedding = embeddingResponse.data[0].embedding; // Build metadata (S3 location only included if chunk has images) const metadata = { text: item.text.slice(0, 1000), // Preview for display page: item.page, has_images: item.has_images }; if (item.s3_key) { metadata.s3_bucket = bucketName; metadata.s3_key = item.s3_key; metadata.block_type = item.block_type || "Figure"; } // Upsert to Pinecone await index.upsert([{ id: item.id, values: embedding, metadata: metadata }]); } console.log(`Indexed ${allItems.length} items`); ``` ``` Indexed 33 items ``` **Why do we truncate the text?** * For embeddings (`[:8000]`): VoyageAI has input token limits * For metadata (`[:1000]`): Pinecone has metadata size limits (\~40KB per vector) We store enough text in metadata to display a preview, but the full context is captured in the embedding. > [!NOTE] > > VoyageAI's free tier has rate limits (3 requests per minute). For production use with many documents, add rate limiting or upgrade your plan. *** ## Step 5: Query and retrieve Now we can search our index. When someone asks a question, we: 1. Embed their query using the same model (`voyage-4`) 2. Find the most similar vectors in Pinecone 3. Return the matches with their S3 object keys ### Create the search function ```python Python theme={null} def search(query: str, top_k: int = 3): """Search for relevant chunks with images.""" # Embed the query query_embedding = vo.embed( [query], model="voyage-4" ).embeddings[0] # Search Pinecone results = index.query( vector=query_embedding, top_k=top_k, include_metadata=True ) return results.matches def create_presigned_image_url(bucket: str, key: str) -> str: return s3.generate_presigned_url( "get_object", Params={"Bucket": bucket, "Key": key}, ExpiresIn=900, ) ``` ```javascript JavaScript theme={null} import { GetObjectCommand } from "@aws-sdk/client-s3"; import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; async function search(query, topK = 3) { // Embed the query const embeddingResponse = await vo.embed({ input: [query], model: "voyage-4" }); const queryEmbedding = embeddingResponse.data[0].embedding; // Search Pinecone const results = await index.query({ vector: queryEmbedding, topK: topK, includeMetadata: true }); return results.matches; } async function createPresignedImageUrl(bucket, key) { return getSignedUrl( s3, new GetObjectCommand({ Bucket: bucket, Key: key }), { expiresIn: 900 } ); } ``` The presigned URL expires in 15 minutes. That only needs to outlive the LLM call. ### Test the search ```python Python theme={null} matches = search("eye and mouth looking patterns by language") for match in matches: print(f"Score: {match.score:.3f}") print(f"Page: {match.metadata['page']}") print(f"Has image: {match.metadata.get('has_images', False)}") if match.metadata.get("s3_key"): print(f"Image: s3://{match.metadata['s3_bucket']}/{match.metadata['s3_key']}") print(f"Text preview: {match.metadata['text'][:100]}...") print("---") ``` ```javascript JavaScript theme={null} const matches = await search("eye and mouth looking patterns by language"); for (const match of matches) { console.log(`Score: ${match.score.toFixed(3)}`); console.log(`Page: ${match.metadata.page}`); console.log(`Has image: ${match.metadata.has_images || false}`); if (match.metadata.s3_key) { console.log(`Image: s3://${match.metadata.s3_bucket}/${match.metadata.s3_key}`); } console.log(`Text preview: ${match.metadata.text.slice(0, 100)}...`); console.log("---"); } ``` ``` Score: 0.412 Page: 10 Has image: True Image: s3://reducto-multimodal-rag-demo/multimodal-rag/chunk-14-figure-page10.png Text preview: # Visual scanning patterns of a talking face when evaluating phonetic information in a native and non-native language (cont.)... --- Score: 0.287 Page: 6 Has image: True Image: s3://reducto-multimodal-rag-demo/multimodal-rag/chunk-9-figure-page6.png Text preview: # Visual scanning patterns of a talking face when evaluating phonetic information in a native and non-native language (cont.)... --- Score: 0.194 Page: 7 Has image: True Image: s3://reducto-multimodal-rag-demo/multimodal-rag/chunk-10-table-page7.png Text preview: # Visual scanning patterns of a talking face when evaluating phonetic information in a native and non-native language (cont.)... --- ``` Every top match for this query carries an image, because the question asks about a charted result. Text-only chunks compete in the same index and rank higher for prose questions. Exact scores depend on the embedding model and index contents. *** ## Step 6: Send to your LLM You now have everything needed for multimodal generation: * **Text context**: `match.metadata["text"]` * **Images**: Mint a presigned S3 GET URL from `match.metadata["s3_bucket"]` and `match.metadata["s3_key"]`, or fetch the raw image bytes. Pass the presigned URL or raw image bytes to any vision-capable LLM (Claude, GPT-4V, Gemini). See [S3 presigned URLs](https://docs.aws.amazon.com/AmazonS3/latest/userguide/using-presigned-url.html). Set the URL expiry longer than the LLM call, such as 15 minutes. *** ## Reducto features for better results These Reducto settings can improve your multimodal RAG pipeline: > [accordion] Figure summaries (enabled by default) > > Reducto automatically generates AI descriptions of figures and includes them in the `embed` field. This is why queries like "show me the revenue chart" can find relevant figures even if "revenue" doesn't appear in surrounding text. > > This is controlled by `summarize_figures` (default: `True`). Keep it enabled for multimodal RAG. > [accordion] Agentic figure extraction > > For documents with complex charts, enable agentic figure extraction for higher accuracy: > > ```python Python theme={null} > result = client.parse.run( > input=upload.file_id, > settings={"return_images": ["figure", "table"]}, > enhance={ > "agentic": [{"scope": "figure"}] > } > ) > ``` > > ```javascript JavaScript theme={null} > const result = await client.parse.run({ > input: upload.file_id, > settings: { return_images: ["figure", "table"] }, > enhance: { > agentic: [{ scope: "figure" }] > } > }); > ``` > > This uses vision LLMs to better understand chart content. It adds latency and cost but improves extraction quality. > [accordion] Embedding-optimized output > > Enable `embedding_optimized` for output specifically tuned for vector embeddings: > > ```python Python theme={null} > result = client.parse.run( > input=upload.file_id, > retrieval={ > "chunking": {"chunk_mode": "section"}, > "embedding_optimized": True > } > ) > ``` > > ```javascript JavaScript theme={null} > const result = await client.parse.run({ > input: upload.file_id, > retrieval: { > chunking: { chunk_mode: "section" }, > embedding_optimized: true > } > }); > ``` > [accordion] Filter block types > > If you only want certain content types, use `filter_blocks` to exclude others from the embed field: > > ```python Python theme={null} > result = client.parse.run( > input=upload.file_id, > retrieval={ > "filter_blocks": ["Header", "Footer", "Page Number"] > } > ) > ``` > > ```javascript JavaScript theme={null} > const result = await client.parse.run({ > input: upload.file_id, > retrieval: { > filter_blocks: ["Header", "Footer", "Page Number"] > } > }); > ``` *** ## Best practices > [accordion] Choose the right chunking strategy > > The `chunk_mode` setting affects how figures relate to surrounding text: > > * **`section`**: Groups content by document sections. Best for structured documents like research papers. > * **`page`**: One chunk per page. Simple and predictable. > * **`variable`**: Adaptive chunking based on content density. > > For multimodal RAG, `section` usually works best because it keeps figures with their explanatory text. > [accordion] Use the embed field for vector search > > Always use `chunk.embed` (not `chunk.content`) for your vector embeddings. The embed field includes AI-generated figure descriptions that make visual content searchable. > [accordion] Not every query needs images > > Sending images to your LLM adds latency and cost. Consider routing: > > * Simple factual questions → Text-only RAG > * Questions about trends, comparisons, or visuals → Multimodal RAG > > You can implement this by checking `has_images` in retrieved chunks before deciding which path to take. > [accordion] Handle rate limits gracefully > > VoyageAI's free tier has a 3 requests per minute limit. For production: > > * Batch multiple texts in a single embedding call > * Add delays between calls > * Upgrade to a paid plan for higher limits > [accordion] Limit retrieved images > > More images means higher LLM costs and slower responses. For most questions, 2-3 images is sufficient. Use `top_k=3` in your search function. *** ## Complete example Here's the full pipeline in a single script: ```python Python theme={null} import os from pathlib import Path import requests import boto3 from pinecone import Pinecone import voyageai from reducto import Reducto # Initialize clients reducto = Reducto() s3 = boto3.client("s3", region_name=os.environ["AWS_REGION"]) pc = Pinecone(api_key=os.environ["PINECONE_API_KEY"]) voyage = voyageai.Client() bucket_name = os.environ["S3_BUCKET_NAME"] index_name = "multimodal-rag" def upload_image_to_s3(image_url: str, s3_key: str) -> str: """Download image from Reducto and upload to S3.""" response = requests.get(image_url) response.raise_for_status() s3.put_object( Bucket=bucket_name, Key=s3_key, Body=response.content, ContentType="image/png", ServerSideEncryption="AES256", ) return s3_key def index_document(file_path): """Parse document, upload images to S3, and index in Pinecone.""" # Parse with image extraction upload = reducto.upload(file=Path(file_path)) result = reducto.parse.run( input=upload.file_id, settings={"return_images": ["figure", "table"]}, retrieval={"chunking": {"chunk_mode": "section"}} ) # Process chunks and upload images records = [] for i, chunk in enumerate(result.result.chunks): s3_key = None for block in chunk.blocks: if block.type in ["Figure", "Table"] and block.image_url: image_id = f"{result.job_id}-chunk-{i}" s3_key = f"multimodal-rag/{image_id}.png" upload_image_to_s3(block.image_url, s3_key) break # Embed and prepare record embedding = voyage.embed([chunk.embed], model="voyage-4").embeddings[0] records.append({ "id": f"{result.job_id}_{i}", "values": embedding, "metadata": { "text": chunk.embed, "page": chunk.blocks[0].bbox.page if chunk.blocks else 0, "s3_bucket": bucket_name if s3_key else "", "s3_key": s3_key or "", "has_image": s3_key is not None } }) # Upsert to Pinecone index = pc.Index(index_name) index.upsert(vectors=records) return len(records) def search(query, top_k=3): """Search for relevant chunks.""" embedding = voyage.embed([query], model="voyage-4").embeddings[0] index = pc.Index(index_name) results = index.query(vector=embedding, top_k=top_k, include_metadata=True) return results.matches def query_with_images(query): """Search and prepare context for LLM.""" matches = search(query, top_k=3) context = "\n\n".join([m.metadata["text"] for m in matches]) images = [ s3.generate_presigned_url( "get_object", Params={ "Bucket": m.metadata["s3_bucket"], "Key": m.metadata["s3_key"], }, ExpiresIn=900, ) for m in matches if m.metadata.get("s3_key") ] return {"context": context, "images": images, "query": query} # Usage indexed = index_document("research-paper.pdf") print(f"Indexed {indexed} chunks") result = query_with_images("What do the experimental results show?") print(f"Context: {len(result['context'])} chars, Images: {len(result['images'])}") # Pass result to your vision LLM ``` ```javascript JavaScript theme={null} import fs from "fs"; import Reducto from "reductoai"; import { S3Client, PutObjectCommand, GetObjectCommand } from "@aws-sdk/client-s3"; import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; import { Pinecone } from "@pinecone-database/pinecone"; import { VoyageAIClient } from "voyageai"; // Initialize clients const reducto = new Reducto(); const s3 = new S3Client({ region: process.env.AWS_REGION }); const pc = new Pinecone({ apiKey: process.env.PINECONE_API_KEY }); const voyage = new VoyageAIClient(); const bucketName = process.env.S3_BUCKET_NAME; const indexName = "multimodal-rag"; async function uploadImageToS3(imageUrl, s3Key) { const response = await fetch(imageUrl); if (!response.ok) throw new Error(`Failed to fetch: ${response.status}`); const imageBuffer = Buffer.from(await response.arrayBuffer()); await s3.send(new PutObjectCommand({ Bucket: bucketName, Key: s3Key, Body: imageBuffer, ContentType: "image/png", ServerSideEncryption: "AES256" })); return s3Key; } async function indexDocument(filePath) { // Parse with image extraction const upload = await reducto.upload({ file: fs.createReadStream(filePath) }); const result = await reducto.parse.run({ input: upload.file_id, settings: { return_images: ["figure", "table"] }, retrieval: { chunking: { chunk_mode: "section" } } }); // Process chunks and upload images const records = []; for (let i = 0; i < result.result.chunks.length; i++) { const chunk = result.result.chunks[i]; let s3Key = null; for (const block of chunk.blocks) { if (["Figure", "Table"].includes(block.type) && block.image_url) { const imageId = `${result.job_id}-chunk-${i}`; s3Key = `multimodal-rag/${imageId}.png`; await uploadImageToS3(block.image_url, s3Key); break; } } // Embed and prepare record const embeddingResponse = await voyage.embed({ input: [chunk.embed], model: "voyage-4" }); records.push({ id: `${result.job_id}_${i}`, values: embeddingResponse.data[0].embedding, metadata: { text: chunk.embed, page: chunk.blocks.length > 0 ? chunk.blocks[0].bbox.page : 0, s3_bucket: s3Key ? bucketName : "", s3_key: s3Key || "", has_image: s3Key !== null } }); } // Upsert to Pinecone const index = pc.index(indexName); await index.upsert(records); return records.length; } async function search(query, topK = 3) { const embeddingResponse = await voyage.embed({ input: [query], model: "voyage-4" }); const index = pc.index(indexName); const results = await index.query({ vector: embeddingResponse.data[0].embedding, topK, includeMetadata: true }); return results.matches; } async function queryWithImages(query) { const matches = await search(query, 3); const context = matches.map(m => m.metadata.text).join("\n\n"); const images = await Promise.all( matches .filter(m => m.metadata.s3_key) .map(m => getSignedUrl( s3, new GetObjectCommand({ Bucket: m.metadata.s3_bucket, Key: m.metadata.s3_key }), { expiresIn: 900 } )) ); return { context, images, query }; } // Usage const indexed = await indexDocument("research-paper.pdf"); console.log(`Indexed ${indexed} chunks`); const result = await queryWithImages("What do the experimental results show?"); console.log(`Context: ${result.context.length} chars, Images: ${result.images.length}`); // Pass result to your vision LLM ``` *** ## Next steps > [card] [Batch Processing](/cookbooks/batch-processing) > > Process multiple documents in parallel. > [card] [Chunking Methods](/configs/parse/chunking-methods) > > Learn about different chunking strategies. > [card] [Chart Extraction](/configs/parse/chart-extraction) > > Configure image extraction and other parse options. > [card] [Response Format](/parse/response-format) > > Understand the full parse response structure. # Cookbooks Source: https://docs.reducto.ai/cookbooks/overview Step-by-step guides for common document processing tasks Cookbooks are end-to-end tutorials showing how to solve real document processing problems with Reducto. Each includes working implementation examples, with both Python and JavaScript wherever the surrounding tooling supports it. *** ## Pipelines & Workflows > [card] [Multimodal RAG](/cookbooks/multimodal-rag-image-results) > > Build a RAG pipeline that returns images alongside text. Uses S3, Pinecone, and VoyageAI. > [card] [Financial Analysis](/cookbooks/financial-analysis) > > Extract structured data from 10-K filings and financial reports using schemas. > [card] [Multilingual Processing](/cookbooks/multilingual-processing) > > Parse documents in Spanish, French, German, and 50+ other languages. > [card] [Batch Processing](/cookbooks/batch-processing) > > Process hundreds of documents in parallel with concurrency control. > [card] [Web Browsing](/cookbooks/web-browsing-browserbase) > > Download documents with a Browserbase agent, then parse them (Python). *** ## Document Examples Real-world document types from our most common customer industries—insurance, finance, logistics, and legal. Each shows how to extract structured data from documents you likely work with. > [card] [Form Filling](/cookbooks/form-filling) > > Automate ACORD insurance forms and other PDF form filling. > [card] [Identity Verification](/cookbooks/identity-verification) > > Extract data from ID cards, passports, and utility bills. > [card] [Contract Review](/cookbooks/redlined-legal-contracts) > > Parse redlined contracts and extract insertions, deletions, and annotations. > [card] [Invoice Extraction](/cookbooks/invoice-extraction) > > Pull line items, totals, and vendor info from freight invoices. *** ## Request a Cookbook Have a document type or use case you'd like to see covered? [Let us know](mailto:support@reducto.ai) and we'll consider adding it. # Contract & Legal Document Review Source: https://docs.reducto.ai/cookbooks/redlined-legal-contracts Parse redlined legal documents and extract insertions, deletions, and annotations as structured data Reducto's `change_tracking` feature extracts strikethroughs, underlines, and annotations from redlined contracts as structured HTML tags—making it easy to list changes programmatically, categorize by type, and build approval workflows. *** ## What you'll build By the end of this cookbook, you'll have a pipeline that parses any redlined contract and extracts every revision as structured data. ``` Redlined Contract (PDF/DOCX) | v Reducto Parse (change_tracking enabled) | v Structured output with , , tags | v Python extraction → List of all changes ``` *** ## Create API Key **1. Open Studio** Go to [studio.reducto.ai](https://studio.reducto.ai) and sign in. From the home page, click **API Keys** in the left sidebar. ![Studio home page with API Keys in sidebar](https://mintcdn.com/reducto/9Avr4qdsIoNo7JLQ/cookbooks/dummy-docs/screenshots/api-1.png?fit=max\&auto=format\&n=9Avr4qdsIoNo7JLQ\&q=85\&s=6fda1435e042681807741c7743273da2) **2. View API Keys** The API Keys page shows your existing keys. Click **Create API Key** in the top right corner. ![API Keys page with Create button](https://mintcdn.com/reducto/9Avr4qdsIoNo7JLQ/cookbooks/dummy-docs/screenshots/api-2.png?fit=max\&auto=format\&n=9Avr4qdsIoNo7JLQ\&q=85\&s=10db7406c2ac7217e4b1d75e028b58e1) **3. Configure Key** In the modal, enter a name for your key and set an expiration policy (or select "Never" for no expiration). Click **Create**. ![New API Key modal with name and expiration fields](https://mintcdn.com/reducto/9Avr4qdsIoNo7JLQ/cookbooks/dummy-docs/screenshots/api-3.png?fit=max\&auto=format\&n=9Avr4qdsIoNo7JLQ\&q=85\&s=afb60f6cfb4d33940669d534dd007343) **4. Copy Your Key** Copy your new API key and store it securely. You won't be able to see it again after closing this dialog. ![Copy API key dialog](https://mintcdn.com/reducto/9Avr4qdsIoNo7JLQ/cookbooks/dummy-docs/screenshots/api-4.png?fit=max\&auto=format\&n=9Avr4qdsIoNo7JLQ\&q=85\&s=c861b1c2f593244957cf15c6fd717f60) Set the key as an environment variable: ```bash theme={null} export REDUCTO_API_KEY="your-api-key-here" ``` Install the SDK: ```bash Python theme={null} pip install reductoai requests ``` ```bash JavaScript theme={null} npm install reductoai ``` *** ## Sample document For this cookbook, we use a 165-page union labor agreement (AFSCME Local 328 vs. Oregon Health & Science University) with extensive redlines showing proposed contract changes. The document includes: * Strikethroughs for deleted clauses * Underlines for new language * Inline annotations explaining changes **Download the sample:** ``` https://static1.squarespace.com/static/5cee0f8eb1a76b0001ca1d78/t/5e45817fe03fd3048a4b1792/1581613498729/Redline+Contract+with+Annotation.pdf ``` > [!TIP] > > Reducto works with both Word documents (which have native track changes metadata) and PDFs (where it visually detects underlines and strikethroughs). Word documents give the best results since the change metadata is embedded in the file. *** ## Step 1: Parse with change tracking ### Upload the document First, upload your redlined contract to Reducto: ```python Python theme={null} from pathlib import Path from reducto import Reducto client = Reducto() upload = client.upload(file=Path("redlined_contract.pdf")) print(f"File ID: {upload.file_id}") ``` ```javascript JavaScript theme={null} import Reducto from "reductoai"; import fs from "fs"; const client = new Reducto(); const upload = await client.upload({ file: fs.createReadStream("redlined_contract.pdf") }); console.log(`File ID: ${upload.file_id}`); ``` ``` File ID: reducto://e1894955-d89d-42ef-a118-f08d61ee890d.pdf ``` ### Parse with change tracking enabled The key setting is `formatting.include: ["change_tracking"]`. This tells Reducto to detect underlines and strikethroughs and wrap them in HTML tags. ```python Python theme={null} result = client.parse.run( input=upload.file_id, formatting={ "include": ["change_tracking"] } ) print(f"Pages: {result.usage.num_pages}") print(f"Credits: {result.usage.credits}") ``` ```javascript JavaScript theme={null} const result = await client.parse.run({ input: upload.file_id, formatting: { include: ["change_tracking"] } }); console.log(`Pages: ${result.usage.num_pages}`); console.log(`Credits: ${result.usage.credits}`); ``` ``` Pages: 165 Credits: 188.0 ``` **Why `change_tracking`?** Without this option, Reducto returns plain text. With it enabled, revisions appear as HTML tags that you can parse programmatically: * `` wraps strikethrough text (deletions) * `` wraps underlined text (insertions) * `` groups related revisions together *** ## Step 2: Handle large documents For large documents like our 165-page contract, Reducto returns results as a URL rather than inline data. This keeps response sizes manageable. ```python Python theme={null} import requests # Check if result is a URL if hasattr(result.result, 'url'): print(f"Result URL: {result.result.url[:80]}...") response = requests.get(result.result.url) data = response.json() chunks = data.get('chunks', []) full_content = "\n".join([c.get('content', '') for c in chunks]) else: full_content = "\n".join([c.content for c in result.result.chunks]) print(f"Content length: {len(full_content)} characters") ``` ```javascript JavaScript theme={null} let fullContent; // Check if result is a URL (large documents) if (result.result.type === "url") { console.log(`Result URL: ${result.result.url.slice(0, 80)}...`); const response = await fetch(result.result.url); const data = await response.json(); const chunks = data.chunks || []; fullContent = chunks.map(c => c.content || "").join("\n"); } else { fullContent = result.result.chunks.map(c => c.content).join("\n"); } console.log(`Content length: ${fullContent.length} characters`); ``` ``` Result URL: https://prod-storage20241010144745140900000001.s3.amazonaws.com/ac80631f... Content length: 365675 characters ``` *** ## Step 3: Understand the output With change tracking enabled, revisions appear as HTML markup in the content. Here's what we found in our sample contract: ``` tags found: 554 (strikethrough) tags found: 289 (underline) tags found: 438 ``` ### Real examples from the document **Simple insertion** (new language added): ```html theme={null} and any PEOPLE deduction ``` **Clause deletion** (entire section removed): ```html theme={null} a. An employee's chosen form of dues or payment in lieu of dues shall recommence upon reinstatement following a period of layoff or extended leave. ``` **Replacement** (old language replaced with new): ```html theme={null} forty-eight (48) fifty (50) ``` ### Tag meanings | Tag | Meaning | Visual in document | | - | - | - | | `` | Strikethrough | ~~deleted text~~ | | `` | Underline | inserted text | | `` | Revision region | Groups related deletions/insertions | A single `` block can contain: * Just a deletion: `removed text` * Just an insertion: `new text` * Both: `old new` *** ## Step 4: Extract changes programmatically Now we parse the HTML tags to get a structured list of all changes. This function uses regex to find every `` block and extract the deletions and insertions within it. ```python Python theme={null} import re def extract_changes(content): """Extract all revision regions from parsed content.""" changes = [] pattern = r'(.*?)' for match in re.finditer(pattern, content, re.DOTALL): change_text = match.group(1) # Extract deletions (strikethrough) deletions = re.findall(r'(.*?)', change_text, re.DOTALL) # Extract insertions (underline) insertions = re.findall(r'(.*?)', change_text, re.DOTALL) changes.append({ "deleted": deletions, "inserted": insertions, }) return changes ``` ```javascript JavaScript theme={null} function extractChanges(content) { const changes = []; // Match all ... blocks const changeRegex = /([\s\S]*?)<\/change>/g; let match; while ((match = changeRegex.exec(content)) !== null) { const changeText = match[1]; // Extract deletions (strikethrough) const deletions = []; const delRegex = /([\s\S]*?)<\/s>/g; let delMatch; while ((delMatch = delRegex.exec(changeText)) !== null) { deletions.push(delMatch[1]); } // Extract insertions (underline) const insertions = []; const insRegex = /([\s\S]*?)<\/u>/g; let insMatch; while ((insMatch = insRegex.exec(changeText)) !== null) { insertions.push(insMatch[1]); } changes.push({ deleted: deletions, inserted: insertions }); } return changes; } ``` **Why regex?** The HTML tags are simple and well-structured. For basic extraction, regex is fast and sufficient. For documents with complex nested changes, consider using an HTML parser like BeautifulSoup. ### Run the extraction ```python Python theme={null} changes = extract_changes(full_content) print(f"Found {len(changes)} revisions") for i, change in enumerate(changes[:5]): print(f"\nRevision {i+1}:") if change["deleted"]: print(f" Deleted: {change['deleted'][0][:60]}...") if change["inserted"]: print(f" Inserted: {change['inserted'][0][:60]}...") ``` ```javascript JavaScript theme={null} const changes = extractChanges(fullContent); console.log(`Found ${changes.length} revisions`); for (let i = 0; i < Math.min(5, changes.length); i++) { const change = changes[i]; console.log(`\nRevision ${i + 1}:`); if (change.deleted.length > 0) { console.log(` Deleted: ${change.deleted[0].slice(0, 60)}...`); } if (change.inserted.length > 0) { console.log(` Inserted: ${change.inserted[0].slice(0, 60)}...`); } } ``` **Output from our sample contract:** ``` Found 555 revisions Revision 1: Deleted: Employees in the bargaining unit are required either to b... Revision 2: Deleted: a. An employee's chosen form of dues or payment in lieu o... Revision 3: Deleted: b. Dues and payments in-lieu-of dues for employees workin... Revision 4: Inserted: Employees covered by this Agreement shall have the right... Revision 5: Inserted: 1.2.2 Holder of Record. During the life of this Agreemen... ``` *** ## Step 5: Categorize changes Not all changes are equal. Some are pure deletions (language removed), some are pure insertions (new language added), and some are replacements (old swapped for new). Categorizing helps prioritize review. ```python Python theme={null} deletions_only = sum(1 for c in changes if c["deleted"] and not c["inserted"]) insertions_only = sum(1 for c in changes if c["inserted"] and not c["deleted"]) replacements = sum(1 for c in changes if c["deleted"] and c["inserted"]) print(f"Total revisions: {len(changes)}") print(f" - Deletions only: {deletions_only}") print(f" - Insertions only: {insertions_only}") print(f" - Replacements: {replacements}") ``` ```javascript JavaScript theme={null} const deletionsOnly = changes.filter(c => c.deleted.length > 0 && c.inserted.length === 0).length; const insertionsOnly = changes.filter(c => c.inserted.length > 0 && c.deleted.length === 0).length; const replacements = changes.filter(c => c.deleted.length > 0 && c.inserted.length > 0).length; console.log(`Total revisions: ${changes.length}`); console.log(` - Deletions only: ${deletionsOnly}`); console.log(` - Insertions only: ${insertionsOnly}`); console.log(` - Replacements: ${replacements}`); ``` **Output:** ``` Total revisions: 555 - Deletions only: 191 - Insertions only: 270 - Replacements: 94 ``` This contract has 191 sections removed entirely, 270 new sections added, and 94 places where language was swapped. *** ## Using Studio You can also extract changes visually in Reducto Studio without writing code. **1. Upload your document** Go to [studio.reducto.ai](https://studio.reducto.ai) and upload your redlined contract. **2. Enable change tracking** In the **Configurations** tab, switch to **Advanced** mode. Expand the **Formatting** section and check `change_tracking`. ![](https://mintcdn.com/reducto/9Avr4qdsIoNo7JLQ/images/parse-change-tracking.png?fit=max\&auto=format\&n=9Avr4qdsIoNo7JLQ\&q=85\&s=129cea3f50a3f1b9bb8b08ac0e9428c0) **3. Run and review** Click **Run**. The results show the parsed content with ``, ``, and `` tags visible in the output. You can search for specific changes using Ctrl+F. **4. Export or deploy** Copy the results, download as JSON, or deploy the pipeline with these settings for repeated use on similar documents. *** ## Complete example Here's a full script that parses a redlined contract and generates a change summary: ```python Python theme={null} import re import requests from pathlib import Path from reducto import Reducto def extract_changes(content): """Extract revision regions from content.""" changes = [] pattern = r'(.*?)' for match in re.finditer(pattern, content, re.DOTALL): change_text = match.group(1) deletions = re.findall(r'(.*?)', change_text, re.DOTALL) insertions = re.findall(r'(.*?)', change_text, re.DOTALL) changes.append({"deleted": deletions, "inserted": insertions}) return changes # Parse the document client = Reducto() upload = client.upload(file=Path("redlined_contract.pdf")) result = client.parse.run( input=upload.file_id, formatting={"include": ["change_tracking"]} ) # Handle URL result for large documents if hasattr(result.result, 'url'): response = requests.get(result.result.url) data = response.json() chunks = data.get('chunks', []) full_content = "\n".join([c.get('content', '') for c in chunks]) else: full_content = "\n".join([c.content for c in result.result.chunks]) # Extract and categorize changes = extract_changes(full_content) deletions_only = sum(1 for c in changes if c["deleted"] and not c["inserted"]) insertions_only = sum(1 for c in changes if c["inserted"] and not c["deleted"]) replacements = sum(1 for c in changes if c["deleted"] and c["inserted"]) # Print summary print(f"Document: {result.usage.num_pages} pages") print(f"Total revisions: {len(changes)}") print(f" - Deletions only: {deletions_only}") print(f" - Insertions only: {insertions_only}") print(f" - Replacements: {replacements}") ``` ```javascript JavaScript theme={null} import Reducto from "reductoai"; import fs from "fs"; function extractChanges(content) { const changes = []; const changeRegex = /([\s\S]*?)<\/change>/g; let match; while ((match = changeRegex.exec(content)) !== null) { const changeText = match[1]; const deletions = [...changeText.matchAll(/([\s\S]*?)<\/s>/g)].map(m => m[1]); const insertions = [...changeText.matchAll(/([\s\S]*?)<\/u>/g)].map(m => m[1]); changes.push({ deleted: deletions, inserted: insertions }); } return changes; } // Parse the document const client = new Reducto(); const upload = await client.upload({ file: fs.createReadStream("redlined_contract.pdf") }); const result = await client.parse.run({ input: upload.file_id, formatting: { include: ["change_tracking"] } }); // Handle URL result for large documents let fullContent; if (result.result.type === "url") { const response = await fetch(result.result.url); const data = await response.json(); fullContent = (data.chunks || []).map(c => c.content || "").join("\n"); } else { fullContent = result.result.chunks.map(c => c.content).join("\n"); } // Extract and categorize const changes = extractChanges(fullContent); const deletionsOnly = changes.filter(c => c.deleted.length > 0 && c.inserted.length === 0).length; const insertionsOnly = changes.filter(c => c.inserted.length > 0 && c.deleted.length === 0).length; const replacements = changes.filter(c => c.deleted.length > 0 && c.inserted.length > 0).length; // Print summary console.log(`Document: ${result.usage.num_pages} pages`); console.log(`Total revisions: ${changes.length}`); console.log(` - Deletions only: ${deletionsOnly}`); console.log(` - Insertions only: ${insertionsOnly}`); console.log(` - Replacements: ${replacements}`); ``` **Output from our sample contract:** ``` Document: 165 pages Total revisions: 555 - Deletions only: 191 - Insertions only: 270 - Replacements: 94 ``` *** ## How change tracking works Reducto uses different detection methods depending on document type: | Document type | Detection method | | - | - | | Word (.docx) | Reads native track changes metadata. Most accurate. | | PDF (digital) | Detects colored text and formatting via embedded character data. | | PDF (scanned) | Uses ML models to visually identify underlines and strikethroughs. | > [!NOTE] > > For best results, use Word documents with Track Changes enabled. The metadata is preserved natively. PDFs require visual detection, which works well but depends on clear formatting. *** ## Best practices > [accordion] Use Word documents when possible > > Word's native Track Changes stores revision metadata directly in the file. This gives Reducto exact information about what was added or removed, including author and timestamp. PDFs require visual detection. > [accordion] Categorize changes for efficient review > > Replacements (where old text is swapped for new) often need careful review. Pure insertions may be less risky. Route different categories to appropriate reviewers. > [accordion] Combine with Extract for clause analysis > > Use Parse with change tracking to get the revisions, then pipe specific clauses through Extract to pull structured fields like dates, amounts, or party names. > [accordion] Handle nested changes carefully > > Some documents have nested revisions (changes within changes). The regex patterns above handle simple cases. For complex documents, consider using an HTML parser like BeautifulSoup. *** ## Use cases > [accordion] Contract review automation > > Extract all changes from incoming redlines and route them to the appropriate reviewer based on clause type. Send indemnification changes to legal, pricing changes to finance. > [accordion] Change approval workflows > > Build approval queues where each revision must be explicitly accepted or rejected before finalizing the agreement. Track who approved what. > [accordion] Compliance tracking > > Monitor changes to policies and procedures. Flag modifications to critical sections for compliance review before they go into effect. > [accordion] Negotiation summaries > > Generate executive summaries showing what the counterparty changed. Brief stakeholders without requiring them to read a 165-page document. *** ## Next steps > [card] [Additional Document Data](/configs/parse/additional-document-data) > > Learn about highlights, hyperlinks, and signatures. > [card] [Extract](/extract/overview) > > Pull structured data from specific clauses. > [card] [Batch Processing](/cookbooks/batch-processing) > > Process multiple contracts in parallel. > [card] [Studio Guide](/studio-parse) > > Visual walkthrough of the Parse pipeline. # Web Browsing (Browserbase) Source: https://docs.reducto.ai/cookbooks/web-browsing-browserbase Extract financial data from investor relations PDFs using Stagehand for AI-powered web browsing and Reducto for document processing Automate the extraction of financial data from investor relations websites by combining Stagehand for AI-powered browser automation with Reducto for PDF processing. *** ## Overview This cookbook demonstrates how to: 1. Navigate to Apple.com's investor relations section using Stagehand AI actions 2. Automatically download PDFs when links are clicked 3. Poll the Browserbase Downloads API until the file is ready 4. Extract the PDF from the ZIP archive downloaded from Browserbase 5. Upload the PDF to Reducto and extract structured iPhone net sales data 6. Output the extracted financial data as formatted JSON *** ## Prerequisites Before starting, you need: * A [Browserbase account](https://browserbase.com) with API key and project ID * A [Reducto account](https://studio.reducto.ai) with API key * A Google API key for Gemini (used by Stagehand) * Python 3.9+ Install the required packages: ```bash theme={null} pip install browserbase stagehand reductoai python-dotenv ``` Set your environment variables: ```bash theme={null} export BROWSERBASE_API_KEY="your-browserbase-api-key" export BROWSERBASE_PROJECT_ID="your-browserbase-project-id" export REDUCTO_API_KEY="your-reducto-api-key" export GOOGLE_API_KEY="your-google-api-key" ``` *** ## Step-by-step breakdown ### Step 1: Initialize Stagehand and create a session Stagehand provides AI-powered browser automation on top of Browserbase. Initialize the clients and start a session: ```python theme={null} import os from browserbase import Browserbase from stagehand import AsyncStagehand bb = Browserbase(api_key=os.environ["BROWSERBASE_API_KEY"]) client = AsyncStagehand( browserbase_api_key=os.environ["BROWSERBASE_API_KEY"], browserbase_project_id=os.environ["BROWSERBASE_PROJECT_ID"], model_api_key=os.environ["GOOGLE_API_KEY"], ) start_response = await client.sessions.start(model_name="google/gemini-2.5-pro") session_id = start_response.data.session_id ``` ### Step 2: Navigate and trigger download with AI actions Use Stagehand's AI-powered actions to navigate the page naturally using plain English instructions: ```python theme={null} await client.sessions.navigate(id=session_id, url="https://www.apple.com/") await client.sessions.act( id=session_id, input="Click the 'Investors' button at the bottom of the page" ) await client.sessions.act( id=session_id, input="Scroll down to the Financial Data section of the page" ) await client.sessions.act( id=session_id, input="Under Quarterly Earnings Reports, click on '2025'" ) await client.sessions.act( id=session_id, input="Click the 'Financial Statements' link under Q4" ) ``` Browserbase automatically captures file downloads when links are clicked. ### Step 3: Poll the Downloads API Browserbase stores downloads and makes them available through the Downloads API. Poll until the download completes: ```python theme={null} import asyncio async def save_downloads_with_retry(bb, session_id, retry_for_seconds=30): start_time = asyncio.get_event_loop().time() while True: elapsed = asyncio.get_event_loop().time() - start_time if elapsed >= retry_for_seconds: raise TimeoutError("Download timeout exceeded") response = await asyncio.to_thread(bb.sessions.downloads.list, session_id) download_buffer = await asyncio.to_thread(response.read) if len(download_buffer) > 100: with open("downloaded_files.zip", "wb") as f: f.write(download_buffer) return len(download_buffer) await asyncio.sleep(2) ``` ### Step 4: Extract PDF from ZIP Browserbase returns downloads as a ZIP archive. Extract the PDF: ```python theme={null} def extract_pdf_from_zip(zip_content): with zipfile.ZipFile(io.BytesIO(zip_content)) as zf: for filename in zf.namelist(): if filename.endswith(".pdf"): return zf.read(filename) ``` ### Step 5: Extract data with Reducto Upload the PDF to Reducto and extract structured financial data using a schema: ```python theme={null} upload = reducto.upload(file=("report.pdf", pdf_content)) # Define schema for iPhone net sales extraction schema = { "type": "object", "properties": { "report_period": { "type": "string", "description": "The fiscal quarter and year of the report" }, "iphone_net_sales": { "type": "object", "description": "iPhone net sales figures", "properties": { "current_quarter": { "type": "number", "description": "iPhone net sales for the current quarter in millions" }, "prior_year_quarter": { "type": "number", "description": "iPhone net sales for the same quarter last year in millions" }, "year_over_year_change": { "type": "number", "description": "Percentage change year over year" } } }, "total_net_sales": { "type": "object", "description": "Total company net sales", "properties": { "current_quarter": { "type": "number", "description": "Total net sales for the current quarter in millions" }, "prior_year_quarter": { "type": "number", "description": "Total net sales for the same quarter last year in millions" } } }, "iphone_percentage_of_total": { "type": "number", "description": "iPhone sales as a percentage of total net sales" } } } result = reducto.extract.run( input=upload.file_id, instructions={"schema": schema} ) ``` ### Step 6: Output results The extracted data is returned as structured JSON matching your schema: ```json theme={null} { "report_period": "Q1 FY2024", "iphone_net_sales": { "current_quarter": 69702, "prior_year_quarter": 65775, "year_over_year_change": 5.97 }, "total_net_sales": { "current_quarter": 119575, "prior_year_quarter": 117154 }, "iphone_percentage_of_total": 58.3 } ``` ## Full implementation For the complete implementation with all helper functions, error handling, and Reducto extraction schema, see the [full source code on GitHub](https://github.com/browserbase/templates/tree/dev/python/browserbase-reducto). *** ## Resources > [card] [Browserbase Template](https://www.browserbase.com/templates/browserbase-reducto) > > View the original Browserbase template > [card] [Source Code](https://github.com/browserbase/templates/tree/dev/python/browserbase-reducto) > > Full source code on GitHub > [card] [Extract API](/extract/overview) > > Learn more about Reducto's Extract API > [card] [Array Extraction](/configs/extract/array-extraction) > > Extract multiple records from documents # Async Client Source: https://docs.reducto.ai/sdk/python/async Process documents concurrently with the async Python SDK The `AsyncReducto` client provides the same interface as the sync client, but allows concurrent document processing. Use it when you need to process multiple documents simultaneously. *** ## Basic Usage ```python theme={null} import asyncio from pathlib import Path from reducto import AsyncReducto async def main(): client = AsyncReducto() # Same initialization as sync upload = await client.upload(file=Path("document.pdf")) result = await client.parse.run(input=upload.file_id) for chunk in result.result.chunks: print(chunk.content) asyncio.run(main()) ``` The async client mirrors the sync client exactly. Every method is available; just add `await`. *** ## Concurrent Processing The real value of async is processing multiple documents simultaneously with `asyncio.gather`: ### Parse Multiple Documents ```python theme={null} import asyncio from pathlib import Path from reducto import AsyncReducto async def process_batch(): client = AsyncReducto() files = ["doc1.pdf", "doc2.pdf", "doc3.pdf"] # Upload all files concurrently uploads = await asyncio.gather(*[ client.upload(file=Path(f)) for f in files ]) # Parse all documents concurrently results = await asyncio.gather(*[ client.parse.run(input=upload.file_id) for upload in uploads ]) return results results = asyncio.run(process_batch()) ``` ### Extract from Multiple Documents ```python theme={null} async def extract_batch(): client = AsyncReducto() files = ["invoice1.pdf", "invoice2.pdf", "invoice3.pdf"] schema = { "type": "object", "properties": { "invoice_number": {"type": "string"}, "total": {"type": "number"} } } uploads = await asyncio.gather(*[ client.upload(file=Path(f)) for f in files ]) results = await asyncio.gather(*[ client.extract.run( input=upload.file_id, instructions={"schema": schema} ) for upload in uploads ]) return results ``` ### Split Multiple Documents ```python theme={null} async def split_batch(): client = AsyncReducto() files = ["report1.pdf", "report2.pdf", "report3.pdf"] split_desc = [ {"name": "Summary", "description": "Executive summary"}, {"name": "Details", "description": "Detailed content"} ] uploads = await asyncio.gather(*[ client.upload(file=Path(f)) for f in files ]) results = await asyncio.gather(*[ client.split.run( input=upload.file_id, split_description=split_desc ) for upload in uploads ]) return results ``` ### Fill Multiple Forms ```python theme={null} async def fill_forms_batch(): client = AsyncReducto() forms = [ (Path("form1.pdf"), "Fill name with 'Alice'"), (Path("form2.pdf"), "Fill name with 'Bob'"), (Path("form3.pdf"), "Fill name with 'Charlie'"), ] async def fill_form(file_path, instructions): upload = await client.upload(file=file_path) return await client.edit.run( document_url=upload.file_id, edit_instructions=instructions ) results = await asyncio.gather(*[ fill_form(path, instr) for path, instr in forms ]) return results ``` ### Run Pipeline on Multiple Documents ```python theme={null} async def pipeline_batch(): client = AsyncReducto() files = ["doc1.pdf", "doc2.pdf", "doc3.pdf"] pipeline_id = "your_pipeline_id" uploads = await asyncio.gather(*[ client.upload(file=Path(f)) for f in files ]) results = await asyncio.gather(*[ client.pipeline.run( input=upload.file_id, pipeline_id=pipeline_id ) for upload in uploads ]) return results ``` *** ## Rate Limiting Control concurrency with semaphores to avoid overwhelming the API: ```python theme={null} import asyncio from reducto import AsyncReducto async def process_with_rate_limit(files: list[str], max_concurrent: int = 5): client = AsyncReducto() semaphore = asyncio.Semaphore(max_concurrent) async def process_one(file_path): async with semaphore: upload = await client.upload(file=Path(file_path)) return await client.parse.run(input=upload.file_id) results = await asyncio.gather(*[ process_one(f) for f in files ]) return results # Process 100 files, max 5 at a time results = asyncio.run(process_with_rate_limit(file_list, max_concurrent=5)) ``` *** ## Context Manager Use the context manager to ensure proper cleanup: ```python theme={null} async def main(): async with AsyncReducto() as client: upload = await client.upload(file=Path("document.pdf")) result = await client.parse.run(input=upload.file_id) return result result = asyncio.run(main()) ``` *** ## Error Handling Error handling works the same as the sync client: ```python theme={null} import reducto async def safe_process(): client = AsyncReducto() try: upload = await client.upload(file=Path("document.pdf")) result = await client.parse.run(input=upload.file_id) return result except reducto.APIConnectionError as e: print(f"Connection failed: {e}") except reducto.RateLimitError as e: print(f"Rate limited: {e}") except reducto.APIStatusError as e: print(f"API error: {e.status_code}") ``` For batch processing, use `return_exceptions=True` to handle failures gracefully: ```python theme={null} async def batch_with_error_handling(files): client = AsyncReducto() async def process_one(file_path): upload = await client.upload(file=Path(file_path)) return await client.parse.run(input=upload.file_id) results = await asyncio.gather( *[process_one(f) for f in files], return_exceptions=True # Don't fail entire batch on one error ) for file_path, result in zip(files, results): if isinstance(result, Exception): print(f"Failed: {file_path} - {result}") else: print(f"Success: {file_path} - {result.usage.num_pages} pages") return results ``` *** ## Advanced Features ### Raw Response Access ```python theme={null} async def get_raw_response(): client = AsyncReducto() response = await client.parse.with_raw_response.run(input=upload.file_id) print(response.headers) return response.parse() ``` ### Streaming Response ```python theme={null} async def stream_response(): client = AsyncReducto() async with client.parse.with_streaming_response.run(input=upload.file_id) as response: async for line in response.iter_lines(): print(line) ``` *** ## When to Use Async > [card] Use Async When > > Processing 10+ documents, building web services, or integrating with async frameworks (FastAPI, aiohttp). > [card] Use Sync When > > Processing single documents, simple scripts, or when async adds unnecessary complexity. *** ## Next Steps * See [error handling](/sdk/python/error-handling) for the complete exception reference * Check [job management](/sdk/python/job-management) for async job polling # Edit Source: https://docs.reducto.ai/sdk/python/edit Fill PDF forms and modify DOCX documents using the Python SDK The `edit.run()` method fills PDF forms and modifies DOCX documents programmatically. You provide instructions describing what to fill, and Edit populates the document. *** ## Basic Usage ```python theme={null} from pathlib import Path from reducto import Reducto client = Reducto() # Upload a form upload = client.upload(file=Path("application.pdf")) # Fill the form result = client.edit.run( document_url=upload.file_id, edit_instructions="Fill in the name field with 'John Doe' and the date with '12/25/2024'" ) # Access the filled document print(result.document_url) # URL to download filled document ``` *** ## Method Signature ```python theme={null} def edit.run( document_url: str, edit_instructions: str, edit_options: dict | None = None, form_schema: list | None = None, priority: bool | None = None ) -> EditResponse ``` ### Parameters | Parameter | Type | Required | Description | | - | - | - | - | | `document_url` | `str` | Yes | File ID (`reducto://...`) or URL of the document to edit | | `edit_instructions` | `str` | Yes | Natural language instructions describing how to fill the form | | `edit_options` | `dict \| None` | No | Edit options (color, overflow pages) | | `form_schema` | `list \| None` | No | Explicit form schema for structured filling | | `priority` | `bool \| None` | No | Request priority processing | *** ## Edit Instructions Provide natural language instructions describing what to fill: ```python theme={null} result = client.edit.run( document_url=upload.file_id, edit_instructions=""" Fill in the following fields: - Full Name: John Doe - Email: john@example.com - Date: December 25, 2024 - Check the 'I agree' checkbox """ ) ``` *** ## Form Schema For more control, provide an explicit form schema: ```python theme={null} result = client.edit.run( document_url=upload.file_id, edit_instructions="Fill the form with the provided data", form_schema=[ {"field_name": "full_name", "value": "John Doe"}, {"field_name": "email", "value": "john@example.com"}, {"field_name": "date", "value": "12/25/2024"}, {"field_name": "agree_checkbox", "value": True} ] ) ``` *** ## Edit Options Customize edit behavior: ```python theme={null} result = client.edit.run( document_url=upload.file_id, edit_instructions="Fill the form", edit_options={ "color": "#0000FF", # Text color (hex) "enable_overflow_pages": True # Add pages if content overflows } ) ``` *** ## Response Structure ```python theme={null} result: EditResponse = client.edit.run(...) # Filled document URL print(result.document_url) # str: URL to download the filled document # Form schema (if auto-detected) print(result.form_schema) # list: Detected form fields ``` *** ## Error Handling ```python theme={null} from reducto import Reducto import reducto try: result = client.edit.run( document_url=upload.file_id, edit_instructions="Fill the name field" ) except reducto.APIConnectionError as e: print(f"Connection failed: {e}") except reducto.APIStatusError as e: print(f"Edit failed: {e.status_code} - {e.response}") ``` *** ## Complete Example ```python theme={null} from pathlib import Path from reducto import Reducto import requests client = Reducto() # Upload form upload = client.upload(file=Path("job-application.pdf")) # Fill form with instructions result = client.edit.run( document_url=upload.file_id, edit_instructions=""" Fill in the job application with: - Applicant Name: Jane Smith - Email: jane@example.com - Phone: 555-1234 - Date: 12/25/2024 - Check the signature checkbox """, edit_options={ "color": "#000000" } ) # Download filled form response = requests.get(result.document_url) with open("filled-application.pdf", "wb") as f: f.write(response.content) print("Form filled and saved!") ``` *** ## Best Practices > [card] Clear Instructions > > Write clear, specific instructions about which fields to fill and with what values. > [card] Use Form Schema > > For precise control, use `form_schema` to specify exact field mappings. *** ## Next Steps * Learn about [form schema design](/configs/edit/form-schema) * Explore the [async client](/sdk/python/async) for concurrent processing * Check out the [edit overview](/editing/edit-overview) for more details # Error Handling Source: https://docs.reducto.ai/sdk/python/error-handling Handle errors and exceptions in the Reducto Python SDK The Reducto Python SDK raises specific exceptions for different error conditions. This guide covers all exception types and how to handle them. *** ## Exception Hierarchy ```python theme={null} reducto.ReductoError (base exception) └── reducto.APIError ├── reducto.APIConnectionError (connection errors) │ └── reducto.APITimeoutError (timeout errors) └── reducto.APIStatusError (HTTP errors) ├── reducto.BadRequestError (400) ├── reducto.AuthenticationError (401) ├── reducto.PermissionDeniedError (403) ├── reducto.NotFoundError (404) ├── reducto.ConflictError (409) ├── reducto.UnprocessableEntityError (422) ├── reducto.RateLimitError (429) └── reducto.InternalServerError (>=500) ``` *** ## Common Exceptions ### APIConnectionError Raised when the SDK is unable to connect to the API (network issues, timeouts): ```python theme={null} from reducto import Reducto import reducto try: result = client.parse.run(input=upload.file_id) except reducto.APIConnectionError as e: print(f"Connection failed: {e}") print(e.__cause__) # underlying exception, likely raised within httpx ``` ### AuthenticationError Raised when API key is missing or invalid (401): ```python theme={null} from reducto import Reducto import reducto try: client = Reducto(api_key="invalid_key") result = client.parse.run(input=upload.file_id) except reducto.AuthenticationError as e: print(f"Authentication failed: {e}") print("Check your API key in Studio") ``` ### RateLimitError Raised when rate limit is exceeded (429): ```python theme={null} from reducto import Reducto import reducto import time try: result = client.parse.run(input=upload.file_id) except reducto.RateLimitError as e: print(f"Rate limit exceeded: {e}") # The SDK automatically retries rate limit errors # But you can also handle it manually time.sleep(1) # Retry the request ``` ### APIStatusError Base class for all HTTP status errors. Contains `status_code` and `response` properties: ```python theme={null} from reducto import Reducto import reducto try: result = client.parse.run(input=upload.file_id) except reducto.APIStatusError as e: print(f"API error: {e.status_code}") print(f"Response: {e.response}") ``` ### APITimeoutError Raised when a request times out: ```python theme={null} from reducto import Reducto import reducto try: result = client.parse.run(input=upload.file_id) except reducto.APITimeoutError as e: print(f"Request timed out: {e}") print("Consider using async methods for long-running operations") ``` *** ## Error Code Reference | Status Code | Error Type | Description | | - | - | - | | 400 | BadRequestError | Invalid request parameters | | 401 | AuthenticationError | Missing or invalid API key | | 403 | PermissionDeniedError | Insufficient permissions | | 404 | NotFoundError | Resource not found | | 409 | ConflictError | Request conflicts with current state | | 422 | UnprocessableEntityError | Request validation failed | | 429 | RateLimitError | Rate limit exceeded | | >=500 | InternalServerError | Server error | | N/A | APIConnectionError | Network/connection error | | N/A | APITimeoutError | Request timed out | *** ## Generic Error Handling Handle all errors with a generic catch: ```python theme={null} from reducto import Reducto import reducto try: result = client.parse.run(input=upload.file_id) except reducto.APIError as e: print(f"Reducto API error: {e}") # Log error, notify user, etc. except Exception as e: print(f"Unexpected error: {e}") ``` *** ## Error Response Details API errors include detailed information: ```python theme={null} from reducto import Reducto import reducto try: result = client.parse.run(input=upload.file_id) except reducto.APIStatusError as e: print(f"Status code: {e.status_code}") print(f"Response: {e.response}") # Access response body if needed if hasattr(e.response, 'text'): print(f"Response body: {e.response.text}") ``` *** ## Retry Logic The SDK automatically retries certain errors by default (2 times with exponential backoff): * Connection errors (network issues) * 408 Request Timeout * 409 Conflict * 429 Rate Limit * > \=500 Internal Server errors You can configure retry behavior: ```python theme={null} from reducto import Reducto # Configure the default for all requests client = Reducto( max_retries=0, # Disable retries (default is 2) ) # Or configure per-request client.with_options(max_retries=5).parse.run( input=upload.file_id ) ``` ### Manual Retry Logic For more control, implement your own retry logic: ```python theme={null} import time from reducto import Reducto import reducto def parse_with_retry(client, upload, max_retries=3): for attempt in range(max_retries): try: return client.parse.run(input=upload.file_id) except (reducto.RateLimitError, reducto.InternalServerError) as e: if attempt == max_retries - 1: raise wait_time = 2 ** attempt # Exponential backoff print(f"Retrying in {wait_time} seconds...") time.sleep(wait_time) raise Exception("Max retries exceeded") result = parse_with_retry(client, upload) ``` *** ## Timeout Handling Configure timeouts for requests: ```python theme={null} from reducto import Reducto import httpx import reducto # Configure default timeout (default is 1 hour) client = Reducto( timeout=20.0, # 20 seconds ) # More granular control client = Reducto( timeout=httpx.Timeout(60.0, read=5.0, write=10.0, connect=2.0), ) # Override per-request try: result = client.with_options(timeout=5.0).parse.run( input=upload.file_id ) except reducto.APITimeoutError as e: print(f"Request timed out: {e}") ``` Note that requests that time out are retried twice by default. ### Long Documents and Timeouts For large documents like lengthy PDFs or spreadsheets with many sheets, we recommend using the [async endpoint](/sdk/python/async). The async endpoint lets you submit a job and poll for results without holding an open connection, which is ideal for documents that take longer to process. If you need to use the sync endpoint with a custom `timeout`, set `max_retries=0` to disable automatic retries. Otherwise, if your request times out while the server is still processing, the SDK will retry and create additional processing jobs for the same document. ```python theme={null} from reducto import Reducto # If using sync with a custom timeout, disable retries client = Reducto(timeout=300.0, max_retries=0) ``` *** ## Best Practices > [card] Handle Specific Exceptions > > Catch specific exception types rather than generic Exception for better error handling. > [card] Log Errors > > Log errors with context (job\_id, request\_id) for debugging. > [card] Retry Transient Errors > > The SDK automatically retries transient errors, but you can customize retry behavior. > [card] Provide User Feedback > > Show meaningful error messages to users, not raw exceptions. > [card] Check Connection Errors > > For `APIConnectionError`, check `e.__cause__` to see the underlying exception. > [card] Handle Rate Limits > > Rate limit errors are automatically retried, but you can also handle them manually. *** ## Complete Example ```python theme={null} from pathlib import Path from reducto import Reducto import reducto def parse_with_comprehensive_error_handling(client, upload): """Parse with comprehensive error handling.""" try: result = client.parse.run(input=upload.file_id) return result except reducto.AuthenticationError as e: print(f"Authentication failed: {e}") print("Check your API key in Studio") raise # Don't retry auth errors except reducto.RateLimitError as e: print(f"Rate limited: {e}") # SDK will automatically retry, but you can also handle manually raise except reducto.APIConnectionError as e: print(f"Connection failed: {e}") print(f"Underlying error: {e.__cause__}") raise except reducto.APITimeoutError as e: print(f"Request timed out: {e}") print("Consider using async methods or increasing timeout") raise except reducto.APIStatusError as e: print(f"API error {e.status_code}: {e}") if e.status_code >= 500: print("Server error - may be transient") raise except Exception as e: print(f"Unexpected error: {e}") raise # Use the function client = Reducto() upload = client.upload(file=Path("document.pdf")) result = parse_with_comprehensive_error_handling(client, upload) ``` *** ## Next Steps * Learn about [job management](/sdk/python/job-management) for async operations * Check out the [async client](/sdk/python/async) for concurrent processing # Extract Source: https://docs.reducto.ai/sdk/python/extract Extract specific fields from documents using the Python SDK The `extract.run()` method pulls specific fields from documents as structured JSON. You define a JSON schema with the fields you need, and Extract returns values matching that schema. *** ## Basic Usage ```python theme={null} from pathlib import Path from reducto import Reducto client = Reducto() # Upload upload = client.upload(file=Path("invoice.pdf")) # Extract with schema result = client.extract.run( input=upload.file_id, instructions={ "schema": { "type": "object", "properties": { "invoice_number": { "type": "string", "description": "The invoice number, typically at the top" }, "total": { "type": "number", "description": "The total amount due" }, "date": { "type": "string", "description": "Invoice date" } } } } ) # Access extracted values print(result.result[0]["invoice_number"]) print(result.result[0]["total"]) ``` *** ## Method Signature ```python theme={null} def extract.run( input: str | list[str], instructions: dict | None = None, settings: dict | None = None, parsing: dict | None = None ) -> ExtractResponse ``` ### Parameters | Parameter | Type | Required | Description | | - | - | - | - | | `input` | `str \| list[str]` | Yes | File ID, URL, or `jobid://` reference(s) | | `instructions` | `dict \| None` | No | Schema and/or system prompt for extraction | | `settings` | `dict \| None` | No | Extraction settings (citations, array extraction, images) | | `parsing` | `dict \| None` | No | Parse configuration (used if input is not `jobid://`) | ### Settings Options | Setting | Type | Default | Description | | - | - | - | - | | `array_extract` | `bool` | `false` | Deprecated. Use `deep_extract` instead. | | `deep_extract` | `bool` | `false` | Agentic mode for complex or long (array-heavy) extractions. Higher cost/latency. | | `citations.enabled` | `bool` | `false` | Include source citations in results | | `citations.numerical_confidence` | `bool` | `true` | Use numeric confidence scores (0-1) | | `include_images` | `bool` | `false` | Include images in the extraction context | | `optimize_for_latency` | `bool` | `false` | Prioritize speed over cost | *** ## Schema Definition The `instructions` parameter requires a `schema` field with a JSON schema: ```python theme={null} schema = { "type": "object", "properties": { "field_name": { "type": "string", # or "number", "boolean", "array", "object" "description": "Clear description of what to extract" } } } result = client.extract.run( input=upload.file_id, instructions={"schema": schema} ) ``` ### Field Descriptions Field descriptions are critical for accurate extraction. Be specific: ```python theme={null} # Good: Specific description { "invoice_total": { "type": "number", "description": "The total amount due, typically at the bottom of the invoice in a 'Total' or 'Amount Due' section" } } # Bad: Vague description { "total": { "type": "number", "description": "Total" } } ``` *** ## System Prompt Add document-level context with `system_prompt`: ```python theme={null} result = client.extract.run( input=upload.file_id, instructions={ "schema": schema, "system_prompt": "This is a medical invoice. Extract billing codes and patient information." } ) ``` *** ## Input Options Extract accepts multiple input formats: ```python theme={null} # From upload result = client.extract.run(input=upload.file_id, instructions={...}) # Public URL result = client.extract.run(input="https://example.com/invoice.pdf", instructions={...}) # Reprocess previous parse job result = client.extract.run(input="jobid://7600c8c5-...", instructions={...}) # Combine multiple parsed documents result = client.extract.run( input=["jobid://job-1", "jobid://job-2", "jobid://job-3"], instructions={...} ) ``` *** ## Deep Extract For documents with repeating data (line items, transactions) or complex/long extractions, enable Deep Extract (higher cost and latency). It supersedes the deprecated `array_extract`: ```python theme={null} result = client.extract.run( input=upload.file_id, instructions={ "schema": { "type": "object", "properties": { "line_items": { "type": "array", "items": { "type": "object", "properties": { "description": {"type": "string"}, "quantity": {"type": "number"}, "price": {"type": "number"} } } } } } }, settings={ "deep_extract": True } ) ``` > [card] [Array Extraction Guide](/configs/extract/array-extraction) > > Detailed guide to array extraction configuration. *** ## Citations Enable citations to get source locations for each extracted value: ```python theme={null} result = client.extract.run( input=upload.file_id, instructions={"schema": schema}, settings={ "citations": { "enabled": True, "numerical_confidence": True # 0-1 confidence score } } ) # With citations enabled, values are wrapped field = result.result[0]["total_amount"] print(f"Value: {field.value}") print(f"Page: {field.citations[0].bbox.page}") print(f"Confidence: {field.citations[0].confidence}") ``` > [!WARNING] > > Citations cannot be used with chunking. If you enable citations, chunking is automatically disabled. *** ## Parsing Configuration Since Extract runs Parse internally, you can configure parsing: ```python theme={null} result = client.extract.run( input=upload.file_id, instructions={"schema": schema}, parsing={ "enhance": { "agentic": [{"scope": "table"}] # For better table extraction }, "formatting": { "table_output_format": "html" # Better for complex tables }, "settings": { "page_range": {"start": 1, "end": 10}, "document_password": "secret" # For encrypted PDFs } } ) ``` These options are ignored if your `input` is a `jobid://` reference. *** ## Response Structure ```python theme={null} result: ExtractResponse = client.extract.run(...) # Top-level fields print(result.job_id) # str: Job identifier print(result.usage.num_pages) # int: Pages processed print(result.usage.credits) # float: Credits used print(result.studio_link) # str: Studio link # Extracted data extracted_data = result.result # list[dict]: Array of extracted objects first_result = extracted_data[0] print(first_result["invoice_number"]) ``` ### With Citations When citations are enabled, the response format changes. Instead of a list, `result.result` is a dict with values wrapped in citation objects: ```python theme={null} # Without citations - result.result is a list result.result[0]["total"] # 1234.56 # With citations - result.result is a dict result.result["total"]["value"] # 1234.56 result.result["total"]["citations"][0]["bbox"]["page"] # 1 result.result["total"]["citations"][0]["confidence"] # "high" ``` *** ## Schemaless Extraction You can also extract without a schema using only a system prompt: ```python theme={null} result = client.extract.run( input=upload.file_id, instructions={ "system_prompt": "Extract all key financial information from this invoice" } ) # The model decides what to extract print(result.result[0]) ``` *** ## Error Handling ```python theme={null} from reducto import Reducto import reducto try: result = client.extract.run( input=upload.file_id, instructions={"schema": schema} ) except reducto.APIConnectionError as e: print(f"Connection failed: {e}") except reducto.APIStatusError as e: print(f"Extraction failed: {e.status_code} - {e.response}") ``` *** ## Complete Example ```python theme={null} from pathlib import Path from reducto import Reducto client = Reducto() # Upload upload = client.upload(file=Path("fidelity-example.pdf")) # Define schema schema = { "type": "object", "properties": { "portfolio_value": { "type": "number", "description": "Total portfolio value at the end of the period" }, "total_income_ytd": { "type": "number", "description": "Total income year-to-date" }, "top_holdings": { "type": "array", "items": {"type": "string"}, "description": "Names of the top 5 holdings" } } } # Extract with configuration result = client.extract.run( input=upload.file_id, instructions={ "schema": schema, "system_prompt": "Extract financial data from this investment statement." }, settings={ "citations": {"enabled": True}, "deep_extract": True # Agentic mode for the top_holdings array }, parsing={ "enhance": { "agentic": [{"scope": "table"}] # Better table extraction } } ) # Process results print(f"Extracted {len(result.result)} results") print(f"Used {result.usage.credits} credits") for i, extracted in enumerate(result.result): print(f"\n=== Result {i + 1} ===") print(f"Portfolio Value: ${extracted['portfolio_value']:,.2f}") print(f"Total Income YTD: ${extracted['total_income_ytd']:,.2f}") print(f"Top Holdings: {', '.join(extracted['top_holdings'])}") ``` *** ## Best Practices > [card] Write Clear Descriptions > > Field descriptions directly impact extraction quality. Be specific about location and format. > [card] Use Deep Extract > > Enable `deep_extract` for complex documents or many repeating items (transactions, line items). Higher cost/latency. Supersedes the deprecated `array_extract`. > [card] Enable Citations for Verification > > Use citations to verify extracted values and show users source locations. > [card] Debug with Parse First > > If extraction fails, check the Parse output first. Extract can only find what Parse sees. *** ## Troubleshooting > [accordion] Missing values > > If expected fields are empty: > > 1. Check the Parse output: `client.parse.run(input=upload.file_id)` > 2. Verify the value appears in the parsed content > 3. Improve field descriptions to match how values appear > 4. Try enabling `deep_extract` for long or complex documents > [accordion] Hallucinated values > > Extract only returns what's on the document. If you need computed values, extract raw data and compute in your code: > > ```python theme={null} > monthly = result.result[0]["monthly_cost"] > annual = monthly * 12 # Compute yourself > ``` *** ## Next Steps * Learn about [schema design best practices](/extraction/best-practices-extract) * Explore [array extraction](/configs/extract/array-extraction) for long documents * Check out [citations](/configs/extract/citations) for source verification * See the [async client](/sdk/python/async) for concurrent processing # Job Management Source: https://docs.reducto.ai/sdk/python/job-management Manage and track async jobs using the Python SDK The SDK provides methods to retrieve job status, cancel jobs, and list jobs for async operations. *** ## Retrieve Job Status Get the status and results of an async job: ```python theme={null} from pathlib import Path from reducto import Reducto client = Reducto() # Start an async job using run_job() upload = client.upload(file=Path("document.pdf")) async_result = client.parse.run_job(input=upload.file_id) job_id = async_result.job_id # Retrieve job status job = client.job.get(job_id) # Check status if job.status == "Completed": print("Job completed!") print(job.result) elif job.status == "Pending" or job.status == "Idle": print("Job still processing...") elif job.status == "Failed": print(f"Job failed: {job.reason}") ``` *** ## Cancel Job Cancel a running job: ```python theme={null} from reducto import Reducto client = Reducto() # Start async job async_result = client.parse.run_job(input=upload.file_id) # Cancel it client.job.cancel(async_result.job_id) ``` *** ## List Jobs Get a list of your jobs with pagination: ```python theme={null} from reducto import Reducto client = Reducto() # List recent jobs (default: 100 per page, max: 500) jobs = client.job.get_all() for job in jobs.jobs: print(f"Job {job.job_id}: {job.status}") # With pagination parameters jobs = client.job.get_all( limit=50, # Max jobs per page (default: 100, max: 500) cursor=None, # Use next_cursor from previous response exclude_configs=True # Exclude raw_config to reduce response size ) # Paginate through all jobs cursor = None while True: jobs = client.job.get_all(cursor=cursor, limit=100) for job in jobs.jobs: print(f"Job {job.job_id}: {job.status}") if not jobs.next_cursor: break cursor = jobs.next_cursor ``` ### Parameters | Parameter | Type | Default | Description | | - | - | - | - | | `limit` | `int` | 100 | Maximum jobs per page (max: 500) | | `cursor` | `str \| None` | None | Pagination cursor from previous response | | `exclude_configs` | `bool` | False | Exclude raw\_config to reduce response size | *** ## Polling for Completion Poll for job completion: ```python theme={null} import time from pathlib import Path from reducto import Reducto import reducto client = Reducto() # Start async job upload = client.upload(file=Path("document.pdf")) async_result = client.parse.run_job(input=upload.file_id) job_id = async_result.job_id # Poll until complete while True: job = client.job.get(job_id) if job.status == "Completed": print("Job completed!") print(job.result) break elif job.status == "Failed": print(f"Job failed: {job.reason}") break print(f"Status: {job.status}, waiting...") time.sleep(2) # Wait 2 seconds before checking again ``` *** ## Job Status Values | Status | Description | | - | - | | `Pending` | Job is queued, waiting for a worker | | `Idle` | Job is idle, waiting to be picked up | | `Completed` | Results are ready | | `Failed` | Processing failed (check `job.reason` for details) | *** ## Error Handling ```python theme={null} from reducto import Reducto import reducto try: job = client.job.get(job_id) except reducto.NotFoundError: print("Job not found") except reducto.APIConnectionError as e: print(f"Connection failed: {e}") except reducto.APIStatusError as e: print(f"Error: {e.status_code} - {e.response}") ``` *** ## Next Steps * Learn about the [async client](/sdk/python/async) for concurrent processing * Explore [error handling](/sdk/python/error-handling) for robust applications # Python SDK Overview Source: https://docs.reducto.ai/sdk/python/overview Get started with the Reducto Python SDK The Reducto Python SDK provides a type-safe interface to the Reducto API. It handles authentication, request formatting, and response parsing automatically. *** ## Quick Start ```python theme={null} from pathlib import Path from reducto import Reducto # Initialize the client (reads REDUCTO_API_KEY from environment) client = Reducto() # Upload a document upload = client.upload(file=Path("invoice.pdf")) # Parse the document result = client.parse.run(input=upload.file_id) # Access the extracted content for chunk in result.result.chunks: print(chunk.content) ``` *** ## Key Features > [card] Type Safety > > Full type hints and IDE autocomplete for all methods and responses using Pydantic models. > [card] Simple API > > Intuitive method names that match the REST API endpoints. > [card] Error Handling > > Clear exception hierarchy with helpful error messages and status codes. > [card] Async Support > > Both synchronous and asynchronous clients with the same interface. > [card] Advanced Features > > Raw responses, streaming, custom HTTP clients, and per-request options. > [card] Automatic Retries > > Built-in retry logic with exponential backoff for transient errors. *** ## Installation ```bash theme={null} pip install reductoai ``` Requires Python 3.9+. Upgrade with `pip install --upgrade reductoai`. *** ## Authentication Set your API key as an environment variable: ```bash theme={null} export REDUCTO_API_KEY="your_api_key_here" ``` The SDK reads it automatically: ```python theme={null} from reducto import Reducto client = Reducto() # Reads REDUCTO_API_KEY from environment ``` Or pass it explicitly: ```python theme={null} client = Reducto(api_key="your_api_key_here") ``` Get your API key from [Reducto Studio](https://studio.reducto.ai/) → API Keys. *** ## Core Methods The SDK provides methods for all Reducto endpoints: ### Upload Upload documents to Reducto's servers before processing: ```python theme={null} upload = client.upload(file=Path("document.pdf")) # Returns: Upload with file_id and presigned_url fields ``` > [card] [Upload Documentation](/sdk/python/upload) > > File upload options, size limits, and presigned URLs. ### Parse Turn full documents into LLM-ready text, tables, and figures: ```python theme={null} result = client.parse.run(input=upload.file_id) # Returns: ParseResponse with chunks, blocks, and metadata ``` > [card] [Parse Documentation](/sdk/python/parse) > > Parse configuration, chunking options, and response structure. ### Extract Pull specific fields from documents using JSON schemas: ```python theme={null} result = client.extract.run( input=upload.file_id, instructions={ "schema": { "type": "object", "properties": { "invoice_number": {"type": "string"}, "total": {"type": "number"} } } } ) ``` > [card] [Extract Documentation](/sdk/python/extract) > > Schema design, array extraction, and citations. ### Split Divide documents into sections based on content type: ```python theme={null} result = client.split.run( input=upload.file_id, split_description=[ {"name": "Introduction", "description": "The introduction section"}, {"name": "Methodology", "description": "The methodology section"}, {"name": "Results", "description": "The results section"} ] ) # Returns: SplitResponse with splits array (name, pages, conf: "high" or "low") ``` > [card] [Split Documentation](/sdk/python/split) > > Split configuration and section types. ### Edit Fill PDF forms and modify DOCX documents: ```python theme={null} result = client.edit.run( document_url=upload.file_id, edit_instructions="Fill name with 'John Doe' and date with '12/25/2024'" ) ``` > [card] [Edit Documentation](/sdk/python/edit) > > Form schemas and document modification. ### Pipeline Run pre-configured workflows from Reducto Studio: ```python theme={null} result = client.pipeline.run( input=upload.file_id, pipeline_id="your_pipeline_id" # From Reducto Studio ) ``` > [card] [Pipeline Documentation](/sdk/python/pipeline) > > Running studio-configured pipelines. *** ## Async Client The SDK provides an async client with the same interface: ```python theme={null} import asyncio from reducto import AsyncReducto async def main(): client = AsyncReducto() upload = await client.upload(file=Path("document.pdf")) result = await client.parse.run(input=upload.file_id) return result # Run the async function result = asyncio.run(main()) ``` > [card] [Async Client Guide](/sdk/python/async) > > Concurrent processing, rate limiting, and batch operations. *** ## Response Types All SDK methods return strongly-typed response objects. The SDK uses Pydantic models for validation and type safety: ```python theme={null} from reducto.types import ParseResponse result: ParseResponse = client.parse.run(input=upload.file_id) # Access typed fields print(result.job_id) # str print(result.usage.num_pages) # int print(result.usage.credits) # float print(result.result.chunks) # list[Chunk] ``` *** ## Error Handling The SDK raises specific exceptions for different error conditions: ```python theme={null} from reducto import Reducto import reducto try: result = client.parse.run(input="invalid-file-id") except reducto.APIConnectionError as e: print(f"Connection failed: {e}") print(e.__cause__) # underlying exception except reducto.RateLimitError as e: print(f"Rate limited: {e}") except reducto.APIStatusError as e: print(f"API error: {e.status_code} - {e.response}") ``` > [card] [Error Handling Guide](/sdk/python/error-handling) > > Complete error handling reference with all exception types. *** ## Advanced Features ### Raw Response Access Access raw HTTP response data including headers: ```python theme={null} response = client.parse.with_raw_response.run(input=upload.file_id) print(response.headers.get('X-My-Header')) parse_result = response.parse() # Get the parsed object ``` ### Streaming Responses Stream large responses without loading everything into memory: ```python theme={null} with client.parse.with_streaming_response.run(input=upload.file_id) as response: for line in response.iter_lines(): print(line) ``` ### Per-Request Options Override client settings for individual requests: ```python theme={null} # Custom timeout for this request client.with_options(timeout=30.0).parse.run(input=upload.file_id) # Custom retry settings client.with_options(max_retries=5).parse.run(input=upload.file_id) ``` ### Custom HTTP Client Configure the underlying HTTP client: ```python theme={null} import httpx from reducto import Reducto, DefaultHttpxClient client = Reducto( http_client=DefaultHttpxClient( proxy="http://my.proxy.com", timeout=httpx.Timeout(60.0), ), ) ``` *** ## Next Steps **1. Get your API key** Create an API key in [Reducto Studio](https://studio.reducto.ai/) and set it as `REDUCTO_API_KEY`. **2. Try the quickstart** Run your first parse operation with the [quickstart example](/quickstart). **3. Explore the methods** Check out the [core methods documentation](/sdk/python/parse) for detailed usage. # Parse Source: https://docs.reducto.ai/sdk/python/parse Parse full documents into LLM-ready content using the Python SDK The `parse.run()` method turns full documents into LLM-ready content: text, tables, figures, layout, and formatting. r-1 is the main Parse model. `settings.model` requires `reductoai>=0.23.0`. Parse returns content organized into chunks for LLM and RAG workflows. > [!NOTE] > > Starting December 7, 2026, Parse defaults to r-1. To keep legacy Parse, set `settings.model` to `"legacy"`. Review [Parse r-1](/parse/r-1) and [r-1 configuration compatibility](/configs/parse/r-1-configuration-compatibility) before moving an existing configuration. *** ## Basic Usage ```python theme={null} from pathlib import Path from reducto import Reducto client = Reducto() # Upload and parse upload = client.upload(file=Path("invoice.pdf")) result = client.parse.run(input=upload.file_id) # Access the results for chunk in result.result.chunks: print(chunk.content) for block in chunk.blocks: print(f" {block.type} on page {block.bbox.page}") ``` *** ## Method Signature ```python theme={null} def parse.run( input: str | list[str], enhance: dict | None = None, formatting: dict | None = None, retrieval: dict | None = None, settings: dict | None = None, spreadsheet: dict | None = None ) -> ParseResponse ``` ### Parameters | Parameter | Type | Required | Description | | - | - | - | - | | `input` | `str \| list[str]` | Yes | File ID (`reducto://...`), URL, or `jobid://` reference | | `enhance` | `dict \| None` | No | Customized agentic processing and legacy enhancement options | | `formatting` | `dict \| None` | No | Output formatting (table formats, metadata) | | `retrieval` | `dict \| None` | No | Chunking and filtering options | | `settings` | `dict \| None` | No | Processing settings such as page range, returned assets, and timeouts. Legacy OCR controls are ignored by r-1. | | `spreadsheet` | `dict \| None` | No | Spreadsheet-specific options | *** ## Input Options The `input` parameter accepts several formats: ```python theme={null} # From upload result = client.parse.run(input=upload.file_id) # Public URL result = client.parse.run(input="https://example.com/doc.pdf") # Presigned S3 URL result = client.parse.run(input="https://bucket.s3.amazonaws.com/doc.pdf?X-Amz-...") # Reprocess previous job result = client.parse.run(input="jobid://7600c8c5-a52f-49d2-8a7d-d75d1b51e141") ``` *** ## Configuration Examples ### Select the Parse Model Set `settings.model` to `"r-1"` to use r-1, or `"r-1-mini"` to use [r-1 mini](/parse/r-1#r-1-mini). Set `"legacy"` to pin the previous pipeline. Until December 7, 2026, a request that omits `settings.model` runs legacy Parse. See [r-1 configuration compatibility](/configs/parse/r-1-configuration-compatibility) before you change an existing configuration. ```python theme={null} result = client.parse.run( input=upload.file_id, settings={ "model": "r-1" # Options: "r-1", "r-1-mini", "legacy" } ) ``` ### Chunking By default, Parse returns the entire document as one chunk. For RAG applications, use variable chunking: ```python theme={null} result = client.parse.run( input=upload.file_id, retrieval={ "chunking": { "chunk_mode": "variable" # Options: "disabled", "variable", "page", "section" } } ) ``` ### Table Output Format Control how tables appear in the output: ```python theme={null} result = client.parse.run( input=upload.file_id, formatting={ "table_output_format": "html" # Options: "dynamic", "html", "md", "json", "csv" } ) ``` ### Augment r-1 with Customized Agentic Processing Legacy Parse supports promptless agentic scopes as general-purpose accuracy enhancements. r-1 handles those improvements natively. With r-1, add agentic processing when you need a custom prompt for a specific scope. Agentic processing adds latency. ```python theme={null} result = client.parse.run( input=upload.file_id, enhance={ "agentic": [ { "scope": "table", "prompt": "Preserve domain-specific notation such as <0.01, ±, and N/A exactly as written." } ] } ) ``` For structured chart data, use [advanced chart extraction](/configs/parse/chart-extraction). ### Figure Descriptions r-1 generates short figure descriptions as part of its full-page pass. You do not need to set `summarize_figures`. For legacy Parse, enable the separate figure-summary model with: ```python theme={null} result = client.parse.run( input=upload.file_id, enhance={ "summarize_figures": True } ) ``` ### Page Range Process only specific pages: ```python theme={null} result = client.parse.run( input=upload.file_id, settings={ "page_range": { "start": 1, "end": 10 } } ) ``` ### Filter Blocks Remove specific content types from output: ```python theme={null} result = client.parse.run( input=upload.file_id, retrieval={ "filter_blocks": ["Header", "Footer", "Page Number"] } ) ``` ### Return Images Get image URLs for figures and tables: ```python theme={null} result = client.parse.run( input=upload.file_id, settings={ "return_images": ["figure", "table"] } ) # Access images from blocks for chunk in result.result.chunks: for block in chunk.blocks: if hasattr(block, 'image_url') and block.image_url: print(f"{block.type}: {block.image_url}") ``` *** ## Response Structure The `ParseResponse` object contains: ```python theme={null} result: ParseResponse = client.parse.run(input=upload.file_id) # Top-level fields print(result.job_id) # str: Unique job identifier print(result.duration) # float: Processing time in seconds print(result.studio_link) # str: Link to view in Studio # Usage information print(result.usage.num_pages) # int: Pages processed print(result.usage.credits) # float: Credits consumed # Result content if result.result.type == "full": chunks = result.result.chunks for chunk in chunks: print(chunk.content) # str: Full text content print(chunk.embed) # str: Embedding-optimized content print(chunk.blocks) # list[Block]: Individual elements ``` ### Chunks Each chunk contains: * `content` (str): Full text content formatted as Markdown * `embed` (str): Content optimized for embeddings * `blocks` (list\[Block]): Individual elements with positions ### Blocks Each block contains: * `type` (str): Element type (`Title`, `Header`, `Text`, `Table`, `Figure`, etc.) * `content` (str): The block's content * `bbox` (BoundingBox): Position on the page (normalized 0-1 coordinates) * `confidence` (str): Confidence level (`"high"` or `"low"`) *** ## URL Results For large documents, the response may return a URL instead of inline content: ```python theme={null} result = client.parse.run(input=upload.file_id) if result.result.type == "url": # Fetch the content from the URL import requests chunks = requests.get(result.result.url).json() else: # Content is inline chunks = result.result.chunks ``` To always get a URL (for consistent handling): ```python theme={null} result = client.parse.run( input=upload.file_id, settings={ "force_url_result": True } ) ``` *** ## Advanced Features ### Raw Response Access Access raw HTTP response data: ```python theme={null} response = client.parse.with_raw_response.run(input=upload.file_id) print(response.headers.get('X-My-Header')) parse_result = response.parse() # Get the parsed object ``` ### Streaming Response Stream large responses: ```python theme={null} with client.parse.with_streaming_response.run(input=upload.file_id) as response: for line in response.iter_lines(): print(line) ``` ### Per-Request Options Override client settings for this request: ```python theme={null} # Custom timeout client.with_options(timeout=30.0).parse.run(input=upload.file_id) # Custom retry settings client.with_options(max_retries=5).parse.run(input=upload.file_id) ``` *** ## Error Handling ```python theme={null} from reducto import Reducto import reducto try: result = client.parse.run(input=upload.file_id) except reducto.APIConnectionError as e: print(f"Connection failed: {e}") print(e.__cause__) # underlying exception except reducto.RateLimitError as e: print(f"Rate limited: {e}") except reducto.APIStatusError as e: print(f"API error: {e.status_code} - {e.response}") except reducto.APITimeoutError as e: print(f"Request timed out: {e}") ``` *** ## Complete Example ```python theme={null} from pathlib import Path from reducto import Reducto client = Reducto() # Upload upload = client.upload(file=Path("fidelity-example.pdf")) # Parse with r-1 and additional configuration result = client.parse.run( input=upload.file_id, formatting={ "table_output_format": "html" }, retrieval={ "chunking": {"chunk_mode": "variable"} }, settings={ "page_range": {"start": 1, "end": 5} } ) # Process results print(f"Processed {result.usage.num_pages} pages") print(f"Used {result.usage.credits} credits") print(f"View in Studio: {result.studio_link}") for i, chunk in enumerate(result.result.chunks): print(f"\n=== Chunk {i + 1} ===") print(chunk.content[:500]) # First 500 chars # Count block types block_types = {} for block in chunk.blocks: block_types[block.type] = block_types.get(block.type, 0) + 1 print(f"Block types: {block_types}") ``` *** ## Best Practices > [card] Use Variable Chunking for RAG > > Enable `chunk_mode: "variable"` for RAG pipelines to get semantically meaningful chunks. > [card] Customize Agentic Processing > > Add a scoped custom prompt when your workflow needs use-case-specific parsing instructions. r-1 handles scanned documents natively. > [card] Filter Headers/Footers > > Use `filter_blocks` to remove headers and footers that pollute search results. > [card] Handle URL Results > > Always check `result.type` and handle URL results for large documents. *** ## Next Steps * Learn about [extracting specific fields](/sdk/python/extract) from parsed documents * Learn how [Parse r-1](/parse/r-1) handles documents and customized agentic processing * Explore [response format details](/parse/response-format) for complete structure * Check out [best practices](/parse/best-practices) for optimization * See the [async client](/sdk/python/async) for concurrent processing # Pipeline Source: https://docs.reducto.ai/sdk/python/pipeline Run pre-configured pipelines using the Python SDK > [!WARNING] > > The `/pipeline` endpoint and pipeline SDK methods are deprecated. Call `/parse`, `/extract`, `/split`, or `/edit` directly instead. The `pipeline.run()` method executes a pipeline you've created in [Reducto Studio](https://studio.reducto.ai). Pipelines bundle parse, extract, split, and edit operations into a single reusable workflow. You configure the pipeline in Studio, deploy it to get a `pipeline_id`, then call it from your code. *** ## Basic Usage ```python theme={null} from pathlib import Path from reducto import Reducto client = Reducto() # Upload document upload = client.upload(file=Path("invoice.pdf")) # Run a deployed pipeline result = client.pipeline.run( input=upload.file_id, pipeline_id="your_pipeline_id" # From Studio deployment ) # Access results based on pipeline type if result.result.extract: print(result.result.extract) elif result.result.parse: for chunk in result.result.parse.result.chunks: print(chunk.content) ``` *** ## Method Signature ```python theme={null} def pipeline.run( input: str | list[str], pipeline_id: str, settings: dict | None = None ) -> PipelineResponse ``` ### Parameters | Parameter | Type | Required | Description | | - | - | - | - | | `input` | `str \| list[str]` | Yes | File ID (`reducto://...`), URL, or list of file IDs for multi-document pipelines | | `pipeline_id` | `str` | Yes | The pipeline ID from Studio deployment | | `settings` | `dict \| None` | No | Settings that override pipeline defaults | *** ## Creating Pipelines Pipelines are created and configured in [Reducto Studio](https://studio.reducto.ai): 1. Go to Studio and create a new pipeline 2. Add and configure steps (Parse, Extract, Split, Edit) 3. Test with sample documents 4. Click **Deploy** and select **Pipeline** 5. Copy the generated `pipeline_id` See [Pipeline Basics](/workflows/pipeline-basics) for detailed instructions. *** ## Response Structure The response structure depends on what steps your pipeline includes: ```python theme={null} result = client.pipeline.run( input=upload.file_id, pipeline_id="your_pipeline_id" ) # Common fields print(result.job_id) # str: Job identifier print(result.usage.num_pages) # int: Pages processed print(result.usage.credits) # float: Credits used # Results - populated based on pipeline type print(result.result.parse) # Parse results (if pipeline includes Parse) print(result.result.extract) # Extract results (if pipeline includes Extract) print(result.result.split) # Split results (if pipeline includes Split) print(result.result.edit) # Edit results (if pipeline includes Edit) ``` ### Parse Results ```python theme={null} if result.result.parse: parse_result = result.result.parse for chunk in parse_result.result.chunks: print(chunk.content) ``` ### Extract Results For Parse + Extract pipelines, extract is a single object: ```python theme={null} if result.result.extract and not isinstance(result.result.extract, list): print(result.result.extract.result) ``` For Parse + Split + Extract pipelines, extract is a list (one per section): ```python theme={null} if result.result.extract and isinstance(result.result.extract, list): for section in result.result.extract: print(f"{section.split_name}: {section.result}") ``` *** ## Multi-Document Pipelines Pass multiple file IDs to process related documents together: ```python theme={null} upload1 = client.upload(file=Path("doc1.pdf")) upload2 = client.upload(file=Path("doc2.pdf")) result = client.pipeline.run( input=[upload1.file_id, upload2.file_id], pipeline_id="your_pipeline_id" ) ``` Multi-document pipelines parse each document separately but run extraction across combined content. > [!WARNING] > > Multi-document pipelines require a pipeline without Split. Split is not supported with multiple input documents. *** ## Error Handling ```python theme={null} from reducto import Reducto import reducto try: result = client.pipeline.run( input=upload.file_id, pipeline_id="your_pipeline_id" ) except reducto.APIConnectionError as e: print(f"Connection failed: {e}") except reducto.APIStatusError as e: print(f"Pipeline failed: {e.status_code} - {e.response}") ``` *** ## Complete Example ```python theme={null} from pathlib import Path from reducto import Reducto client = Reducto() # Upload upload = client.upload(file=Path("fidelity-example.pdf")) # Run pipeline result = client.pipeline.run( input=upload.file_id, pipeline_id="k9798h9mwt0wmq5qz5e45qxbfx7yj4bq" # Example ID ) print(f"Job ID: {result.job_id}") print(f"Credits used: {result.usage.credits}") # Handle different pipeline types if result.result.extract: if isinstance(result.result.extract, list): # Split + Extract pipeline for section in result.result.extract: print(f"\n{section.split_name}:") print(section.result) else: # Parse + Extract pipeline print("Extracted data:", result.result.extract.result) elif result.result.parse: # Parse-only pipeline for chunk in result.result.parse.result.chunks: print(chunk.content[:200]) ``` *** ## Best Practices > [card] Create Pipelines in Studio > > Use Studio to configure and test pipelines before deploying to production. > [card] Version Your Pipelines > > Use version names when deploying to track configuration changes. *** ## Next Steps * Learn how to [create pipelines in Studio](/workflows/pipeline-basics) * Explore [multi-document pipelines](/workflows/multi-document-pipelines) * Check out the [async client](/sdk/python/async) for concurrent processing # Split Source: https://docs.reducto.ai/sdk/python/split Split documents into sections using the Python SDK The `split.run()` method divides documents into sections based on descriptions you provide. You define what sections to look for, and Split identifies which pages belong to each section. *** ## Basic Usage ```python theme={null} from pathlib import Path from reducto import Reducto client = Reducto() # Upload upload = client.upload(file=Path("document.pdf")) # Split the document - split_description is required result = client.split.run( input=upload.file_id, split_description=[ {"name": "Summary", "description": "Executive summary or overview section"}, {"name": "Financial Data", "description": "Tables with financial figures"}, {"name": "Notes", "description": "Footnotes or additional notes"} ] ) # Access splits for split in result.result.splits: print(f"Section: {split.name}") print(f"Pages: {split.pages}") print(f"Confidence: {split.conf}") ``` *** ## Method Signature ```python theme={null} def split.run( input: str, split_description: list[dict], parsing: dict | None = None, settings: dict | None = None, split_rules: str | None = None ) -> SplitResponse ``` ### Parameters | Parameter | Type | Required | Description | | - | - | - | - | | `input` | `str` | Yes | File ID (`reducto://...`), URL, or `jobid://` reference | | `split_description` | `list[dict]` | Yes | List of sections to identify, each with `name`, `description`, and optional `partition_key` | | `parsing` | `dict \| None` | No | Parse configuration (page range, OCR settings) | | `settings` | `dict \| None` | No | Split settings (e.g., `table_cutoff`) | | `split_rules` | `str \| None` | No | Natural language prompt describing rules for splitting | *** ## Split Description The `split_description` parameter is required. Each entry defines a section to find: ```python theme={null} split_description = [ { "name": "Cover Page", "description": "Title page with company logo and report title" }, { "name": "Table of Contents", "description": "Page listing all sections with page numbers" }, { "name": "Financial Statements", "description": "Balance sheet, income statement, and cash flow tables" } ] result = client.split.run( input=upload.file_id, split_description=split_description ) ``` ### With Partition Key Use `partition_key` when a section type repeats multiple times and you want to group by a specific identifier: ```python theme={null} split_description = [ { "name": "Account Holdings", "description": "Investment holdings for a specific account", "partition_key": "account_number" # Group pages by account number } ] ``` The `partition_key` is a string describing what identifier to look for (e.g., "account number", "patient ID", "invoice number"). Split will find all instances of that section and group them by the identifier value it finds in the document. *** ## Split Rules The `split_rules` parameter is a natural language prompt that controls how pages are classified. The default rule allows pages to belong to multiple sections only at boundaries: ```python theme={null} # Allow pages to belong to multiple sections result = client.split.run( input=upload.file_id, split_description=[...], split_rules="Pages can belong to multiple sections if they contain content from both." ) # Force exclusive classification result = client.split.run( input=upload.file_id, split_description=[...], split_rules="Each page must belong to exactly one section. Choose the most relevant section." ) ``` *** ## Parsing Configuration Configure how the document is parsed before splitting: ```python theme={null} result = client.split.run( input=upload.file_id, split_description=[...], parsing={ "settings": { "page_range": {"start": 1, "end": 20} } } ) ``` *** ## Response Structure ```python theme={null} result: SplitResponse = client.split.run(...) # Top-level fields print(result.usage.num_pages) # int: Pages processed print(result.usage.credits) # float: Credits used # Splits for split in result.result.splits: print(split.name) # str: Section name (from split_description) print(split.pages) # list[int]: Page numbers in this section (1-indexed) print(split.conf) # str: Confidence level ("high" or "low") print(split.partitions) # list | None: Sub-sections when partition_key is used ``` ### Split Object Each split contains: * `name` (str): The section name you defined * `pages` (list\[int]): Page numbers belonging to this section (1-indexed) * `conf` (str): Confidence level (`"high"` or `"low"`) * `partitions` (list | None): When using `partition_key`, contains sub-sections with their own `name`, `pages`, and `conf` *** ## Error Handling ```python theme={null} from reducto import Reducto import reducto try: result = client.split.run( input=upload.file_id, split_description=[{"name": "Summary", "description": "..."}] ) except reducto.APIConnectionError as e: print(f"Connection failed: {e}") except reducto.APIStatusError as e: print(f"Split failed: {e.status_code} - {e.response}") ``` *** ## Complete Example ```python theme={null} from pathlib import Path from reducto import Reducto client = Reducto() # Upload upload = client.upload(file=Path("fidelity-example.pdf")) # Define sections to find split_description = [ { "name": "Account Summary", "description": "Overview of account balances and holdings" }, { "name": "Holdings Detail", "description": "Detailed list of individual holdings with values" }, { "name": "Transaction History", "description": "Recent transactions and activity" } ] # Split the document result = client.split.run( input=upload.file_id, split_description=split_description ) # Process results print(f"Found {len(result.result.splits)} sections") for split in result.result.splits: print(f"\n=== {split.name} ===") print(f"Pages: {split.pages}") print(f"Confidence: {split.conf}") ``` *** ## Chaining with Extract A common pattern is to split a document then extract different schemas from each section: ```python theme={null} # Split first split_result = client.split.run( input=upload.file_id, split_description=[ {"name": "Summary", "description": "Account summary"}, {"name": "Holdings", "description": "Holdings table"} ] ) # Extract from each section using page ranges for split in split_result.result.splits: if split.pages: extract_result = client.extract.run( input=f"jobid://{split_result.job_id}", instructions={"schema": get_schema_for(split.name)}, parsing={ "settings": { "page_range": { "start": split.pages[0], "end": split.pages[-1] } } } ) print(f"{split.name}: {extract_result.result}") ``` *** ## Best Practices > [card] Write Clear Descriptions > > Detailed section descriptions improve classification accuracy. > [card] Use Partition Keys > > Use `partition_key` with a string identifier when sections repeat multiple times. *** ## Next Steps * Learn about [split configuration options](/configs/split/configuration) * Explore the [async client](/sdk/python/async) for concurrent processing * See [chaining endpoints](/workflows/chaining-endpoints) for Split + Extract workflows # Upload Source: https://docs.reducto.ai/sdk/python/upload Upload documents to Reducto using the Python SDK The `upload` method uploads files to Reducto's servers and returns a file reference that you can use with other endpoints. *** ## Basic Usage ```python theme={null} from pathlib import Path from reducto import Reducto client = Reducto() # Upload a file upload = client.upload(file=Path("document.pdf")) # Use the file_id in other operations print(upload.file_id) # "reducto://abc123def456.pdf" ``` *** ## Method Signature ```python theme={null} def upload( file: Path | bytes | tuple[str, bytes, str] | BinaryIO, extension: str | None = None ) -> Upload ``` ### Parameters | Parameter | Type | Required | Description | | - | - | - | - | | `file` | `Path \| bytes \| tuple \| BinaryIO` | Yes | The file to upload. Can be a `Path` object, file-like object, bytes, or a tuple of `(filename, contents, media_type)`. | | `extension` | `str \| None` | No | Override the file extension (e.g., `".pdf"`). Useful when uploading bytes without filename context. | ### Returns `Upload` with the following fields: * `file_id` (str): The file reference to use with other endpoints (format: `reducto://...`) * `presigned_url` (str | None): A presigned URL for the uploaded file (if applicable) *** ## Upload Options ### From File Path The most common way to upload. Use `pathlib.Path`: ```python theme={null} from pathlib import Path upload = client.upload(file=Path("invoice.pdf")) ``` ### From File Object ```python theme={null} with open("document.pdf", "rb") as f: upload = client.upload(file=f) ``` ### From Bytes When uploading raw bytes, use a tuple to provide filename and content type: ```python theme={null} with open("document.pdf", "rb") as f: file_bytes = f.read() upload = client.upload( file=("document.pdf", file_bytes, "application/pdf") ) ``` ### With Extension Override Use `extension` when the file type cannot be inferred: ```python theme={null} upload = client.upload( file=Path("data_file"), extension=".pdf" ) ``` ### From URL (Presigned URLs) For large files, you can generate a presigned URL and pass it directly to parse/extract endpoints without uploading: ```python theme={null} # For files > 100MB, use presigned URLs # See: /upload/large-files for details # You can pass presigned URLs directly to parse/extract result = client.parse.run(input="https://bucket.s3.amazonaws.com/doc.pdf?X-Amz-...") ``` *** ## Async Upload The async client uses the same interface: ```python theme={null} import asyncio from reducto import AsyncReducto async def main(): client = AsyncReducto() # If you pass a PathLike instance, the file contents will be read asynchronously automatically upload = await client.upload(file=Path("document.pdf")) return upload result = asyncio.run(main()) ``` *** ## File Size Limits * **Direct upload**: Up to 100MB * **Presigned URLs**: Up to 5GB For files larger than 100MB, use presigned URLs instead of the upload endpoint. > [card] [Large File Upload Guide](/upload/large-files) > > Complete guide to uploading large files using presigned URLs. *** ## Supported File Types The SDK accepts any file type that Reducto supports. Common formats include: * **Documents**: PDF, DOCX, DOC, RTF * **Images**: PNG, JPEG, TIFF, BMP * **Spreadsheets**: XLSX, XLS, CSV * **Presentations**: PPTX, PPT > [card] [Supported File Formats](/upload/overview#supported-file-types) > > Complete list of supported file formats. *** ## Error Handling ```python theme={null} from reducto import Reducto import reducto try: upload = client.upload(file=Path("document.pdf")) except reducto.APIConnectionError as e: print(f"Connection failed: {e}") except reducto.APIStatusError as e: print(f"Upload failed: {e.status_code} - {e.response}") ``` Common errors: * **File not found**: The file path doesn't exist * **File too large**: File exceeds 100MB limit (use presigned URLs) * **Invalid file type**: File format not supported * **Network error**: Connection issues during upload *** ## Examples ### Upload and Parse ```python theme={null} from pathlib import Path from reducto import Reducto client = Reducto() # Upload upload = client.upload(file=Path("invoice.pdf")) # Parse immediately result = client.parse.run(input=upload.file_id) ``` ### Batch Upload ```python theme={null} from pathlib import Path from reducto import Reducto client = Reducto() files = ["doc1.pdf", "doc2.pdf", "doc3.pdf"] uploads = [] for file_path in files: upload = client.upload(file=Path(file_path)) uploads.append(upload) print(f"Uploaded {file_path}: {upload.file_id}") ``` ### Upload with Extension Override ```python theme={null} from pathlib import Path from reducto import Reducto client = Reducto() # Upload with explicit extension upload = client.upload( file=Path("document_without_extension"), extension=".pdf" ) print(upload.file_id) # "reducto://..." ``` *** ## Best Practices > [card] Use Path Objects > > Prefer `pathlib.Path` over string paths for better cross-platform compatibility. > [card] Handle Large Files > > For files > 100MB, use presigned URLs instead of direct upload. > [card] Reuse File IDs > > File IDs can be reused across multiple operations without re-uploading. > [card] Error Handling > > Always wrap uploads in try/except blocks for production code. *** ## Next Steps * Learn about [parsing documents](/sdk/python/parse) after upload * Explore [extracting data](/sdk/python/extract) from uploaded files * Check out [large file uploads](/upload/large-files) for files > 100MB # Account & Settings Source: https://docs.reducto.ai/studio-account Manage API keys, usage, billing, and organization settings in Reducto Studio Your Reducto account is managed through [Studio](https://studio.reducto.ai). The main sidebar provides access to Home, Usage, API Keys, Webhooks, and Billing. The sidebar footer contains Documentation, Support, Give Feedback, and Settings. This page covers common account management tasks. *** ## API Keys ![](https://mintcdn.com/reducto/R99njFX82lRP6p3f/images/studio-api-keys.png?fit=max\&auto=format\&n=R99njFX82lRP6p3f\&q=85\&s=e23f2ba71494b1f28b6f6fdf65dfd24e) API keys authenticate your requests to Reducto. Navigate to **API Keys** in the sidebar to manage them. > [!NOTE] > > All roles can create API keys. Only **Owners** and **Admins** can edit or delete keys. See [Roles & Permissions](/studio-roles-permissions) for details. To create a new API key: 1. Click **Create API Key** 2. Give it a descriptive name (e.g., "production", "staging", "local-dev") 3. Copy the key immediately, you won't be able to see it again The table shows each key's name, prefix, creator, creation date, and expiration. Deleting a key immediately revokes access, and any requests using that key will fail. Organization Owners and Admins are notified by email a week and a day before an API key expires. Rotate keys ahead of their expiration to avoid service disruptions. > [!NOTE] > > For security, create separate API keys for different environments (development, staging, production). If a key is compromised, you can revoke it without affecting other environments. *** ## Usage The Usage page tracks your Reducto consumption. Navigate to **Usage** in the sidebar to view your dashboard. ![](https://mintcdn.com/reducto/R99njFX82lRP6p3f/images/studio-usage-dashboard.png?fit=max\&auto=format\&n=R99njFX82lRP6p3f\&q=85\&s=0978871f00ef9a81cbfb48dcf1ddda04) ### Usage chart The chart shows usage over time, broken down by product (Parse, Extract, Split, Edit, Classify). The unit on the y axis depends on your pricing model (see the [Pricing Migration Guide](/reference/pricing-migration#which-pricing-model-am-i-on)): * **Pages**: accounts on per-product pricing. The chart shows billable pages, the count Reducto uses for pricing. * **Credits**: accounts on credit-based pricing. The chart shows credits consumed. Use the filters to: * **Group by**: Switch the breakdown. Accounts on per-product pricing can group by Product or API key. Accounts on credit-based pricing can also group by Feature or File type. * **Product / API key**: Filter to specific products or API keys. Accounts on credit-based pricing can also filter by Feature and File type. * **Time range**: View the last 7, 30, or 90 days, or pick a custom range * **Granularity**: Toggle between Day, Week, and Month views Click **Download** to export your usage data. You can also export usage data programmatically via the [Usage Export API](/reference/usage-export-api). ### Request logs Below the chart, the Request Logs table shows individual API requests. Each entry includes: * Job ID * Date and time * Type (Parse, Extract, Split, Edit, Classify) * Page count processed * Status Studio shows jobs from the last 12 hours. Click **Refresh** to load the latest entries. Use the region dropdown to filter by data center (US, EU, etc.). Click the **...** menu on any request to see the full job details, including the exact configuration used. Click **Preview** to recreate that pipeline in a new Studio instance. ![](https://mintcdn.com/reducto/R99njFX82lRP6p3f/images/studio-request-log.png?fit=max\&auto=format\&n=R99njFX82lRP6p3f\&q=85\&s=88697f95feae53083981dc883061595d) *** ## Billing Navigate to **Billing** in the sidebar to see your current plan, usage alerts, and billing history. > [!NOTE] > > The billing page is only visible for account owners and organizations on a subscription plan. From the Billing page you can: * **View invoices**: See past and upcoming charges under Billing history * **Update payment methods**: Click **Add payment method** * **Manage subscription**: Click **Manage plan** to open the Stripe customer portal * **Download receipts**: Click **View invoices in Stripe** ### Subscription changes Monthly and yearly plans are available. To change your plan, use **Manage plan** or contact [support@reducto.ai](mailto:support@reducto.ai). ### Usage alerts Configure usage alerts from the Billing page to get an email when your current invoice crosses a threshold you set. Click **Create alert** to open the dialog. ![](https://mintcdn.com/reducto/R99njFX82lRP6p3f/images/studio-usage-alerts.png?fit=max\&auto=format\&n=R99njFX82lRP6p3f\&q=85\&s=34e27f1e3a9c999da2fc30df0751adcd) Each alert has: * **Threshold**: The invoice amount (in USD) that triggers the alert * **Recipients**: The organization members who receive the email * **Label**: An optional name for the alert (e.g., "Budget warning") Reducto compares your current invoice to the threshold every hour. Recipients are emailed once per period when usage crosses the threshold. Editing the threshold re-arms the alert. *** ## Organization Settings The **Settings** page lets you manage your profile and organization. ### Account settings On the **Account** tab, you can: * Edit your name and email * Change your profile picture * Set a password or enable two-factor authentication (2FA) * Choose a theme (Light or Dark) * Manage cookie preferences ### Organization management If you're an organization Owner or Admin, you can: * Invite team members * Manage member roles and permissions See [Roles & Permissions](/studio-roles-permissions) for a full breakdown of what each role can do. *** ## Webhooks Configure webhooks to receive notifications when jobs complete. Navigate to **Webhooks** in the sidebar. Webhooks let you: * Get notified when async jobs finish * Trigger downstream workflows automatically * Avoid polling the API for job status See [Webhook Configuration](/recipes/svix-webhooks) for setup details. *** ## Related > [card] [API Quickstart](/quickstart) > > Set up your API key and make your first request. > [card] [Credit Usage](/faq/credit-usage-overview) > > How credits are calculated and billed. > [card] [Webhook Setup](/recipes/svix-webhooks) > > Configure webhooks for async job notifications. > [card] [Security Policies](/security/policies) > > Data handling, retention, and compliance information. # Deploying to Production Source: https://docs.reducto.ai/studio-deploy-pipeline Publish pipelines, integrate with API, and monitor production usage Deploying a pipeline creates a stable endpoint that your applications can call. You configure everything in Studio and deployment packages it into a single Pipeline ID. Your code stays simple: upload a document, pass the Pipeline ID, receive results. ## Pipeline vs Direct API When you click **Deploy**, Studio offers two options: ![Deploy Pipeline](https://mintcdn.com/reducto/HIMlsEXojApe7G_x/images/deploy_pipeline.png?fit=max\&auto=format\&n=HIMlsEXojApe7G_x\&q=85\&s=8d179fedf6b91e566ec2f67ca8c574d4) Deploy dialog with Pipeline and Direct API options **Pipeline ID** encapsulates your entire workflow behind a single identifier. Whether you built a parse-only pipeline or a multi-step flow (parse → extract → edit), your API call looks the same: ```python theme={null} result = client.pipeline.run( input=upload, pipeline_id="k9xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" ) ``` Benefits of Pipeline ID: * **Simple integration**: One ID, one call, any complexity * **Update without code changes**: Modify settings in Studio, redeploy, and all API calls use the new configuration automatically * **Team collaboration**: Non-engineers can iterate on configurations without touching code **Direct API Call** exports the raw configuration as code. You get the exact settings you configured, embedded in your application. For multi-step workflows, this means separate calls for each step: ```python theme={null} # Direct API: multiple calls, explicit config parse_result = client.parse.run(input=upload, options={"ocr_system": "multilingual", ...}) extract_result = client.extract.run(input=parse_result.job_id, schema={...}) ``` Use Direct API when you need runtime flexibility (changing settings per-request) or want configuration in version control. > [!NOTE] > > For most production use cases, Pipeline ID is the better choice. It keeps your code clean and lets you iterate on document processing independently from your application releases. *** ## Using your Pipeline ID Once deployed, integrate the Pipeline ID into your application: ```python Python theme={null} from pathlib import Path from reducto import Reducto client = Reducto() upload = client.upload(file=Path("fidelity-example.pdf")) result = client.pipeline.run( input=upload, pipeline_id="k9798h9mwt0wmq5qz5e45qxbfx7yj4bq" ) # Access results based on your pipeline type print(result.result) ``` ```javascript Node.js theme={null} import Reducto from "reductoai"; import fs from "fs"; const client = new Reducto(); const upload = await client.upload({ file: fs.createReadStream("fidelity-example.pdf"), }); const result = await client.pipeline.run({ input: upload, pipelineId: "k9798h9mwt0wmq5qz5e45qxbfx7yj4bq" }); console.log(result.result); ``` ```bash cURL theme={null} # Upload document UPLOAD=$(curl -s -X POST "https://platform.reducto.ai/upload" \ -H "Authorization: Bearer $REDUCTO_API_KEY" \ -F "file=@fidelity-example.pdf") FILE_ID=$(echo $UPLOAD | jq -r '.file_id') # Run pipeline curl -X POST "https://platform.reducto.ai/pipeline" \ -H "Authorization: Bearer $REDUCTO_API_KEY" \ -H "Content-Type: application/json" \ -d "{\"input\": \"$FILE_ID\", \"pipeline_id\": \"k9798h9mwt0wmq5qz5e45qxbfx7yj4bq\"}" ``` For high-volume processing, use the async endpoint: ```python theme={null} job = client.pipeline.run_job( input=upload, pipeline_id="k9798h9mwt0wmq5qz5e45qxbfx7yj4bq" ) # Poll for completion or use webhooks result = client.job.get(job.job_id) ``` See [Async Pipeline API](/api-reference/pipeline-async) and [Async invocation pattern](/workflows/async-overview) for webhook configuration and batch processing patterns. *** ## Managing deployed pipelines Studio provides tools to manage your pipelines after deployment. Access them from the **Three dotted** menu in the top-right corner of any pipeline. ### Activity View all published versions of your pipeline. Each deployment creates a new version with a timestamp, allowing you to: * See what configuration was active at any point in time * Compare versions to understand what changed * Roll back by redeploying a previous version's settings Click **... → Activity** to open the version browser. Select any version to view its configuration or restore that state. ### Execution Logs Track API calls made to your deployed pipeline. Click **... → Execution Logs** to view recent executions. Use this to verify your integration is working and to debug failed requests. > [!NOTE] > > Execution logs appear once the pipeline has been called via the API. Testing in Studio (clicking Run) doesn't create execution logs. ### Duplicate Pipeline Create a copy of your pipeline to experiment with different settings without affecting production. The duplicate is independent, so you can modify and deploy it separately. ### Delete Pipeline Remove a pipeline permanently. This invalidates the Pipeline ID; any API calls using it will fail. Delete only pipelines you're certain are no longer in use. *** ## Updating deployed pipelines To update a deployed pipeline: 1. Open the pipeline in Studio 2. Modify your configuration (settings, schema, instructions) 3. Test with representative documents 4. Click **Deploy** again A new version is created. The Pipeline ID stays the same, so existing integrations automatically use the updated configuration on their next call. > [!WARNING] > > Changes take effect immediately after deployment. Test thoroughly before redeploying production pipelines. *** ## Troubleshooting > [accordion] API returns 'pipeline not found' > > * Verify the Pipeline ID is copied correctly (no extra spaces or missing characters) > * Confirm the pipeline is deployed, not just saved—check for the deployed indicator in Studio > * Ensure your API key belongs to the same organization that owns the pipeline > [accordion] Results differ from Studio testing > > * Check you're using the correct Pipeline ID (you may have multiple pipelines) > * Verify no runtime overrides are being passed in your API call > * Compare the document format—Studio and API should receive identical files > * Review Activity to confirm you're using the expected configuration version > [accordion] Processing takes longer than expected > > * Complex documents (many pages, dense tables) take longer to process > * Multi-step pipelines (parse → extract → edit) have cumulative processing time > * For batch processing, use the async endpoint to avoid timeout issues > [accordion] How do I debug a failing extraction? > > 1. Check Execution Logs to confirm the API call reached Reducto > 2. Run the same document in Studio to see results interactively > 3. For Extract pipelines, use the Compare tab to validate against expected output > 4. Review the underlying Parse results—if Parse misses content, Extract can't find it *** ## Related > [card] [Pipeline Basics](/workflows/pipeline-basics) > > How pipelines orchestrate multi-step document workflows. > [card] [Pipeline API Reference](/api-reference/pipeline) > > Full API documentation for synchronous pipeline calls. > [card] [Async Pipeline API](/api-reference/pipeline-async) > > Process large documents and batches asynchronously. > [card] [Studio Quickstart](/studio-quickstart) > > Build and test your first pipeline from scratch. # Edit Pipeline Source: https://docs.reducto.ai/studio-edit Fill forms and modify documents with natural language instructions in Reducto Studio Edit fills PDF forms and modifies documents using natural language instructions. You describe what values should go where, and Edit handles field detection, mapping, and insertion automatically. While [Parse](/parse/overview) reads documents and [Extract](/extract/overview) pulls specific data out, Edit writes data back in. This completes the document processing loop: parse a document to understand it, extract data from one source, edit that data into a different form. ## Start with a document Upload a blank form template to your Edit pipeline. Edit works with: * **PDFs** with fillable fields (text boxes, checkboxes, dropdowns) * **PDFs** without form fields (Edit uses vision to detect fillable areas) * **DOCX** documents with form controls or table cells ![Edit Pipeline Example](https://mintcdn.com/reducto/HIMlsEXojApe7G_x/images/edit_pipeline_example.png?fit=max\&auto=format\&n=HIMlsEXojApe7G_x\&q=85\&s=0aed654e41d1c81bdcddaaca33fa7e81) Edit pipeline interface showing the configuration panel The Configurations tab shows two sections: **Form** **Instructions** for describing what to fill, and **Results** for the optional form schema. ## Write your instructions The Instructions panel is where you describe what data to fill. Be specific about values and which fields they belong to: ```text theme={null} Fill this W-9 form with: - Name: Acme Corporation - Business name: Acme Corp LLC - Tax classification: LLC (check the checkbox) - Address: 123 Main Street, San Francisco, CA 94102 - TIN: 12-3456789 ``` **Writing effective instructions:** Be as specific as possible: * **Use the exact labels from the form.** "Applicant First Name" instead of "Name." * **Specify the section when available.** "Section 2: Personal Information → Phone Number." * **Number inputs for multi-row or repeatable fields.** "First asset:", "Second asset:" * **Include formatting hints.** If a field requires a specific format, state it explicitly. Examples: `Phone: (555) 123-4567`, `Date: MM/DD/YYYY` * **Be explicit about checkboxes.** Say "Check the 'US Citizen' checkbox" rather than "US Citizen: Yes"—the LLM needs to understand you mean check a box, not fill text. * **(Optional) Match the form's field order.** Present instructions in the same sequence as the form. ## Form Schema (recommended) The Results section shows an optional **Form Schema** panel. This is for repeatable form filling. When you run Edit without a form schema, it detects all fillable fields automatically and returns the detected form schema in the response. You can then import that form schema for subsequent runs—this skips detection, making fills faster and more consistent. **How to use it:** 1. Run Edit once without a form schema to detect the form schema fields 2. Form schema will appear in Configurations under Results 3. Edit, duplicate, or delete existing fields. Add missing fields. 4. Now, even when Edit instructions are updated, the form schema will be used in the pipeline moving forward This is useful when you're filling the same form template repeatedly with different data. ## Run and view results Click **Run** to execute the edit. Edit: 1. Detects form fields (or uses your form schema if provided) 2. Analyzes context around each field (labels, surrounding text) 3. Maps your instructions to fields 4. Fills values and returns the edited document The Results tab shows a before/after comparison: ![Before and after](https://mintcdn.com/reducto/Ya0_HQxb9L1990IV/edit-before-after.png?fit=max\&auto=format\&n=Ya0_HQxb9L1990IV\&q=85\&s=d7c11b8854884ebc6233bb061e9dfc5f) Before and after comparison showing the original form and filled version On the left, the original blank form. On the right, the filled result. Review each field for accuracy before deploying. The toolbar provides: * **Download** — Save the filled document * **Copy URL** — Get a link to the filled document ## Troubleshooting > [accordion] Why are some fields still empty? > > Several things can cause unfilled fields: > > * **Instructions didn't match**: The LLM couldn't map your instructions to that field. Use terms that appear on the form itself. > * **Dropdown mismatch**: The value must exactly match an available option ("CA" vs "California") > * **Detection missed it**: Use Form Schema to explicitly define field locations > > Try being more specific in your instructions, or provide a form schema for consistent results. > [accordion] Why is text getting cut off in PDF fields? > > PDF form fields have fixed sizes. When your content exceeds the field capacity, it gets truncated. > > **Solutions:** > > * Enable overflow pages in the API (`enable_overflow_pages: true`) to create appendix pages for long text > * Abbreviate values in your instructions if the form expects short values > [accordion] Why isn't the checkbox getting checked? > > Be explicit about checkbox actions. Instead of "US Citizen: Yes", say "Check the 'US Citizen' checkbox". The LLM needs to understand you mean check a box, not type the word "Yes" into a text field. > [accordion] Why is the wrong field getting filled? > > When fields have similar labels, the LLM may map incorrectly. > > **Solutions:** > > * Be more specific: "Applicant First Name: John" rather than "Name: John" > * Reference position: "The name field in the top-left of page 1" > * Use Form Schema to define exactly which field is which using coordinates > [accordion] No form fields were detected—what now? > > The PDF has no widgets and vision couldn't detect fillable areas. This happens with: > > * Scanned images without clear form structure > * Fields that blend into the background > * Documents that aren't actually fillable forms > > **Solution:** Provide a Form Schema defining field locations manually. See [Form Schema](/configs/edit/form-schema). *** ## Related > [card] [Edit API](/editing/edit-overview) > > API reference with all parameters and options. > [card] [Form Schema](/configs/edit/form-schema) > > Pre-define field locations for faster, more consistent filling. # Extract Pipeline Source: https://docs.reducto.ai/studio-extract Pull specific fields from documents as structured JSON in Reducto Studio Extract answers the question "What is the value of X?" You define the fields you need, and Extract returns those specific values as structured JSON. Unlike Parse, which returns all content, Extract returns only what you ask for. Under the hood, Extract runs [Parse](/parse/overview) first—handling OCR, layout detection, and table parsing—then uses an LLM to locate and extract the fields you specified. This means **Extract can only return what Parse sees**. > [!TIP] > > If a value doesn't appear in the Parse output, prioritize iterating on Parse configurations. ## Start with a document Let's walk through extracting data from this [Fidelity investment statement](https://studio.reducto.ai/share/md726aw3w7mfs46659ttkqry0s7se3pd). We want to pull out specific values—portfolio value, holdings, and account details—not the entire document content. Create an Extract pipeline in Studio and upload the document. The Configurations tab shows three panels: **Build System Prompt** for document-level context, **Schema Builder** for defining fields, and **Settings** for extraction options. ![Extract Pipeline Example](https://mintcdn.com/reducto/HIMlsEXojApe7G_x/images/extract_pipeline_example.png?fit=max\&auto=format\&n=HIMlsEXojApe7G_x\&q=85\&s=7165e0414ae66b09a5ea7e01b26e2778) Extract pipeline interface showing document preview and configuration panels ## Define your schema The Schema Builder is where you specify what to extract. You have two options: ### Option 1: Generate with natural language Click **Generate** to describe what you need in plain English. For example: ```text theme={null} Extract the total portfolio value and a list of all holdings with their names, quantities, and current values. ``` Studio generates a schema from your description. Review the fields, adjust types or descriptions as needed, then run. ### Option 2: Build manually Click **Add Field** to define each field yourself. Each field needs: * **Name** — The key in your JSON output (e.g., `portfolioValue`) * **Type** — The data type (text, number, date, array, object, etc.) * **Description** — Instructions for the LLM on where to find this value ![Schema Builder](https://mintcdn.com/reducto/HIMlsEXojApe7G_x/images/schema_builder.png?fit=max\&auto=format\&n=HIMlsEXojApe7G_x\&q=85\&s=331591de0c93f318076ccd8249370f39) Schema Builder with portfolioValue and holdings array defined In this example, we define: * `portfolioValue` (text) — "Total portfolio value" * `holdings` (array) — "list of all my holdings" The `holdings` array expands to show nested fields. Click on an array or object field to add nested structure—each holding could have `name`, `quantity`, `currentValue`, etc. ### Field types | Type | Use for | Example | | - | - | - | | `text` | Names, IDs, addresses | Account number, customer name | | `number` | Amounts, quantities | Total due, item count | | `boolean` | Yes/no values | Is paid, has signature | | `date` | Dates in any format | Invoice date, due date | | `enum` | Fixed set of options | Status (pending/approved/rejected) | | `array` | Lists of items | Holdings, transactions, line items | | `object` | Grouped fields | Address (street, city, zip) | ### Writing good descriptions Field descriptions directly influence extraction accuracy. The LLM uses them to locate values. **Specific descriptions work better:** * ✓ "Total portfolio value in USD, displayed prominently at the top right of page 1" * ✓ "Account holder's full name as shown in the mailing address" * ✗ "The total" * ✗ "Name" Include location hints when the document has multiple similar values. See [Schema Best Practices](/extraction/best-practices-extract) for more guidance. ### Build System Prompt Use the Build System Prompt panel to provide document-level context: ```text theme={null} This is a Fidelity investment statement. Extract values from the portfolio summary on page 1. Amounts are in USD. Account numbers follow the format XXX-XXXXXX. ``` Field-specific guidance belongs in schema descriptions. The system prompt sets overall context that applies to all fields. ## Settings The Settings section (below Schema Builder) controls extraction behavior. Configure these before running: ![Extract Settings](https://mintcdn.com/reducto/HIMlsEXojApe7G_x/images/extract_settings.png?fit=max\&auto=format\&n=HIMlsEXojApe7G_x\&q=85\&s=bfacb0b64e58ea7fa5e4a85ea85e6de9) **Array extraction** — Enable for documents with long lists (hundreds of transactions, extensive line items). Without this, items toward the end may be missed due to LLM context limits. See [Array Extraction](/configs/extract/array-extraction) for details. **Citations** — Returns page number, bounding box, and source text for each value. Useful for verification or showing users where values came from. See [Citations](/configs/extract/citations) for working with this data. **Include images** — Sends page images to the LLM alongside text. Helps with visually complex documents but increases cost. ## Run and view results Click **Run** to execute the extraction. The Results tab shows your extracted data: Each field from your schema appears with its extracted value. For arrays like `holdings`, you see each item expanded with its nested fields. The toolbar provides: * **Copy** — Copy JSON to clipboard * **Download** — Save as file * **JSON** — Toggle raw API response view ## Troubleshooting > [accordion] Why is a field returning null or incorrect values? > > First, check if the value appears in the Parse output. Create a Parse pipeline with the same document—if the value isn't there, Extract can't find it either. Fix the parsing first (enable agentic mode, adjust OCR settings, or change table format to HTML). > > If the value is in Parse output, refine your schema. Make the description more specific, add location hints like "found in the header on page 1", or try a different field name. > [accordion] Why are items missing from my array? > > For long documents with many items (hundreds of transactions, extensive line items), enable [Deep Extract](https://docs.reducto.ai/configs/extract/deep-extract). It helps with extremely long and complex extractions. > [accordion] Why do results vary between runs? > > LLM outputs are non-deterministic. Small variations are normal. If you need consistent results, use ground truth comparison to track accuracy over time and catch regressions. > [accordion] Why is extraction slow? > > Extract runs Parse first, then the extraction LLM. Large documents take longer. If you enabled "Include images", that adds processing time and cost. Disable it unless you need visual context for extraction. *** ## Related > [card] [Extract API](/extract/overview) > > API reference with all parameters. > [card] [Schema Best Practices](/extraction/best-practices-extract) > > Detailed guidance on schema design. > [card] [Array Extraction](/configs/extract/array-extraction) > > Handle long documents with repeating data. > [card] [Citations](/configs/extract/citations) > > Trace values back to source locations. # Parse Pipeline Source: https://docs.reducto.ai/studio-parse Turn documents into structured data in Studio Parse converts documents into structured data. It identifies text blocks, tables, figures, headers, and key-value pairs, returning everything as markdown with coordinates for each element. The output is designed for downstream processing, whether that's feeding an LLM, building a search index, or piping into Extract for structured data extraction. ![Example Parse Pipeline](https://mintcdn.com/reducto/HIMlsEXojApe7G_x/images/example_parse_pipeline.png?fit=max\&auto=format\&n=HIMlsEXojApe7G_x\&q=85\&s=e3ded297a40fd48b5e483f5e6c46a0c3) Parse pipeline in Studio showing a financial statement with detected regions ## What Parse extracts Parse breaks your document into **chunks**, each representing a semantic unit of content: * **Text blocks**: Paragraphs and body text, preserving reading order across columns * **Tables**: Structured data with rows and columns, output as markdown, HTML, or JSON * **Figures**: Images, charts, and diagrams with optional AI-generated descriptions * **Headers**: Section titles with hierarchy levels for document structure * **Key-value pairs**: Form-like content where a label maps to a value * **Footers**: Page numbers, disclaimers, and repeated bottom-of-page content Each chunk includes bounding box coordinates linking it back to its source location. In Studio, these appear as colored overlays on your document: ![Bounding box colors](https://mintcdn.com/reducto/Ya0_HQxb9L1990IV/parsed-doc-colors.png?fit=max\&auto=format\&n=Ya0_HQxb9L1990IV\&q=85\&s=734f6f1eb8eb55912df3b80d5e3bbb0c) Bounding boxes showing detected content types Click any box to jump to its output, or click output text to highlight its source. ## When to adjust configurations The default configuration handles most documents well. The **Configurations** tab contains the whole list of available configurations, split into 5 buckets: **Enhance**: r-1 handles general parsing accuracy natively. Enable agentic processing when a workflow needs custom prompts or specialized augmentation. It adds latency and, on legacy Parse, cost. **Retrieval**: Configure chunking for RAG pipelines. The default may produce segments too large or small for your embedding model. Set chunking mode to `variable` with a target size around 500-1000 characters. **Formatting**: Control output structure. Switch table format to `html` or `json` for programmatic use. Formatting also includes configurations for detecting attributes like change tracking, hyperlinks, highlights, and more. **Spreadsheet**: Handle Excel and CSV files. Control inclusions of cell colors, formulas, dropdowns, and hidden items in your outputs, as well as table clustering detection. **Settings**: Core processing controls such as specifying page ranges, returning images, and forcing a URL result. See [Parse Configurations](/configs/overview) for the complete reference. ## Working with results The Results tab shows parsed output as formatted markdown by default. The toolbar offers several options: * **Copy**: Copy the output to your clipboard * **Download**: Save results as a file * **JSON**: Toggle to see the raw API response structure The JSON view is useful for understanding exactly what your code will receive. Each chunk includes: ```json theme={null} { "type": "text", "content": "Your extracted text here...", "bbox": { "left": 0.05, "top": 0.12, "width": 0.4, "height": 0.03, "page": 0 } } ``` Bounding boxes use normalized coordinates (0-1 range relative to page dimensions), making them consistent across different document sizes. ## Processing multiple files Studio supports batch processing. Add multiple files using the **Add file** button in the file carousel, then check **All Files** before clicking Run to process the entire batch with your current configuration. This is helpful for testing configurations across a representative sample before deploying. If results vary significantly across documents, you may need to adjust settings or consider whether a single pipeline can handle your document variety. ## From Parse to Extract Parse alone gives you the document's content and structure. If you need specific fields, such as invoice totals, contract dates, or patient names, add an **Extract** step. Click **Add** in the pipeline header to chain Parse → Extract, creating a multi-step pipeline you can deploy with a single Pipeline ID. See [Extract Pipeline](/studio-extract) for schema configuration. *** ## Related > [card] [Parse API](/parse/overview) > > API reference and response schema. > [card] [Parse Configurations](/configs/overview) > > All configuration options with examples. # Studio Quickstart Source: https://docs.reducto.ai/studio-quickstart Build and deploy your first document workflow in Reducto Studio. [Studio](https://studio.reducto.ai/) is Reducto's visual interface. Build document workflows by configuring classification, parsing, extraction, and editing steps, test on real documents with the citation viewer, then deploy as a Pipeline ID callable from code. Studio also manages API keys, account settings, and team access. > [!NOTE] > > If you just want an API Key, visit your [account's API Keys page](https://studio.reducto.ai/api-keys) to create and manage your keys. Follow our [API quickstart here](https://docs.reducto.ai/quickstart). > [!NOTE] > > [Classify](/classify/overview) is available via the [API](/api-reference/classify) but is not yet integrated into Studio. You can try it interactively at [classify.reducto.ai](https://classify.reducto.ai). This guide walks you through your first structured extraction from a document of your choice. *** ## 1. Visit Studio Sign-up or log-in at [studio.reducto.ai](https://studio.reducto.ai/). If you're the first to sign-up from your organization, you'll be asked to create and set a name for the organization. You should then land on the Studio home page. New organizations see two starting points: **Build with Reducto**, which creates an API key for code or agent workflows, and **Start with a document**, which uploads a file or picks a sample. You can also click **Create pipeline** to configure one yourself. ![Studio Empty State](https://mintcdn.com/reducto/R99njFX82lRP6p3f/images/studio_empty_state.png?fit=max\&auto=format\&n=R99njFX82lRP6p3f\&q=85\&s=859751177559784be76cb5132ddb58ce) ## 2. Create an Extract Pipeline The goal here is to upload a document and see the structured output Reducto produces. Extract reads your document, and extracts the data you want in a schema shape you define. **1. Create a new pipeline** Click **Create pipeline**, and select **Extract**. ![Create Pipeline](https://mintcdn.com/reducto/R99njFX82lRP6p3f/images/create_pipeline.png?fit=max\&auto=format\&n=R99njFX82lRP6p3f\&q=85\&s=75beb5f5b346f5c4eb8c9de36c37da67) **2. Upload a document** Drag and drop a file or upload directly. ![Upload File](https://mintcdn.com/reducto/6gaOh-RCUb9JOUR4/images/upload_file.png?fit=max\&auto=format\&n=6gaOh-RCUb9JOUR4\&q=85\&s=7676e4c782690eaff636b2f63b1004f8) **3. Generate a JSON schema** Hit **Generate** and then choose between **Fast** and **Enhanced** mode. You can use natural language to describe what type of data you want to extract from your document. Reducto will automatically create a well formed schema for you. Alternatively, you can manually configure your schema by building one in the schema builder directly. ![Generate Schema](https://mintcdn.com/reducto/6gaOh-RCUb9JOUR4/images/generate_schema.png?fit=max\&auto=format\&n=6gaOh-RCUb9JOUR4\&q=85\&s=c7d2880f2b2eedffbc9bb9e98d61c406) **4. Run the pipeline** Hit **Run**. Once the pipeline is done, the **Results** tab will contain the Extract results. ![Extract Results](https://mintcdn.com/reducto/6gaOh-RCUb9JOUR4/images/extract_results.png?fit=max\&auto=format\&n=6gaOh-RCUb9JOUR4\&q=85\&s=30693f803b03a1e9fc153ddefc17d7f3) Review results and compare them to your original document. If you enable [citations](https://docs.reducto.ai/configs/extract/citations), bounding boxes will appear on the original document, linking them to where the data was extracted from. While most customers find value from our default configurations, you may need to adjust yours depending on your documents, schema, and goals. See details [here](https://docs.reducto.ai/configs/overview) on our endpoint configurations. ## 3. Create an API Key Once you're ready to move into production, go back to the homepage and hit **API Keys**. Here you'll be able to create, manage, and edit your account's API keys. ![Api Keys](https://mintcdn.com/reducto/R99njFX82lRP6p3f/images/api_keys.png?fit=max\&auto=format\&n=R99njFX82lRP6p3f\&q=85\&s=86512aebdf3d355d2cb1a2f7c63c3cdd) Once you have an API key, deploying your new pipeline into production is easy. In any pipeline, hit **Deploy** to get a small code snippet, easily copyable into your workflow. *Congrats, you've just created your first pipeline! 🎉* *** ## Next steps > [card] [API Quickstart](/quickstart) > > Learn how to use Reducto's SDKs to call your pipelines in production. > [card] [Extract Pipeline](/studio-extract) > > Define JSON schemas to pull structured data from documents, with automatic field detection and confidence scoring. > [card] [Reducto CLI Quickstart](/cli) > > Learn how to use Reducto's CLI to easily let agents use our endpoints. > [card] [Deploy to Production](/studio-deploy-pipeline) > > Learn how to Deploy your pipeline with a Pipeline ID. # Roles & Permissions Source: https://docs.reducto.ai/studio-roles-permissions Organization roles in Reducto Studio and what each role can do Reducto Studio uses three roles to control what members of your organization can do. Roles are assigned per-organization, so a user can have different roles in different orgs. ## Roles | Role | Description | | - | - | | **Owner** | Full control over the organization, including destructive actions like deleting the org. | | **Admin** | Day-to-day management of members, API keys, and billing. Cannot delete the organization. | | **Member** | Can use Studio and the API but cannot manage org settings. | Roles are hierarchical: Owners can do everything Admins can, and Admins can do everything Members can. *** ## API Keys API key management has two distinct permission levels: | Action | Owner | Admin | Member | | - | :-: | :-: | :-: | | **Create** API keys | Yes | Yes | Yes | | **View** API keys | Yes | Yes | Yes | | **Edit** (rename) API keys | Yes | Yes | No | | **Delete** API keys | Yes | Yes | No | > [!NOTE] > > API keys are scoped to the organization, not to the individual who created them. Any valid key authenticates requests on behalf of the entire org. *** ## Full Permissions Matrix | Capability | Owner | Admin | Member | | - | :-: | :-: | :-: | | Use Studio (pipelines, processors, files) | Yes | Yes | Yes | | Call the API with an org key | Yes | Yes | Yes | | View usage and request logs | Yes | Yes | Yes | | Create API keys | Yes | Yes | Yes | | Edit/delete API keys | Yes | Yes | No | | Manage billing and payment methods | Yes | Yes | No | | Invite members | Yes | Yes | No | | Remove members | Yes | Yes | No | | Change member roles | Yes | Yes | No | | Delete the organization | Yes | No | No | *** ## Managing Roles To change a member's role: 1. Navigate to **Settings** in the sidebar footer 2. Find the member in the users list 3. Use the role dropdown to select a new role You can only change roles for members at or below your own privilege level. For example, an Admin cannot change another Admin's role to Member, but an Owner can. *** ## Related > [card] [Account & Settings](/studio-account) > > API key management, usage, and billing overview. > [card] [Security Policies](/security/policies) > > Data handling, retention, and compliance. # Split Pipeline Source: https://docs.reducto.ai/studio-split Divide documents into sections for targeted workflows in Studio Split divides a document into named sections based on descriptions you provide. It runs Parse under the hood first, then uses the parsed content to identify which pages belong to each section based on your definitions. Studio then lets you chain Extract steps that target specific sections—so you can apply different schemas to different parts of the same document. ## How Split works in Studio When you add a Split step to a pipeline, Studio shows two configuration areas: 1. **Instructions** — Rules for how pages should be classified (the default allows overlap only at section boundaries) 2. **Sections & Partitions** — The sections you want to identify, each with a Title and Description ![Split Pipeline Example](https://mintcdn.com/reducto/HIMlsEXojApe7G_x/images/split_pipeline_example.png?fit=max\&auto=format\&n=HIMlsEXojApe7G_x\&q=85\&s=8158d3b32744b6a2e29fd47a0d11a29d) Split pipeline After running Split, Studio automatically enables an Extract step with a **section dropdown**. This dropdown shows all the sections you defined, letting you choose which section to extract from. You can add multiple Extract steps, each targeting a different section with its own schema. ![Extract Dropdown](https://mintcdn.com/reducto/HIMlsEXojApe7G_x/images/Extract_dropdown.png?fit=max\&auto=format\&n=HIMlsEXojApe7G_x\&q=85\&s=756c9eb83ac0014fc9d77655469a41a5) Extract step with section selector *** ## Partitions For sections that repeat with different identifiers (like multiple accounts in one statement), use partitions. Click **ADD PARTITION** under any section to specify a partition key. For example, if a statement contains holdings for accounts A, B, and C: * **Section**: "Account Holdings" * **Partition key**: "account number" Split returns separate results for each account, and the Extract dropdown shows each partition as a separate option. *** ## Instructions The Instructions text area controls how Split handles page classification: ```text theme={null} Split the document into the applicable sections. Sections may only overlap at their first and last page if at all. ``` This default means a page can belong to multiple sections only at boundaries. Customize for your use case: | Goal | Instructions | | - | - | | Allow full overlap | "Pages can belong to multiple sections if they contain content matching multiple descriptions." | | Force exclusive assignment | "Each page must belong to exactly one section. Assign to the most specific matching section." | *** ## Troubleshooting > [accordion] Why is a section returning no pages? > > Check your description. If you said "Account Summary" but the document header says "Portfolio Overview", Split won't match. Use terms that appear in the actual document. > > Try broadening the description first, then narrow down once you confirm Split can find the section. > [accordion] Why are pages showing up in the wrong section? > > Add more distinguishing details to your descriptions. If pages look similar, mention specific text, headers, or visual elements: "The page with the pie chart showing asset allocation" is more specific than "holdings breakdown". > [accordion] Why don't partitions appear in results? > > The partition key must appear as identifiable text in the document. If you're partitioning by "account number" but the document doesn't have visible account numbers (only account names), use "account name" as the partition key instead. > [accordion] Can I have multiple Extract steps for different sections? > > Yes. After Split, you can add multiple Extract steps. Each Extract step has its own section dropdown, schema, and settings. Use the **Add** button in the pipeline header to add additional steps. *** ## Related > [card] [Split API Reference](/split) > > Complete API documentation for programmatic Split calls. > [card] [Extract Pipeline](/studio-extract) > > Define extraction schemas for your Split sections. > [card] [Parse Pipeline](/studio-parse) > > Split runs Parse internally—understand what it sees. > [card] [Deploy Pipelines](/studio-quickstart#4-deploy-for-api-access) > > Deploy your Split + Extract pipeline for API access.