---
title: "Ingest a zip archive or individual files"
method: POST
path: "/v2/workspaces/{id}/ingest"
tags: ["Ingest"]
---

# Ingest a zip archive or individual files

`POST /v2/workspaces/{id}/ingest`

## Request

- Base URL: `https://api.infolang.ai`
- URL: `https://api.infolang.ai/v2/workspaces/{id}/ingest`
- Auth: one of:
  - HTTP bearer
  - HTTP bearer
  - HTTP bearer

Requires Member. ONE endpoint, two intake paths chosen by `Content-Type`:

- `application/zip` / `application/octet-stream` — body is the raw zip bytes, max 10 MB compressed.
- `multipart/form-data` — 1..N individual text files in repeated `files` parts (each part's filename becomes the memory `source`). Total request body max 10 MB, max 200 files, max 5 MB per file.

Both paths run the same pipeline and return the same shape, and the STATUS CODE tells you whether the job in the body can still change:

- **200** — the job is terminal (`complete` or `failed`), counts are final, do not poll. Small jobs (up to one wave of write batches, 32 chunks) finish inside the request.
- **202** — the job is `processing` and is still running server-side. Counts are zero, `Location` names the job URL and `Retry-After` the poll interval; poll until `status` leaves `processing`.
- **413** — the upload chunks to more than 960 pieces, the per-job work ceiling. Refused before anything is written or billed, with the limit named in the message. Split the upload.

`files_processed` counts files whose EVERY chunk was confirmed stored, counted from write confirmations — a job that stored nothing reports 0. Files that were attempted is `files_total - files_skipped.length`.

Job-level rejections (200 + `failed` job): zip only — `zip_bomb` (50 MB declared uncompressed / 100x compression-ratio caps), `too_many_entries` (200 entries), `invalid_zip`; multipart only — `too_many_files` (more than 200 `files` parts) and `no_usable_files` (every uploaded file was skipped by the controls below).

Per-file violations are SKIPPED and reported in `files_skipped` (capped at 100 rows) while the rest of the job proceeds — identical controls on both paths: over 5 MB, nested archives (`.zip`/`.tar`/`.gz`/… are never recursed into, including when uploaded as one of several multipart files), hostile names (absolute paths, `..` segments, backslashes, control chars, over 512 chars), extensions outside the text allowlist, and content that is not valid UTF-8. The compression-ratio and nested-archive-extraction controls are zip-only — multipart bodies are not compressed.

Chunks land as Kairos memories in `ns`, tagged `ingest:{job_id}` (plus `tag_prefix` when given), `source` set to the sanitized entry path or multipart filename. Free-plan workspaces are rejected up front when the estimated source tokens would exceed the plan inclusion.

Requires the `ingest:write` scope. Accepts all three lanes: a session JWT (bounded by the caller's role on this workspace) or an `il_*` machine credential whose own scope set, workspace set and validity window cover it — both ceilings apply and neither can raise the other, so a credential carrying `ingest:write` is still refused where the role does not permit it.

A credential with no scope record resolves to `LEGACY_DEFAULT`, which INCLUDES `ingest:write` — every key minted before scoping keeps working here.

## Path parameters

- `id` string, required

## Query parameters

- `ns` string: target namespace (default `default`)
- `tag_prefix` string: extra tag applied to every chunk

## Response `200`

TERMINAL job — nothing left to poll. `complete`, or `failed` with a job-level rejection: `zip_bomb`, `too_many_entries`, `invalid_zip` (zip path), `too_many_files`, `no_usable_files` (multipart path), or `upstream_error` when the chunk writes failed

- object
  - `job_id` string, required
  - `workspace_id` string, required
  - `namespace` string, required
  - `status` 'processing' | 'complete' | 'failed', required
  - `files_total` integer, required
  - `files_processed` integer, required
  - `files_skipped` object[], required
    - `name` string, required
    - `reason` string, required
  - `chunks_written` integer, required
  - `tokens_ingested` integer, required
  - `error` object
    - `code` string, required
    - `message` string, required
  - `created` string, required
  - `finished` string

## Other responses

- `202`: ACCEPTED and still running — `status: "processing"`, counts zero. `Location` names the job URL to poll and `Retry-After` the interval in seconds
- `401`: Missing/invalid credential, or `credential_expired` — the credential is outside its `[not_before, expires_at)` window and must be replaced
- `403`: `insufficient_scope` — the credential's own scope set, or the caller's role on this workspace, does not cover the scope this route requires (the missing scope is named in the message). Or `forbidden` — the credential is not scoped to this workspace, or holds no grant on it
- `413`: payload_too_large — zip exceeds the 10 MB compressed cap, the multipart request body exceeds 10 MB, or the upload chunks to more than the 960-chunk per-job work ceiling
- `422`: Body is not a zip, wrong content type, or a multipart body with no `files` part
- `429`: quota_exceeded — free-plan ingest inclusion would be exceeded

---

[API](https://skmtc.dev/infolang/apis/infolang-api.md) · [All operations](https://skmtc.dev/infolang/apis/infolang-api/llms.txt) · [OpenAPI document](https://skmtc.dev/infolang/apis/infolang-api/revisions/d04bf6de72c8?raw)
