ZIP Files

Upload a ZIP archive of many documents in a single call.

Upload a ZIP archive containing multiple documents in a single call. Each file inside the ZIP is extracted and processed as an independent document.

Each file inside the ZIP becomes a separate document — they can be of different types. Bundle a batch from an external system into one ZIP and send a single request. You can also combine a ZIP upload with the Classifier to detect each type automatically.

Step 1 — Upload the ZIP

Upload the ZIP exactly like any other file — same endpoint, same info field. Invofox detects the MIME type automatically.

cURL
$curl -X POST https://api.invofox.com/v1/ingest/uploads \
> -H "x-api-key: $INVOFOX_API_KEY" \
> -F "files=@batch.zip" \
> -F 'info={"type":"<TYPE_ID>"}'

The upload response does not include documentIds — Invofox doesn’t know how many files the ZIP contains until it’s extracted. Save the importId instead:

Upload response
1{
2 "accountId": "666839c78ea472012a9b8875",
3 "environmentId": "6a338e5239207f9311eccd36",
4 "importId": "6a34b66df55a9cdc5e6d1ef0", // ← save this
5 "files": [
6 { "id": "6a34b66df55a9cdc5e6d1ef3", "filename": "batch.zip" }
7 ]
8 // no documentId — that's expected on a ZIP upload
9}

Any clientData you include in the info field is propagated to every document extracted from the ZIP — all of them share the exact same clientData.

Step 2 — Get the results

Retrieve the documentIds once processing is done. Webhooks are the recommended approach; you can also poll the import.

Option A · Webhooks (Recommended)

Listen for import.processed to know when the whole ZIP is done, then fetch each document. No polling.

Option B · Poll the import

Check the import status until processed, then collect documentIds from the extracted files and fetch each one.

A document.processed event fires for each file as it finishes, carrying the full extracted document in its data field — same payload format as Step 3 of Integrating Invofox.

Because you don’t know upfront how many files a ZIP contains, you can’t rely on counting document.processed events to know when the ZIP is fully done. Instead, listen for import.processed — this event fires once, after every file in the ZIP has been processed. Iterate data.files[] for the documentIds (the ZIP’s own entry has an empty documentIds — skip it).

import.processed event
1{
2 "type": "import.processed",
3 "id": "6a34b66df55a9cdc5e6d1ef0",
4 "timestamp": "2026-06-19T03:25:00.500Z",
5 "data": {
6 "id": "6a34b66df55a9cdc5e6d1ef0",
7 "status": "processed",
8 "files": [
9 { "id": "6a34b66df55a9cdc5e6d1ef3", "filename": "batch.zip", "status": "processed", "documentIds": [], "clientData": {} },
10 { "id": "6a34b66e6e5e80ef22435412", "filename": "invoice-001.pdf", "status": "processed", "documentIds": ["6a34b68afb139487f5bcaf92"], "clientData": {} },
11 { "id": "6a34b66e6e5e80ef2243541d", "filename": "invoice-002.pdf", "status": "processed", "documentIds": ["6a34b68bfb139487f5bcafb7"], "clientData": {} }
12 ]
13 }
14}

Option B · Poll the import

Poll GET /v1/ingest/imports/{importId} until status is "processed". While processing, status is "processing" and the documents counter increases as each file is extracted — don’t rely on it until status is "processed". The response holds one entry for the ZIP itself (compressed: true, documentIds: []) plus one entry per extracted file. The real documentIds are on those child entries — iterate over all files[] and skip any with compressed: true.

Import response (processed)
1{
2 "id": "6a34b66df55a9cdc5e6d1ef0",
3 "status": "processed", // ← wait for this
4 "documents": 2, // ← total documents extracted (reliable once processed)
5 "files": [
6 {
7 "id": "6a34b66df55a9cdc5e6d1ef3",
8 "filename": "batch.zip",
9 "status": "processed",
10 "compressed": true, // ← the ZIP itself
11 "documentIds": [] // ← skip this entry
12 },
13 {
14 "id": "6a34b66e6e5e80ef22435412",
15 "filename": "invoice-001.pdf", // ← keeps its original name inside the ZIP
16 "status": "processed",
17 "compressed": false,
18 "parentFileId": "6a34b66df55a9cdc5e6d1ef3", // ← points to the ZIP entry
19 "documentIds": ["6a34b68afb139487f5bcaf92"] // ← fetch this
20 },
21 {
22 "id": "6a34b66e6e5e80ef2243541d",
23 "filename": "invoice-002.pdf",
24 "status": "processed",
25 "compressed": false,
26 "parentFileId": "6a34b66df55a9cdc5e6d1ef3",
27 "documentIds": ["6a34b68bfb139487f5bcafb7"]
28 }
29 ]
30}

Each extracted file keeps its original name inside the archive (files[].filename) — unlike the Splitter, which renames its outputs to split_n.pdf. Collect every documentId across the child entries.

Fetch each document

For each documentId, call GET /documents/{documentId} — same response format as the standard flow. Documents extracted from a ZIP carry import.fromZip: true and import.filename set to the file’s name inside the archive:

Document from a ZIP
1{
2 "result": {
3 "_id": "6a34b68afb139487f5bcaf92",
4 "type": "<document type id or alias>",
5 "name": "invoice-001.pdf", // ← keeps its name inside the ZIP
6 "publicState": "approved",
7 "confidence": "high",
8 "data": { "...": "extracted fields" },
9 "import": {
10 "ref": "6a34b66df55a9cdc5e6d1ef0", // ← importId
11 "file": "6a34b66e6e5e80ef22435412", // ← fileId of the extracted file
12 "fromZip": true, // ← marks a document that came from a ZIP
13 "filename": "invoice-001.pdf"
14 }
15 }
16}

A file inside the ZIP can fail on its own (unsupported format, corrupt, or password-protected) while the import as a whole still reaches status: "processed" — so always check each child’s status/error. See Rejected files for how to handle the file.rejected webhook.