Invoice Data Extraction Logo
Invoice Data Extraction
Start Extraction
Pricing
Extraction Guide
API & Agents
Sign inCreate account
Sign inCreate account
Start Extraction
Pricing
Extraction Guide
API & Agents
  1. Home
  2. API & Agents
  3. REST API Reference

REST API Reference

Every endpoint, field, limit and error of the Invoice Data Extraction API, with a ready-to-run script. Building in Python or Node.js? The SDKs upload, submit, wait and read the rows for you: Python SDK, Node.js SDK.

This page as Markdown: /docs/api.md

Base URL

api.invoicedataextraction.com/v1

Auth

Bearer token (API key)

Output

xlsx / csv / json
Get API KeyView PricingStart with 50 free pages monthly, no credit card required
REST API· HTTP endpointsPython SDK· pip installNode.js SDK· npm installAgent Guide· for AI agentsChangelog· what changed

On this page

LLM-ready REST API docs for any language

Building in another language (Go, Java, C#, PHP, Ruby, etc.)? The REST API reference is structured so that an AI coding assistant can build a complete, working HTTP integration for you. Copy as Markdown and paste into your preferred LLM.

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).

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). 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 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 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. 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, and this reference as Markdown.

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, Step 5, Get Results, Get Extraction Details, Download Output, Cancel Extraction, and 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:

{
  "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:

CodeStatusRetryableMessage
UNAUTHENTICATED401NoMissing 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_EXPIRED401NoThis API key has expired. Create a new key at https://invoicedataextraction.com/dashboard?view=API and use it instead.
API_KEY_REVOKED401NoThis API key has been revoked. Create a new key at https://invoicedataextraction.com/dashboard?view=API and use it instead.
NOT_FOUND404NoThe requested endpoint does not exist. Check the path against the API reference at https://invoicedataextraction.com/api.
RATE_LIMITED429YesToo 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_ERROR500YesSomething went wrong on our side. Retry after a short delay. If it keeps failing, email [email protected].

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:

{
  "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.

EndpointsLimit
Upload endpoints (create session, get part URLs, complete upload)600 requests per minute
Submit extraction30 requests per minute
Poll extraction status120 requests per minute
Get results60 requests per minute
List extractions60 requests per minute
Get extraction details60 requests per minute
Download output30 requests per minute
Delete extraction30 requests per minute
Cancel extraction30 requests per minute
Answer questions30 requests per minute
Check credit balance60 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

FieldTypeRequiredDescription
upload_session_idstringYesYour 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.
filesarrayYesThe files you want to upload (1 to 6,000 files).

Each item in files:

FieldTypeRequiredDescription
file_idstringYesYour 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_namestringYesThe file name, including extension. Must end in .pdf, .jpg, .jpeg, or .png.
file_size_bytesintegerYesThe exact size of the file in bytes.

File Limits

TypeMax Size
PDF150 MB
JPG / JPEG / PNG5 MB
Total batch size2 GB
Max files per session6,000

Example Request

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)

{
  "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

CodeStatusRetryableMessage
DUPLICATE_FILE_NAME400NoEach file must have a unique file_name. Rename the duplicates listed in details and create the session again.
DUPLICATE_FILE_ID400NoEach file must have a unique file_id. Change the duplicates listed in details and create the session again.
FILE_TOO_LARGE400NoA 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_EXCEEDED400NoThe 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_CREDITS402NoNot 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_INITIALIZED409NoThis 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

FieldTypeRequiredDescription
file_idstringYesThe file ID you used when creating the upload session.
part_numbersarray of integersYesThe 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)

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]
  }'
{
  "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)

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]
  }'
{
  "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 at the end of this document.

Error Codes

CodeStatusRetryableMessage
FILE_NOT_FOUND404NoThis 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_UPLOADABLE409NoThis 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

FieldTypeRequiredDescription
file_idstringYesThe file ID you used when creating the upload session.
partsarrayYesThe part numbers and ETags from your part uploads.

Each item in parts:

FieldTypeRequiredDescription
part_numberintegerYesThe part number (matches what you requested in Step 2).
e_tagstringYesThe ETag returned in the response header when you uploaded this part. Include the surrounding quotes (e.g., "\"a1b2c3...\"")

Example Request

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:

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)

{
  "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

CodeStatusRetryableMessage
FILE_NOT_FOUND404NoThis 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_ABORTED409NoThis file was aborted and cannot be completed. To upload it again, create a new upload session.
INVALID_COMPLETION_PARTS400NoThe 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_MISMATCH422NoThe 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_FOUND409NoThis upload session is no longer available. Create a new upload session and upload the files again.
UPLOAD_COMPLETE_FAILED502YesCompleting 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

FieldTypeRequiredDescription
submission_idstringYesYour 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_idstringYesThe upload session ID from Step 1.
file_idsarray of stringsYesThe file IDs to include in this extraction. Must reference files that were completed in Step 3.
task_namestringYesYour own label for this extraction task, for your internal reference (3-40 characters).
promptstring or objectYesYour extraction instructions. See below.
output_structurestringYes"automatic", "per_invoice", or "per_line_item".
optionsobjectNoConfiguration options. See below.

Output structure

Controls how the extracted data is structured:

ValueMeaning
automaticThe AI decides based on your prompt and documents.
per_invoiceEach invoice becomes a single row (spreadsheet/CSV) or object (JSON).
per_line_itemEach 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):

"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:

"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.

Each item in fields:

FieldTypeRequiredDescription
namestringYesThe name for this data point in the output (2-50 characters). Prefer clear, descriptive names (e.g., "Invoice Number", not "Field A").
promptstringNoSpecific instructions for extracting this data point (3–600 characters). Use this to clarify ambiguities or instruct special handling.
FieldTypeRequiredDescription
general_promptstringNoInstructions 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.

FieldTypeDefaultDescription
exclude_columnsarray 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_languagestringAccount preferenceThe 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_colorstringAccount preferenceHighlight 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_colorstringAccount preferenceHighlight 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_emailbooleanfalseSet 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_valuesbooleanfalseSet 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; XLSX and CSV are unchanged. Fixed at submission for this extraction. See JSON value types.
ask_questionsbooleanfalseSet 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 and the extraction continues. See 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:

CodeLanguage
enEnglish
arArabic
zh-HantTraditional Chinese
zh-HansSimplified Chinese
nlDutch
frFrench
deGerman
elGreek
heHebrew
itItalian
jaJapanese
plPolish
ptPortuguese
esSpanish
thThai

Example: String prompt

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

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)

{
  "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 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:

CodeStatusRetryableMessage
SUBMISSIONS_PAUSED503YesExtraction 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

ParameterRequiredDescription
waitNoSeconds 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.
scopeNoOne 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)

{
  "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:

{
  "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."
    }
  ]
}
FieldDescription
questions[].question_idThe question's identifier. Use it when answering.
questions[].typesingle_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[].questionWhat the extraction needs settled, in plain words.
questions[].example_from_documentsA real instance from your documents that the answer would change. Present when one is visible.
questions[].scope.levelWhat 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_toThe scope in words, for whoever is answering.
questions[].choicesThe 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_approachOn a free_text question: what the extraction does if you accept the recommendation instead of answering in your own words.
questions[].previous_answer_rejectedPresent, and true, when an earlier answer to this question was refused (Answer Questions says what is refused). Answer differently.
progressAs on the processing response.
answer_byISO 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, where a person can answer them too.

Answer with Answer Questions. A request held with wait is answered the moment the status becomes input_required.

Completed

{
  "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=..."
  }
}
FieldDescription
credits_deductedThe number of credits charged for this extraction (one credit per successful page).
credits_balanceYour total credit balance after this extraction was charged (paid plus free credits), the same figure 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_reservedCredits held by your extractions still being processed. Your usable balance is credits_balance minus credits_reserved.
output_structureThe 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_atISO 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 returns OUTPUT_EXPIRED.
pages.successful_countNumber of pages successfully processed.
pages.failed_countNumber of pages that failed processing.
pages.successfulList of successfully processed pages. Each item has file_name (the uploaded file name) and page (the page number within that file).
pages.failedList of pages that failed processing. Same shape as successful.
pages.failure_reasonsPage-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_notesPrompt 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_neededResult-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_urlPresigned download URL for the Excel (.xlsx) file. null if not generated, or if the extraction is past output_expires_at.
output.csv_urlPresigned download URL for the CSV file. null if not generated, or if the extraction is past output_expires_at.
output.json_urlPresigned 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: 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 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).

{
  "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.

{
  "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.

CodeRetryableMessage
CONCURRENT_TASK_LIMITYesYou already have the maximum number of extractions running. Wait for one to finish, then submit again with a new submission_id.
NO_PAGES_FOUNDNoNo readable pages were found in the uploaded files. Check that each file opens and contains pages, then upload them again.
VALIDATION_FAILEDNoThe 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_REJECTEDNoThe 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_UNCLEARNoThe 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_EXCEEDEDNoA 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_EXCEEDEDNoThe 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_STALLEDYesThis extraction was never picked up for processing. Submit it again with a new submission_id.
INTERNAL_ERRORYesProcessing failed on our side. Submit the extraction again with a new submission_id; if it fails again, email [email protected] 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. Cancelled extractions do not produce output files. credits_balance and credits_reserved are as on the completed response.

{
  "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:

ValueMeaning
userCancelled from the dashboard or with Cancel Extraction.
unansweredThe extraction asked a question and no answer came by answer_by (Input required). Submit it again.
answers_rejectedThree answers to the extraction's questions were refused (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. To get a spreadsheet, download a file with the URLs in the completed response; 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), 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).

Endpoint

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

Authentication: Bearer token in the Authorization header.

Query Parameters

ParameterRequiredDescription
offsetNoNumber of rows to skip. Default 0.
limitNoRows per page. Default 100, min 1, max 1000.
scopeNoOne of own, team. Same semantics as on Step 5.

Example Request

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

Success Response (200)

{
  "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": []
  }
}
FieldDescription
json_typed_valuesWhich 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).
columnsThe output column names in order, including Source File and Review Needed unless excluded at submission.
rowsThe 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, limitThe page requested.
row_countRows on this page.
total_rowsRows in the whole extraction.
has_more, next_offsethas_more is true when rows remain after this page; next_offset is the offset to request next, null when there are no more.
review_neededcount 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 documents. Check count before relying on the data.
pagesThe page-level results of the extraction, in the shape Step 5 documents. Data from pages.failed is missing from the rows.
output_structure, output_expires_atAs 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

CodeStatusRetryableMessage
INVALID_INPUT400Noextraction_id is not a valid UUID, offset is not a whole number, or limit is outside 1 to 1000.
EXTRACTION_NOT_FOUND404NoNo extraction found for this extraction_id. Check the ID, or the extraction may belong to a different account.
OUTPUT_NOT_AVAILABLE404NoOutput 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_EXPIRED404NoOutput 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.

Endpoint

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

Authentication: Bearer token in the Authorization header.

Query Parameters

ParameterRequiredDescription
formatYesxlsx, csv, or json
scopeNoOne of own, team. Same semantics as on Step 5: 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

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

Success Response (200)

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

Error Codes

CodeStatusRetryableMessage
EXTRACTION_NOT_FOUND404NoNo extraction found for this extraction_id. Check the ID, or the extraction may belong to a different account.
OUTPUT_NOT_AVAILABLE404NoOutput 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_EXPIRED404NoOutput 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, List Extractions, and 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. For the extracted rows themselves, use Get Results. For fresh signed download URLs (which expire 5 minutes after generation), use Step 5 or Download Output.

Endpoint

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

Authentication: Bearer token in the Authorization header.

Query Parameters

ParameterTypeRequiredDescription
statusstringNoFilter by status. One of processing, input_required, completed, cancelled, failed. Omit to include all statuses.
submission_methodstringNoFilter by where the extraction was submitted from. One of api, web_app. Omit to include both.
created_afterISO 8601NoInclusive lower bound on created_at. Example: 2026-01-01T00:00:00Z.
created_beforeISO 8601NoInclusive upper bound on created_at.
scopestringNoOne of own, team. See Scope below.
limitintegerNoItems per page. Default 25, min 1, max 100.
cursorstringNoOpaque 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).
  • completed: processing finished successfully and output is (or was) available.
  • cancelled: processing was cancelled from the dashboard or with 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

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

Success Response (200)

{
  "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)

FieldDescription
extraction_idUUID of the extraction. Use with Get Extraction Details, Step 5, Get Results, Download Output, and Delete Extraction.
submission_idThe submission_id you supplied when submitting via API. null for extractions submitted via the web dashboard.
task_nameThe label provided at submission time (your task_name for API submissions; the run name set in the dashboard for web submissions).
statusOne of processing, input_required, completed, cancelled, failed.
created_atISO 8601 timestamp of when the extraction was created.
submission_methodOne of api, web_app. Identifies whether the extraction was submitted via the API or the web dashboard.
file_countNumber of files in the extraction.
file_names_previewUp to the first 5 uploaded file names. Useful for identifying a task in a history view without fetching the full record.
file_names_truncatedtrue when file_names_preview does not include every uploaded file name. Use Get Extraction Details for the complete file_names array when available.
output_structureReturns 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":

FieldDescription
credits_deductedThe number of credits charged for the extraction (one credit per successful page).
available_outputsList of output formats currently available for download (subset of ["xlsx", "csv", "json"]). Empty when past output_expires_at.
output_expires_atISO 8601 timestamp at which output files become unavailable. Currently 90 days after created_at.

When status: "processing":

FieldDescription
progressInteger 0–100 indicating approximate processing completion.

When status: "input_required":

FieldDescription
progressAs for processing.
answer_byThe moment by which the extraction's questions must be answered. The questions themselves are on Step 5 and Get Extraction Details.

When status: "cancelled":

FieldDescription
credits_deductedCredits charged for AI work already completed before cancellation. No output files are available.
cancellation_reasonWhy the extraction stopped: user, unanswered or answers_rejected. See Cancelled under Step 5.

When status: "failed":

FieldDescription
error.codeThe failure reason code (e.g. INSUFFICIENT_CREDITS, PROMPT_REJECTED, INTERNAL_ERROR). See the Step 5 error codes for the full list and the meaning of each.
error.retryableWhether retrying the same submission could succeed.

To get the full failure message and any structured error.details, fetch the extraction with Get Extraction Details.

Under scope=team

Each item additionally includes:

"submitted_by": { "email": "[email protected]" }

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

Pagination fields

FieldDescription
has_moretrue if more rows exist after the current page; false when the result set is exhausted.
next_cursorOpaque 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:

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

CodeStatusRetryableMessage
INVALID_INPUT400NoReturned 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).
FORBIDDEN403NoReturned 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, 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) 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 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

ParameterRequiredDescription
scopeNoOne of own, team. Same semantics as on List Extractions: team admins default to team, others default to own.

Example Request

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

Success Response (200)

{
  "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)

FieldDescription
extraction_idUUID of the extraction.
submission_idThe submission_id you supplied at submission time. null for extractions submitted via the web dashboard.
task_nameThe label provided at submission time.
statusOne of processing, input_required, completed, cancelled, failed.
created_atISO 8601 timestamp of when the extraction was created.
submission_methodOne of api, web_app.
file_countNumber of files in the extraction.
file_namesList of original file names submitted, in submission order.
output_structureThe 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.
promptThe 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.
optionsAlways 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"

FieldDescription
credits_deductedThe number of credits charged for this extraction.
available_outputsList of output formats currently available for download (subset of ["xlsx", "csv", "json"]). Empty when past output_expires_at.
output_expires_atISO 8601 timestamp at which the output files become unavailable.
pagesSame shape as the Step 5 completed payload.
ai_uncertainty_notesSame shape as the polling endpoint. Empty array if none. These are prompt notes about assumptions made when your prompt left room for interpretation.
review_neededSame 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"

FieldDescription
progressInteger 0–100 indicating approximate processing completion.

When status: "input_required"

FieldDescription
progressAs for processing.
answer_byThe moment by which the questions must be answered.
questionsThe open questions, in the shape Input required documents. Answer them with Answer Questions.

When status: "cancelled"

FieldDescription
credits_deductedCredits charged for AI work already completed before cancellation. No output files are available.
cancellation_reasonWhy the extraction stopped: user, unanswered or answers_rejected. See Cancelled under Step 5.

When status: "failed"

FieldDescription
error.codeFailure reason code. Same set as the Step 5 error codes.
error.messageHuman-readable failure message.
error.retryableWhether retrying the same submission could succeed.
error.detailsStructured failure context (e.g. file names, credit balance) when applicable; null otherwise.

Note: unlike the status endpoint (Step 5), 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:

"submitted_by": { "email": "[email protected]" }

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

CodeStatusRetryableMessage
INVALID_INPUT400Noextraction_id is not a valid UUID, or scope is not own/team.
EXTRACTION_NOT_FOUND404NoNo extraction found for this extraction_id within the requested scope.
FORBIDDEN403NoReturned 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 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.

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

ParameterRequiredDescription
scopeNoOne of own, team. Same semantics as on Step 5: 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

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:

{
  "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 returns for a cancelled extraction:

{
  "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

CodeStatusRetryableMessage
INVALID_INPUT400Noextraction_id is not a valid UUID, or scope is not own/team.
EXTRACTION_NOT_FOUND404NoNo extraction found for this extraction_id. Check the ID, or the extraction may belong to a different account.
EXTRACTION_NOT_CANCELLABLE409NoThis 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}.
FORBIDDEN403NoReturned 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). 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

ParameterRequiredDescription
scopeNoOne of own, team. Same semantics as on Step 5: 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

FieldTypeRequiredDescription
answersarrayYesOne item per question you are answering.

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

FieldTypeDescription
question_idstringThe question_id from the status response. Required.
choice_idstringThe choice_id of the choice you are taking. single_choice questions only. Send text beside it to add detail.
textstringYour own words, 1 to 1000 characters. Accepted on every question, alone or beside a choice_id.
accept_recommendedbooleantrue to take the recommended choice, or on a free_text question the recommended approach. Not combined with choice_id or text.

Example Request

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 returns it: processing once every open question has its answer, input_required with the questions still waiting, or whatever the extraction has become.

{
  "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

CodeStatusRetryableMessage
INVALID_INPUT400Noextraction_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_FOUND404NoNo extraction found for this extraction_id. Check the ID, or the extraction may belong to a different account.
FORBIDDEN403NoReturned 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 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 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

ParameterRequiredDescription
scopeNoOne of own, team. Same semantics as on Step 5: 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

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

Success Response (200)

{
  "success": true
}

Error Codes

CodeStatusRetryableMessage
EXTRACTION_NOT_FOUND404NoNo extraction found for this extraction_id. Check the ID, or the extraction may belong to a different account.
EXTRACTION_IN_PROGRESS409NoThis 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

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

Success Response (200)

{
  "success": true,
  "credits_balance": 150,
  "credits_reserved": 10
}
FieldDescription
credits_balanceYour total credit balance (paid + free credits).
credits_reservedCredits 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.

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. 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 options.json_typed_values: true and the same file carries native JSON types instead. Each value is what the spreadsheet cell holds, in JSON's own types:

Column typeValue with json_typed_values
number, currencyA 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.
percentageA 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.
booleanJSON true or false. Ask for true/false values in your prompt.
text, date, datetimeA string, in the format you asked for. Ask for YYYY-MM-DD if you want dates you can sort or parse.
empty cellnull, 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.

Structured JSON fields

You can ask for a field to return structured JSON.

Example prompt:

"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:

"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:

{
  "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:

[
  {
    "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.


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.

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.`);

Invoice Data Extraction

The AI-native automation platform for high-accuracy invoice extraction

Platform

  • Start Extraction
  • Home
  • Pricing
  • API
  • Python SDK
  • Node.js SDK

Solutions

  • Invoice to Excel
  • Invoice OCR Software
  • Bank Statement Converter
  • Receipt OCR
  • Utility Bill Extraction
  • Payroll Data Extraction
  • PDF Data Extraction

Resources

  • Articles
  • Contact

Trust & Security

  • Security
  • Subprocessors
  • AI Data Use

Legal

  • Terms of Service
  • Data Processing Addendum
  • Privacy Policy
  • Refund Policy
  • US State Privacy Rights
  • EEA/UK Privacy Rights
English
Sign inCreate account

© 2026 Invoice Data Extraction, DEH Technologies LLC

Secure by Design. Your data is never used for AI training.