# Invoice Data Extraction Python SDK

Official Python SDK for [Invoice Data Extraction](https://invoicedataextraction.com). Uploads your files, submits the extraction, waits for it to finish and hands you the rows as data, or a spreadsheet, in a few lines of code.

- Python 3.9 or later

## Install

```bash
pip install invoicedataextraction-sdk
```

## Quick Start

```python
import json
import os
import sys

from invoicedataextraction import InvoiceDataExtraction
from invoicedataextraction.errors import SdkError, ApiResponseError

try:
    client = InvoiceDataExtraction(
        api_key=os.environ.get("INVOICE_DATA_EXTRACTION_API_KEY"),
    )

    result = client.extract(
        folder_path="./invoices",
        prompt="Extract invoice number, date, vendor name, and total amount",
        output_structure="per_invoice",
        json_typed_values=True,
        console_output=True,  # remove to disable console logging
    )

    if result["status"] == "completed":
        for row in client.iterate_results(extraction_id=result["extraction_id"]):
            print(row)  # one dict per extracted row, keyed by your output columns
except (SdkError, ApiResponseError) as error:
    print(json.dumps(error.body, indent=2), file=sys.stderr)
    raise SystemExit(1)
```

`extract(...)` uploads every supported file in the folder (pass a `folder_path` or a list of `files`), submits the extraction, waits until it finishes and returns the result: the final status response from the API, for a completed, failed or cancelled extraction. `iterate_results(...)` then reads the extracted rows straight from the API, one dict per row, with amounts as numbers and empty cells as `None` because the extraction was submitted with `json_typed_values`. Check `result["pages"]["failed_count"]` to verify that all uploaded pages were processed (when `console_output` is enabled, failed pages are logged automatically), and `result["review_needed"]["count"]` for rows that need a human's check before you rely on the data. To get a spreadsheet, add `download={"formats": ["xlsx"], "output_path": "./output"}` and the file is saved when the extraction completes.

Generate an API key from your [dashboard](https://invoicedataextraction.com/dashboard?view=API). Every account includes 50 free pages per month. Additional credits can be purchased on a pay-as-you-go basis with no subscription needed.

## Constructor

```python
import os
from invoicedataextraction import InvoiceDataExtraction

client = InvoiceDataExtraction(
    api_key=os.environ.get("INVOICE_DATA_EXTRACTION_API_KEY"),
)
```

| Parameter | Required | Description |
|-----------|----------|-------------|
| `api_key` | Yes | Your API key. |
| `base_url` | No | API base URL. Defaults to `https://api.invoicedataextraction.com/v1`. Only needed for testing or non-production environments. |

## `extract(...)`

Run a complete extraction in a single call. Pass a folder path or a list of file paths and tell the SDK what to extract; it uploads the files, submits the extraction and waits for it to finish, then returns the result: credits deducted and your remaining balance, successful and failed pages, Review Needed warnings, and prompt notes in `ai_uncertainty_notes`. Read the extracted rows with `get_results(...)` or `iterate_results(...)` afterwards, or pass `download` to save the output files to disk as Excel, CSV or JSON.

Underlying API workflow: upload session → submit extraction → wait for the result, then download output when `download` is set. See [File limits](#file-limits) for size and count constraints.

```python
result = client.extract(
    folder_path="./invoices",
    prompt="Extract invoice number, date, vendor name, and total amount",
    output_structure="per_invoice",
    json_typed_values=True,
    console_output=True,  # remove to disable console logging
)
```

### Parameters

| Parameter | Required | Description |
|-----------|----------|-------------|
| `folder_path` | One of `folder_path` or `files` | Path to a local folder. The SDK uploads every supported file in the folder (`.pdf`, `.jpg`, `.jpeg`, `.png`). Not recursive. |
| `files` | One of `folder_path` or `files` | List of local file paths to upload. Supported types: `.pdf`, `.jpg`, `.jpeg`, `.png`. |
| `prompt` | Yes | Extraction instructions. String or dict; see [Prompt](#prompt) below. |
| `output_structure` | Yes | Controls how the extracted data is structured; see [Output structure](#output-structure) below. |
| `task_name` | No | Your label for this extraction (3–40 characters). Appears in the [web dashboard](https://invoicedataextraction.com/dashboard). If omitted, the SDK generates one as `extraction_YYYYMMDD_HHMMSS`. |
| `exclude_columns` | No | List of system-generated columns to exclude from output. By default, output files include a "Source File" column indicating which uploaded file/page each row was extracted from, and a "Review Needed" column marking rows that need human verification. If your workflow requires an exact output structure, you can exclude either column. Valid values: `"source_file"`, `"review_needed"`. Excluding `"review_needed"` removes only the export column; Review Needed warnings can still be generated and returned in the completed response. |
| `output_language` | No | The language of the text the AI writes for you: `"automatic"` or a language code; see [Output options](#output-options) below. Defaults to your account preference. |
| `review_needed_fill_color` | No | Highlight color for Review Needed cells in the XLSX file; see [Output options](#output-options). Defaults to your account preference. |
| `affected_field_fill_color` | No | Highlight color for the XLSX cells a Review Needed warning refers to; see [Output options](#output-options). Defaults to your account preference. |
| `send_completion_email` | No | Boolean. `True` to be emailed when this extraction finishes; see [Output options](#output-options). Off by default. |
| `json_typed_values` | No | Boolean. `True` to receive native JSON types (numbers, booleans, `None`) instead of strings in the JSON output and in the rows `get_results(...)` returns; recommended for a new integration that reads the rows. See [Output options](#output-options). Off by default. |
| `ask_questions` | No | Boolean. `True` to let the extraction stop and ask when the documents leave something unsettled; see [Questions](#questions) below. Off by default. |
| `on_questions` | No | Callable called as `on_questions(questions, status)` when the extraction stops to ask, with the questions and the `input_required` status they came in; returns the answers and the call carries on. See [Questions](#questions). |
| `download` | No | Download options; see [Download](#download) below. If omitted, no files are downloaded. |
| `polling` | No | Polling options; see [Polling](#polling) below. |
| `console_output` | No | Boolean. When `True`, the SDK logs progress to the console during upload, polling, and download. Off by default. |
| `on_update` | No | Callable for lifecycle updates; see [on_update](#on_update) below. |

### Output structure

Controls how the extracted data is structured:

| Value | Meaning |
|-------|---------|
| `automatic` | The AI decides based on your prompt and documents. |
| `per_invoice` | Each invoice becomes a single row (spreadsheet/CSV) or object (JSON). |
| `per_line_item` | Each individual product/service listed within an invoice becomes its own row (spreadsheet/CSV) or object (JSON). |

### Prompt

The `prompt` tells the AI what data to extract. It can be a string or a dict.

**String.** Describe what you want in natural language (max 2,500 characters):

```python
prompt="Extract invoice number, date, vendor name, and total amount"
```

With a string, the AI chooses output field names based on your instructions.

**Dict.** Use a dict when you need exact output field names. Each `name` is guaranteed to appear exactly as written in the extracted data. You can also add optional per-field and general instructions:

```python
prompt={
    "fields": [
        {"name": "Invoice Number"},
        {"name": "Invoice Date", "prompt": "The date the invoice was issued, NOT the due date"},
        {"name": "Vendor Name"},
        {"name": "Total Amount", "prompt": "No currency symbol, 2 decimal places"},
    ],
    "general_prompt": "Extract one record per invoice or credit note. Ignore email cover letters. Dates should be in YYYY-MM-DD format.",
}
```

Each item in `fields`:

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `name` | string | Yes | The name for this data point in the output (2–50 characters). Prefer clear, descriptive names (e.g., "Invoice Number", not "Field A"). |
| `prompt` | string | No | Specific instructions for extracting this data point (3–600 characters). Use this to clarify ambiguities or instruct special handling. |

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `general_prompt` | string | No | Instructions that apply to the full task and across all fields (max 1,500 characters). Use this to provide special handling instructions, specify output formatting, or describe the extraction goal. |

`fields` must be a non-empty list.

For guidance on writing effective prompts, see the [Extraction Guide](https://invoicedataextraction.com/extraction-guide).

### Output options

Five optional keyword arguments shape what the extraction produces. Each applies to this extraction only and leaves your account preferences unchanged; leave one out and the account preference (or the default) applies. All are accepted by `extract(...)` and `submit_extraction(...)`, and `get_extraction(...)` returns the values that applied under `extraction["options"]`.

| Parameter | Values | Default | Description |
|-----------|--------|---------|-------------|
| `output_language` | `"automatic"` or a code below | Account preference (automatic) | The language of the text the AI writes for you: Review Needed messages and prompt notes. With `"automatic"`, the language follows your prompt (your instructions and field names), not the language of your documents, and is English when that is unclear. Extracted values, file names and your field names are never translated. |
| `review_needed_fill_color` | `"none"`, `"yellow"`, `"orange"`, `"red"` | Account preference (orange) | Highlight color for cells in the Review Needed column that carry a warning. XLSX files only; has no effect when the Review Needed column is excluded. |
| `affected_field_fill_color` | `"none"`, `"yellow"`, `"orange"`, `"red"` | Account preference (orange) | Highlight color for the extracted data cells a Review Needed warning refers to. XLSX files only; has no effect when the Review Needed column is excluded. |
| `send_completion_email` | `True` / `False` | `False` | Email your account's address when this extraction finishes processing, whether it completes, fails or is cancelled. A submission refused before processing starts (for example for insufficient credits or an encrypted file) sends no email; its failure appears in the returned result. The web app's "Email me when extraction tasks finish" preference does not apply to API submissions. |
| `json_typed_values` | `True` / `False` | `False` | Receive the JSON output with native JSON types instead of strings: amounts, quantities and rates as numbers, yes/no fields as booleans, and a cell with nothing in it as `None`. Applies to the JSON output file and to the rows `get_results(...)` returns; XLSX and CSV are unchanged. Fixed at submission. See [JSON value types](#json-value-types). |

Language codes for `output_language`:

| Code | Language |
|------|----------|
| `en` | English |
| `ar` | Arabic |
| `zh-Hant` | Traditional Chinese |
| `zh-Hans` | Simplified Chinese |
| `nl` | Dutch |
| `fr` | French |
| `de` | German |
| `el` | Greek |
| `he` | Hebrew |
| `it` | Italian |
| `ja` | Japanese |
| `pl` | Polish |
| `pt` | Portuguese |
| `es` | Spanish |
| `th` | Thai |

The SDK checks these values before sending the request; a value outside the sets above is an `INVALID_INPUT` error whose `details["issues"][0]["path"]` names the parameter.

### Questions

With `ask_questions=True`, the extraction can stop to ask when the documents leave something unsettled, instead of deciding on its own: which of two names is the supplier, what date format the columns should use. Turn it on when you, or a person watching the dashboard, can answer within a few minutes, and leave it off for a job nobody is watching; the API reference's [Input required](https://invoicedataextraction.com/api#input-required) section says what happens to an unanswered question. Pass `on_questions` and the SDK calls it with the questions, sends back the answers it returns, and carries on to the result:

```python
def answer(questions, status):
    return [
        {"question_id": question["question_id"], "accept_recommended": True}
        # or {"question_id": ..., "choice_id": "b"}, or {"question_id": ..., "text": "DD/MM/YYYY"}
        for question in questions
    ]

result = client.extract(
    folder_path="./invoices",
    prompt="Extract invoice number, date, vendor name, and total amount",
    output_structure="per_invoice",
    json_typed_values=True,
    ask_questions=True,
    on_questions=answer,
)
```

Each question is a dict carrying `question_id`, `type` (`single_choice` or `free_text`), the `question` in plain words, an `example_from_documents` when one is visible, a `scope` (its `level` is `extraction`, and `applies_to` says in words what the answer governs), the `choices` of a `single_choice` question (each with a `choice_id`, a `label`, `cell_would_contain` when known, and `recommended: True` on one of them), and on a `free_text` question the `recommended_approach`. An answer names the `question_id` and gives one of: `choice_id` (with `text` beside it to add detail), `text` alone, or `accept_recommended: True`. An answer governs the whole extraction, every document in it and not only the example; where the right answer differs by document type, say so in `text`. The full shape, what happens when nobody answers, and the deadline are in the API reference under [Input required](https://invoicedataextraction.com/api#input-required).

`on_questions` is called with `(questions, status)`, the questions and the `input_required` status they came in, and may be called more than once in one `extract(...)` call: once for each batch of questions the extraction asks, and again with what still waits if it answered only some of them. If it raises, or if the answers it returned are refused (see [`answer_questions(...)`](#answer_questions)), `extract(...)` raises with nothing posted and the extraction keeps waiting: find it in the [web dashboard](https://invoicedataextraction.com/dashboard) or with `list_extractions(status="input_required")`, and answer with `answer_questions(...)`. With `polling["timeout_ms"]` set, the timeout bounds each wait, not the whole call: the time spent in `on_questions` is not counted, and a new wait starts after the answers are posted.

Without `on_questions`, `extract(...)` returns the `input_required` status as it is, with `questions` and `answer_by`; answer with `answer_questions(...)` and wait again with `wait_for_extraction_to_finish(...)`. The questions also appear in the [web dashboard](https://invoicedataextraction.com/dashboard), where a person can answer them; if the questions are not all answered within about four minutes the extraction pauses and the account owner is emailed, and if the questions are not all answered by `answer_by` the extraction is cancelled with `cancellation_reason: "unanswered"`.

### Download

When `download` is provided, the SDK saves the output files to disk after a successful extraction: the way to get a spreadsheet.

```python
download={
    "formats": ["xlsx", "csv", "json"],
    "output_path": "./output",
}
```

| Field | Required | Description |
|-------|----------|-------------|
| `formats` | Yes | List of output formats to download. One or more of `"xlsx"`, `"csv"`, `"json"`. |
| `output_path` | Yes | Destination folder for downloaded files. Created automatically if it doesn't exist. |

Downloaded files are named `{task_name}_{timestamp}.{format}`.

Auto-download is a best-effort convenience. If the extraction completed but a download fails, the SDK surfaces a warning through `console_output` / `on_update` and still returns the completed extraction response. You can retry the download later using `download_output(...)`.

Auto-download does not overwrite existing files. If a generated file path already exists, the SDK skips that file and surfaces a warning.

### Returns

`extract(...)` returns the terminal polling response from the API unchanged, for completed, failed, and cancelled extractions. An extraction submitted with `ask_questions` and no `on_questions` handler returns the `input_required` response instead when it stops to ask; see [Questions](#questions).

**Verifying results:** When `extract(...)` returns a completed extraction, check `result["pages"]["failed_count"]`. If it's `0`, every uploaded page was processed successfully and is included in the output. If it's greater than `0`, inspect `result["pages"]["failed"]` and `result["pages"]["failure_reasons"]` to see which specific files/pages failed and why; those pages are not included in the output. This is the primary check to confirm that everything you submitted was extracted without issue.

**Completed:**

```json
{
  "success": true,
  "status": "completed",
  "extraction_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "credits_deducted": 25,
  "credits_balance": 125,
  "credits_reserved": 0,
  "output_structure": "per_invoice",
  "output_expires_at": "2026-07-14T10:30:00Z",
  "pages": {
    "successful_count": 10,
    "failed_count": 2,
    "successful": [
      { "file_name": "invoice-1.pdf", "page": 1 }
    ],
    "failed": [
      { "file_name": "damaged.pdf", "page": 1 }
    ],
    "failure_reasons": [
      {
        "code": "PROCESSING_FILE_SIZE_LIMIT_EXCEEDED",
        "message": "The upload was accepted, but during processing part of the PDF became too large for our file-processing limit. This can happen when a compressed PDF is processed internally. Split the PDF into smaller page chunks and resubmit.",
        "affected_pages": [
          { "file_name": "damaged.pdf", "pages": [1] }
        ]
      }
    ]
  },
  "ai_uncertainty_notes": [],
  "review_needed": {
    "count": 1,
    "items": [
      {
        "message": "Check whether the extracted total should include the handwritten adjustment near the bottom of the document.",
        "affected_fields": ["Total Amount"],
        "output_row_numbers": [4],
        "source_references": ["invoice-1.pdf (Page 2)"]
      }
    ]
  },
  "output": {
    "xlsx_url": "https://...",
    "csv_url": "https://...",
    "json_url": "https://..."
  }
}
```

| Field | Description |
|-------|-------------|
| `credits_deducted` | Credits charged for this extraction (one credit per successful page). |
| `credits_balance` | Your total credit balance after this extraction was charged (paid plus free credits), the same figure `get_credits_balance()` returns. Use it to warn before the balance runs out. |
| `credits_reserved` | Credits held by your extractions still being processed. Your usable balance is `credits_balance` minus `credits_reserved`. |
| `output_structure` | The output structure used: `"per_invoice"` or `"per_line_item"`. If you submitted `"automatic"`, this tells you what the AI chose. |
| `output_expires_at` | ISO 8601 timestamp marking when the generated output files will be deleted under the 90-day retention policy. After this time, `output.*_url` fields are `None` and `download_output(...)` raises `OUTPUT_EXPIRED`. See [Output expiry](#output-expiry). |
| `pages.successful_count` | Number of pages successfully processed. |
| `pages.failed_count` | Number of pages that failed processing. |
| `pages.successful` | List of successfully processed pages. Each item has `file_name` (the uploaded file name) and `page` (the page number within that file). |
| `pages.failed` | List of pages that failed processing. Same shape as `successful`. |
| `pages.failure_reasons` | Page-failure reason metadata when available. Empty list if none. Each item has `code`, user-facing `message`, and `affected_pages` grouped by uploaded `file_name` with source-file page numbers. The current public `code` value is `"PROCESSING_FILE_SIZE_LIMIT_EXCEEDED"`. |
| `ai_uncertainty_notes` | Prompt notes: areas where your prompt left room for interpretation and the AI made an assumption about how to apply it to the documents. Empty list if none. Each note has a `topic`, a `description` of what was assumed, and a `suggested_prompt_additions` list of prompt additions you can use to remove the ambiguity in future extractions. Each suggestion has a `purpose` (why you'd add it) and `instructions` (prompt text you can add). |
| `review_needed` | Result-level warnings for records that need human verification before you rely on the output. Always present on completed responses as `{"count": ..., "items": [...]}`. Check `result["review_needed"]["count"]`; if greater than `0`, route the listed rows for manual verification. Each item has `message`, `affected_fields`, `output_row_numbers`, and `source_references`. `affected_fields` is populated only for field-specific concerns. `output_row_numbers` contains one or more 1-based extracted data row numbers and does not include the Excel/CSV header row. |
| `output` | Presigned download URLs for each format (`xlsx_url`, `csv_url`, `json_url`). `None` if not available, including when the output has aged past `output_expires_at`. URLs expire after 5 minutes; use `download_output(...)` or `get_download_url(...)` for a fresh URL while output is still retained. |

To work with the extracted rows without downloading a file, call `get_results(...)` for one page or `iterate_results(...)` for every row.

File uploads are all-or-nothing: if `extract(...)` returns without raising, every file was uploaded successfully. The only failures to check for are in `pages.failed` and `pages.failure_reasons`, which describe pages that failed during extraction processing. If `pages.failed_count` is `0`, all uploaded files and pages were processed successfully.

We strongly recommend checking `result["review_needed"]["count"]` before relying on extracted data. If it is greater than `0`, route the listed rows for manual verification in your workflow.

**Failed:**

When the extraction task itself fails, `extract(...)` returns the failed polling response; it does not raise. The failure details are in the returned response body, not on `error.body`.

```json
{
  "success": false,
  "status": "failed",
  "extraction_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "error": {
    "code": "INSUFFICIENT_CREDITS",
    "message": "Insufficient credits to process this extraction.",
    "retryable": false,
    "details": { "credits_required": 25, "credits_balance": 15, "credits_reserved": 10 }
  }
}
```

See the [API docs](/api#failed) for the full list of task failure codes.

**Cancelled:**

If an extraction is cancelled while queued or processing, from the web app or with `cancel_extraction(...)`, `extract(...)` returns the cancelled polling response unchanged. No output files are available.

```json
{
  "success": true,
  "status": "cancelled",
  "extraction_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "credits_deducted": 4,
  "cancellation_reason": "user",
  "credits_balance": 146,
  "credits_reserved": 0
}
```

`credits_deducted` covers the work done before the extraction stopped; `credits_balance` and `credits_reserved` are as on the completed response. `cancellation_reason` says why it stopped:

| Value | Meaning |
|-------|---------|
| `user` | Cancelled from the web app or with `cancel_extraction(...)`. |
| `unanswered` | The extraction asked a question and no answer came by `answer_by`; see [Questions](#questions). Submit it again. |
| `answers_rejected` | Three answers to the extraction's questions were refused; the API reference's [Answer Questions](https://invoicedataextraction.com/api#answer-questions) says what is refused. Submit it again and answer differently. |

### When `extract(...)` raises

`extract(...)` only raises before a terminal extraction response is available, for example if upload, submission, or polling fails due to invalid input, network errors, or a polling timeout. These are SDK/API errors and are read from `error.body` as described in [Errors](#errors).

---

## Staged Workflow

`extract(...)` runs the full pipeline in one call. If you need control over individual steps, for example uploading files in one part of your system and triggering extraction in another, running multiple extractions against the same uploaded files, or fitting each step into your own error handling and retry logic, use these methods instead:

```python
import json
import os
import sys

from invoicedataextraction import InvoiceDataExtraction
from invoicedataextraction.errors import SdkError, ApiResponseError

try:
    client = InvoiceDataExtraction(
        api_key=os.environ.get("INVOICE_DATA_EXTRACTION_API_KEY"),
    )

    upload = client.upload_files(
        files=["./invoice1.pdf", "./invoice2.pdf"],
        console_output=True,
    )

    submitted = client.submit_extraction(
        upload_session_id=upload["upload_session_id"],
        file_ids=upload["file_ids"],
        prompt="Extract invoice number and total",
        output_structure="per_invoice",
        json_typed_values=True,
    )

    result = client.wait_for_extraction_to_finish(
        extraction_id=submitted["extraction_id"],
        console_output=True,
    )

    # Verify all pages were processed
    if result["pages"]["failed_count"] > 0:
        print("Some pages failed processing:", result["pages"]["failed"])

    # Work with the rows directly...
    for row in client.iterate_results(extraction_id=submitted["extraction_id"]):
        print(row["Invoice Number"], row["Total Amount"])

    # ...or download a file
    client.download_output(
        extraction_id=submitted["extraction_id"],
        format="xlsx",
        file_path="./output/invoices.xlsx",
    )
except (SdkError, ApiResponseError) as error:
    print(json.dumps(error.body, indent=2), file=sys.stderr)
    raise SystemExit(1)
```

## `upload_files(...)`

Upload local files without starting an extraction. Use this when you want to upload once and submit extractions separately, for example to run different prompts against the same files, or to upload in one part of your system and extract in another.

Underlying API workflow: create upload session → upload file parts → complete each file. See [File limits](#file-limits) for size and count constraints.

| Parameter | Required | Description |
|-----------|----------|-------------|
| `folder_path` | One of `folder_path` or `files` | Path to a local folder. The SDK uploads every supported file in the folder (`.pdf`, `.jpg`, `.jpeg`, `.png`). Not recursive. |
| `files` | One of `folder_path` or `files` | List of local file paths to upload. Supported types: `.pdf`, `.jpg`, `.jpeg`, `.png`. |
| `upload_session_id` | No | Your own session ID. If omitted, the SDK generates one. If an upload fails partway through, that session cannot be resumed; start a new upload with a fresh session ID. |
| `console_output` | No | Boolean. When `True`, the SDK logs upload progress to the console. |
| `on_update` | No | Callable for upload lifecycle updates; see [on_update](#on_update). |

### Returns

```json
{
  "upload_session_id": "session_a1b2c3d4-...",
  "file_ids": ["file_abc123", "file_def456"]
}
```

Pass `upload_session_id` and `file_ids` to `submit_extraction(...)` to start an extraction.

File uploads are all-or-nothing. If any file fails to upload, the method raises immediately; there is no partial success state. If `upload_files(...)` returns without raising, every file was uploaded successfully.

The API checks your credit balance when the upload session is created. If you don't have enough credits, `upload_files(...)` raises `INSUFFICIENT_CREDITS` before any files are uploaded.

## `submit_extraction(...)`

Submit an extraction task for files that have already been uploaded. The method returns immediately; it does not wait for the extraction to finish.

Underlying API endpoint: `POST /extractions`.

| Parameter | Required | Description |
|-----------|----------|-------------|
| `upload_session_id` | Yes | The upload session ID returned by `upload_files(...)`. |
| `file_ids` | Yes | List of file IDs returned by `upload_files(...)`. |
| `prompt` | Yes | Extraction instructions. String or dict; see [Prompt](#prompt). |
| `output_structure` | Yes | Controls how the extracted data is structured; see [Output structure](#output-structure). |
| `task_name` | No | Your label for this extraction (3–40 characters). Appears in the [web dashboard](https://invoicedataextraction.com/dashboard). If omitted, the SDK generates one as `extraction_YYYYMMDD_HHMMSS`. |
| `exclude_columns` | No | List of system-generated columns to exclude from output. By default, output files include a "Source File" column indicating which uploaded file/page each row was extracted from, and a "Review Needed" column marking rows that need human verification. If your workflow requires an exact output structure, you can exclude either column. Valid values: `"source_file"`, `"review_needed"`. Excluding `"review_needed"` removes only the export column; Review Needed warnings can still be generated and returned in the completed response. |
| `output_language`, `review_needed_fill_color`, `affected_field_fill_color`, `send_completion_email`, `json_typed_values` | No | The per-extraction output options, exactly as on `extract(...)`; see [Output options](#output-options). |
| `ask_questions` | No | Boolean. `True` to let the extraction stop and ask; `wait_for_extraction_to_finish(...)` then returns the `input_required` response for you to answer with `answer_questions(...)`. See [Questions](#questions). |
| `submission_id` | No | Your own idempotency ID for this submission. If omitted, the SDK generates one. If a request fails or times out, retry with the same `submission_id` to safely retrieve the existing task instead of creating a duplicate. |

### Returns

```json
{
  "success": true,
  "extraction_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "submission_state": "received"
}
```

The task is now queued for processing. Use `extraction_id` to poll for results with `wait_for_extraction_to_finish(...)` or `check_extraction(...)`. Submitted tasks also appear in the [web dashboard](https://invoicedataextraction.com/dashboard) where you can view progress and results.

## `wait_for_extraction_to_finish(...)`

Wait for an extraction to reach a terminal state (`completed`, `failed`, or `cancelled`), or to stop and ask (`input_required`, for an extraction submitted with `ask_questions`). Use this after `submit_extraction(...)` when you want the SDK to handle the waiting for you. The SDK asks the API to hold each status request until the extraction finishes or 30 seconds pass, so a run that takes three minutes needs about six requests, not a poll every few seconds; see [Polling](#polling) to tune this.

Underlying API endpoint: `GET /extractions/{extraction_id}?wait=30` (held by the API, repeated until the extraction finishes).

| Parameter | Required | Description |
|-----------|----------|-------------|
| `extraction_id` | Yes | The extraction ID returned by `submit_extraction(...)`. |
| `polling` | No | Polling options; see [Polling](#polling). |
| `console_output` | No | Boolean. When `True`, the SDK logs polling progress to the console. |
| `on_update` | No | Callable for waiting lifecycle updates; see [on_update](#on_update). |

### Returns

Returns the terminal polling response from the API unchanged, the same shape documented for [`extract(...)` returns](#returns), or the `input_required` response when the extraction stops to ask.

When the extraction completes, you get the full result with `credits_deducted`, `credits_balance`, `pages`, `ai_uncertainty_notes`, `review_needed`, and `output` URLs. Check `result["pages"]["failed_count"]` to verify all pages were processed, check `result["review_needed"]["count"]` for result-level warnings before relying on the data, and use `ai_uncertainty_notes` for prompt assumptions you may want to clarify in future runs. When it fails, you get the failed response with `result["error"]["code"]` and `result["error"]["message"]`. If the task is cancelled while the SDK is waiting, from the web app or with `cancel_extraction(...)`, you get the cancelled response with `credits_deducted` and `cancellation_reason`. If it stops to ask, you get the `input_required` response with `questions` and `answer_by`: answer with `answer_questions(...)`, then call this method again. In all cases the response is returned, not raised.

If `polling.timeout_ms` is set and the extraction hasn't finished in time, the method raises `SDK_TIMEOUT_ERROR` within that time: the hold, the request and the pause between requests are each cut to the time left. The extraction may still be processing; you can check later with `check_extraction(...)` or from the [web dashboard](https://invoicedataextraction.com/dashboard).

## `get_results(...)`

Read one page of the extracted rows as JSON, straight from the API, so you can work with the data without downloading a file. Each row is a dict whose keys are your output columns, exactly as the JSON output file has them, and the Review Needed items for the rows on the page come back alongside. Use `iterate_results(...)` to walk every row without managing pages yourself.

Underlying API endpoint: `GET /extractions/{extraction_id}/results`.

```python
page = client.get_results(
    extraction_id="a1b2c3d4-e5f6-7890-abcd-ef1234567890",
    limit=100,
)

for row in page["rows"]:
    print(row["Invoice Number"], row["Total Amount"])

if page["review_needed"]["count"] > 0:
    print("Rows to check:", page["review_needed"]["items"])
```

### Parameters

| Parameter | Required | Description |
|-----------|----------|-------------|
| `extraction_id` | Yes | The extraction whose rows you want. It must be completed. |
| `offset` | No | Number of rows to skip. Default `0`. |
| `limit` | No | Rows per page, from 1 to 1000. Default `100`. |
| `scope` | No | `"own"` or `"team"`. Same semantics as on `list_extractions(...)`. |

### Returns

```json
{
  "success": true,
  "extraction_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "status": "completed",
  "output_structure": "per_invoice",
  "output_expires_at": "2026-07-26T10:30:00Z",
  "json_typed_values": true,
  "columns": ["Invoice Number", "Invoice Date", "Vendor Name", "Total Amount", "Source File", "Review Needed"],
  "rows": [
    {
      "Invoice Number": "INV-1001",
      "Invoice Date": "2026-01-15",
      "Vendor Name": "Acme Ltd",
      "Total Amount": 1250,
      "Source File": "invoice-1.pdf (Page 1)",
      "Review Needed": null
    }
  ],
  "offset": 0,
  "limit": 100,
  "row_count": 1,
  "total_rows": 1,
  "has_more": false,
  "next_offset": null,
  "review_needed": {
    "count": 0,
    "items": []
  },
  "pages": {
    "successful_count": 1,
    "failed_count": 0,
    "successful": [{ "file_name": "invoice-1.pdf", "page": 1 }],
    "failed": [],
    "failure_reasons": []
  }
}
```

| Field | Description |
|-------|-------------|
| `json_typed_values` | Which mode the rows are in: `False` when every value is a string, `True` when the extraction was submitted with `json_typed_values` and values carry native JSON types; see [JSON value types](#json-value-types). |
| `columns` | The output column names in order, including `Source File` and `Review Needed` unless excluded at submission. |
| `rows` | The rows on this page, one dict per row with a value under every column. Row `i` of the page (counting from 0) is data row `offset + i + 1`, the numbering `review_needed["items"][...]["output_row_numbers"]` uses. |
| `offset`, `limit` | The page requested. |
| `row_count` | Rows on this page. |
| `total_rows` | Rows in the whole extraction. |
| `has_more`, `next_offset` | `has_more` is `True` when rows remain after this page; `next_offset` is the `offset` to request next, `None` when there are no more. |
| `review_needed` | `count` is the number of Review Needed items for the whole extraction; `items` holds only those whose `output_row_numbers` fall on this page, in the same shape as on `extract(...)` returns. Check `count` before relying on the data. |
| `pages` | The page-level results of the extraction, in the same shape as on `extract(...)` returns. Data from `pages["failed"]` is missing from the rows. |
| `output_structure`, `output_expires_at` | As on the completed extraction response. |

The rows of a completed extraction do not change, so pages are stable: request them in order, passing `next_offset` back as `offset` until `has_more` is `False`. Rows are available for the same 90 days as the output files; `output_expires_at` says when they stop being.

If the extraction has not completed, the method raises `OUTPUT_NOT_AVAILABLE`; once it is past its 90-day retention window, `OUTPUT_EXPIRED`; see [Output expiry](#output-expiry).

## `iterate_results(...)`

Auto-paginating generator over the rows of `get_results(...)`. Yields one row at a time and fetches the next page when the current one is exhausted. Use this when you want every row without writing the paging loop yourself.

Underlying API endpoint: `GET /extractions/{extraction_id}/results` (paged).

```python
for row in client.iterate_results(extraction_id="a1b2c3d4-e5f6-7890-abcd-ef1234567890"):
    print(row["Invoice Number"], row["Total Amount"])
```

### Parameters

Identical to [`get_results(...)`](#get_results). A caller-provided `offset` is the row to start from; `limit` is the page size the iterator fetches with.

### Behavior

- **Yields individual rows, not pages.** Each row is the same dict `get_results(...)` returns in `rows`, in the same order.
- **Pages are fetched lazily.** The iterator does not request the next page until every row of the current page has been yielded. Breaking out of the `for` loop early prevents the next page from being fetched.
- **Mid-stream errors propagate.** If a page request fails, the iterator raises on the corresponding `next()` call and the consumer's `for` loop re-raises. Rows already yielded remain yielded.
- **Defensive guard on bad pagination state.** If the API ever returns `has_more: True` without a usable `next_offset`, the iterator raises `SDK_HTTP_ERROR` rather than risking an infinite loop.

Validation runs eagerly: `iterate_results(<invalid kwargs>)` raises synchronously at the call site before the generator is returned.

### Return type

A generator object (Python iterator). Usable with `for ... in ...`, `list(...)`, `next(...)`, etc.

## `download_output(...)`

Save one output file of a completed extraction to disk: the way to get a spreadsheet after the staged workflow, or to retry a failed auto-download from `extract(...)`.

Underlying API workflow: request a fresh presigned download URL → download the file → write to disk.

| Parameter | Required | Description |
|-----------|----------|-------------|
| `extraction_id` | Yes | The extraction ID whose output you want to download. |
| `format` | Yes | A single output format: `"xlsx"`, `"csv"`, or `"json"`. |
| `file_path` | Yes | Full destination file path on disk. The file extension must match the requested `format`. The parent directory is created automatically if it doesn't exist. |

`download_output(...)` does not overwrite existing files. If `file_path` already exists, the SDK raises `SDK_FILESYSTEM_ERROR` with guidance to choose a new path or remove the existing file.

The extraction must be completed before downloading. If the output is not available, for example because the extraction hasn't finished or the format was not generated, the method raises `OUTPUT_NOT_AVAILABLE`. If the output existed but has aged past the 90-day retention window, the method raises `OUTPUT_EXPIRED`. See [Output expiry](#output-expiry).

### Returns

```json
{
  "success": true,
  "extraction_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "format": "xlsx",
  "file_path": "./output/invoices.xlsx"
}
```

---

## `check_extraction(...)`

Check the current status of a submitted extraction with a single request. Use this when you want to control when each check is made, for example in a job queue where you check on your own schedule rather than having the SDK wait with `wait_for_extraction_to_finish(...)`. Pass `wait_seconds` and the API holds the request until the extraction finishes or the seconds pass, so one call can replace a loop.

Underlying API endpoint: `GET /extractions/{extraction_id}`, with `?wait=` when `wait_seconds` is set.

| Parameter | Required | Description |
|-----------|----------|-------------|
| `extraction_id` | Yes | The extraction ID to check. |
| `wait_seconds` | No | Whole seconds from `0` to `45`, default `0`. With a value above `0`, the API holds the request until the extraction leaves `processing` or the time is up, whichever comes first; a response that arrives after the full wait is an ordinary `processing` response. With `0`, the current status is returned immediately. |

### Returns

Returns the current polling response from the API unchanged. The response may represent a `processing`, `input_required`, `completed`, `cancelled`, or `failed` extraction, the same shapes documented for [`extract(...)` returns](#returns) and [Questions](#questions). A `processing` response includes a `progress` field (0–100) indicating approximate completion.

`check_extraction(...)` wraps the polling endpoint and is intended for "is it done yet?" checks. To retrieve the full record (including the original `prompt`, `options`, full `pages`, prompt notes in `ai_uncertainty_notes`, Review Needed warnings, and the full failure `message`/`details`) for any extraction in any state, use [`get_extraction(...)`](#get_extraction).

## `get_download_url(...)`

Request a fresh presigned download URL for an extraction's output. Use this when you want to handle the download yourself rather than using `download_output(...)`.

Underlying API endpoint: `GET /extractions/{extraction_id}/output?format={format}`.

| Parameter | Required | Description |
|-----------|----------|-------------|
| `extraction_id` | Yes | The extraction ID whose output you want to download. |
| `format` | Yes | A single output format: `"xlsx"`, `"csv"`, or `"json"`. |

### Returns

```json
{
  "download_url": "https://storage.example.com/...?X-Amz-Signature=...",
  "format": "xlsx",
  "expires_in_seconds": 300
}
```

The URL is a temporary, pre-authenticated link. Make a plain `GET` request to it, with no `Authorization` header. It expires after 5 minutes.

The extraction must be completed before requesting a download URL. If the output is not available, the method raises `OUTPUT_NOT_AVAILABLE`. If the output existed but has aged past the 90-day retention window, the method raises `OUTPUT_EXPIRED`. See [Output expiry](#output-expiry).

## `cancel_extraction(...)`

Stop an extraction that is still queued or processing. The request is recorded at once and the extraction stops at the next point where it can; wait for it with `wait_for_extraction_to_finish(...)` or check it with `check_extraction(...)` until its status is `cancelled`. `credits_deducted` on that response covers the work done before it stopped. An extraction that was about to finish may complete instead, in which case it is charged as a completed extraction and its output is available as usual.

Cancelled extractions produce no output files. To remove one and its uploaded files, use `delete_extraction(...)`.

Underlying API endpoint: `POST /extractions/{extraction_id}/cancel`.

```python
cancel = client.cancel_extraction(extraction_id=extraction_id)

if cancel["status"] == "cancelled":
    print("Already stopped:", cancel["cancellation_reason"])
else:
    result = client.wait_for_extraction_to_finish(extraction_id=extraction_id)
    print(result["status"])  # "cancelled", or "completed" if it finished first
```

| Parameter | Required | Description |
|-----------|----------|-------------|
| `extraction_id` | Yes | The extraction ID to cancel. |
| `scope` | No | `"own"` or `"team"`. Team admins default to `team`, so they can cancel any team member's extraction; other callers default to `own` and may not pass `"team"`. |

### Returns

While the extraction is still processing, the response confirms that the request was recorded:

```json
{
  "success": true,
  "extraction_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "status": "processing",
  "cancellation_requested": true,
  "cancel_requested_at": "2026-09-10T14:03:22.418Z"
}
```

`status` stays `processing` until the extraction has stopped. `cancel_requested_at` is when the request was first recorded; calling again returns the same result and records nothing new.

If the extraction has already been cancelled, the response is the cancelled polling response documented under [`extract(...)` returns](#returns), with `cancellation_reason`.

An extraction that has already completed or failed cannot be cancelled: the method raises `EXTRACTION_NOT_CANCELLABLE`, whose `details["status"]` is `"completed"` or `"failed"`. Get its result with `check_extraction(...)` or `get_extraction(...)`.

## `answer_questions(...)`

Answer the questions an extraction stopped to ask; see [Questions](#questions). Use it when you handle the questions yourself rather than through `on_questions`: `wait_for_extraction_to_finish(...)` or `check_extraction(...)` returned `input_required`, you answer, then you wait again. Answer every open question in one call, or across several; the extraction continues the moment every open question has an answer.

Underlying API endpoint: `POST /extractions/{extraction_id}/answers`.

```python
status = client.wait_for_extraction_to_finish(extraction_id=extraction_id)

if status["status"] == "input_required":
    answers = [
        {"question_id": question["question_id"], "accept_recommended": True}
        for question in status["questions"]
    ]
    client.answer_questions(extraction_id=extraction_id, answers=answers)
    result = client.wait_for_extraction_to_finish(extraction_id=extraction_id)
```

| Parameter | Required | Description |
|-----------|----------|-------------|
| `extraction_id` | Yes | The extraction that is waiting. |
| `answers` | Yes | List with one dict per question you are answering. Each names the `question_id` and gives one of: `choice_id` (with `text` beside it to add detail), `text` alone (your own words, 1 to 1000 characters, accepted on every question), or `accept_recommended: True`. |
| `scope` | No | `"own"` or `"team"`. Team admins default to `team`, so they can answer for any team member's extraction; other callers default to `own` and may not pass `"team"`. |

### Returns

The extraction's status after the answers, exactly as `check_extraction(...)` would return it next: `processing` once every open question has its answer, `input_required` with the questions still waiting, or whatever the extraction has become. An answer to a question that is no longer waiting changes nothing and returns the current status, so a call can be repeated after a dropped connection. A `failed` status is returned, not raised, as on `check_extraction(...)`.

A request that could never be right raises `INVALID_INPUT`, whose `details["issues"]` name the answer and the field: a `question_id` the extraction never asked, a `choice_id` it does not offer, empty or over-long `text`, `accept_recommended` sent with `choice_id` or `text`, or a question answered twice in one call.

## `delete_extraction(...)`

Permanently delete an extraction, its output files, and its uploaded source files. Use this when you need to remove data immediately rather than waiting for automatic [data retention](https://invoicedataextraction.com/security). Extractions that are currently being processed cannot be deleted.

If you created multiple extractions from the same upload session, deleting one will not affect the others: source files are only removed when no other extraction is using them.

Underlying API endpoint: `DELETE /extractions/{extraction_id}`.

| Parameter | Required | Description |
|-----------|----------|-------------|
| `extraction_id` | Yes | The extraction ID to delete. |

### Returns

Returns the API response unchanged.

## `get_credits_balance()`

Check your current credit balance and reserved credits.

Underlying API endpoint: `GET /credits/balance`.

This method takes no arguments.

### Returns

```json
{
  "success": true,
  "credits_balance": 150,
  "credits_reserved": 10
}
```

| Field | Description |
|-------|-------------|
| `credits_balance` | Your total credit balance (paid + free credits). |
| `credits_reserved` | Credits reserved by extractions currently being processed. Your usable balance is `credits_balance` minus `credits_reserved`. |

## `list_extractions(...)`

Retrieve a paginated list of your extractions, with optional filters. Items use a slim shape designed for browsing; for the full record (including the original `prompt`, `options`, full `pages`, prompt notes in `ai_uncertainty_notes`, `review_needed`, and the full failure `message`/`details`) call `get_extraction(...)` for a specific item.

`list_extractions(...)` returns a single page. To iterate every matching extraction without writing the cursor loop yourself, use `iterate_extractions(...)`.

Underlying API endpoint: `GET /extractions`.

```python
page = client.list_extractions(
    status="completed",
    submission_method="api",
    limit=50,
)

for item in page["extractions"]:
    print(item["extraction_id"], item["task_name"], item["created_at"])

if page["has_more"]:
    next_page = client.list_extractions(
        status="completed",
        submission_method="api",
        limit=50,
        cursor=page["next_cursor"],
    )
```

### Parameters

All filters are optional. Call `list_extractions()` with no arguments to list every extraction visible to your API key.

| Parameter | Required | Description |
|-----------|----------|-------------|
| `status` | No | One of `"processing"`, `"completed"`, `"cancelled"`, or `"failed"`. Filter by current status. `cancelled` represents tasks cancelled while queued or processing, from the web app or with `cancel_extraction(...)`. |
| `submission_method` | No | `"api"` or `"web_app"`. Filter by how the extraction was submitted. The `web_app` value matches the database column verbatim. |
| `created_after` | No | ISO 8601 string or timezone-aware `datetime.datetime`. Returns extractions created on or after this timestamp. `datetime` values are serialized via `isoformat()` before being sent. Naive datetimes are rejected: the API requires an offset. |
| `created_before` | No | ISO 8601 string or timezone-aware `datetime.datetime`. Returns extractions created on or before this timestamp. |
| `limit` | No | Integer from 1 to 100. The number of items to return per page. |
| `cursor` | No | Opaque pagination token returned as `next_cursor` from a previous page. Treat it as a string and pass it back unchanged. |
| `scope` | No | `"own"` or `"team"`. Only relevant for Team accounts. Team admins default to team-visible history; pass `"own"` to list only your own extractions. Non-admins can omit it. |

For team admins, omitting `scope` returns team-visible history, equivalent to the dashboard's **Team tasks** view. Use `scope="own"` when you want only your own extractions. `scope="team"` is accepted for explicitness, but only team admins can use it.

### Returns

```json
{
  "success": true,
  "extractions": [
    {
      "extraction_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
      "submission_id": "sub_abc",
      "task_name": "March invoices",
      "status": "completed",
      "created_at": "2026-04-15T10:30:00Z",
      "submission_method": "api",
      "file_count": 3,
      "file_names_preview": ["a.pdf", "b.pdf", "c.pdf"],
      "file_names_truncated": false,
      "output_structure": "per_invoice",
      "credits_deducted": 5,
      "available_outputs": ["xlsx", "csv", "json"],
      "output_expires_at": "2026-07-14T10:30:00Z"
    }
  ],
  "has_more": true,
  "next_cursor": "eyJjIjoiMjAyNi0wNC0xNVQxMDozMDowMFoiLCJpIjoxMjM0fQ"
}
```

When no extractions match, the SDK returns `{"success": True, "extractions": [], "has_more": False, "next_cursor": None}`.

#### List item shape

Every list item includes these fields:

| Field | Description |
|-------|-------------|
| `extraction_id` | The extraction's UUID. Use this with `get_extraction(...)`, `get_download_url(...)`, etc. |
| `submission_id` | Your idempotency ID from `submit_extraction(...)`, or `None` for web-app submissions. |
| `task_name` | The label you gave the extraction at submission, or `None`. |
| `status` | `"processing"`, `"completed"`, `"cancelled"`, or `"failed"`. `cancelled` represents a task cancelled while queued or processing, from the web app or with `cancel_extraction(...)`. |
| `created_at` | ISO 8601 timestamp. |
| `submission_method` | `"api"` or `"web_app"`. |
| `file_count` | Total number of files uploaded for this extraction. |
| `file_names_preview` | The first up to 5 file names, in submission order. Use `get_extraction(...)` to retrieve the full list. |
| `file_names_truncated` | `True` when `file_count > len(file_names_preview)` (i.e., the extraction has more files than fit in the preview). |
| `output_structure` | `"per_invoice"`, `"per_line_item"`, `"automatic"` (only while an automatic run is still resolving), or `None` for legacy/unknown rows. |

Status-specific fields:

- **Completed items** add `credits_deducted` (number), `available_outputs` (a list of `"xlsx"`/`"csv"`/`"json"` indicating which formats can currently be downloaded, empty when the output has aged past `output_expires_at`), and `output_expires_at` (ISO 8601 string).
- **Cancelled items** represent tasks cancelled while queued or processing, from the web app or with `cancel_extraction(...)`. They add `credits_deducted` and `cancellation_reason` (`"user"`, `"unanswered"` or `"answers_rejected"`; see the cancelled shape under [`extract(...)` returns](#returns)); no output files are available.
- **Processing items** add `progress` (0-100).
- **Failed items** add `error` with the slim `{"code": ..., "retryable": ...}` shape; for the full message and details, call `get_extraction(...)` on that extraction.

When a team admin lists team-visible history, every item also includes `submitted_by` with shape `{"email": str | None}` identifying the team member who created the extraction. The field is absent in own-only listings. The SDK never exposes a user ID.

## `iterate_extractions(...)`

Auto-paginating generator over `list_extractions(...)`. Yields one extraction summary record at a time and transparently fetches the next page when the current one is exhausted. Use this when you want to process every matching extraction without writing the cursor loop yourself.

Underlying API endpoint: `GET /extractions` (paged).

```python
for extraction in client.iterate_extractions(status="completed"):
    print(extraction["extraction_id"], extraction["task_name"])
```

### Parameters

Identical to [`list_extractions(...)`](#list_extractions), including `scope` for team-admin listing behavior. A caller-provided `cursor` is used as the starting point; the iterator manages cursor advancement from that point on.

### Behavior

- **Yields individual records, not pages.** The iterator yields each list item directly, in the same order as `list_extractions(...)` would return them.
- **Pages are fetched lazily.** The iterator does not request the next page until every item from the current page has been yielded. Breaking out of the `for` loop early (or otherwise terminating the iterator) prevents the next page from being fetched.
- **Filters are preserved across pages.** The original arguments you pass are reused for every page; only `cursor` advances.
- **Mid-stream errors propagate.** If a page request fails, the iterator raises on the corresponding `next()` call and the consumer's `for` loop re-raises. Items already yielded remain yielded.
- **Defensive guard on bad pagination state.** If the API ever returns `has_more: True` without a usable `next_cursor`, the iterator raises `SDK_HTTP_ERROR` rather than risking an infinite loop.

Validation runs eagerly: `iterate_extractions(<invalid kwargs>)` raises synchronously at the call site before the generator is returned. You don't need to start iterating to discover bad input.

### Return type

A generator object (Python iterator). Usable with `for ... in ...`, `list(...)`, `next(...)`, etc.

## `get_extraction(...)`

Retrieve a single extraction's full record. The record is the same regardless of state: `processing`, `completed`, `cancelled`, and `failed` extractions are all returned with `success: true` and the failure details (when present) on `extraction["error"]`.

Use this when you want the full picture of an extraction: the original `prompt` and `options`, the complete file list, page-level results, prompt notes in `ai_uncertainty_notes`, Review Needed warnings, the full failure error (`message` and `details`, not just `code` and `retryable`), and `available_outputs` so you can decide what to download.

`get_extraction(...)` is record retrieval, distinct from `check_extraction(...)`, which wraps the polling endpoint and is intended for "is it done yet?" checks against in-flight extractions.

Underlying API endpoint: `GET /extractions/{extraction_id}/details`.

```python
result = client.get_extraction(
    extraction_id="a1b2c3d4-e5f6-7890-abcd-ef1234567890",
)
extraction = result["extraction"]

if extraction["status"] == "failed":
    print(extraction["error"]["code"], extraction["error"]["message"])
elif extraction["status"] == "completed":
    print("Available formats:", extraction["available_outputs"])
```

### Parameters

| Parameter | Required | Description |
|-----------|----------|-------------|
| `extraction_id` | Yes | The extraction ID to retrieve. |

### Returns

```json
{
  "success": true,
  "extraction": {
    "extraction_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
    "submission_id": "sub_abc",
    "task_name": "March invoices",
    "status": "completed",
    "created_at": "2026-04-15T10:30:00Z",
    "submission_method": "api",
    "file_count": 3,
    "file_names": ["a.pdf", "b.pdf", "c.pdf"],
    "output_structure": "per_invoice",
    "prompt": "Extract invoice number, date, vendor, total",
    "options": {
      "exclude_columns": [],
      "output_language": "automatic",
      "review_needed_fill_color": "orange",
      "affected_field_fill_color": "orange",
      "send_completion_email": false,
      "json_typed_values": false
    },
    "credits_deducted": 5,
    "available_outputs": ["xlsx", "csv", "json"],
    "output_expires_at": "2026-07-14T10:30:00Z",
    "pages": {
      "successful_count": 6,
      "failed_count": 0,
      "successful": [{ "file_name": "a.pdf", "page": 1 }],
      "failed": [],
      "failure_reasons": []
    },
    "ai_uncertainty_notes": [],
    "review_needed": {
      "count": 0,
      "items": []
    }
  }
}
```

#### Record shape

Every record includes these fields:

| Field | Description |
|-------|-------------|
| `extraction_id`, `submission_id`, `task_name`, `created_at`, `submission_method`, `output_structure` | Same semantics as the [list item shape](#list-item-shape). |
| `file_count` | Total number of files uploaded. |
| `file_names` | Full list of file names, in submission order (no truncation). |
| `prompt` | The original `prompt` you submitted: a string, a structured dict (`{"fields": [...], "general_prompt": "..."}`), an empty string for web-app submissions with no explicit prompt, or `None` only for legacy/edge rows. |
| `options` | Always present with every field: `exclude_columns`, `output_language`, `review_needed_fill_color`, `affected_field_fill_color`, `send_completion_email` and `json_typed_values`: the values that applied to this extraction, including the account preferences that filled any option left out at submission. See [Output options](#output-options). |

Status-specific fields:

- **Completed records** add `credits_deducted`, `available_outputs`, `output_expires_at`, full `pages` (with `successful`, `failed`, and `failure_reasons` lists), prompt notes in `ai_uncertainty_notes`, and result-level `review_needed` warnings (same shape as on `extract(...)` returns).
- **Cancelled records** represent tasks cancelled while queued or processing, from the web app or with `cancel_extraction(...)`. They add `credits_deducted` and `cancellation_reason`; no output files are available.
- **Processing records** add `progress` (0-100).
- **Failed records** add `error` with the full shape `{"code": ..., "message": ..., "retryable": ..., "details": ...}`; `get_extraction(...)` exposes the full failure information regardless of when the extraction failed.

For team-admin lookups, the record may also include `submitted_by` with shape `{"email": str | None}`.

The details endpoint never includes signed download URLs; call `download_output(...)` or `get_download_url(...)` to actually download files.

### `get_extraction(...)` does not raise on failed extractions

A `failed` extraction is a valid record. `get_extraction(...)` returns it like any other state, with the failure details on `extraction["error"]`. It only raises for request-level failures: invalid input, authentication errors, `EXTRACTION_NOT_FOUND`, network failures, etc.

### Common workflow: list, then download

Browsing past extractions and downloading their output is a two-step pattern. List/details responses don't include signed download URLs. Instead, use `list_extractions(...)` or `iterate_extractions(...)` to find the extraction you want, then call `get_download_url(...)` (or `download_output(...)`) for a fresh signed URL when you're ready to download:

```python
for extraction in client.iterate_extractions(
    status="completed",
    submission_method="api",
):
    task_name = extraction.get("task_name") or ""
    if task_name.startswith("March invoices"):
        if "xlsx" in extraction["available_outputs"]:
            client.download_output(
                extraction_id=extraction["extraction_id"],
                format="xlsx",
                file_path=f"./march/{extraction['extraction_id']}.xlsx",
            )
        else:
            # available_outputs is empty when output_expires_at has passed:
            # the underlying file has been deleted by the 90-day retention policy.
            print(f"Output no longer available for {extraction['extraction_id']}")
        break
```

## Working with Output Files

You can control the structure and formatting of all output files in two main ways:

- use `output_structure` to choose the top-level record shape, such as `per_invoice` or `per_line_item`
- use your prompt to describe the fields, grouping, and overall structure you want, such as "one row per product" or "one row per PO"

You can also use your prompt to:

- specify missing-value placeholders, such as empty string, `N/A`, or `0`
- specify formatting requirements, such as `YYYY-MM-DD`, digits only, or no currency symbol
- specify the intended output type, such as text, number, date, datetime, boolean, currency, or percentage

Each column is given a type from your prompt and the documents: text, number, currency, percentage, boolean, date or datetime. XLSX uses that type for its cell types; the JSON output uses it as described below; CSV is plain text.

### Working with JSON Output

#### JSON value types

By default, every value in the JSON output file is a string, and so is every value in the rows `get_results(...)` and `iterate_results(...)` return, which read the same data. In a column typed as a number, currency or percentage, a value that reads as a number is written as plain digits with a decimal point and no thousands separators or currency symbol (`"1234.5"`); a value that does not read as a number is written as extracted. Other columns hold the extracted text, and a cell with nothing in it holds the missing-value placeholder from your prompt, or `""` when you set none.

Submit with `json_typed_values=True` (on `extract(...)` or `submit_extraction(...)`) and the same file, and the same rows, carry native JSON types instead. Each value is what the spreadsheet cell holds, in JSON's own types (Python's `int`/`float`, `bool` and `None` once parsed):

| Column type | Value with `json_typed_values` |
|---|---|
| number, currency | A JSON number (`1234.5`). A currency column never carries the symbol; ask for a separate currency-code field if you need it. If you ask for the symbol in the value, the column is text and the value stays a string. |
| percentage | A JSON number as the spreadsheet stores it: the fraction when the document shows a percent sign (`19%` is `0.19`), the number as printed when you ask for no sign. |
| boolean | JSON `true` or `false`. Ask for `true`/`false` values in your prompt. |
| text, date, datetime | A string, in the format you asked for. Ask for `YYYY-MM-DD` if you want dates you can sort or parse. |
| empty cell | `null` (`None`), in every column type. A missing-value placeholder from your prompt is kept as the value: as a number if it reads as one in that column (`0` in a currency column is `0`), otherwise as the string you gave. |

A value that cannot be read as its column's type stays as the extracted text, so a number column can hold a string. That is deliberate: it lets you see a value that needs a look rather than losing it. Check `review_needed` as usual; those rows are usually the same ones.

Nested JSON in a field (below) is unaffected: the field is a string containing JSON whose inner values are strings, in either mode.

The option is fixed at submission and applies to this extraction's JSON output only; XLSX and CSV are unchanged. `get_results(...)` tells you which mode an extraction's rows are in through its `json_typed_values` field. In string mode, if you plan to parse a value, state the formatting clearly in your prompt: "Do not include currency symbol", "Use digits only", "Return true or false", "Use YYYY-MM-DD format".

#### Structured JSON fields

You can ask for a field to return structured JSON.

Example prompt:

```json
"prompt": {
  "fields": [
    { "name": "Invoice Number" },
    {
      "name": "Line Items",
      "prompt": "Return a JSON array with keys description, quantity, unit_price, and amount. Use digits only for quantity. Use a full stop as the decimal separator. Do not include currency symbols in unit_price or amount. Do not use thousands separators. Use an empty string when a value is missing."
    }
  ]
}
```

Example JSON output value:

```json
"Line Items": "[{\"description\":\"Widget\",\"quantity\":\"2\",\"unit_price\":\"9.99\",\"amount\":\"19.98\"}]"
```

In the example above, `Line Items` is a string whose content is valid JSON.

Use nested line-item JSON like above, mainly for smaller or simpler cases, such as when there are only a few line items and you want a single invoice-level object.

#### Recommended approach for line items

If you need detailed line item extraction, prefer `output_structure: "per_line_item"` instead of returning line items inside a nested JSON field.

This is strongly recommended when:

- invoices may contain around 7 or more line items
- line items need detailed per-field instructions
- you want the most reliable line item extraction

In `per_line_item`, define invoice-level fields and line-item fields as separate top-level fields.

Many workflows can use the `per_line_item` output directly, with one row/object per line item.

If your workflow needs a nested structure such as `{ invoice_fields..., line_items: [...] }`, include your own stable invoice identifier such as `Invoice Number` so you can group related line item rows back into invoices in your own system.

Do not rely on `Source File` alone to group rows into invoices. `Source File` helps you trace where a row came from, but it is not a stable invoice identifier.

Example prompt for the recommended approach:

```json
{
  "prompt": {
    "fields": [
      { "name": "Invoice Number" },
      { "name": "Invoice Date", "prompt": "Use YYYY-MM-DD format" },
      { "name": "Vendor Name" },
      { "name": "Line Item Description" },
      { "name": "Line Item Quantity", "prompt": "Use digits only" },
      { "name": "Line Item Unit Price" },
      { "name": "Line Item Amount" }
    ],
    "general_prompt": "For amount fields don't use thousands separators, use full stops as the decimal separator and do not include currency symbols."
  },
  "output_structure": "per_line_item"
}
```

Example JSON output rows:

```json
[
  {
    "Invoice Number": "INV-1001",
    "Invoice Date": "2025-01-15",
    "Vendor Name": "Acme Ltd",
    "Line Item Description": "Widget A",
    "Line Item Quantity": "2",
    "Line Item Unit Price": "9.99",
    "Line Item Amount": "19.98"
  },
  {
    "Invoice Number": "INV-1001",
    "Invoice Date": "2025-01-15",
    "Vendor Name": "Acme Ltd",
    "Line Item Description": "Widget B",
    "Line Item Quantity": "1",
    "Line Item Unit Price": "5.00",
    "Line Item Amount": "5.00"
  }
]
```

Both rows above belong to the same invoice because they share the same `Invoice Number`. If your workflow needs one record per line item, you can use the rows as-is. If your workflow needs a nested invoice structure, you can group rows that share the same invoice identifier to build your own `{ invoice_fields..., line_items: [...] }` structure.

### CSV Output

CSV is a plain-text export. Every value in the CSV file is written as text.

### XLSX Output

XLSX uses the most appropriate spreadsheet cell type for each value by default, and follows explicit prompt instructions where provided.

## File Limits

| Type | Max size |
|------|----------|
| PDF | 150 MB |
| JPG / JPEG / PNG | 5 MB |
| Total batch size | 2 GB |
| Max files per session | 6,000 |

Applies to `extract(...)` and `upload_files(...)`.

## Polling

`extract(...)` and `wait_for_extraction_to_finish(...)` accept a `polling` dict that controls how the SDK waits for an extraction to finish. By default the SDK does not poll in the usual sense: it asks the API to hold each status request until the extraction finishes or 30 seconds pass, and sends the next one as soon as the previous one comes back still `processing`. A run that takes three minutes therefore costs about six requests instead of eighteen ten-second polls, and you learn of the result within a second of it being ready.

| Field | Default | Description |
|-------|---------|-------------|
| `wait_seconds` | `30` | Whole seconds from `0` to `45` for which the API holds each status request. `0` turns holding off and the SDK polls plainly, `interval_ms` apart. |
| `interval_ms` | `10000` | The minimum spacing between status requests, in milliseconds (minimum `5000`). A held request that has already spent it is followed immediately; a request answered sooner is followed after the remainder. |
| `timeout_ms` | `None` | Maximum time to wait in milliseconds, after which `SDK_TIMEOUT_ERROR` is raised. `None` means no timeout. With a timeout set, nothing runs past it: the hold, the request and the pause between requests are each cut to the time left, so the method returns or raises within `timeout_ms`. |

Each status request has a read timeout of the wait plus 30 seconds (so 60 seconds by default, 30 seconds for a plain poll); a request that outlives it is an `SDK_NETWORK_ERROR`, which the loop treats like any transient error and retries. A one-off check with the same hold is `check_extraction(...)` with `wait_seconds`.

## `on_update`

Optional callable that receives lifecycle updates across all stages. Use this when you want to handle progress reporting yourself, for example to update a UI, feed a progress bar, or route updates to your own logging instead of the built-in `console_output`.

```python
def on_update(payload):
    # payload is a dict with: stage, level, message, progress, extraction_id
    print(payload["message"])
```

| Field | Description |
|-------|-------------|
| `stage` | Current lifecycle stage: `"upload"`, `"submission"`, `"waiting"`, `"questions"` (the extraction stopped to ask), `"download"`, or `"completion"`. |
| `level` | `"info"`, `"warn"`, or `"error"`. |
| `message` | Human-readable status message. |
| `progress` | Numeric progress when available, otherwise `None`. |
| `extraction_id` | The extraction ID once available, otherwise `None`. |

Used by: `extract(...)`, `upload_files(...)`, `wait_for_extraction_to_finish(...)`.

## Output expiry

There are two unrelated time limits on output files. Don't confuse them:

| Limit | What expires | Duration | What to do |
|-------|--------------|----------|------------|
| Signed download URL | The presigned URL itself | 5 minutes | Request a fresh URL via `download_output(...)` or `get_download_url(...)` |
| Output file retention | The generated file in storage | 90 days from `created_at` | Re-run the extraction; the original output is gone |

`output_expires_at` (on completed responses) tells you when the underlying file will be deleted. After that timestamp:

- `output["xlsx_url"]`, `output["csv_url"]`, `output["json_url"]` on polling/extract responses are `None`.
- `available_outputs` on list/details responses is an empty list.
- `get_download_url(...)` and `download_output(...)` raise `OUTPUT_EXPIRED`.

`OUTPUT_NOT_AVAILABLE` is a different error: it means the extraction either hasn't completed, or the requested format was never generated for it. `OUTPUT_EXPIRED` means the output existed but has aged out of retention.

## Conventions

- Method names are snake_case: `extract(...)`, `upload_files(...)`, `submit_extraction(...)`.
- All parameter names are snake_case: `api_key`, `folder_path`, `output_structure`, `task_name`, `upload_session_id`, `file_ids`, `console_output`, `on_update`, `file_path`.
- Response fields are snake_case, matching the API exactly. The SDK returns the same JSON shapes as the raw API: if you have the API docs, those response examples are valid for the SDK too.
- The `files` parameter accepts local file paths as strings only. File objects, byte streams, and in-memory buffers are not supported in v1.

## Rate Limits

All API endpoints are rate limited per API key. The SDK automatically retries rate-limited requests, but you should be aware of the limits if you are making many calls. Sustained overuse will result in a `RATE_LIMITED` error.

| Endpoints | Limit |
|-----------|-------|
| Upload endpoints (create session, get part URLs, complete upload) | 600 requests per minute |
| Submit extraction | 30 requests per minute |
| Poll extraction status | 120 requests per minute |
| Get results | 60 requests per minute |
| List extractions | 60 requests per minute |
| Get extraction details | 60 requests per minute |
| Download output | 30 requests per minute |
| Delete extraction | 30 requests per minute |
| Cancel extraction | 30 requests per minute |
| Check credit balance | 60 requests per minute |

A status request held open with `wait_seconds` counts as one request, however long it is held.

## Errors

SDK methods raise exceptions on failure:

- On failure, a method raises an `SdkError` (for SDK-level and validation errors) or `ApiResponseError` (for API response errors).
- The structured error body is available on `error.body`.
- `error.body` uses the same JSON error shape as the API.

Note: if you let an exception go uncaught, Python will usually only show the top-level error message in the traceback. To read the full structured SDK/API error payload, catch the exception and inspect `error.body`.

Error body shape:

```json
{
  "success": false,
  "error": {
    "code": "SOME_ERROR_CODE",
    "message": "Human-readable message.",
    "retryable": false,
    "details": null
  }
}
```

Read the error like this:

```python
from invoicedataextraction import InvoiceDataExtraction
from invoicedataextraction.errors import SdkError, ApiResponseError

try:
    client.check_extraction(extraction_id="...")
except (SdkError, ApiResponseError) as error:
    print(error.body["error"]["code"])
    print(error.body["error"]["message"])
    print(error.body["error"]["retryable"])
    print(error.body["error"]["details"])
```

Every error includes a `code` (machine-readable), `message` (human-readable), and `retryable` (whether retrying may succeed). The `message` is descriptive enough to act on directly in most cases. `details` provides additional context when available; for example, `INVALID_INPUT` errors include a `details.issues` list with the specific validation problems.

`INVALID_INPUT` can come from either the SDK (caught before the request is sent) or the API. Handle it the same way in both cases.

Authentication errors (`UNAUTHENTICATED`, `API_KEY_EXPIRED`, `API_KEY_REVOKED`) indicate a problem with your API key; generate a new one from your [dashboard](https://invoicedataextraction.com/dashboard?view=API).

The SDK automatically retries `RATE_LIMITED` and transient `INTERNAL_ERROR` responses, and a gateway error between you and the API (a non-JSON `429`, `502`, `503` or `504` response), but will surface them if retries are exhausted.

Method-specific errors like `EXTRACTION_NOT_FOUND`, `OUTPUT_NOT_AVAILABLE`, `OUTPUT_EXPIRED`, `EXTRACTION_IN_PROGRESS`, `EXTRACTION_NOT_CANCELLABLE`, and `INSUFFICIENT_CREDITS` are documented in the relevant method sections above. For full endpoint-level error details, see the [API docs](/api).

#### Extraction task failure codes

When an extraction task itself fails, the failure code comes from the API-owned extraction failure taxonomy documented in the REST API docs. In the SDK, that code appears on `result["error"]["code"]` from `extract(...)`, `check_extraction(...)`, and `wait_for_extraction_to_finish(...)`, or on `extraction["error"]["code"]` from `get_extraction(...)`.

Branch on `error["code"]`, `error["retryable"]`, and any returned `error["details"]` directly. The SDK does not provide subclasses for individual extraction failure codes.

### Task failure vs SDK/API failure:

- After an extraction task has been accepted, the task itself can still finish with `status: "failed"`.
- That is a task outcome, not an SDK error.
- `check_extraction(...)`, `wait_for_extraction_to_finish(...)`, and `extract(...)` return the polling response body for task states such as `processing`, `completed`, `cancelled`, and `failed`.
- When a task ends with `status: "failed"`, the failure details are in the returned response body, not on `error.body`.
- `error.body` is only used when the SDK method/request itself fails: validation errors, authentication errors, network failures, timeouts, or other operational failures.

### SDK-specific error codes:

| Code | When the SDK uses it |
|------|-----------------------|
| `SDK_FILESYSTEM_ERROR` | A local filesystem operation failed, such as reading an input file, creating a directory, or writing a downloaded file. |
| `SDK_NETWORK_ERROR` | A network request failed before the SDK received a valid HTTP response, including a status request that outlived its read timeout (see [Polling](#polling)). |
| `SDK_HTTP_ERROR` | The SDK received an unexpected HTTP response shape, such as a non-JSON response or another response that does not match the documented contract. |
| `SDK_TIMEOUT_ERROR` | `wait_for_extraction_to_finish(...)` timed out before the extraction finished. |
| `SDK_DOWNLOAD_ERROR` | An SDK-managed download step failed. |
| `SDK_UPLOAD_ERROR` | An SDK-managed upload orchestration step failed. |

## Method to API Endpoint Mapping

| SDK Method | Underlying API |
|------------|----------------|
| `extract(...)` | `upload_files(...)` → `submit_extraction(...)` → `wait_for_extraction_to_finish(...)`, with `answer_questions(...)` when the extraction asks and `on_questions` is set, then `download_output(...)` when `download` is set |
| `upload_files(...)` | `POST /uploads/sessions` → `POST /uploads/sessions/{id}/parts` → `POST /uploads/sessions/{id}/complete` |
| `submit_extraction(...)` | `POST /extractions` |
| `wait_for_extraction_to_finish(...)` | `GET /extractions/{extraction_id}?wait=30` (held by the API, repeated) |
| `get_results(...)` | `GET /extractions/{extraction_id}/results` |
| `iterate_results(...)` | `GET /extractions/{extraction_id}/results` (auto-paginated) |
| `download_output(...)` | `GET /extractions/{extraction_id}/output?format={format}` → presigned URL download |
| `check_extraction(...)` | `GET /extractions/{extraction_id}`, with `?wait=` when `wait_seconds` is set |
| `get_download_url(...)` | `GET /extractions/{extraction_id}/output?format={format}` |
| `cancel_extraction(...)` | `POST /extractions/{extraction_id}/cancel` |
| `answer_questions(...)` | `POST /extractions/{extraction_id}/answers` |
| `delete_extraction(...)` | `DELETE /extractions/{extraction_id}` |
| `get_credits_balance()` | `GET /credits/balance` |
| `list_extractions(...)` | `GET /extractions` |
| `iterate_extractions(...)` | `GET /extractions` (auto-paginated) |
| `get_extraction(...)` | `GET /extractions/{extraction_id}/details` |


---

# REST API Reference

The documentation above covers the Python SDK; prefer its methods over calling the REST API directly. The documentation below is the REST API reference the SDK is built on: the full endpoint contract, every error code and every response shape. The SDK covers every endpoint in it; use the reference for endpoint-level detail.

Note: The REST API reference below includes a "Node.js Example" section with a complete Node.js script. It shows the API workflow that the SDK's extract(...) performs for you, but you are building a Python integration: all code you write should be in Python.

---

# Invoice Data Extraction API

## Overview

Extracting data from your documents has four parts:

1. **Upload.** Create an upload session, upload each file in parts, then complete each upload (Steps 1 to 3).
2. **Submit.** Submit an extraction task that names the uploaded files and says what to extract (Step 4). Set `options.json_typed_values` to `true` so that amounts, quantities and rates come back as numbers.
3. **Wait.** Ask for the task's status with `wait`: the API holds the request and answers the moment the extraction finishes (Step 5).
4. **Get the rows.** Read the extracted rows as JSON, in pages, with the Review Needed items alongside (Step 6). To get a spreadsheet, download the XLSX, CSV or JSON file with the URLs on the completed response instead ([Download Output](#download-output)).

Set `options.ask_questions` to `true` and the extraction can stop to ask you when the documents leave something unsettled: the status then carries the questions, you answer them, and the extraction continues ([Input required](#input-required)). Turn it on when you, or a person watching the dashboard, can answer within a few minutes, and leave it off for a job nobody is watching; [Input required](#input-required) says what happens to an unanswered question.

Each file in the session is uploaded and completed independently. If a file fails at any stage, you can still upload, complete, and submit the other files.

Extraction tasks submitted via API appear in your [web dashboard](https://invoicedataextraction.com/dashboard) alongside tasks submitted from the web app. You can view progress and results, and download output, from either.

**Versioning.** The API is versioned by URL path (`/v1`). Backward-compatible additions ship without a version change and are announced in the [changelog](https://invoicedataextraction.com/changelog). Breaking changes, if ever needed, ship as a new version path with at least 6 months of dual operation. The contract is also published as an [OpenAPI 3.1 specification](https://invoicedataextraction.com/openapi.yaml), and this reference as [Markdown](https://invoicedataextraction.com/api.md).

## Authentication

All API requests require a Bearer token in the `Authorization` header:

```
Authorization: Bearer YOUR_API_KEY
```

Generate and manage your API keys from your dashboard at https://invoicedataextraction.com/dashboard?view=API. Every account includes 50 free pages per month.

### Identifying your client

Optionally send an `X-SDK-Name` header naming the client your requests come from (up to 32 characters; the official SDKs send `node` and `python`), and `X-SDK-Version` with its version. It helps us support your integration when you write in.

### Teams and your API key

If you're a team admin, your API key has access to your team's extractions by default. See the `scope` query parameter on [List Extractions](#list-extractions), [Step 5](#step-5-wait-for-the-extraction-to-finish), [Get Results](#step-6-get-results), [Get Extraction Details](#get-extraction-details), [Download Output](#download-output), [Cancel Extraction](#cancel-extraction), and [Delete Extraction](#delete-extraction).

Visibility is tied to your **current** team membership, not to the team you were in when you created the key. If you leave a team, all of your API keys lose team-scope visibility immediately. If you join or create a new team, the keys you already have gain visibility into that team.

## Error Responses

All endpoints return errors in this format:

```json
{
  "success": false,
  "error": {
    "code": "ERROR_CODE",
    "message": "Human-readable error message.",
    "retryable": false,
    "details": null
  }
}
```

`retryable` indicates whether the same request can be retried. When `true`, the error is transient (e.g., a temporary server issue) and retrying after a short delay may succeed. When `false`, the request itself is invalid and retrying will produce the same error.

The following errors can be returned by any endpoint:

| Code | Status | Retryable | Message |
|------|--------|-----------|---------|
| `UNAUTHENTICATED` | 401 | No | Missing or invalid API key. Send it as a Bearer token in the Authorization header. Create or view your keys at https://invoicedataextraction.com/dashboard?view=API. |
| `API_KEY_EXPIRED` | 401 | No | This API key has expired. Create a new key at https://invoicedataextraction.com/dashboard?view=API and use it instead. |
| `API_KEY_REVOKED` | 401 | No | This API key has been revoked. Create a new key at https://invoicedataextraction.com/dashboard?view=API and use it instead. |
| `NOT_FOUND` | 404 | No | The requested endpoint does not exist. Check the path against the API reference at https://invoicedataextraction.com/api. |
| `RATE_LIMITED` | 429 | Yes | Too many requests to this endpoint. Wait the number of seconds in details.retry_after_seconds (also sent as the Retry-After header), then retry. |
| `INTERNAL_ERROR` | 500 | Yes | Something went wrong on our side. Retry after a short delay. If it keeps failing, email support@invoicedataextraction.com. |

Every message says what happened and what to do next, so a client that reads only the response body can act on it.

`details` is always present. It is `null` when there is no additional context, or an object with error-specific information. For example, `INVALID_INPUT` errors include validation issues:

```json
{
  "success": false,
  "error": {
    "code": "INVALID_INPUT",
    "message": "Request validation failed. Check details for specific issues.",
    "retryable": false,
    "details": {
      "issues": [
        { "message": "file_name must end with a supported extension: .pdf, .jpg, .jpeg, or .png.", "path": ["files", 0, "file_name"] }
      ]
    }
  }
}
```

## Rate Limits

All endpoints are rate limited per API key. If you exceed the limit, the API returns a `429` status whose `details.retry_after_seconds` says how many seconds to wait before retrying; the same value is sent as the `Retry-After` header.

| Endpoints | Limit |
|-----------|-------|
| Upload endpoints (create session, get part URLs, complete upload) | 600 requests per minute |
| Submit extraction | 30 requests per minute |
| Poll extraction status | 120 requests per minute |
| Get results | 60 requests per minute |
| List extractions | 60 requests per minute |
| Get extraction details | 60 requests per minute |
| Download output | 30 requests per minute |
| Delete extraction | 30 requests per minute |
| Cancel extraction | 30 requests per minute |
| Answer questions | 30 requests per minute |
| Check credit balance | 60 requests per minute |

A status poll held open with `wait` counts as one request, however long it is held.

---

# Step 1: Create Upload Session

Creates an upload for one or more files. Returns the part size you should use when chunking files for upload.

## Endpoint

```
POST https://api.invoicedataextraction.com/v1/uploads/sessions
```

**Authentication:** Bearer token in the `Authorization` header.

```
Authorization: Bearer YOUR_API_KEY
```

## Request Body

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `upload_session_id` | string | Yes | Your unique identifier for this upload session. Only letters, numbers, dots, underscores, colons, and hyphens (1-200 characters), the same rule as `file_id`. Use a different ID for each new session. If a request fails or times out, you can safely retry with the same ID and files: the existing session is returned without creating duplicates. |
| `files` | array | Yes | The files you want to upload (1 to 6,000 files). |

Each item in `files`:

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `file_id` | string | Yes | Your unique identifier for this file within the session. Only letters, numbers, dots, underscores, colons, and hyphens (1-200 characters). You'll use this ID to reference the file when requesting part URLs and completing the upload. |
| `file_name` | string | Yes | The file name, including extension. Must end in `.pdf`, `.jpg`, `.jpeg`, or `.png`. |
| `file_size_bytes` | integer | Yes | The exact size of the file in bytes. |

### File Limits

| Type | Max Size |
|------|----------|
| PDF | 150 MB |
| JPG / JPEG / PNG | 5 MB |
| Total batch size | 2 GB |
| Max files per session | 6,000 |

## Example Request

```bash
curl -X POST "https://api.invoicedataextraction.com/v1/uploads/sessions" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "upload_session_id": "sess_001",
    "files": [
      {
        "file_id": "file_001",
        "file_name": "invoice-1.pdf",
        "file_size_bytes": 120450
      },
      {
        "file_id": "file_002",
        "file_name": "receipt.jpg",
        "file_size_bytes": 84200
      }
    ]
  }'
```

## Success Response (200)

```json
{
  "success": true,
  "upload_session_id": "sess_001",
  "files": [
    {
      "file_id": "file_001",
      "file_name": "invoice-1.pdf",
      "part_size": 8388608
    },
    {
      "file_id": "file_002",
      "file_name": "receipt.jpg",
      "part_size": 8388608
    }
  ]
}
```

`part_size` is the chunk size in bytes to use when splitting files for multipart upload. This value is the same for all files in the session. Files smaller than `part_size` are uploaded as a single part.

## Error Codes

| Code | Status | Retryable | Message |
|------|--------|-----------|---------|
| `DUPLICATE_FILE_NAME` | 400 | No | Each file must have a unique file_name. Rename the duplicates listed in details and create the session again. |
| `DUPLICATE_FILE_ID` | 400 | No | Each file must have a unique file_id. Change the duplicates listed in details and create the session again. |
| `FILE_TOO_LARGE` | 400 | No | A file exceeds the maximum size for its type. Split or compress the file named in details to within the limit shown, then create the session again. |
| `TOTAL_UPLOAD_SIZE_LIMIT_EXCEEDED` | 400 | No | The combined size of all files exceeds the maximum for one upload session. Split the files across two or more sessions; details shows the limit. |
| `INSUFFICIENT_CREDITS` | 402 | No | Not enough credits for this upload session: each file needs at least one credit, and details shows your balance. Buy credits at https://invoicedataextraction.com/dashboard?view=Billing, or upload fewer files. In details, `credits_reserved` are credits held by extractions currently being processed. |
| `SESSION_ALREADY_INITIALIZED` | 409 | No | This upload_session_id is already in use. Create the session again with a new upload_session_id. |

## Idempotency

You can safely retry a failed or timed-out request using the same `upload_session_id` and `files`. If the session was already created, the existing session is returned. If you need a new session with different files, use a different `upload_session_id`.

## Next Step

After creating the upload session, request presigned part URLs for each file to begin uploading.

---

# Step 2: Get Part Upload URLs

For each file, request presigned URLs for the parts you need to upload. You then `PUT` your file bytes directly to these URLs.

## Endpoint

```
POST https://api.invoicedataextraction.com/v1/uploads/sessions/{upload_session_id}/parts
```

`{upload_session_id}` is the ID you provided when creating the upload session in Step 1.

**Authentication:** Bearer token in the `Authorization` header.

## Request Body

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `file_id` | string | Yes | The file ID you used when creating the upload session. |
| `part_numbers` | array of integers | Yes | The part numbers you want upload URLs for (1-indexed). |

### How to calculate part numbers

Use the `part_size` from the Step 1 response to determine how many parts your file needs:

```
total_parts = ceil(file_size_bytes / part_size)
part_numbers = [1, 2, 3, ..., total_parts]
```

Files smaller than `part_size` need only one part: `[1]`.

In the examples below, `part_size` is `8388608` (8 MB):
- A 120 KB file is smaller than 8 MB, so it needs only part `[1]`.
- A 20 MB file needs `ceil(20_000_000 / 8_388_608) = 3` parts: `[1, 2, 3]`.

## Example: Small file (single part)

```bash
curl -X POST "https://api.invoicedataextraction.com/v1/uploads/sessions/sess_001/parts" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "file_id": "file_001",
    "part_numbers": [1]
  }'
```

```json
{
  "success": true,
  "upload_session_id": "sess_001",
  "file_id": "file_001",
  "file_name": "invoice-1.pdf",
  "part_size": 8388608,
  "part_urls": [
    {
      "part_number": 1,
      "url": "https://storage.example.com/...?X-Amz-Signature=..."
    }
  ]
}
```

## Example: Large file (multiple parts)

```bash
curl -X POST "https://api.invoicedataextraction.com/v1/uploads/sessions/sess_001/parts" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "file_id": "file_002",
    "part_numbers": [1, 2, 3]
  }'
```

```json
{
  "success": true,
  "upload_session_id": "sess_001",
  "file_id": "file_002",
  "file_name": "large-report.pdf",
  "part_size": 8388608,
  "part_urls": [
    {
      "part_number": 1,
      "url": "https://storage.example.com/...?X-Amz-Signature=..."
    },
    {
      "part_number": 2,
      "url": "https://storage.example.com/...?X-Amz-Signature=..."
    },
    {
      "part_number": 3,
      "url": "https://storage.example.com/...?X-Amz-Signature=..."
    }
  ]
}
```

## Uploading parts

Once you have the presigned URLs, split your file into chunks and upload each one. Each presigned URL is valid for 15 minutes. One request can ask for up to 1,000 part numbers; for a very large file, request URLs in batches just before uploading each batch rather than all at once.

### How it works

1. **Read the file as binary** (Buffer, ArrayBuffer, Uint8Array, etc.).
2. **Slice into chunks** of `part_size` bytes (returned in the Step 1 response). The last chunk is usually smaller, which is fine.
3. **PUT each chunk** to the corresponding presigned URL. Send the raw bytes as the request body, with no special headers or encoding.
4. **Capture the `ETag` response header** from each PUT response. The ETag is a quoted string (e.g., `"d41d8cd98f00b204e9800998ecf8427e"`). Keep the quotes: you'll need the exact value in Step 3.

See the [full Node.js example](#nodejs-example) at the end of this document.

## Error Codes

| Code | Status | Retryable | Message |
|------|--------|-----------|---------|
| `FILE_NOT_FOUND` | 404 | No | This file_id was not registered when the upload session was created. Use a file_id from that session, or create a new session that includes this file. |
| `FILE_NOT_UPLOADABLE` | 409 | No | This file has already been completed or aborted, so no more parts can be uploaded for it. To upload it again, create a new upload session. |

## Next Step

After uploading all parts for a file, complete the upload with the ETags from each part.

---

# Step 3: Complete File Upload

After uploading all parts for a file, call this endpoint with the ETags to finalize the upload. Call this once per file.

## Endpoint

```
POST https://api.invoicedataextraction.com/v1/uploads/sessions/{upload_session_id}/complete
```

`{upload_session_id}` is the ID you provided when creating the upload session in Step 1.

**Authentication:** Bearer token in the `Authorization` header.

## Request Body

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `file_id` | string | Yes | The file ID you used when creating the upload session. |
| `parts` | array | Yes | The part numbers and ETags from your part uploads. |

Each item in `parts`:

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `part_number` | integer | Yes | The part number (matches what you requested in Step 2). |
| `e_tag` | string | Yes | The ETag returned in the response header when you uploaded this part. Include the surrounding quotes (e.g., `"\"a1b2c3...\""`) |

## Example Request

```bash
curl -X POST "https://api.invoicedataextraction.com/v1/uploads/sessions/sess_001/complete" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "file_id": "file_001",
    "parts": [
      {
        "part_number": 1,
        "e_tag": "\"a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4\""
      }
    ]
  }'
```

For a multi-part file:

```bash
curl -X POST "https://api.invoicedataextraction.com/v1/uploads/sessions/sess_001/complete" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "file_id": "file_002",
    "parts": [
      { "part_number": 1, "e_tag": "\"a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4\"" },
      { "part_number": 2, "e_tag": "\"f6e5d4c3b2a1f6e5d4c3b2a1f6e5d4c3\"" },
      { "part_number": 3, "e_tag": "\"9876543210ab9876543210ab9876543210\"" }
    ]
  }'
```

## Success Response (200)

```json
{
  "success": true,
  "upload_session_id": "sess_001",
  "file_id": "file_001",
  "file_name": "invoice-1.pdf"
}
```

## Idempotency

If a file has already been completed, calling this endpoint again returns a success response. This makes it safe to retry if your connection drops before you receive the response.

## Error Codes

| Code | Status | Retryable | Message |
|------|--------|-----------|---------|
| `FILE_NOT_FOUND` | 404 | No | This file_id was not registered when the upload session was created. Use a file_id from that session, or create a new session that includes this file. |
| `FILE_ABORTED` | 409 | No | This file was aborted and cannot be completed. To upload it again, create a new upload session. |
| `INVALID_COMPLETION_PARTS` | 400 | No | The parts provided to complete this file upload are invalid. Fix the problem described in details (every part from 1 to the part count, each with its e_tag), then resend. |
| `OBJECT_SIZE_MISMATCH` | 422 | No | The uploaded bytes do not match the file_size_bytes declared when the session was created; details shows both sizes. Create a new session declaring the actual size and upload the file again. |
| `UPLOAD_ID_NOT_FOUND` | 409 | No | This upload session is no longer available. Create a new upload session and upload the files again. |
| `UPLOAD_COMPLETE_FAILED` | 502 | Yes | Completing the file upload failed on our side. Retry the same request; if it keeps failing, create a new upload session. |

## Next Step

After completing all files, submit an extraction task.

---

# Step 4: Submit Extraction Task

Submit an extraction task referencing your uploaded files. You tell the API what data to extract using a `prompt`.

## Endpoint

```
POST https://api.invoicedataextraction.com/v1/extractions
```

**Authentication:** Bearer token in the `Authorization` header.

## Request Body

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `submission_id` | string | Yes | Your unique identifier for this submission. Only letters, numbers, dots, underscores, colons, and hyphens (1-200 characters), the same rule as `file_id`. If a request fails or times out, retry with the same `submission_id` to safely retrieve the existing task instead of creating a duplicate. Use a different ID for each new extraction task (e.g., a UUID). |
| `upload_session_id` | string | Yes | The upload session ID from Step 1. |
| `file_ids` | array of strings | Yes | The file IDs to include in this extraction. Must reference files that were completed in Step 3. |
| `task_name` | string | Yes | Your own label for this extraction task, for your internal reference (3-40 characters). |
| `prompt` | string or object | Yes | Your extraction instructions. See below. |
| `output_structure` | string | Yes | `"automatic"`, `"per_invoice"`, or `"per_line_item"`. |
| `options` | object | No | Configuration options. See below. |

### Output structure

Controls how the extracted data is structured:

| Value | Meaning |
|-------|---------|
| `automatic` | The AI decides based on your prompt and documents. |
| `per_invoice` | Each invoice becomes a single row (spreadsheet/CSV) or object (JSON). |
| `per_line_item` | Each individual product/service listed within an invoice becomes its own row (spreadsheet/CSV) or object (JSON). |

### Prompt

The `prompt` field tells the AI what data to extract from your documents. It can be either a **string** or an **object**.

**As a string.** Describe what you want in natural language (max 2,500 characters):

```json
"prompt": "Extract invoice number, date, vendor name, total amount, and all line items with descriptions and amounts"
```

**As an object.** Define exact output field names, with optional per-field and general instructions:

```json
"prompt": {
  "fields": [
    { "name": "Invoice Number" },
    { "name": "Invoice Date", "prompt": "The date the invoice was issued, NOT the payment due date" },
    { "name": "Vendor Name" },
    { "name": "Total Amount", "prompt": "Do not include currency symbol, use 2 decimal places" }
  ],
  "general_prompt": "One row for each product. Do not extract shipping lines."
}
```

Use an object when you need exact output field names: each `name` is guaranteed to appear exactly as written in the extracted data. With a string, the AI chooses field names based on your instructions.

For guidance on writing effective prompts, see the [Extraction Guide](https://invoicedataextraction.com/extraction-guide).

Each item in `fields`:

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `name` | string | Yes | The name for this data point in the output (2-50 characters). Prefer clear, descriptive names (e.g., "Invoice Number", not "Field A"). |
| `prompt` | string | No | Specific instructions for extracting this data point (3–600 characters). Use this to clarify ambiguities or instruct special handling. |

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `general_prompt` | string | No | Instructions that apply to the full task and across all fields (max 1,500 characters). Use this to provide special handling instructions, specify output structure/formatting, or describe the extraction goal. |

### Options

The `options` object is optional, and so is every field in it. The three output preferences (`output_language`, `review_needed_fill_color`, `affected_field_fill_color`) default to the settings on your web app **Preferences** page: automatic language and orange highlights unless you have changed them there. A value you send applies to this extraction only and leaves your account preferences unchanged.

| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `exclude_columns` | array of strings | `[]` | System-generated columns to exclude from output files. By default, output files include a "Source File" column indicating which uploaded file/page each row was extracted from, and a "Review Needed" column marking rows that need human verification. If your workflow requires an exact output structure, you can exclude either column. Valid values: `"source_file"`, `"review_needed"`. Excluding `"review_needed"` only removes the export column; completed API responses still include `review_needed` so you can programmatically check whether manual review is required. |
| `output_language` | string | Account preference | The language of the text the AI writes for you: Review Needed messages and prompt notes. Either `"automatic"` or one of the language codes below. With `"automatic"`, the language follows your prompt (your instructions and field names), not the language of your documents, and is English when that is unclear. Extracted values, file names and your field names are never translated. |
| `review_needed_fill_color` | string | Account preference | Highlight color for cells in the Review Needed column that carry a warning. XLSX files only; has no effect when the Review Needed column is excluded. One of `"none"`, `"yellow"`, `"orange"`, `"red"`. |
| `affected_field_fill_color` | string | Account preference | Highlight color for the extracted data cells a Review Needed warning refers to. XLSX files only; has no effect when the Review Needed column is excluded. One of `"none"`, `"yellow"`, `"orange"`, `"red"`. |
| `send_completion_email` | boolean | `false` | Set to `true` to be emailed at your account's email address when this extraction finishes processing, whether it completes, fails or is cancelled. A submission refused before processing starts (for example for insufficient credits or an encrypted file) sends no email; its failure appears when you poll. Applies to this extraction only; the web app's "Email me when extraction tasks finish" preference does not apply to API submissions. |
| `json_typed_values` | boolean | `false` | Set to `true` to receive the JSON output with native JSON types instead of strings: amounts, quantities and rates as JSON numbers, yes/no fields as JSON booleans, and a cell with nothing in it as `null`. Recommended for a new integration that reads the rows or the JSON file. Applies to the JSON output file and [Get Results](#step-6-get-results); XLSX and CSV are unchanged. Fixed at submission for this extraction. See [JSON value types](#json-value-types). |
| `ask_questions` | boolean | `false` | Set to `true` to let the extraction stop and ask you when the documents leave something unsettled, instead of deciding on its own. The status is then `input_required` and carries the questions; answer them with [Answer Questions](#answer-questions) and the extraction continues. See [Input required](#input-required) for what a question looks like and how long it waits. Off, the extraction never stops to ask. |

Language codes for `output_language`:

| Code | Language |
|------|----------|
| `en` | English |
| `ar` | Arabic |
| `zh-Hant` | Traditional Chinese |
| `zh-Hans` | Simplified Chinese |
| `nl` | Dutch |
| `fr` | French |
| `de` | German |
| `el` | Greek |
| `he` | Hebrew |
| `it` | Italian |
| `ja` | Japanese |
| `pl` | Polish |
| `pt` | Portuguese |
| `es` | Spanish |
| `th` | Thai |

## Example: String prompt

```bash
curl -X POST "https://api.invoicedataextraction.com/v1/extractions" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "submission_id": "sub_001",
    "upload_session_id": "sess_001",
    "file_ids": ["file_001", "file_002"],
    "task_name": "January invoices",
    "prompt": "Extract invoice number, date, vendor name, and total amount",
    "output_structure": "per_invoice",
    "options": {
      "json_typed_values": true
    }
  }'
```

## Example: Object prompt

```bash
curl -X POST "https://api.invoicedataextraction.com/v1/extractions" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "submission_id": "sub_002",
    "upload_session_id": "sess_001",
    "file_ids": ["file_001", "file_002"],
    "task_name": "January invoices",
    "prompt": {
      "fields": [
        { "name": "Invoice Number" },
        { "name": "Invoice Date", "prompt": "The date the invoice was issued, NOT the payment due date" },
        { "name": "Vendor Name" },
        { "name": "Line Item Description" },
        { "name": "Line Item Amount", "prompt": "Do not include currency symbol, use 2 decimal places" }
      ],
      "general_prompt": "Dates should be in YYYY-MM-DD format. Ignore email cover letters."
    },
    "output_structure": "per_line_item",
    "options": {
      "exclude_columns": ["source_file"],
      "send_completion_email": true,
      "json_typed_values": true
    }
  }'
```

## Success Response (202)

```json
{
  "success": true,
  "extraction_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "submission_state": "received"
}
```

The task is now queued for processing. Use the `extraction_id` to wait for the result (Step 5).

Once submitted, the extraction task also appears in the [web dashboard](https://invoicedataextraction.com/dashboard) alongside tasks submitted from the web app, where you can view its progress and results.

## Error Codes

In addition to the general API errors, submission can return:

| Code | Status | Retryable | Message |
|------|--------|-----------|---------|
| `SUBMISSIONS_PAUSED` | 503 | Yes | Extraction submissions are temporarily paused while we deploy an update. Retry in a few minutes; when details.resumes_at is present, that is the expected resume time. |

When available, `SUBMISSIONS_PAUSED` includes `details.resumes_at` with the expected resume time in UTC. This time is advisory; retry after a few minutes if it is absent.

## Idempotency

If a request fails or times out, you can safely retry with the same `submission_id`. If the task was already created, the existing task is returned without creating a duplicate. Use a different `submission_id` for each new extraction task.

## Next Step

After submitting, wait for the extraction to finish (Step 5).

---

# Step 5: Wait for the Extraction to Finish

After submitting, ask this endpoint for the extraction's status. Add `wait` and the API holds your request until the extraction leaves `processing` or the wait runs out, so a handful of calls replaces a polling loop. The status ends as one of `completed`, `failed` or `cancelled`; an extraction submitted with `options.ask_questions` can also stop at `input_required` while it waits for your answers.

## Endpoint

```
GET https://api.invoicedataextraction.com/v1/extractions/{extraction_id}?wait=45
```

`{extraction_id}` is returned in the Step 4 response.

**Authentication:** Bearer token in the `Authorization` header.

### Query Parameters

| Parameter | Required | Description |
|-----------|----------|-------------|
| `wait` | No | Seconds to hold the request open, from `1` to `45`. The response is sent as soon as the extraction leaves `processing`, or when the time is up, whichever comes first. A response that arrives after the full wait is an ordinary processing response: call again. Without `wait`, the current status is returned immediately. |
| `scope` | No | One of `own`, `team`. Team admins default to `team` (so they can check any of their team members' runs); other callers default to `own`. Passing `scope=team` from a non-admin returns `403 FORBIDDEN`. |

## Response

The response always includes `success`, `status`, and `extraction_id`. `status` is exactly one of `processing`, `input_required`, `completed`, `failed`, `cancelled`, and `success` is `true` for all of these except `failed`. Branch on `status`, and treat a value you do not recognise as non-terminal: keep polling, with your own timeout as the backstop. The rest of the response depends on the status.

### Processing (keep polling)

```json
{
  "success": true,
  "status": "processing",
  "extraction_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "progress": 42
}
```

`progress` is an integer from 0 to 100 indicating approximate completion. The task is still being processed: call again, with `wait` to be answered as soon as it finishes.

### Input required

An extraction submitted with `options.ask_questions: true` can stop to ask about something the documents did not settle. Its status is then `input_required`, from the moment the questions exist, and the response carries them:

```json
{
  "success": true,
  "status": "input_required",
  "extraction_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "progress": 22,
  "answer_by": "2026-09-12T11:14:00Z",
  "questions": [
    {
      "question_id": "q_2524",
      "type": "single_choice",
      "question": "Which name should fill the “Supplier name” column when the invoice issuer and payment recipient differ?",
      "example_from_documents": "The invoices show “Northgate Search Services” as the invoice issuer and say “Please make payment to NSS Franchise Holdings Ltd”.",
      "scope": {
        "level": "extraction",
        "applies_to": "Every document in this extraction, not only the example. If the answer differs by document type, say so in text."
      },
      "choices": [
        { "choice_id": "a", "label": "Use the invoice issuer's name", "cell_would_contain": "Northgate Search Services", "recommended": true },
        { "choice_id": "b", "label": "Use the payment recipient's name", "cell_would_contain": "NSS Franchise Holdings Ltd" }
      ]
    },
    {
      "question_id": "q_2529",
      "type": "free_text",
      "question": "What date format should I use in the “invoice_date” and “due_date” columns?",
      "example_from_documents": "The files show dates such as “Aug 27, 2026”, “28 Aug 2026”, and “2026/09/08”.",
      "scope": {
        "level": "extraction",
        "applies_to": "Every document in this extraction, not only the example. If the answer differs by document type, say so in text."
      },
      "choices": [],
      "recommended_approach": "I will use YYYY-MM-DD for every value in the “invoice_date” and “due_date” columns."
    }
  ]
}
```

| Field | Description |
|-------|-------------|
| `questions[].question_id` | The question's identifier. Use it when answering. |
| `questions[].type` | `single_choice`: the question offers `choices`; answer with one of them, with words added if you like. `free_text`: the question wants your own words; answer with them, or accept the recommended approach. Words are accepted on every question, so a type you do not recognise can still be answered with `text`. |
| `questions[].question` | What the extraction needs settled, in plain words. |
| `questions[].example_from_documents` | A real instance from your documents that the answer would change. Present when one is visible. |
| `questions[].scope.level` | What the answer governs. `extraction`: every document in the extraction, not only the one in the example. This is the only value today; treat a value you do not recognise as narrower than the extraction and read `applies_to`. |
| `questions[].scope.applies_to` | The scope in words, for whoever is answering. |
| `questions[].choices` | The ways to answer a `single_choice` question, each with a `choice_id` and a `label`. One carries `recommended: true`. `cell_would_contain` is the value that choice would put in the spreadsheet cell for the example, when known. Empty for `free_text`. |
| `questions[].recommended_approach` | On a `free_text` question: what the extraction does if you accept the recommendation instead of answering in your own words. |
| `questions[].previous_answer_rejected` | Present, and `true`, when an earlier answer to this question was refused ([Answer Questions](#answer-questions) says what is refused). Answer differently. |
| `progress` | As on the processing response. |
| `answer_by` | ISO 8601. The moment by which the questions must be answered. |

An answer governs the whole extraction: every document in it, not only the one in the example, which is one instance the answer would change. Where the right answer differs by document type, say so in `text`; words are accepted on every question.

What happens, and when: questions appear the moment they exist, and every open question of the extraction is listed together. If the questions are not all answered within about four minutes, the extraction pauses and waits, and the account owner is emailed that a task is waiting for an answer, with a second email before the deadline. If the questions are not all answered by `answer_by`, which is 40 hours after the files were uploaded, the extraction is cancelled with `cancellation_reason: unanswered` and the work done so far is charged. The extraction continues the moment every open question has an answer. The same questions appear in the [web dashboard](https://invoicedataextraction.com/dashboard), where a person can answer them too.

Answer with [Answer Questions](#answer-questions). A request held with `wait` is answered the moment the status becomes `input_required`.

### Completed

```json
{
  "success": true,
  "status": "completed",
  "extraction_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "credits_deducted": 25,
  "credits_balance": 125,
  "credits_reserved": 0,
  "output_structure": "per_invoice",
  "output_expires_at": "2026-07-26T10:30:00Z",
  "pages": {
    "successful_count": 10,
    "failed_count": 2,
    "successful": [
      { "file_name": "invoice-1.pdf", "page": 1 },
      { "file_name": "invoice-1.pdf", "page": 2 }
    ],
    "failed": [
      { "file_name": "damaged.pdf", "page": 1 }
    ],
    "failure_reasons": [
      {
        "code": "PROCESSING_FILE_SIZE_LIMIT_EXCEEDED",
        "message": "The upload was accepted, but during processing part of the PDF became too large for our file-processing limit. This can happen when a compressed PDF is processed internally. Split the PDF into smaller page chunks and resubmit.",
        "affected_pages": [
          { "file_name": "damaged.pdf", "pages": [1] }
        ]
      }
    ]
  },
  "ai_uncertainty_notes": [
    {
      "topic": "Documents to extract from",
      "description": "Your files often contain a 'Tax Invoice' with an attached 'Delivery Note'. I treated the 'Tax Invoice' pages as the main source of data, and ignored the attached 'Delivery Note' pages as supporting context.",
      "suggested_prompt_additions": [
        {
          "purpose": "To confirm this handling",
          "instructions": ["Extract from 'Tax Invoice' only"]
        },
        {
          "purpose": "To extract from both",
          "instructions": ["Extract from 'Tax Invoice' and 'Delivery Note'"]
        }
      ]
    }
  ],
  "review_needed": {
    "count": 1,
    "items": [
      {
        "message": "Check whether the extracted total should include the handwritten adjustment near the bottom of the document.",
        "affected_fields": ["Total Amount"],
        "output_row_numbers": [4],
        "source_references": ["invoice-1.pdf (Page 2)"]
      }
    ]
  },
  "output": {
    "xlsx_url": "https://storage.example.com/...?X-Amz-Signature=...",
    "csv_url": "https://storage.example.com/...?X-Amz-Signature=...",
    "json_url": "https://storage.example.com/...?X-Amz-Signature=..."
  }
}
```

| Field | Description |
|-------|-------------|
| `credits_deducted` | The number of credits charged for this extraction (one credit per successful page). |
| `credits_balance` | Your total credit balance after this extraction was charged (paid plus free credits), the same figure [Check Credit Balance](#check-credit-balance) returns. Use it to warn before the balance runs out; credits are bought in the dashboard at https://invoicedataextraction.com/dashboard?view=Billing. |
| `credits_reserved` | Credits held by your extractions still being processed. Your usable balance is `credits_balance` minus `credits_reserved`. |
| `output_structure` | The effective output structure. Returns the AI-determined structure when 'automatic' was provided; otherwise returns the submitted value (`"per_invoice"`, or `"per_line_item"`) |
| `output_expires_at` | ISO 8601 timestamp at which the output files become unavailable (currently 90 days after submission). After this point, the `output.*_url` fields are `null` and [Download Output](#download-output) returns `OUTPUT_EXPIRED`. |
| `pages.successful_count` | Number of pages successfully processed. |
| `pages.failed_count` | Number of pages that failed processing. |
| `pages.successful` | List of successfully processed pages. Each item has `file_name` (the uploaded file name) and `page` (the page number within that file). |
| `pages.failed` | List of pages that failed processing. Same shape as `successful`. |
| `pages.failure_reasons` | Page-failure reason metadata when available. Empty array if none. Each item has a `code`, user-facing `message`, and `affected_pages` grouped by uploaded `file_name` with source-file page numbers. |
| `ai_uncertainty_notes` | Prompt notes: areas where your prompt left room for interpretation and the AI made an assumption about how to apply it to the documents. Empty array if none. Each note has a `topic`, a `description` of what was assumed, and a `suggested_prompt_additions` array of prompt additions you can use to remove the ambiguity in future extractions. Each item has a `purpose` (why you'd add it) and `instructions` (prompt text you can add). |
| `review_needed` | Result-level warnings for records that need human verification before you rely on the output. Always present on completed responses as `{ "count": number, "items": [...] }`. Check `review_needed.count`; if greater than `0`, alert your workflow or team to manually verify the listed rows. Each item has a user-facing `message`, `affected_fields`, `output_row_numbers`, and `source_references`. `affected_fields` is populated only when the issue is tied to specific output fields; it is empty for broader row/document review concerns. `output_row_numbers` contains one or more 1-based data row numbers and does not include the header row; for example, `1` means the first extracted row, not Excel/CSV worksheet row 2. This response is returned even if you excluded the Review Needed export column with `options.exclude_columns`. You can also review these items in the web dashboard. |
| `output.xlsx_url` | Presigned download URL for the Excel (.xlsx) file. `null` if not generated, or if the extraction is past `output_expires_at`. |
| `output.csv_url` | Presigned download URL for the CSV file. `null` if not generated, or if the extraction is past `output_expires_at`. |
| `output.json_url` | Presigned download URL for the JSON file. `null` if not generated, or if the extraction is past `output_expires_at`. |

Read the extracted rows with [Get Results](#step-6-get-results): they come back as JSON, in pages, with the Review Needed items for each page alongside, without downloading a file.

To get a file instead, make a plain `GET` request to one of the `output` URLs; no `Authorization` header or other authentication is needed. The URLs expire after **5 minutes**. If a URL has expired, [Download Output](#download-output) gives a fresh one, provided the extraction itself is within its 90-day retention window.

We strongly recommend checking `review_needed.count` before relying on extracted data. If it is greater than `0`, route the listed rows for manual verification in your workflow.

### Failed

When an extraction fails, the response uses the standard error format plus `status: "failed"` and `extraction_id`.

**`INSUFFICIENT_CREDITS`**: `credits_balance` is your total credit balance. `credits_reserved` are credits held by extractions currently being processed (your available credits = balance minus reserved).

```json
{
  "success": false,
  "status": "failed",
  "extraction_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "error": {
    "code": "INSUFFICIENT_CREDITS",
    "message": "Not enough credits for this extraction; details shows the credits required and your balance. Buy credits at https://invoicedataextraction.com/dashboard?view=Billing or submit fewer pages, then submit again.",
    "retryable": false,
    "details": {
      "credits_required": 25,
      "credits_balance": 15,
      "credits_reserved": 10
    }
  }
}
```

**`FILE_PAGE_LIMIT_EXCEEDED`** / **`ENCRYPTED_FILE`**: `details.file_names` lists the affected files.

```json
{
  "success": false,
  "status": "failed",
  "extraction_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "error": {
    "code": "ENCRYPTED_FILE",
    "message": "One or more files are password-protected; details lists them. Remove the protection, upload the files again and submit again.",
    "retryable": false,
    "details": {
      "file_names": ["protected-invoice.pdf"]
    }
  }
}
```

All other error codes have `details: null`. Where the next step is to submit again, use a new `submission_id`: the same one returns this failed task.

| Code | Retryable | Message |
|------|-----------|---------|
| `CONCURRENT_TASK_LIMIT` | Yes | You already have the maximum number of extractions running. Wait for one to finish, then submit again with a new submission_id. |
| `NO_PAGES_FOUND` | No | No readable pages were found in the uploaded files. Check that each file opens and contains pages, then upload them again. |
| `VALIDATION_FAILED` | No | The files did not pass validation against the file count, size or type limits. Check the limits in the API reference at https://invoicedataextraction.com/api, adjust the files and submit again. |
| `PROMPT_REJECTED` | No | The prompt was rejected because it does not describe data to extract from documents. Rewrite it as extraction instructions, naming the fields you want, and submit again. |
| `PROMPT_UNCLEAR` | No | The prompt could not be understood well enough to extract data. Name the fields you want and how each should look, then submit again. |
| `FILE_SIZE_LIMIT_EXCEEDED` | No | A file grew past the size limit during processing, which can happen with heavily compressed PDFs. Split large files into smaller documents and submit again. |
| `PROCESSING_FILE_SIZE_LIMIT_EXCEEDED` | No | The upload was accepted, but during processing part of the PDF became too large for our file-processing limit. This can happen when a compressed PDF is processed internally. Split the PDF into smaller page chunks and resubmit. |
| `SUBMISSION_STALLED` | Yes | This extraction was never picked up for processing. Submit it again with a new submission_id. |
| `INTERNAL_ERROR` | Yes | Processing failed on our side. Submit the extraction again with a new submission_id; if it fails again, email support@invoicedataextraction.com with the extraction_id. |

`FILE_PAGE_LIMIT_EXCEEDED` reads: "One or more files exceed the maximum page count for a single file; details lists them. Split those files into smaller PDFs and submit again." An unknown `extraction_id` is not a failed extraction but a `404 EXTRACTION_NOT_FOUND` response, as on every endpoint.

### Cancelled

An extraction can be cancelled while it is queued or processing, from the dashboard or with [Cancel Extraction](#cancel-extraction). Cancelled extractions do not produce output files. `credits_balance` and `credits_reserved` are as on the completed response.

```json
{
  "success": true,
  "status": "cancelled",
  "extraction_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "credits_deducted": 4,
  "cancellation_reason": "user",
  "credits_balance": 146,
  "credits_reserved": 0
}
```

`cancellation_reason` says why the extraction stopped:

| Value | Meaning |
|-------|---------|
| `user` | Cancelled from the dashboard or with [Cancel Extraction](#cancel-extraction). |
| `unanswered` | The extraction asked a question and no answer came by `answer_by` ([Input required](#input-required)). Submit it again. |
| `answers_rejected` | Three answers to the extraction's questions were refused ([Answer Questions](#answer-questions)). Submit it again and answer differently. |

`credits_deducted` covers the work done before the extraction stopped.

## Waiting for the Result

Use `wait` and let the API hold each request:

```
loop:
    GET /extractions/{extraction_id}?wait=45
    if status == "processing":      continue                 # the wait elapsed; ask again
    if status == "input_required":  answer the questions (Answer Questions); continue
    if status == "completed":       read the rows (Step 6), or download a file; stop
    if status == "failed":          if error.retryable, submit again with a new submission_id;
                                    otherwise fix what error.message says first; stop
    if status == "cancelled":       stop; no output is available
```

A held request is answered the moment the status changes, so a run that takes three minutes needs about four calls instead of a poll every few seconds. If you poll without `wait`, leave at least **5 seconds** between calls. Processing time depends on the number and size of your files.

## Next Step

Read the extracted rows with [Get Results](#step-6-get-results). To get a spreadsheet, download a file with the URLs in the completed response; [Download Output](#download-output) gives a fresh URL when one has expired.

---

# Step 6: Get Results

Returns the extracted rows as JSON in the response body, in pages, once the extraction has completed. Each row is one object whose keys are your output columns, exactly as the JSON output file has them: native JSON types when the extraction was submitted with `options.json_typed_values`, strings otherwise (see [JSON value types](#json-value-types)), and the `Source File` and `Review Needed` columns are present unless you excluded them at submission. The Review Needed items for the rows on the page come back alongside, so the rows that need a human's check are visible in the same response as the data. To get a spreadsheet, download a file instead ([Download Output](#download-output)).

## Endpoint

```
GET https://api.invoicedataextraction.com/v1/extractions/{extraction_id}/results?offset=0&limit=100
```

**Authentication:** Bearer token in the `Authorization` header.

### Query Parameters

| Parameter | Required | Description |
|-----------|----------|-------------|
| `offset` | No | Number of rows to skip. Default `0`. |
| `limit` | No | Rows per page. Default `100`, min `1`, max `1000`. |
| `scope` | No | One of `own`, `team`. Same semantics as on [Step 5](#step-5-wait-for-the-extraction-to-finish). |

## Example Request

```bash
curl "https://api.invoicedataextraction.com/v1/extractions/a1b2c3d4-e5f6-7890-abcd-ef1234567890/results?limit=100" \
  -H "Authorization: Bearer $API_KEY"
```

## Success Response (200)

```json
{
  "success": true,
  "extraction_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "status": "completed",
  "output_structure": "per_invoice",
  "output_expires_at": "2026-07-26T10:30:00Z",
  "json_typed_values": true,
  "columns": ["Invoice Number", "Invoice Date", "Vendor Name", "Total Amount", "Source File", "Review Needed"],
  "rows": [
    {
      "Invoice Number": "INV-1001",
      "Invoice Date": "2026-01-15",
      "Vendor Name": "Acme Ltd",
      "Total Amount": 1250,
      "Source File": "invoice-1.pdf (Page 1)",
      "Review Needed": null
    }
  ],
  "offset": 0,
  "limit": 100,
  "row_count": 1,
  "total_rows": 1,
  "has_more": false,
  "next_offset": null,
  "review_needed": {
    "count": 0,
    "items": []
  },
  "pages": {
    "successful_count": 1,
    "failed_count": 0,
    "successful": [{ "file_name": "invoice-1.pdf", "page": 1 }],
    "failed": [],
    "failure_reasons": []
  }
}
```

| Field | Description |
|-------|-------------|
| `json_typed_values` | Which mode the rows are in: `false` when every value is a string, `true` when the extraction was submitted with `options.json_typed_values` and values carry native JSON types ([JSON value types](#json-value-types)). |
| `columns` | The output column names in order, including `Source File` and `Review Needed` unless excluded at submission. |
| `rows` | The rows on this page, one object per row with a value under every column. Row `i` of the page (counting from 0) is data row `offset + i + 1`, the numbering that `review_needed.items[].output_row_numbers` uses. |
| `offset`, `limit` | The page requested. |
| `row_count` | Rows on this page. |
| `total_rows` | Rows in the whole extraction. |
| `has_more`, `next_offset` | `has_more` is `true` when rows remain after this page; `next_offset` is the `offset` to request next, `null` when there are no more. |
| `review_needed` | `count` is the number of Review Needed items for the whole extraction; `items` holds only those whose `output_row_numbers` fall on this page, in the shape [Step 5](#step-5-wait-for-the-extraction-to-finish) documents. Check `count` before relying on the data. |
| `pages` | The page-level results of the extraction, in the shape [Step 5](#step-5-wait-for-the-extraction-to-finish) documents. Data from `pages.failed` is missing from the rows. |
| `output_structure`, `output_expires_at` | As on the completed polling response. |

## Pagination

Request pages in order, passing `next_offset` back as `offset` until `has_more` is `false`. The rows of a completed extraction do not change, so pages are stable.

## Error Codes

| Code | Status | Retryable | Message |
|------|--------|-----------|---------|
| `INVALID_INPUT` | 400 | No | `extraction_id` is not a valid UUID, `offset` is not a whole number, or `limit` is outside 1 to 1000. |
| `EXTRACTION_NOT_FOUND` | 404 | No | No extraction found for this extraction_id. Check the ID, or the extraction may belong to a different account. |
| `OUTPUT_NOT_AVAILABLE` | 404 | No | Output is not available for this extraction: it has not completed, or this format was not generated. Poll the extraction until its status is completed; the completed response lists the outputs that exist. |
| `OUTPUT_EXPIRED` | 404 | No | Output is no longer available: extractions are kept for 90 days after submission. To get this data again, upload the files and submit a new extraction. |

Rows are available for the same 90 days as the output files; `output_expires_at` says when they stop being.

---

# Download Output

Gives a fresh download URL for one of the output files: the XLSX, CSV or JSON file of a completed extraction. The completed response already carries URLs, valid for 5 minutes; use this endpoint when they have expired or when you download later. To get the data as rows rather than as a file, use [Get Results](#step-6-get-results).

## Endpoint

```
GET https://api.invoicedataextraction.com/v1/extractions/{extraction_id}/output?format={format}
```

**Authentication:** Bearer token in the `Authorization` header.

### Query Parameters

| Parameter | Required | Description |
|-----------|----------|-------------|
| `format` | Yes | `xlsx`, `csv`, or `json` |
| `scope` | No | One of `own`, `team`. Same semantics as on [Step 5](#step-5-wait-for-the-extraction-to-finish): team admins default to `team`, others default to `own`. Lets admins fetch a fresh download URL for any of their team members' extractions. |

## Example Request

```bash
curl "https://api.invoicedataextraction.com/v1/extractions/a1b2c3d4-e5f6-7890-abcd-ef1234567890/output?format=xlsx" \
  -H "Authorization: Bearer $API_KEY"
```

## Success Response (200)

```json
{
  "download_url": "https://storage.example.com/...?X-Amz-Signature=...",
  "format": "xlsx",
  "expires_in_seconds": 300
}
```

## Error Codes

| Code | Status | Retryable | Message |
|------|--------|-----------|---------|
| `EXTRACTION_NOT_FOUND` | 404 | No | No extraction found for this extraction_id. Check the ID, or the extraction may belong to a different account. |
| `OUTPUT_NOT_AVAILABLE` | 404 | No | Output is not available for this extraction: it has not completed, or this format was not generated. Poll the extraction until its status is completed; the completed response lists the outputs that exist. |
| `OUTPUT_EXPIRED` | 404 | No | Output is no longer available: extractions are kept for 90 days after submission. To get this data again, upload the files and submit a new extraction. |

The same `output_expires_at` timestamp returned by [Step 5](#step-5-wait-for-the-extraction-to-finish), [List Extractions](#list-extractions), and [Get Extraction Details](#get-extraction-details) tells you when this endpoint will start returning `OUTPUT_EXPIRED`.

---

# List Extractions

Returns a paginated list of your extractions, newest first. Useful for syncing extraction history into your own systems, finding in-progress runs, or correlating runs you submitted via the dashboard with API workflows.

This endpoint returns slim summary items, including a preview of the first few uploaded file names to help identify each extraction. To get the full canonical record for a single extraction (the complete file name list, original prompt, options, page-level results, prompt notes in `ai_uncertainty_notes`, and Review Needed warnings), use [Get Extraction Details](#get-extraction-details). For the extracted rows themselves, use [Get Results](#step-6-get-results). For fresh signed download URLs (which expire 5 minutes after generation), use [Step 5](#step-5-wait-for-the-extraction-to-finish) or [Download Output](#download-output).

## Endpoint

```
GET https://api.invoicedataextraction.com/v1/extractions
```

**Authentication:** Bearer token in the `Authorization` header.

## Query Parameters

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `status` | string | No | Filter by status. One of `processing`, `input_required`, `completed`, `cancelled`, `failed`. Omit to include all statuses. |
| `submission_method` | string | No | Filter by where the extraction was submitted from. One of `api`, `web_app`. Omit to include both. |
| `created_after` | ISO 8601 | No | Inclusive lower bound on `created_at`. Example: `2026-01-01T00:00:00Z`. |
| `created_before` | ISO 8601 | No | Inclusive upper bound on `created_at`. |
| `scope` | string | No | One of `own`, `team`. See [Scope](#scope) below. |
| `limit` | integer | No | Items per page. Default `25`, min `1`, max `100`. |
| `cursor` | string | No | Opaque pagination token. Pass the `next_cursor` from the previous response to fetch the next page. |

### Scope

For most callers, `scope` can be omitted:

- **Individual users** and **non-admin team members**: default scope is `own`. Returns only your own extractions. Passing `scope=team` returns `403 FORBIDDEN`.
- **Team admins**: default scope is `team`. Returns all extractions from your team members, plus your own pre-team extractions. Pass `scope=own` to restrict to extractions you submitted personally.

In the web dashboard, team admins land on **My tasks** by default and can switch to **Team tasks** for the same team-visible history returned by API `scope=team`.

When `scope=team`, each item includes a `submitted_by` field with the submitter's email address.

### Status filter semantics

The public `status` value is computed from the underlying run's state:

- `processing`: the extraction is queued or actively being processed.
- `input_required`: the extraction has stopped to ask and is waiting for answers ([Input required](#input-required)).
- `completed`: processing finished successfully and output is (or was) available.
- `cancelled`: processing was cancelled from the dashboard or with [Cancel Extraction](#cancel-extraction) before output creation.
- `failed`: terminal failure. Includes pre-submission rejections (insufficient credits, file validation errors), processing-time failures, and stale submissions.

## Example Request

```bash
curl "https://api.invoicedataextraction.com/v1/extractions?status=completed&limit=50" \
  -H "Authorization: Bearer $API_KEY"
```

## Success Response (200)

```json
{
  "success": true,
  "extractions": [
    {
      "extraction_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
      "submission_id": "sub_001",
      "task_name": "January invoices",
      "status": "completed",
      "created_at": "2026-04-27T10:30:00Z",
      "submission_method": "api",
      "file_count": 12,
      "file_names_preview": [
        "invoice-001.pdf",
        "invoice-002.pdf",
        "invoice-003.pdf",
        "invoice-004.pdf",
        "invoice-005.pdf"
      ],
      "file_names_truncated": true,
      "output_structure": "per_invoice",
      "credits_deducted": 25,
      "available_outputs": ["xlsx", "csv", "json"],
      "output_expires_at": "2026-07-26T10:30:00Z"
    },
    {
      "extraction_id": "b2c3d4e5-f6a7-8901-bcde-f23456789012",
      "submission_id": null,
      "task_name": "Q1 receipts",
      "status": "processing",
      "created_at": "2026-04-27T11:15:00Z",
      "submission_method": "web_app",
      "file_count": 4,
      "file_names_preview": [
        "receipt-april.pdf",
        "receipt-may.pdf",
        "receipt-june.pdf",
        "receipt-july.pdf"
      ],
      "file_names_truncated": false,
      "output_structure": "per_invoice",
      "progress": 42
    },
    {
      "extraction_id": "c3d4e5f6-a7b8-9012-cdef-345678901234",
      "submission_id": "sub_002",
      "task_name": "Vendor reconciliation",
      "status": "failed",
      "created_at": "2026-04-26T09:00:00Z",
      "submission_method": "api",
      "file_count": 8,
      "file_names_preview": [
        "vendor-001.pdf",
        "vendor-002.pdf",
        "vendor-003.pdf",
        "vendor-004.pdf",
        "vendor-005.pdf"
      ],
      "file_names_truncated": true,
      "output_structure": "automatic",
      "error": {
        "code": "INSUFFICIENT_CREDITS",
        "retryable": false
      }
    }
  ],
  "has_more": true,
  "next_cursor": "eyJjIjoiMjAyNi0wNC0yNlQwOTowMDowMFoiLCJpIjo1NjAxfQ"
}
```

### Common fields (all items)

| Field | Description |
|-------|-------------|
| `extraction_id` | UUID of the extraction. Use with [Get Extraction Details](#get-extraction-details), [Step 5](#step-5-wait-for-the-extraction-to-finish), [Get Results](#step-6-get-results), [Download Output](#download-output), and [Delete Extraction](#delete-extraction). |
| `submission_id` | The `submission_id` you supplied when submitting via API. `null` for extractions submitted via the web dashboard. |
| `task_name` | The label provided at submission time (your `task_name` for API submissions; the run name set in the dashboard for web submissions). |
| `status` | One of `processing`, `input_required`, `completed`, `cancelled`, `failed`. |
| `created_at` | ISO 8601 timestamp of when the extraction was created. |
| `submission_method` | One of `api`, `web_app`. Identifies whether the extraction was submitted via the API or the web dashboard. |
| `file_count` | Number of files in the extraction. |
| `file_names_preview` | Up to the first 5 uploaded file names. Useful for identifying a task in a history view without fetching the full record. |
| `file_names_truncated` | `true` when `file_names_preview` does not include every uploaded file name. Use [Get Extraction Details](#get-extraction-details) for the complete `file_names` array when available. |
| `output_structure` | Returns the AI-determined structure when `automatic` was submitted, otherwise returns the submitted value (`per_invoice`, or `per_line_item`). |

### Status-conditional fields

When `status: "completed"`:

| Field | Description |
|-------|-------------|
| `credits_deducted` | The number of credits charged for the extraction (one credit per successful page). |
| `available_outputs` | List of output formats currently available for download (subset of `["xlsx", "csv", "json"]`). Empty when past `output_expires_at`. |
| `output_expires_at` | ISO 8601 timestamp at which output files become unavailable. Currently 90 days after `created_at`. |

When `status: "processing"`:

| Field | Description |
|-------|-------------|
| `progress` | Integer 0–100 indicating approximate processing completion. |

When `status: "input_required"`:

| Field | Description |
|-------|-------------|
| `progress` | As for `processing`. |
| `answer_by` | The moment by which the extraction's questions must be answered. The questions themselves are on [Step 5](#input-required) and [Get Extraction Details](#get-extraction-details). |

When `status: "cancelled"`:

| Field | Description |
|-------|-------------|
| `credits_deducted` | Credits charged for AI work already completed before cancellation. No output files are available. |
| `cancellation_reason` | Why the extraction stopped: `user`, `unanswered` or `answers_rejected`. See [Cancelled](#cancelled) under Step 5. |

When `status: "failed"`:

| Field | Description |
|-------|-------------|
| `error.code` | The failure reason code (e.g. `INSUFFICIENT_CREDITS`, `PROMPT_REJECTED`, `INTERNAL_ERROR`). See the [Step 5 error codes](#failed) for the full list and the meaning of each. |
| `error.retryable` | Whether retrying the same submission could succeed. |

To get the full failure message and any structured `error.details`, fetch the extraction with [Get Extraction Details](#get-extraction-details).

### Under `scope=team`

Each item additionally includes:

```json
"submitted_by": { "email": "alice@example.com" }
```

`email` is `null` for any user whose record could not be looked up.

### Pagination fields

| Field | Description |
|-------|-------------|
| `has_more` | `true` if more rows exist after the current page; `false` when the result set is exhausted. |
| `next_cursor` | Opaque token to pass back as `cursor` to fetch the next page. `null` when `has_more` is `false`. |

## Pagination

Use cursor-based pagination by passing the previous response's `next_cursor` into the next request:

```javascript
const allExtractions = [];
let cursor = null;

while (true) {
  const params = new URLSearchParams({ limit: "100", status: "completed" });
  if (cursor) params.set("cursor", cursor);

  const response = await fetch(
    `https://api.invoicedataextraction.com/v1/extractions?${params}`,
    { headers: { Authorization: `Bearer ${API_KEY}` } }
  );
  const data = await response.json();

  allExtractions.push(...data.extractions);

  if (!data.has_more) break;
  cursor = data.next_cursor;
}
```

Cursors are opaque: don't try to construct or decode them. They encode the position in the result set; passing one between requests is the only supported use.

## Error Codes

| Code | Status | Retryable | Message |
|------|--------|-----------|---------|
| `INVALID_INPUT` | 400 | No | Returned for malformed query parameters (unknown `status`/`submission_method` values, invalid `created_after`/`created_before` timestamps, `created_after` > `created_before`, malformed `cursor`, out-of-range `limit`, repeated query keys). |
| `FORBIDDEN` | 403 | No | Returned when a non-admin caller passes `scope=team`. |

---

# Get Extraction Details

Returns the full canonical record for a single extraction by ID, including the original prompt, options, all file names, page-level results, prompt notes in `ai_uncertainty_notes`, and Review Needed warnings. Use this when you have an `extraction_id` (from [List Extractions](#list-extractions), the dashboard, your own database, etc.) and want stable metadata about the extraction.

## When to use this vs the status endpoint

The two endpoints serve different needs:

- Use the status endpoint ([Step 5](#step-5-wait-for-the-extraction-to-finish)) when you've just submitted an extraction and want to wait for it to finish. It returns live state including signed download URLs, and uses `success: false` for failed extractions so polling clients can stop.
- Use this endpoint when you want **stable metadata** for a known extraction, including the original prompt and options. It never returns `success: false` for a failed extraction; failed extractions are valid records here, with the failure reason inside the `error` field. This means a routine SDK call to "get the record for this ID" doesn't need to special-case `success: false` as an error.

This endpoint does **not** include signed download URLs. Use [Download Output](#download-output) to fetch a fresh signed URL when you actually need to download a file.

## Endpoint

```
GET https://api.invoicedataextraction.com/v1/extractions/{extraction_id}/details
```

**Authentication:** Bearer token in the `Authorization` header.

### Query Parameters

| Parameter | Required | Description |
|-----------|----------|-------------|
| `scope` | No | One of `own`, `team`. Same semantics as on [List Extractions](#list-extractions): team admins default to `team`, others default to `own`. |

## Example Request

```bash
curl "https://api.invoicedataextraction.com/v1/extractions/a1b2c3d4-e5f6-7890-abcd-ef1234567890/details" \
  -H "Authorization: Bearer $API_KEY"
```

## Success Response (200)

```json
{
  "success": true,
  "extraction": {
    "extraction_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
    "submission_id": "sub_001",
    "task_name": "January invoices",
    "status": "completed",
    "created_at": "2026-04-27T10:30:00Z",
    "submission_method": "api",
    "file_count": 12,
    "file_names": ["invoice-1.pdf", "invoice-2.pdf", "..."],
    "output_structure": "per_invoice",
    "prompt": {
      "fields": [
        { "name": "Invoice Number" },
        { "name": "Invoice Date", "prompt": "The date the invoice was issued, NOT the payment due date" },
        { "name": "Vendor Name" },
        { "name": "Total Amount", "prompt": "Do not include currency symbol, use 2 decimal places" }
      ],
      "general_prompt": "Dates should be in YYYY-MM-DD format."
    },
    "options": {
      "exclude_columns": [],
      "output_language": "automatic",
      "review_needed_fill_color": "orange",
      "affected_field_fill_color": "orange",
      "send_completion_email": false,
      "json_typed_values": false
    },
    "credits_deducted": 25,
    "available_outputs": ["xlsx", "csv", "json"],
    "output_expires_at": "2026-07-26T10:30:00Z",
    "pages": {
      "successful_count": 10,
      "failed_count": 2,
      "successful": [
        { "file_name": "invoice-1.pdf", "page": 1 },
        { "file_name": "invoice-1.pdf", "page": 2 }
      ],
      "failed": [
        { "file_name": "damaged.pdf", "page": 1 }
      ],
      "failure_reasons": [
        {
          "code": "PROCESSING_FILE_SIZE_LIMIT_EXCEEDED",
          "message": "The upload was accepted, but during processing part of the PDF became too large for our file-processing limit. This can happen when a compressed PDF is processed internally. Split the PDF into smaller page chunks and resubmit.",
          "affected_pages": [
            { "file_name": "damaged.pdf", "pages": [1] }
          ]
        }
      ]
    },
    "ai_uncertainty_notes": [],
    "review_needed": {
      "count": 0,
      "items": []
    }
  }
}
```

### Common fields (all statuses)

| Field | Description |
|-------|-------------|
| `extraction_id` | UUID of the extraction. |
| `submission_id` | The `submission_id` you supplied at submission time. `null` for extractions submitted via the web dashboard. |
| `task_name` | The label provided at submission time. |
| `status` | One of `processing`, `input_required`, `completed`, `cancelled`, `failed`. |
| `created_at` | ISO 8601 timestamp of when the extraction was created. |
| `submission_method` | One of `api`, `web_app`. |
| `file_count` | Number of files in the extraction. |
| `file_names` | List of original file names submitted, in submission order. |
| `output_structure` | The current/effective output structure. Returns the AI-determined structure when available; otherwise falls back to the requested value (`automatic`, `per_invoice`, or `per_line_item`). May be `null` for older extractions where no level was recorded. |
| `prompt` | The original prompt. String prompts are returned as strings; structured prompts are returned as `{ "fields": [...], "general_prompt": "..." }`. Web-app extractions submitted with no prompt return an empty string.|
| `options` | Always present, in the shape `POST /v1/extractions` accepts, with every field filled: `exclude_columns`, `output_language`, `review_needed_fill_color`, `affected_field_fill_color`, `send_completion_email`, `json_typed_values` and `ask_questions`. The values are the ones that apply to this extraction, including the account preferences that filled any field left out at submission. |

### When `status: "completed"`

| Field | Description |
|-------|-------------|
| `credits_deducted` | The number of credits charged for this extraction. |
| `available_outputs` | List of output formats currently available for download (subset of `["xlsx", "csv", "json"]`). Empty when past `output_expires_at`. |
| `output_expires_at` | ISO 8601 timestamp at which the output files become unavailable. |
| `pages` | Same shape as the [Step 5 completed payload](#completed). |
| `ai_uncertainty_notes` | Same shape as the polling endpoint. Empty array if none. These are prompt notes about assumptions made when your prompt left room for interpretation. |
| `review_needed` | Same shape as the polling endpoint. Always present on completed records. Check `review_needed.count`; if greater than `0`, the listed output rows need human verification before you rely on the data. `output_row_numbers` are 1-based extracted data row numbers and do not include the Excel/CSV header row. |

### When `status: "processing"`

| Field | Description |
|-------|-------------|
| `progress` | Integer 0–100 indicating approximate processing completion. |

### When `status: "input_required"`

| Field | Description |
|-------|-------------|
| `progress` | As for `processing`. |
| `answer_by` | The moment by which the questions must be answered. |
| `questions` | The open questions, in the shape [Input required](#input-required) documents. Answer them with [Answer Questions](#answer-questions). |

### When `status: "cancelled"`

| Field | Description |
|-------|-------------|
| `credits_deducted` | Credits charged for AI work already completed before cancellation. No output files are available. |
| `cancellation_reason` | Why the extraction stopped: `user`, `unanswered` or `answers_rejected`. See [Cancelled](#cancelled) under Step 5. |

### When `status: "failed"`

| Field | Description |
|-------|-------------|
| `error.code` | Failure reason code. Same set as the [Step 5 error codes](#failed). |
| `error.message` | Human-readable failure message. |
| `error.retryable` | Whether retrying the same submission could succeed. |
| `error.details` | Structured failure context (e.g. file names, credit balance) when applicable; `null` otherwise. |

Note: unlike the status endpoint ([Step 5](#step-5-wait-for-the-extraction-to-finish)), this endpoint always returns `success: true` even when `status: "failed"`. Failed extractions are valid records; the failure detail lives in the `error` field.

### Under `scope=team`

Each response additionally includes a `submitted_by` field with the submitter's email address:

```json
"submitted_by": { "email": "alice@example.com" }
```

This field is included on every response under `scope=team` (including for extractions you submitted yourself), and is omitted entirely under `scope=own`. `email` is `null` for any user whose record could not be looked up.

## Error Codes

| Code | Status | Retryable | Message |
|------|--------|-----------|---------|
| `INVALID_INPUT` | 400 | No | `extraction_id` is not a valid UUID, or `scope` is not `own`/`team`. |
| `EXTRACTION_NOT_FOUND` | 404 | No | No extraction found for this `extraction_id` within the requested scope. |
| `FORBIDDEN` | 403 | No | Returned when a non-admin caller passes `scope=team`. |

---

# Cancel Extraction

Stops an extraction that is still queued or processing. The request is recorded at once and the extraction stops at the next point where it can; wait for it with [Step 5](#step-5-wait-for-the-extraction-to-finish) until its status is `cancelled`. `credits_deducted` on that response covers the work done before it stopped. An extraction that was about to finish may complete instead, in which case it is charged as a completed extraction and its output is available as usual.

Cancelled extractions produce no output files. To remove one and its uploaded files, use [Delete Extraction](#delete-extraction).

## Endpoint

```
POST https://api.invoicedataextraction.com/v1/extractions/{extraction_id}/cancel
```

**Authentication:** Bearer token in the `Authorization` header. The request has no body.

### Query Parameters

| Parameter | Required | Description |
|-----------|----------|-------------|
| `scope` | No | One of `own`, `team`. Same semantics as on [Step 5](#step-5-wait-for-the-extraction-to-finish): team admins default to `team`, allowing them to cancel any of their team members' extractions. Other callers default to `own` and may not pass `scope=team`. |

## Example Request

```bash
curl -X POST "https://api.invoicedataextraction.com/v1/extractions/a1b2c3d4-e5f6-7890-abcd-ef1234567890/cancel" \
  -H "Authorization: Bearer $API_KEY"
```

## Success Response (200)

While the extraction is still processing, the response confirms that the request was recorded:

```json
{
  "success": true,
  "extraction_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "status": "processing",
  "cancellation_requested": true,
  "cancel_requested_at": "2026-09-10T14:03:22.418Z"
}
```

`status` stays `processing` until the extraction has stopped, so keep polling. `cancel_requested_at` is when the request was first recorded.

If the extraction has already been cancelled, the response is the one [Step 5](#step-5-wait-for-the-extraction-to-finish) returns for a cancelled extraction:

```json
{
  "success": true,
  "status": "cancelled",
  "extraction_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "credits_deducted": 4,
  "cancellation_reason": "user",
  "credits_balance": 146,
  "credits_reserved": 0
}
```

## Idempotency

Calling this endpoint again for the same extraction returns the same result and records nothing new: while the extraction is still processing, the original `cancel_requested_at`; once it has been cancelled, its cancelled status.

## Error Codes

| Code | Status | Retryable | Message |
|------|--------|-----------|---------|
| `INVALID_INPUT` | 400 | No | `extraction_id` is not a valid UUID, or `scope` is not `own`/`team`. |
| `EXTRACTION_NOT_FOUND` | 404 | No | No extraction found for this extraction_id. Check the ID, or the extraction may belong to a different account. |
| `EXTRACTION_NOT_CANCELLABLE` | 409 | No | This extraction has already completed or failed, so there is nothing to cancel; details.status says which. Get its result with `GET /v1/extractions/{extraction_id}`. |
| `FORBIDDEN` | 403 | No | Returned when a non-admin caller passes `scope=team`. |

`EXTRACTION_NOT_CANCELLABLE` carries `details: { "status": "completed" }` or `{ "status": "failed" }`.

---

# Answer Questions

Answers the questions an extraction stopped to ask ([Input required](#input-required)). Answer every open question in one request, or across several; the extraction continues the moment every open question has an answer.

## Endpoint

```
POST https://api.invoicedataextraction.com/v1/extractions/{extraction_id}/answers
```

**Authentication:** Bearer token in the `Authorization` header.

### Query Parameters

| Parameter | Required | Description |
|-----------|----------|-------------|
| `scope` | No | One of `own`, `team`. Same semantics as on [Step 5](#step-5-wait-for-the-extraction-to-finish): team admins default to `team`, allowing them to answer for any of their team members' extractions. Other callers default to `own` and may not pass `scope=team`. |

## Request Body

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `answers` | array | Yes | One item per question you are answering. |

Each item in `answers` names a question and gives one of three answers:

| Field | Type | Description |
|-------|------|-------------|
| `question_id` | string | The `question_id` from the status response. Required. |
| `choice_id` | string | The `choice_id` of the choice you are taking. `single_choice` questions only. Send `text` beside it to add detail. |
| `text` | string | Your own words, 1 to 1000 characters. Accepted on every question, alone or beside a `choice_id`. |
| `accept_recommended` | boolean | `true` to take the recommended choice, or on a `free_text` question the recommended approach. Not combined with `choice_id` or `text`. |

## Example Request

```bash
curl -X POST "https://api.invoicedataextraction.com/v1/extractions/a1b2c3d4-e5f6-7890-abcd-ef1234567890/answers" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "answers": [
      { "question_id": "q_2524", "choice_id": "b" },
      { "question_id": "q_2529", "text": "DD/MM/YYYY" },
      { "question_id": "q_2477", "accept_recommended": true }
    ]
  }'
```

## Success Response (200)

The extraction's status after your answers, exactly as [Step 5](#step-5-wait-for-the-extraction-to-finish) returns it: `processing` once every open question has its answer, `input_required` with the questions still waiting, or whatever the extraction has become.

```json
{
  "success": true,
  "status": "processing",
  "extraction_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "progress": 22
}
```

Your own words are screened before they are used. An answer given in bad faith, one that tries to misuse the service rather than answer the question, is refused and never applied: the question is asked again with `previous_answer_rejected: true`, and after three refused answers the extraction is cancelled with `cancellation_reason: answers_rejected` and the work done so far is charged. An answer that is vague or leaves the question open is not refused: it is applied as it is, and the extraction may ask about the same matter again, as a new question without that flag. Answer it on its merits.

## Idempotency

An answer to a question that is no longer waiting changes nothing: the response is the extraction's current status. A request can therefore be repeated after a dropped connection, and an answer that arrives after the extraction has moved on is not an error.

## Error Codes

| Code | Status | Retryable | Message |
|------|--------|-----------|---------|
| `INVALID_INPUT` | 400 | No | `extraction_id` is not a valid UUID, `scope` is not `own`/`team`, or the body could never be right: `details.issues` names the answer and the field, for a `question_id` the extraction never asked, a `choice_id` it does not offer, empty or over-long `text`, `accept_recommended` sent with `choice_id` or `text`, or a question answered twice in one request. |
| `EXTRACTION_NOT_FOUND` | 404 | No | No extraction found for this extraction_id. Check the ID, or the extraction may belong to a different account. |
| `FORBIDDEN` | 403 | No | Returned when a non-admin caller passes `scope=team`. |

---

# Delete Extraction

Permanently deletes an extraction, its output files, and its uploaded source files. Extractions that are currently being processed cannot be deleted; [cancel](#cancel-extraction) one first, then delete it once its status is `cancelled`.

**Note:** Deleting an extraction removes the uploaded source files associated with it. If you created multiple extractions from the same upload session, deleting one will not affect the others: source files are only removed when no other extraction is using them.

Our standard [data retention policies](https://invoicedataextraction.com/security) apply automatically: uploaded documents and processing data are deleted on a schedule. Use this endpoint if you need to delete an extraction and its data immediately rather than waiting for automatic retention.

## Endpoint

```
DELETE https://api.invoicedataextraction.com/v1/extractions/{extraction_id}
```

**Authentication:** Bearer token in the `Authorization` header.

### Query Parameters

| Parameter | Required | Description |
|-----------|----------|-------------|
| `scope` | No | One of `own`, `team`. Same semantics as on [Step 5](#step-5-wait-for-the-extraction-to-finish): team admins default to `team`, allowing them to delete any of their team members' extractions. Other callers default to `own` and may not pass `scope=team`. |

## Example Request

```bash
curl -X DELETE "https://api.invoicedataextraction.com/v1/extractions/a1b2c3d4-e5f6-7890-abcd-ef1234567890" \
  -H "Authorization: Bearer $API_KEY"
```

## Success Response (200)

```json
{
  "success": true
}
```

## Error Codes

| Code | Status | Retryable | Message |
|------|--------|-----------|---------|
| `EXTRACTION_NOT_FOUND` | 404 | No | No extraction found for this extraction_id. Check the ID, or the extraction may belong to a different account. |
| `EXTRACTION_IN_PROGRESS` | 409 | No | This extraction is still being processed and cannot be deleted. Wait for it to complete or fail, then delete it. |

---

# Check Credit Balance

Returns your current credit balance, including credits reserved by extractions that are currently being processed.

## Endpoint

```
GET https://api.invoicedataextraction.com/v1/credits/balance
```

**Authentication:** Bearer token in the `Authorization` header.

## Example Request

```bash
curl "https://api.invoicedataextraction.com/v1/credits/balance" \
  -H "Authorization: Bearer $API_KEY"
```

## Success Response (200)

```json
{
  "success": true,
  "credits_balance": 150,
  "credits_reserved": 10
}
```

| Field | Description |
|-------|-------------|
| `credits_balance` | Your total credit balance (paid + free credits). |
| `credits_reserved` | Credits reserved by extractions currently being processed. Up to this amount will be deducted when processing completes depending on number of successful pages. Your usable balance is `credits_balance` minus `credits_reserved`. |

---

# Node.js Example

A ready-to-run script that handles the full workflow: reads files from a local folder, uploads them, submits an extraction task, waits for it to finish, reads the extracted rows as JSON and saves a spreadsheet. No dependencies beyond Node.js 18+.

Save this as `extract.js`, set the three configuration variables at the top (`API_KEY`, `FOLDER_PATH`, `PROMPT`), and run with `node extract.js`. You'll have extraction results in minutes.

```javascript
import { readdir, readFile, stat, writeFile, mkdir } from "fs/promises";
import { join, extname } from "path";

// ---------------------------------------------------------------------------
// Configuration: set these before running
// ---------------------------------------------------------------------------

// Your API key. Get one at: https://invoicedataextraction.com/dashboard?view=API
// IMPORTANT: This is hardcoded here for simplicity. In production, load from an
// environment variable (e.g. process.env.INVOICE_DATA_EXTRACTION_API_KEY) and never commit to Git.
const API_KEY = "YOUR_API_KEY";

// Absolute path to the local folder containing the files you want to process.
const FOLDER_PATH = "/Users/you/Documents/invoices";

// Tell the AI what data to extract from each document (plain-text instruction).
const PROMPT = "Extract invoice number, date, vendor name, and total amount";
// For exact output column names, pass an object instead:
//   const PROMPT = { fields: [{ name: "Invoice Number" }, { name: "Total", prompt: "No currency symbol" }], general_prompt: "..." };

// A label for this extraction task (3-40 characters). Used in your dashboard and output filenames.
const TASK_NAME = "My extraction task";

// How rows are grouped in the output: "automatic" (AI decides), "per_invoice", or "per_line_item".
const OUTPUT_STRUCTURE = "automatic";

// Which output files to save beside the rows, if any. Any combination of "xlsx", "csv", "json".
// "xlsx" gives you a spreadsheet to open; set to [] to skip the files.
const DOWNLOAD_FORMATS = ["xlsx"];

// ---------------------------------------------------------------------------
// Internal constants: no changes needed
// ---------------------------------------------------------------------------

const API_BASE = "https://api.invoicedataextraction.com/v1";
const SUPPORTED_EXTENSIONS = new Set([".pdf", ".jpg", ".jpeg", ".png"]);
const MAX_RETRIES = 3;

async function apiRequest(path, body) {
  for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
    const response = await fetch(`${API_BASE}${path}`, {
      method: "POST",
      headers: {
        Authorization: `Bearer ${API_KEY}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify(body),
    });
    const text = await response.text();
    let data;
    try {
      data = JSON.parse(text);
    } catch {
      // Non-JSON response: a rate limit or an error at the gateway in front of the API.
      // Retry on 429/503, throw on anything else.
      if ((response.status === 429 || response.status === 503) && attempt < MAX_RETRIES) {
        const delayMs = 5000 * attempt;
        console.warn(`Non-JSON ${response.status} response, retrying in ${delayMs / 1000}s...`);
        await new Promise((resolve) => setTimeout(resolve, delayMs));
        continue;
      }
      throw new Error(`API returned non-JSON response (${response.status}): ${text.slice(0, 200)}`);
    }
    if (data.success) return data;

    // If the error is retryable and we have attempts left, wait and retry
    if (data.error.retryable && attempt < MAX_RETRIES) {
      // Use the Retry-After header if present (rate limit responses), otherwise exponential backoff
      const retryAfter = response.headers.get("Retry-After");
      const delayMs = retryAfter ? parseInt(retryAfter, 10) * 1000 : 1000 * attempt;
      console.warn(`Retryable error (${data.error.code}), retrying in ${delayMs / 1000}s...`);
      await new Promise((resolve) => setTimeout(resolve, delayMs));
      continue;
    }

    throw new Error(
      `API error: ${data.error.code}: ${data.error.message}` +
        (data.error.details ? `\nDetails: ${JSON.stringify(data.error.details)}` : "")
    );
  }
}

// ---------------------------------------------------------------------------
// Step 1: Discover local files and create an upload session
// ---------------------------------------------------------------------------

// Scan the folder for supported file types
const entries = await readdir(FOLDER_PATH);
const files = [];

for (const entry of entries) {
  // Skip unsupported file types and subfolders
  const ext = extname(entry).toLowerCase();
  if (!SUPPORTED_EXTENSIONS.has(ext)) continue;
  const filePath = join(FOLDER_PATH, entry);
  const fileStat = await stat(filePath);
  if (!fileStat.isFile()) continue;

  // Add this file to the list with its size in bytes.
  // file_id must be unique within the session and can only contain letters, numbers,
  // dots, underscores, colons, and hyphens (no spaces). Use your own IDs (e.g., database
  // row IDs, UUIDs, or a simple counter).
  files.push({
    file_id: `file_${files.length + 1}`,
    file_name: entry,
    file_size_bytes: fileStat.size,
    localPath: filePath, // kept locally, not sent to the API
  });
}

// Optional: before uploading, you could calculate the credits required and check
// your balance. Each page costs one credit: for PDFs, count the pages; for
// images, each file is one credit. Then call GET /credits/balance to compare
// against your available balance (credits_balance minus credits_reserved).

// Generate a unique ID for this upload session (must be different for each new session)
const uploadSessionId = `session_${Date.now()}`;

// Create the upload session: registers all files with the API
let session;
try {
  session = await apiRequest("/uploads/sessions", {
    upload_session_id: uploadSessionId,
    files: files.map(({ file_id, file_name, file_size_bytes }) => ({
      file_id,
      file_name,
      file_size_bytes,
    })),
  });
} catch (error) {
  // Session creation failure is fatal: no files can be uploaded without a session
  console.error(`Failed to create upload session: ${error.message}`);
  process.exit(1);
}

console.log(`Upload session created: ${session.upload_session_id} (${files.length} files)`);

// The chunk size in bytes: always the same for all files in the session, so we read it from the first
const partSize = session.files[0].part_size;

// ---------------------------------------------------------------------------
// Steps 2 & 3: For each file, upload chunks, then complete the upload
// ---------------------------------------------------------------------------

const completedFileIds = [];

for (const file of files) {
  try {
    // Read the entire file into memory as a binary buffer
    const fileBuffer = await readFile(file.localPath);

    // Calculate how many parts this file needs
    const totalParts = Math.ceil(fileBuffer.length / partSize);
    const partNumbers = Array.from({ length: totalParts }, (_, i) => i + 1);

    // Request a presigned upload URL for each part
    const partsData = await apiRequest(`/uploads/sessions/${uploadSessionId}/parts`, {
      file_id: file.file_id,
      part_numbers: partNumbers,
    });

    // Upload each chunk to its presigned URL via PUT
    const completedParts = [];

    for (const { part_number, url } of partsData.part_urls) {
      // Slice the file buffer into a chunk for this part
      const start = (part_number - 1) * partSize;
      const end = Math.min(start + partSize, fileBuffer.length);
      const chunk = fileBuffer.subarray(start, end);

      // PUT the raw bytes directly to the presigned URL
      const putResponse = await fetch(url, { method: "PUT", body: chunk });
      if (!putResponse.ok) {
        const errorBody = await putResponse.text();
        throw new Error(
          `Upload failed for ${file.file_name} part ${part_number}: ${putResponse.status} ${putResponse.statusText}\n${errorBody}`
        );
      }

      // Save the ETag: needed to complete the upload in Step 3
      completedParts.push({
        part_number,
        e_tag: putResponse.headers.get("etag"),
      });
    }

    console.log(`Uploaded: ${file.file_name} (${totalParts} part${totalParts > 1 ? "s" : ""})`);

    // Complete the file upload with the collected ETags
    await apiRequest(`/uploads/sessions/${uploadSessionId}/complete`, {
      file_id: file.file_id,
      parts: completedParts,
    });

    console.log(`Completed: ${file.file_name}`);
    completedFileIds.push(file.file_id);
  } catch (error) {
    // By default, abort on any file failure to avoid silent partial uploads.
    // If you'd prefer to continue with remaining files, remove the process.exit.
    console.error(`Failed: ${file.file_name}: ${error.message}`);
    process.exit(1);
  }
}

// All files uploaded and completed successfully
console.log(`\n${completedFileIds.length} files ready for extraction.`);

// ---------------------------------------------------------------------------
// Steps 4 & 5: Submit the extraction task and wait until it finishes
// ---------------------------------------------------------------------------

// Retryable polling errors (e.g., concurrent task limit, temporary server issues) trigger
// a fresh submission. Non-retryable errors require action from you: the log message tells
// you what to fix before re-running the script.

// Optional: human-readable guidance for non-retryable error codes (see error reference above).
// This just improves the console output: the API works the same without it.
const NON_RETRYABLE_GUIDANCE = {
  INSUFFICIENT_CREDITS: "Purchase credits at https://invoicedataextraction.com/dashboard?view=Billing then re-run this script.",
  FILE_PAGE_LIMIT_EXCEEDED: "Split the affected files into smaller documents and re-upload.",
  ENCRYPTED_FILE: "Remove encryption from the affected files and re-upload.",
  NO_PAGES_FOUND: "Check that your files are valid and contain extractable content.",
  PROMPT_REJECTED: "Revise your prompt to clearly describe what data to extract.",
  PROMPT_UNCLEAR: "Revise your prompt with clearer instructions and re-run.",
  FILE_SIZE_LIMIT_EXCEEDED: "Split large files into smaller documents and re-upload.",
  PROCESSING_FILE_SIZE_LIMIT_EXCEEDED: "Split the PDF into smaller page chunks and re-upload.",
};

const MAX_SUBMISSION_ATTEMPTS = 2;
const POLL_INTERVAL_MS = 1000; // pause between status requests; the API holds each one for up to 45 s

let result;

for (let attempt = 1; attempt <= MAX_SUBMISSION_ATTEMPTS; attempt++) {
  // Each attempt needs a unique submission_id
  const submissionId = `sub_${Date.now()}_${attempt}`;

  const run = await apiRequest("/extractions", {
    submission_id: submissionId,
    upload_session_id: uploadSessionId,
    file_ids: completedFileIds,
    task_name: TASK_NAME,
    prompt: PROMPT,
    output_structure: OUTPUT_STRUCTURE,
    // Typed values: amounts, quantities and rates come back as JSON numbers, yes/no
    // fields as booleans and empty cells as null, in the rows read in Step 6 and in
    // the JSON file. Leave the option out and every value is a string.
    options: { json_typed_values: true },
  });

  console.log(`\nExtraction task submitted (extraction_id: ${run.extraction_id})`);

  // Ask for the status until completed, failed, or cancelled. `wait=45` has the
  // API hold each request for up to 45 seconds and answer as soon as the run
  // finishes, so a run of a few minutes takes a handful of calls.
  let lastFailureCode = null;
  let consecutivePollErrors = 0;
  const MAX_CONSECUTIVE_POLL_ERRORS = 10;
  while (true) {
    const response = await fetch(`${API_BASE}/extractions/${run.extraction_id}?wait=45`, {
      headers: { Authorization: `Bearer ${API_KEY}` },
    });
    const data = await response.json();

    if (data.status === "completed") {
      result = data;
      break;
    }

    if (data.status === "failed") {
      const { code, message, details, retryable } = data.error;
      console.error(`\nExtraction failed: ${code}: ${message}`);
      if (details) console.error(`Details: ${JSON.stringify(details)}`);

      if (!retryable) {
        const guidance = NON_RETRYABLE_GUIDANCE[code] || "Check the error above and re-run when resolved.";
        console.error(`\nAction required: ${guidance}`);
        process.exit(1);
      }

      // Retryable: wait, then submit again.
      // Concurrent task limit means we wait longer (5 min) for other processing tasks to finish.
      // Other retryable errors are transient, so a short delay (10s) suffices.
      const delayMs = code === "CONCURRENT_TASK_LIMIT" ? 300_000 : 10_000;
      console.log(`Retrying in ${delayMs / 1000}s (attempt ${attempt}/${MAX_SUBMISSION_ATTEMPTS})...`);
      await new Promise((resolve) => setTimeout(resolve, delayMs));
      lastFailureCode = code;
      break;
    }

    if (data.status === "cancelled") {
      console.error(
        `\nExtraction was cancelled. Credits deducted: ${data.credits_deducted ?? 0}. No output files are available.`
      );
      process.exit(1);
    }

    // Still processing: reset the error counter and ask again
    if (data.status === "processing") {
      consecutivePollErrors = 0;
      console.log(`Processing... ${data.progress ?? 0}%`);
    } else {
      consecutivePollErrors++;
      console.warn(`Polling issue (HTTP ${response.status}), retrying in ${POLL_INTERVAL_MS / 1000}s... (${consecutivePollErrors}/${MAX_CONSECUTIVE_POLL_ERRORS})`);
      if (consecutivePollErrors >= MAX_CONSECUTIVE_POLL_ERRORS) {
        console.error(`\nToo many consecutive polling errors. The extraction may still be processing. Check your dashboard or retry later.`);
        process.exit(1);
      }
    }
    await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
  }

  if (result) break;

  if (lastFailureCode && attempt === MAX_SUBMISSION_ATTEMPTS) {
    const exitMessage = lastFailureCode === "CONCURRENT_TASK_LIMIT"
      ? `\nStill hitting the concurrent task limit after ${MAX_SUBMISSION_ATTEMPTS} attempts. Wait for your other extractions to finish, then re-run.`
      : `\nGave up after ${MAX_SUBMISSION_ATTEMPTS} attempts. There may be temporary service issues. Wait and try again later.`;
    console.error(exitMessage);
    process.exit(1);
  }
}

console.log(`\nExtraction completed!`);
console.log(`Credits deducted: ${result.credits_deducted}`);
console.log(`Output structure: ${result.output_structure}`);
console.log(`Pages: ${result.pages.successful_count} successful, ${result.pages.failed_count} failed`);

if (result.pages.failed_count > 0) {
  console.warn(`\nWarning: ${result.pages.failed_count} page(s) failed to extract. Data from these pages is missing from the output.`);
  for (const page of result.pages.failed) {
    console.warn(`  - ${page.file_name} (page ${page.page})`);
  }
}

if (result.ai_uncertainty_notes.length > 0) {
  console.log(`\n--- Prompt Notes ---`);
  console.log(`The AI made prompt assumptions in ${result.ai_uncertainty_notes.length} area(s). Review these and consider adding the suggested prompt additions to improve future extractions.\n`);
  result.ai_uncertainty_notes.forEach((note, i) => {
    console.log(`  [${i + 1}] ${note.topic}`);
    console.log(`  ${note.description}`);
    for (const suggestion of note.suggested_prompt_additions) {
      console.log(`    → ${suggestion.purpose}: "${suggestion.instructions}"`);
    }
    console.log();
  });
  console.log(`---`);
}

if (result.review_needed.count > 0) {
  console.warn(`\nReview Needed: ${result.review_needed.count} row(s) need manual verification before you rely on the output.`);
  result.review_needed.items.forEach((item, i) => {
    console.warn(`  [${i + 1}] ${item.message}`);
    console.warn(`      Output row(s): ${item.output_row_numbers.join(", ")}`);
    if (item.source_references.length > 0) {
      console.warn(`      Source: ${item.source_references.join(", ")}`);
    }
  });
}

// ---------------------------------------------------------------------------
// Step 6: Read the extracted rows as JSON
// ---------------------------------------------------------------------------

// The rows come back in pages of up to 1000. Each row is an object keyed by your
// output columns, exactly as the JSON file holds it: this is the data to pass on
// to your own system.
const rows = [];
let columns = [];
let offset = 0;

while (true) {
  const response = await fetch(
    `${API_BASE}/extractions/${result.extraction_id}/results?offset=${offset}&limit=1000`,
    { headers: { Authorization: `Bearer ${API_KEY}` } }
  );
  const page = await response.json();
  if (!page.success) {
    console.error(`Could not read the rows: ${page.error.code}: ${page.error.message}`);
    process.exit(1);
  }
  columns = page.columns;
  rows.push(...page.rows);
  if (!page.has_more) break;
  offset = page.next_offset;
}

console.log(`\n${rows.length} row(s) extracted. Columns: ${columns.join(", ")}`);
for (const row of rows.slice(0, 3)) console.log(row);
if (rows.length > 3) console.log(`... and ${rows.length - 3} more.`);

// ---------------------------------------------------------------------------
// Optional: save the output files
// ---------------------------------------------------------------------------

// The completed response carries a download URL per format, valid for 5 minutes.
// A plain GET to the URL returns the file; no Authorization header is needed.
if (DOWNLOAD_FORMATS.length > 0) {
  const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
  const safeName = TASK_NAME.replace(/[^a-zA-Z0-9_-]/g, "_");
  await mkdir("output", { recursive: true });

  for (const format of DOWNLOAD_FORMATS) {
    const url = result.output[`${format}_url`];
    if (!url) {
      console.warn(`No ${format} download available.`);
      continue;
    }
    const response = await fetch(url);
    if (!response.ok) {
      console.error(`Failed to download ${format}: ${response.status}`);
      continue;
    }
    const buffer = Buffer.from(await response.arrayBuffer());
    const outputPath = `output/${safeName}_${timestamp}.${format}`;
    await writeFile(outputPath, buffer);
    console.log(`Saved: ${outputPath}`);
  }
}

console.log(`\nDone. Extraction ${result.extraction_id} completed successfully.`);
```
