Invoice Data Extraction API
Overview
Extracting data from your documents has four parts:
- Upload. Create an upload session, upload each file in parts, then complete each upload (Steps 1 to 3).
- Submit. Submit an extraction task that names the uploaded files and says what to extract (Step 4). Set
options.json_typed_valuestotrueso that amounts, quantities and rates come back as numbers. - Wait. Ask for the task's status with
wait: the API holds the request and answers the moment the extraction finishes (Step 5). - 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:
| Code | Status | Retryable | Message |
|---|---|---|---|
UNAUTHENTICATED | 401 | No | Missing or invalid API key. Send it as a Bearer token in the Authorization header. Create or view your keys at https://invoicedataextraction.com/dashboard?view=API. |
API_KEY_EXPIRED | 401 | No | This API key has expired. Create a new key at https://invoicedataextraction.com/dashboard?view=API and use it instead. |
API_KEY_REVOKED | 401 | No | This API key has been revoked. Create a new key at https://invoicedataextraction.com/dashboard?view=API and use it instead. |
NOT_FOUND | 404 | No | The requested endpoint does not exist. Check the path against the API reference at https://invoicedataextraction.com/api. |
RATE_LIMITED | 429 | Yes | Too many requests to this endpoint. Wait the number of seconds in details.retry_after_seconds (also sent as the Retry-After header), then retry. |
INTERNAL_ERROR | 500 | Yes | Something went wrong on our side. Retry after a short delay. If it keeps failing, email [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.
| Endpoints | Limit |
|---|---|
| Upload endpoints (create session, get part URLs, complete upload) | 600 requests per minute |
| Submit extraction | 30 requests per minute |
| Poll extraction status | 120 requests per minute |
| Get results | 60 requests per minute |
| List extractions | 60 requests per minute |
| Get extraction details | 60 requests per minute |
| Download output | 30 requests per minute |
| Delete extraction | 30 requests per minute |
| Cancel extraction | 30 requests per minute |
| Answer questions | 30 requests per minute |
| Check credit balance | 60 requests per minute |
A status poll held open with wait counts as one request, however long it is held.
Step 1: Create Upload Session
Creates an upload for one or more files. Returns the part size you should use when chunking files for upload.
Endpoint
POST https://api.invoicedataextraction.com/v1/uploads/sessions
Authentication: Bearer token in the Authorization header.
Authorization: Bearer YOUR_API_KEY
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
upload_session_id | string | Yes | Your unique identifier for this upload session. Only letters, numbers, dots, underscores, colons, and hyphens (1-200 characters), the same rule as file_id. Use a different ID for each new session. If a request fails or times out, you can safely retry with the same ID and files: the existing session is returned without creating duplicates. |
files | array | Yes | The files you want to upload (1 to 6,000 files). |
Each item in files:
| Field | Type | Required | Description |
|---|---|---|---|
file_id | string | Yes | Your unique identifier for this file within the session. Only letters, numbers, dots, underscores, colons, and hyphens (1-200 characters). You'll use this ID to reference the file when requesting part URLs and completing the upload. |
file_name | string | Yes | The file name, including extension. Must end in .pdf, .jpg, .jpeg, or .png. |
file_size_bytes | integer | Yes | The exact size of the file in bytes. |
File Limits
| Type | Max Size |
|---|---|
| 150 MB | |
| JPG / JPEG / PNG | 5 MB |
| Total batch size | 2 GB |
| Max files per session | 6,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
| Code | Status | Retryable | Message |
|---|---|---|---|
DUPLICATE_FILE_NAME | 400 | No | Each file must have a unique file_name. Rename the duplicates listed in details and create the session again. |
DUPLICATE_FILE_ID | 400 | No | Each file must have a unique file_id. Change the duplicates listed in details and create the session again. |
FILE_TOO_LARGE | 400 | No | A file exceeds the maximum size for its type. Split or compress the file named in details to within the limit shown, then create the session again. |
TOTAL_UPLOAD_SIZE_LIMIT_EXCEEDED | 400 | No | The combined size of all files exceeds the maximum for one upload session. Split the files across two or more sessions; details shows the limit. |
INSUFFICIENT_CREDITS | 402 | No | Not enough credits for this upload session: each file needs at least one credit, and details shows your balance. Buy credits at https://invoicedataextraction.com/dashboard?view=Billing, or upload fewer files. In details, credits_reserved are credits held by extractions currently being processed. |
SESSION_ALREADY_INITIALIZED | 409 | No | This upload_session_id is already in use. Create the session again with a new upload_session_id. |
Idempotency
You can safely retry a failed or timed-out request using the same upload_session_id and files. If the session was already created, the existing session is returned. If you need a new session with different files, use a different upload_session_id.
Next Step
After creating the upload session, request presigned part URLs for each file to begin uploading.
Step 2: Get Part Upload URLs
For each file, request presigned URLs for the parts you need to upload. You then PUT your file bytes directly to these URLs.
Endpoint
POST https://api.invoicedataextraction.com/v1/uploads/sessions/{upload_session_id}/parts
{upload_session_id} is the ID you provided when creating the upload session in Step 1.
Authentication: Bearer token in the Authorization header.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
file_id | string | Yes | The file ID you used when creating the upload session. |
part_numbers | array of integers | Yes | The part numbers you want upload URLs for (1-indexed). |
How to calculate part numbers
Use the part_size from the Step 1 response to determine how many parts your file needs:
total_parts = ceil(file_size_bytes / part_size)
part_numbers = [1, 2, 3, ..., total_parts]
Files smaller than part_size need only one part: [1].
In the examples below, part_size is 8388608 (8 MB):
- A 120 KB file is smaller than 8 MB, so it needs only part
[1]. - A 20 MB file needs
ceil(20_000_000 / 8_388_608) = 3parts:[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
- Read the file as binary (Buffer, ArrayBuffer, Uint8Array, etc.).
- Slice into chunks of
part_sizebytes (returned in the Step 1 response). The last chunk is usually smaller, which is fine. - PUT each chunk to the corresponding presigned URL. Send the raw bytes as the request body, with no special headers or encoding.
- Capture the
ETagresponse 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
| Code | Status | Retryable | Message |
|---|---|---|---|
FILE_NOT_FOUND | 404 | No | This file_id was not registered when the upload session was created. Use a file_id from that session, or create a new session that includes this file. |
FILE_NOT_UPLOADABLE | 409 | No | This file has already been completed or aborted, so no more parts can be uploaded for it. To upload it again, create a new upload session. |
Next Step
After uploading all parts for a file, complete the upload with the ETags from each part.
Step 3: Complete File Upload
After uploading all parts for a file, call this endpoint with the ETags to finalize the upload. Call this once per file.
Endpoint
POST https://api.invoicedataextraction.com/v1/uploads/sessions/{upload_session_id}/complete
{upload_session_id} is the ID you provided when creating the upload session in Step 1.
Authentication: Bearer token in the Authorization header.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
file_id | string | Yes | The file ID you used when creating the upload session. |
parts | array | Yes | The part numbers and ETags from your part uploads. |
Each item in parts:
| Field | Type | Required | Description |
|---|---|---|---|
part_number | integer | Yes | The part number (matches what you requested in Step 2). |
e_tag | string | Yes | The ETag returned in the response header when you uploaded this part. Include the surrounding quotes (e.g., "\"a1b2c3...\"") |
Example Request
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
| Code | Status | Retryable | Message |
|---|---|---|---|
FILE_NOT_FOUND | 404 | No | This file_id was not registered when the upload session was created. Use a file_id from that session, or create a new session that includes this file. |
FILE_ABORTED | 409 | No | This file was aborted and cannot be completed. To upload it again, create a new upload session. |
INVALID_COMPLETION_PARTS | 400 | No | The parts provided to complete this file upload are invalid. Fix the problem described in details (every part from 1 to the part count, each with its e_tag), then resend. |
OBJECT_SIZE_MISMATCH | 422 | No | The uploaded bytes do not match the file_size_bytes declared when the session was created; details shows both sizes. Create a new session declaring the actual size and upload the file again. |
UPLOAD_ID_NOT_FOUND | 409 | No | This upload session is no longer available. Create a new upload session and upload the files again. |
UPLOAD_COMPLETE_FAILED | 502 | Yes | Completing the file upload failed on our side. Retry the same request; if it keeps failing, create a new upload session. |
Next Step
After completing all files, submit an extraction task.
Step 4: Submit Extraction Task
Submit an extraction task referencing your uploaded files. You tell the API what data to extract using a prompt.
Endpoint
POST https://api.invoicedataextraction.com/v1/extractions
Authentication: Bearer token in the Authorization header.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
submission_id | string | Yes | Your unique identifier for this submission. Only letters, numbers, dots, underscores, colons, and hyphens (1-200 characters), the same rule as file_id. If a request fails or times out, retry with the same submission_id to safely retrieve the existing task instead of creating a duplicate. Use a different ID for each new extraction task (e.g., a UUID). |
upload_session_id | string | Yes | The upload session ID from Step 1. |
file_ids | array of strings | Yes | The file IDs to include in this extraction. Must reference files that were completed in Step 3. |
task_name | string | Yes | Your own label for this extraction task, for your internal reference (3-40 characters). |
prompt | string or object | Yes | Your extraction instructions. See below. |
output_structure | string | Yes | "automatic", "per_invoice", or "per_line_item". |
options | object | No | Configuration options. See below. |
Output structure
Controls how the extracted data is structured:
| Value | Meaning |
|---|---|
automatic | The AI decides based on your prompt and documents. |
per_invoice | Each invoice becomes a single row (spreadsheet/CSV) or object (JSON). |
per_line_item | Each individual product/service listed within an invoice becomes its own row (spreadsheet/CSV) or object (JSON). |
Prompt
The prompt field tells the AI what data to extract from your documents. It can be either a string or an object.
As a string. Describe what you want in natural language (max 2,500 characters):
"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:
| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | The name for this data point in the output (2-50 characters). Prefer clear, descriptive names (e.g., "Invoice Number", not "Field A"). |
prompt | string | No | Specific instructions for extracting this data point (3–600 characters). Use this to clarify ambiguities or instruct special handling. |
| Field | Type | Required | Description |
|---|---|---|---|
general_prompt | string | No | Instructions that apply to the full task and across all fields (max 1,500 characters). Use this to provide special handling instructions, specify output structure/formatting, or describe the extraction goal. |
Options
The options object is optional, and so is every field in it. The three output preferences (output_language, review_needed_fill_color, affected_field_fill_color) default to the settings on your web app Preferences page: automatic language and orange highlights unless you have changed them there. A value you send applies to this extraction only and leaves your account preferences unchanged.
| Field | Type | Default | Description |
|---|---|---|---|
exclude_columns | array of strings | [] | System-generated columns to exclude from output files. By default, output files include a "Source File" column indicating which uploaded file/page each row was extracted from, and a "Review Needed" column marking rows that need human verification. If your workflow requires an exact output structure, you can exclude either column. Valid values: "source_file", "review_needed". Excluding "review_needed" only removes the export column; completed API responses still include review_needed so you can programmatically check whether manual review is required. |
output_language | string | Account preference | The language of the text the AI writes for you: Review Needed messages and prompt notes. Either "automatic" or one of the language codes below. With "automatic", the language follows your prompt (your instructions and field names), not the language of your documents, and is English when that is unclear. Extracted values, file names and your field names are never translated. |
review_needed_fill_color | string | Account preference | Highlight color for cells in the Review Needed column that carry a warning. XLSX files only; has no effect when the Review Needed column is excluded. One of "none", "yellow", "orange", "red". |
affected_field_fill_color | string | Account preference | Highlight color for the extracted data cells a Review Needed warning refers to. XLSX files only; has no effect when the Review Needed column is excluded. One of "none", "yellow", "orange", "red". |
send_completion_email | boolean | false | Set to true to be emailed at your account's email address when this extraction finishes processing, whether it completes, fails or is cancelled. A submission refused before processing starts (for example for insufficient credits or an encrypted file) sends no email; its failure appears when you poll. Applies to this extraction only; the web app's "Email me when extraction tasks finish" preference does not apply to API submissions. |
json_typed_values | boolean | false | Set to true to receive the JSON output with native JSON types instead of strings: amounts, quantities and rates as JSON numbers, yes/no fields as JSON booleans, and a cell with nothing in it as null. Recommended for a new integration that reads the rows or the JSON file. Applies to the JSON output file and Get Results; XLSX and CSV are unchanged. Fixed at submission for this extraction. See JSON value types. |
ask_questions | boolean | false | Set to true to let the extraction stop and ask you when the documents leave something unsettled, instead of deciding on its own. The status is then input_required and carries the questions; answer them with Answer Questions 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:
| Code | Language |
|---|---|
en | English |
ar | Arabic |
zh-Hant | Traditional Chinese |
zh-Hans | Simplified Chinese |
nl | Dutch |
fr | French |
de | German |
el | Greek |
he | Hebrew |
it | Italian |
ja | Japanese |
pl | Polish |
pt | Portuguese |
es | Spanish |
th | Thai |
Example: String prompt
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:
| Code | Status | Retryable | Message |
|---|---|---|---|
SUBMISSIONS_PAUSED | 503 | Yes | Extraction submissions are temporarily paused while we deploy an update. Retry in a few minutes; when details.resumes_at is present, that is the expected resume time. |
When available, SUBMISSIONS_PAUSED includes details.resumes_at with the expected resume time in UTC. This time is advisory; retry after a few minutes if it is absent.
Idempotency
If a request fails or times out, you can safely retry with the same submission_id. If the task was already created, the existing task is returned without creating a duplicate. Use a different submission_id for each new extraction task.
Next Step
After submitting, wait for the extraction to finish (Step 5).
Step 5: Wait for the Extraction to Finish
After submitting, ask this endpoint for the extraction's status. Add wait and the API holds your request until the extraction leaves processing or the wait runs out, so a handful of calls replaces a polling loop. The status ends as one of completed, failed or cancelled; an extraction submitted with options.ask_questions can also stop at input_required while it waits for your answers.
Endpoint
GET https://api.invoicedataextraction.com/v1/extractions/{extraction_id}?wait=45
{extraction_id} is returned in the Step 4 response.
Authentication: Bearer token in the Authorization header.
Query Parameters
| Parameter | Required | Description |
|---|---|---|
wait | No | Seconds to hold the request open, from 1 to 45. The response is sent as soon as the extraction leaves processing, or when the time is up, whichever comes first. A response that arrives after the full wait is an ordinary processing response: call again. Without wait, the current status is returned immediately. |
scope | No | One of own, team. Team admins default to team (so they can check any of their team members' runs); other callers default to own. Passing scope=team from a non-admin returns 403 FORBIDDEN. |
Response
The response always includes success, status, and extraction_id. status is exactly one of processing, input_required, completed, failed, cancelled, and success is true for all of these except failed. Branch on status, and treat a value you do not recognise as non-terminal: keep polling, with your own timeout as the backstop. The rest of the response depends on the status.
Processing (keep polling)
{
"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."
}
]
}
| Field | Description |
|---|---|
questions[].question_id | The question's identifier. Use it when answering. |
questions[].type | single_choice: the question offers choices; answer with one of them, with words added if you like. free_text: the question wants your own words; answer with them, or accept the recommended approach. Words are accepted on every question, so a type you do not recognise can still be answered with text. |
questions[].question | What the extraction needs settled, in plain words. |
questions[].example_from_documents | A real instance from your documents that the answer would change. Present when one is visible. |
questions[].scope.level | What the answer governs. extraction: every document in the extraction, not only the one in the example. This is the only value today; treat a value you do not recognise as narrower than the extraction and read applies_to. |
questions[].scope.applies_to | The scope in words, for whoever is answering. |
questions[].choices | The ways to answer a single_choice question, each with a choice_id and a label. One carries recommended: true. cell_would_contain is the value that choice would put in the spreadsheet cell for the example, when known. Empty for free_text. |
questions[].recommended_approach | On a free_text question: what the extraction does if you accept the recommendation instead of answering in your own words. |
questions[].previous_answer_rejected | Present, and true, when an earlier answer to this question was refused (Answer Questions says what is refused). Answer differently. |
progress | As on the processing response. |
answer_by | ISO 8601. The moment by which the questions must be answered. |
An answer governs the whole extraction: every document in it, not only the one in the example, which is one instance the answer would change. Where the right answer differs by document type, say so in text; words are accepted on every question.
What happens, and when: questions appear the moment they exist, and every open question of the extraction is listed together. If the questions are not all answered within about four minutes, the extraction pauses and waits, and the account owner is emailed that a task is waiting for an answer, with a second email before the deadline. If the questions are not all answered by answer_by, which is 40 hours after the files were uploaded, the extraction is cancelled with cancellation_reason: unanswered and the work done so far is charged. The extraction continues the moment every open question has an answer. The same questions appear in the web dashboard, 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=..."
}
}
| Field | Description |
|---|---|
credits_deducted | The number of credits charged for this extraction (one credit per successful page). |
credits_balance | Your total credit balance after this extraction was charged (paid plus free credits), the same figure Check Credit Balance returns. Use it to warn before the balance runs out; credits are bought in the dashboard at https://invoicedataextraction.com/dashboard?view=Billing. |
credits_reserved | Credits held by your extractions still being processed. Your usable balance is credits_balance minus credits_reserved. |
output_structure | The effective output structure. Returns the AI-determined structure when 'automatic' was provided; otherwise returns the submitted value ("per_invoice", or "per_line_item") |
output_expires_at | ISO 8601 timestamp at which the output files become unavailable (currently 90 days after submission). After this point, the output.*_url fields are null and Download Output returns OUTPUT_EXPIRED. |
pages.successful_count | Number of pages successfully processed. |
pages.failed_count | Number of pages that failed processing. |
pages.successful | List of successfully processed pages. Each item has file_name (the uploaded file name) and page (the page number within that file). |
pages.failed | List of pages that failed processing. Same shape as successful. |
pages.failure_reasons | Page-failure reason metadata when available. Empty array if none. Each item has a code, user-facing message, and affected_pages grouped by uploaded file_name with source-file page numbers. |
ai_uncertainty_notes | Prompt notes: areas where your prompt left room for interpretation and the AI made an assumption about how to apply it to the documents. Empty array if none. Each note has a topic, a description of what was assumed, and a suggested_prompt_additions array of prompt additions you can use to remove the ambiguity in future extractions. Each item has a purpose (why you'd add it) and instructions (prompt text you can add). |
review_needed | Result-level warnings for records that need human verification before you rely on the output. Always present on completed responses as { "count": number, "items": [...] }. Check review_needed.count; if greater than 0, alert your workflow or team to manually verify the listed rows. Each item has a user-facing message, affected_fields, output_row_numbers, and source_references. affected_fields is populated only when the issue is tied to specific output fields; it is empty for broader row/document review concerns. output_row_numbers contains one or more 1-based data row numbers and does not include the header row; for example, 1 means the first extracted row, not Excel/CSV worksheet row 2. This response is returned even if you excluded the Review Needed export column with options.exclude_columns. You can also review these items in the web dashboard. |
output.xlsx_url | Presigned download URL for the Excel (.xlsx) file. null if not generated, or if the extraction is past output_expires_at. |
output.csv_url | Presigned download URL for the CSV file. null if not generated, or if the extraction is past output_expires_at. |
output.json_url | Presigned download URL for the JSON file. null if not generated, or if the extraction is past output_expires_at. |
Read the extracted rows with Get Results: 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.
| Code | Retryable | Message |
|---|---|---|
CONCURRENT_TASK_LIMIT | Yes | You already have the maximum number of extractions running. Wait for one to finish, then submit again with a new submission_id. |
NO_PAGES_FOUND | No | No readable pages were found in the uploaded files. Check that each file opens and contains pages, then upload them again. |
VALIDATION_FAILED | No | The files did not pass validation against the file count, size or type limits. Check the limits in the API reference at https://invoicedataextraction.com/api, adjust the files and submit again. |
PROMPT_REJECTED | No | The prompt was rejected because it does not describe data to extract from documents. Rewrite it as extraction instructions, naming the fields you want, and submit again. |
PROMPT_UNCLEAR | No | The prompt could not be understood well enough to extract data. Name the fields you want and how each should look, then submit again. |
FILE_SIZE_LIMIT_EXCEEDED | No | A file grew past the size limit during processing, which can happen with heavily compressed PDFs. Split large files into smaller documents and submit again. |
PROCESSING_FILE_SIZE_LIMIT_EXCEEDED | No | The upload was accepted, but during processing part of the PDF became too large for our file-processing limit. This can happen when a compressed PDF is processed internally. Split the PDF into smaller page chunks and resubmit. |
SUBMISSION_STALLED | Yes | This extraction was never picked up for processing. Submit it again with a new submission_id. |
INTERNAL_ERROR | Yes | Processing failed on our side. Submit the extraction again with a new submission_id; if it fails again, email [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:
| Value | Meaning |
|---|---|
user | Cancelled from the dashboard or with Cancel Extraction. |
unanswered | The extraction asked a question and no answer came by answer_by (Input required). Submit it again. |
answers_rejected | Three 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
| Parameter | Required | Description |
|---|---|---|
offset | No | Number of rows to skip. Default 0. |
limit | No | Rows per page. Default 100, min 1, max 1000. |
scope | No | One of own, team. Same semantics as on Step 5. |
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": []
}
}
| Field | Description |
|---|---|
json_typed_values | Which mode the rows are in: false when every value is a string, true when the extraction was submitted with options.json_typed_values and values carry native JSON types (JSON value types). |
columns | The output column names in order, including Source File and Review Needed unless excluded at submission. |
rows | The rows on this page, one object per row with a value under every column. Row i of the page (counting from 0) is data row offset + i + 1, the numbering that review_needed.items[].output_row_numbers uses. |
offset, limit | The page requested. |
row_count | Rows on this page. |
total_rows | Rows in the whole extraction. |
has_more, next_offset | has_more is true when rows remain after this page; next_offset is the offset to request next, null when there are no more. |
review_needed | count is the number of Review Needed items for the whole extraction; items holds only those whose output_row_numbers fall on this page, in the shape Step 5 documents. Check count before relying on the data. |
pages | The 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_at | As on the completed polling response. |
Pagination
Request pages in order, passing next_offset back as offset until has_more is false. The rows of a completed extraction do not change, so pages are stable.
Error Codes
| Code | Status | Retryable | Message |
|---|---|---|---|
INVALID_INPUT | 400 | No | extraction_id is not a valid UUID, offset is not a whole number, or limit is outside 1 to 1000. |
EXTRACTION_NOT_FOUND | 404 | No | No extraction found for this extraction_id. Check the ID, or the extraction may belong to a different account. |
OUTPUT_NOT_AVAILABLE | 404 | No | Output is not available for this extraction: it has not completed, or this format was not generated. Poll the extraction until its status is completed; the completed response lists the outputs that exist. |
OUTPUT_EXPIRED | 404 | No | Output is no longer available: extractions are kept for 90 days after submission. To get this data again, upload the files and submit a new extraction. |
Rows are available for the same 90 days as the output files; output_expires_at says when they stop being.
Download Output
Gives a fresh download URL for one of the output files: the XLSX, CSV or JSON file of a completed extraction. The completed response already carries URLs, valid for 5 minutes; use this endpoint when they have expired or when you download later. To get the data as rows rather than as a file, use Get Results.
Endpoint
GET https://api.invoicedataextraction.com/v1/extractions/{extraction_id}/output?format={format}
Authentication: Bearer token in the Authorization header.
Query Parameters
| Parameter | Required | Description |
|---|---|---|
format | Yes | xlsx, csv, or json |
scope | No | One of own, team. Same semantics as on Step 5: 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
| Code | Status | Retryable | Message |
|---|---|---|---|
EXTRACTION_NOT_FOUND | 404 | No | No extraction found for this extraction_id. Check the ID, or the extraction may belong to a different account. |
OUTPUT_NOT_AVAILABLE | 404 | No | Output is not available for this extraction: it has not completed, or this format was not generated. Poll the extraction until its status is completed; the completed response lists the outputs that exist. |
OUTPUT_EXPIRED | 404 | No | Output is no longer available: extractions are kept for 90 days after submission. To get this data again, upload the files and submit a new extraction. |
The same output_expires_at timestamp returned by Step 5, 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
| Parameter | Type | Required | Description |
|---|---|---|---|
status | string | No | Filter by status. One of processing, input_required, completed, cancelled, failed. Omit to include all statuses. |
submission_method | string | No | Filter by where the extraction was submitted from. One of api, web_app. Omit to include both. |
created_after | ISO 8601 | No | Inclusive lower bound on created_at. Example: 2026-01-01T00:00:00Z. |
created_before | ISO 8601 | No | Inclusive upper bound on created_at. |
scope | string | No | One of own, team. See Scope below. |
limit | integer | No | Items per page. Default 25, min 1, max 100. |
cursor | string | No | Opaque pagination token. Pass the next_cursor from the previous response to fetch the next page. |
Scope
For most callers, scope can be omitted:
- Individual users and non-admin team members: default scope is
own. Returns only your own extractions. Passingscope=teamreturns403 FORBIDDEN. - Team admins: default scope is
team. Returns all extractions from your team members, plus your own pre-team extractions. Passscope=ownto 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)
| Field | Description |
|---|---|
extraction_id | UUID of the extraction. Use with Get Extraction Details, Step 5, Get Results, Download Output, and Delete Extraction. |
submission_id | The submission_id you supplied when submitting via API. null for extractions submitted via the web dashboard. |
task_name | The label provided at submission time (your task_name for API submissions; the run name set in the dashboard for web submissions). |
status | One of processing, input_required, completed, cancelled, failed. |
created_at | ISO 8601 timestamp of when the extraction was created. |
submission_method | One of api, web_app. Identifies whether the extraction was submitted via the API or the web dashboard. |
file_count | Number of files in the extraction. |
file_names_preview | Up to the first 5 uploaded file names. Useful for identifying a task in a history view without fetching the full record. |
file_names_truncated | true when file_names_preview does not include every uploaded file name. Use Get Extraction Details for the complete file_names array when available. |
output_structure | Returns the AI-determined structure when automatic was submitted, otherwise returns the submitted value (per_invoice, or per_line_item). |
Status-conditional fields
When status: "completed":
| Field | Description |
|---|---|
credits_deducted | The number of credits charged for the extraction (one credit per successful page). |
available_outputs | List of output formats currently available for download (subset of ["xlsx", "csv", "json"]). Empty when past output_expires_at. |
output_expires_at | ISO 8601 timestamp at which output files become unavailable. Currently 90 days after created_at. |
When status: "processing":
| Field | Description |
|---|---|
progress | Integer 0–100 indicating approximate processing completion. |
When status: "input_required":
| Field | Description |
|---|---|
progress | As for processing. |
answer_by | The moment by which the extraction's questions must be answered. The questions themselves are on Step 5 and Get Extraction Details. |
When status: "cancelled":
| Field | Description |
|---|---|
credits_deducted | Credits charged for AI work already completed before cancellation. No output files are available. |
cancellation_reason | Why the extraction stopped: user, unanswered or answers_rejected. See Cancelled under Step 5. |
When status: "failed":
| Field | Description |
|---|---|
error.code | The failure reason code (e.g. INSUFFICIENT_CREDITS, PROMPT_REJECTED, INTERNAL_ERROR). See the Step 5 error codes for the full list and the meaning of each. |
error.retryable | Whether retrying the same submission could succeed. |
To get the full failure message and any structured error.details, fetch the extraction with Get Extraction Details.
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
| Field | Description |
|---|---|
has_more | true if more rows exist after the current page; false when the result set is exhausted. |
next_cursor | Opaque token to pass back as cursor to fetch the next page. null when has_more is false. |
Pagination
Use cursor-based pagination by passing the previous response's next_cursor into the next request:
const allExtractions = [];
let cursor = null;
while (true) {
const params = new URLSearchParams({ limit: "100", status: "completed" });
if (cursor) params.set("cursor", cursor);
const response = await fetch(
`https://api.invoicedataextraction.com/v1/extractions?${params}`,
{ headers: { Authorization: `Bearer ${API_KEY}` } }
);
const data = await response.json();
allExtractions.push(...data.extractions);
if (!data.has_more) break;
cursor = data.next_cursor;
}
Cursors are opaque: don't try to construct or decode them. They encode the position in the result set; passing one between requests is the only supported use.
Error Codes
| Code | Status | Retryable | Message |
|---|---|---|---|
INVALID_INPUT | 400 | No | Returned for malformed query parameters (unknown status/submission_method values, invalid created_after/created_before timestamps, created_after > created_before, malformed cursor, out-of-range limit, repeated query keys). |
FORBIDDEN | 403 | No | Returned when a non-admin caller passes scope=team. |
Get Extraction Details
Returns the full canonical record for a single extraction by ID, including the original prompt, options, all file names, page-level results, prompt notes in ai_uncertainty_notes, and Review Needed warnings. Use this when you have an extraction_id (from List Extractions, 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: falsefor 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: falsefor a failed extraction; failed extractions are valid records here, with the failure reason inside theerrorfield. This means a routine SDK call to "get the record for this ID" doesn't need to special-casesuccess: falseas 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
| Parameter | Required | Description |
|---|---|---|
scope | No | One 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)
| Field | Description |
|---|---|
extraction_id | UUID of the extraction. |
submission_id | The submission_id you supplied at submission time. null for extractions submitted via the web dashboard. |
task_name | The label provided at submission time. |
status | One of processing, input_required, completed, cancelled, failed. |
created_at | ISO 8601 timestamp of when the extraction was created. |
submission_method | One of api, web_app. |
file_count | Number of files in the extraction. |
file_names | List of original file names submitted, in submission order. |
output_structure | The current/effective output structure. Returns the AI-determined structure when available; otherwise falls back to the requested value (automatic, per_invoice, or per_line_item). May be null for older extractions where no level was recorded. |
prompt | The original prompt. String prompts are returned as strings; structured prompts are returned as { "fields": [...], "general_prompt": "..." }. Web-app extractions submitted with no prompt return an empty string. |
options | Always present, in the shape POST /v1/extractions accepts, with every field filled: exclude_columns, output_language, review_needed_fill_color, affected_field_fill_color, send_completion_email, json_typed_values and ask_questions. The values are the ones that apply to this extraction, including the account preferences that filled any field left out at submission. |
When status: "completed"
| Field | Description |
|---|---|
credits_deducted | The number of credits charged for this extraction. |
available_outputs | List of output formats currently available for download (subset of ["xlsx", "csv", "json"]). Empty when past output_expires_at. |
output_expires_at | ISO 8601 timestamp at which the output files become unavailable. |
pages | Same shape as the Step 5 completed payload. |
ai_uncertainty_notes | Same shape as the polling endpoint. Empty array if none. These are prompt notes about assumptions made when your prompt left room for interpretation. |
review_needed | Same shape as the polling endpoint. Always present on completed records. Check review_needed.count; if greater than 0, the listed output rows need human verification before you rely on the data. output_row_numbers are 1-based extracted data row numbers and do not include the Excel/CSV header row. |
When status: "processing"
| Field | Description |
|---|---|
progress | Integer 0–100 indicating approximate processing completion. |
When status: "input_required"
| Field | Description |
|---|---|
progress | As for processing. |
answer_by | The moment by which the questions must be answered. |
questions | The open questions, in the shape Input required documents. Answer them with Answer Questions. |
When status: "cancelled"
| Field | Description |
|---|---|
credits_deducted | Credits charged for AI work already completed before cancellation. No output files are available. |
cancellation_reason | Why the extraction stopped: user, unanswered or answers_rejected. See Cancelled under Step 5. |
When status: "failed"
| Field | Description |
|---|---|
error.code | Failure reason code. Same set as the Step 5 error codes. |
error.message | Human-readable failure message. |
error.retryable | Whether retrying the same submission could succeed. |
error.details | Structured failure context (e.g. file names, credit balance) when applicable; null otherwise. |
Note: unlike the status endpoint (Step 5), 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
| Code | Status | Retryable | Message |
|---|---|---|---|
INVALID_INPUT | 400 | No | extraction_id is not a valid UUID, or scope is not own/team. |
EXTRACTION_NOT_FOUND | 404 | No | No extraction found for this extraction_id within the requested scope. |
FORBIDDEN | 403 | No | Returned when a non-admin caller passes scope=team. |
Cancel Extraction
Stops an extraction that is still queued or processing. The request is recorded at once and the extraction stops at the next point where it can; wait for it with Step 5 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
| Parameter | Required | Description |
|---|---|---|
scope | No | One 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
| Code | Status | Retryable | Message |
|---|---|---|---|
INVALID_INPUT | 400 | No | extraction_id is not a valid UUID, or scope is not own/team. |
EXTRACTION_NOT_FOUND | 404 | No | No extraction found for this extraction_id. Check the ID, or the extraction may belong to a different account. |
EXTRACTION_NOT_CANCELLABLE | 409 | No | This extraction has already completed or failed, so there is nothing to cancel; details.status says which. Get its result with GET /v1/extractions/{extraction_id}. |
FORBIDDEN | 403 | No | Returned when a non-admin caller passes scope=team. |
EXTRACTION_NOT_CANCELLABLE carries details: { "status": "completed" } or { "status": "failed" }.
Answer Questions
Answers the questions an extraction stopped to ask (Input required). Answer every open question in one request, or across several; the extraction continues the moment every open question has an answer.
Endpoint
POST https://api.invoicedataextraction.com/v1/extractions/{extraction_id}/answers
Authentication: Bearer token in the Authorization header.
Query Parameters
| Parameter | Required | Description |
|---|---|---|
scope | No | One of own, team. Same semantics as on Step 5: team admins default to team, allowing them to answer for any of their team members' extractions. Other callers default to own and may not pass scope=team. |
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
answers | array | Yes | One item per question you are answering. |
Each item in answers names a question and gives one of three answers:
| Field | Type | Description |
|---|---|---|
question_id | string | The question_id from the status response. Required. |
choice_id | string | The choice_id of the choice you are taking. single_choice questions only. Send text beside it to add detail. |
text | string | Your own words, 1 to 1000 characters. Accepted on every question, alone or beside a choice_id. |
accept_recommended | boolean | true to take the recommended choice, or on a free_text question the recommended approach. Not combined with choice_id or text. |
Example Request
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
| Code | Status | Retryable | Message |
|---|---|---|---|
INVALID_INPUT | 400 | No | extraction_id is not a valid UUID, scope is not own/team, or the body could never be right: details.issues names the answer and the field, for a question_id the extraction never asked, a choice_id it does not offer, empty or over-long text, accept_recommended sent with choice_id or text, or a question answered twice in one request. |
EXTRACTION_NOT_FOUND | 404 | No | No extraction found for this extraction_id. Check the ID, or the extraction may belong to a different account. |
FORBIDDEN | 403 | No | Returned when a non-admin caller passes scope=team. |
Delete Extraction
Permanently deletes an extraction, its output files, and its uploaded source files. Extractions that are currently being processed cannot be deleted; cancel 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
| Parameter | Required | Description |
|---|---|---|
scope | No | One 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
| Code | Status | Retryable | Message |
|---|---|---|---|
EXTRACTION_NOT_FOUND | 404 | No | No extraction found for this extraction_id. Check the ID, or the extraction may belong to a different account. |
EXTRACTION_IN_PROGRESS | 409 | No | This extraction is still being processed and cannot be deleted. Wait for it to complete or fail, then delete it. |
Check Credit Balance
Returns your current credit balance, including credits reserved by extractions that are currently being processed.
Endpoint
GET https://api.invoicedataextraction.com/v1/credits/balance
Authentication: Bearer token in the Authorization header.
Example Request
curl "https://api.invoicedataextraction.com/v1/credits/balance" \
-H "Authorization: Bearer $API_KEY"
Success Response (200)
{
"success": true,
"credits_balance": 150,
"credits_reserved": 10
}
| Field | Description |
|---|---|
credits_balance | Your total credit balance (paid + free credits). |
credits_reserved | Credits reserved by extractions currently being processed. Up to this amount will be deducted when processing completes depending on number of successful pages. Your usable balance is credits_balance minus credits_reserved. |
Working with Output Files
You can control the structure and formatting of all output files in two main ways:
- use
output_structureto choose the top-level record shape, such asper_invoiceorper_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, or0 - 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 type | Value with json_typed_values |
|---|---|
| number, currency | A JSON number (1234.5). A currency column never carries the symbol; ask for a separate currency-code field if you need it. If you ask for the symbol in the value, the column is text and the value stays a string. |
| percentage | A JSON number as the spreadsheet stores it: the fraction when the document shows a percent sign (19% is 0.19), the number as printed when you ask for no sign. |
| boolean | JSON true or false. Ask for true/false values in your prompt. |
| text, date, datetime | A string, in the format you asked for. Ask for YYYY-MM-DD if you want dates you can sort or parse. |
| empty cell | null, 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.`);