Skip to main content

Ingesting files

POST /v1/ingest/files uploads up to 20 files in one multipart request, creates a document for each, and starts an asynchronous ingest job that extracts the text, splits it into chunks, and indexes the chunks as text embeddings. Once a document is ready, chat completions against a bot that reads the same namespace can cite it (Citations).

Requires the ingest scope.

Upload files

curl -s "https://api.chatbots.ecourtdate.com/v1/ingest/files" \
-H "Authorization: Bearer $API_KEY" \
-F "file=@traffic-division-faq.pdf" \
-F "file=@fee-schedule-scan.pdf" \
-F "file=@jury-duty-faq.md" \
-F "namespace=general"

Response (200):

{
"job_id": "bfcde661-9eec-4142-a790-ca9a62c8e0f8",
"document_ids": [
"17136f01-972d-4956-868e-8159833072e4",
"4b1f0c8e-2a77-4c0f-9d3e-6f2a1b9c7d10",
"9a7e5d21-53c4-4b8a-a0e1-2c7f3b4d5e6f"
],
"status": "processing"
}

The response returns as soon as the files are stored. No text has been extracted yet: every document starts in status processing with chunk_count 0, and the job is the handle you poll to learn when that changes (Poll the job).

Request

The body is multipart/form-data with these parts:

PartTypeDescription
filebinaryRequired. One part per file, each named exactly file (a part named files is rejected). 1 to 20 parts per request. Include a filename in the part's Content-Disposition; the file type is decided from it (Accepted formats).
namespacestringThe knowledge-base namespace to store the documents in. Default general when the field is omitted; an explicitly empty value is a 400. Must match ^[a-z0-9][a-z0-9_-]{0,63}$ (lowercase letters, digits, _ and -, 1 to 64 characters).

Unknown form fields are ignored. The per-part Content-Type header is never used to decide the file type, so you can send it accurately, send a generic application/octet-stream, or omit it.

Response

FieldTypeDescription
job_idstringUUID of the ingest job that processes this request. Poll it at GET /v1/ingest/jobs/{jobId}.
document_idsstring[]UUID of the document created for each file, in the order the file parts were sent.
statusstringAlways processing on this response. The job's live status is on the job object.

One job is created per request, covering all of its documents.

Python example

Any HTTP client that can send multipart bodies works. With the requests library, a Python example looks like this:

import os
import requests

API_KEY = os.environ["API_KEY"]
BASE_URL = "https://api.chatbots.ecourtdate.com/v1"

files = [
("file", ("traffic-division-faq.pdf", open("traffic-division-faq.pdf", "rb"), "application/pdf")),
("file", ("fee-schedule-scan.pdf", open("fee-schedule-scan.pdf", "rb"), "application/octet-stream")),
("file", ("jury-duty-faq.md", open("jury-duty-faq.md", "rb"), "text/markdown")),
]

response = requests.post(
f"{BASE_URL}/ingest/files",
headers={"Authorization": f"Bearer {API_KEY}"},
files=files,
data={"namespace": "general"},
)
response.raise_for_status()
job = response.json()
print(job["job_id"], job["document_ids"])

Send an X-Request-ID header on ingest calls: the id is attached to the job's processing logs, so quoting it lets support trace a specific upload end to end.

Accepted formats

The file type is decided by the filename extension (compared case-insensitively) and confirmed by inspecting the content. The multipart Content-Type of the part plays no role.

ExtensionContent check
.pdfMust begin with the PDF signature (%PDF-). Scanned or image-only PDFs upload successfully but fail during processing; see OCR.
.docxMust begin with the Office Open XML (zip) signature.
.xlsxMust begin with the Office Open XML (zip) signature.
.txt, .mdMust decode as UTF-8.
.html, .htmMust decode as UTF-8.
.csvMust decode as UTF-8.
.emlMust decode as UTF-8.

Any other extension, or a filename without one, is rejected. A file whose content does not match its extension (a .pdf that starts with something other than %PDF-, a .txt that is not valid UTF-8) is rejected as well. Empty (0-byte) files are rejected.

Filenames

The filename from the part is validated, then lightly sanitized before it is stored and shown as the document's filename:

  • The name, extension included, may be at most 255 bytes when encoded as UTF-8 (so a name of 130 two-byte characters plus .txt is too long). Longer names are rejected with Filename exceeds 255 bytes.; nothing is truncated.
  • Path separators (/ or \), control characters, and other non-printable characters (for example a non-breaking space or a zero-width joiner) are rejected with Filename contains invalid characters.. Send the bare file name, not reports/2026/notice.pdf.
  • Leading dots and surrounding whitespace are removed. A name consisting only of an extension (.txt) therefore has no extension left and is rejected.
  • Ordinary spaces and printable non-ASCII characters are kept as sent.

The stored name can therefore differ slightly from the one you sent; read it back from the document object.

Limits

LimitValueOn violation
Files per request20400, param: file
Size per file25 MiB (26,214,400 bytes), inclusive400, param names the part
Request body525,336,576 bytes (20 x 25 MiB plus 1 MiB of multipart overhead)413 request_too_large
Filename255 UTF-8 bytes including the extension; no path separators, control characters, or other non-printable characters400, param names the part

The body cap is checked before authentication, so an oversized request is rejected with 413 even without a valid key. The 5 MiB body cap that applies to JSON endpoints (Conventions) does not apply here.

Validation is atomic

Every file in the request is validated (count, emptiness, size, filename, extension, content signature or UTF-8 check) before anything is stored. If any file fails, the whole request returns 400, no document is created, no job is created, and nothing is indexed. Fix the offending file and resend the request; you never have to clean up partial uploads.

The error names the first failing file through param:

{
"error": {
"message": "File type '.exe' is not allowed. Allowed: .csv, .docx, .eml, .htm, .html, .md, .pdf, .txt, .xlsx.",
"type": "invalid_request_error",
"param": "file[1]",
"code": null
}
}

Validation messages you can expect:

MessageCause
namespace must match ^[a-z0-9][a-z0-9_-]{0,63}$ (lowercase letters, digits, '_' and '-', 1 to 64 characters).The namespace field is empty or does not match the pattern (param: namespace).
Too many files: 21 (max 20).More than 20 file parts. Nothing is stored.
Uploaded file is empty.A 0-byte part.
File exceeds the 26214400 byte limit.A part larger than 25 MiB.
Filename exceeds 255 bytes.The filename is longer than 255 UTF-8 bytes (Filenames).
Filename contains invalid characters.The filename contains /, \, or a control character.
File type '.exe' is not allowed. Allowed: ...Extension not in Accepted formats. The type is 'unknown' when no extension survives sanitization.
File content does not match its '.pdf' extension.Signature check failed for .pdf, .docx, or .xlsx.
File 'notes.txt' is not valid UTF-8 text.A text-type file that is not UTF-8.

Branch on the HTTP status and param, not on the message text.

Poll the job

Processing happens after the response. Poll GET /v1/ingest/jobs/{jobId} until status is terminal:

curl -s "https://api.chatbots.ecourtdate.com/v1/ingest/jobs/bfcde661-9eec-4142-a790-ca9a62c8e0f8" \
-H "Authorization: Bearer $API_KEY"
{
"job_id": "bfcde661-9eec-4142-a790-ca9a62c8e0f8",
"status": "completed_with_errors",
"total_documents": 3,
"completed_documents": 2,
"failed_documents": 1,
"errors": [
{
"document_id": "4b1f0c8e-2a77-4c0f-9d3e-6f2a1b9c7d10",
"error": "No readable text was found in this document. Scanned or image-only files must be run through OCR before upload."
}
]
}

status is one of processing, completed, completed_with_errors, or failed; the counts and errors[] advance as each document is processed and are final once the status is terminal. The full lifecycle, the retry behavior, the polling guidance, and the ingest.completed webhook that fires at the end are covered in Jobs and webhooks.

import time

def wait_for_job(job_id, interval=2.0, max_interval=30.0):
while True:
r = requests.get(
f"{BASE_URL}/ingest/jobs/{job_id}",
headers={"Authorization": f"Bearer {API_KEY}"},
)
r.raise_for_status()
job = r.json()
if job["status"] != "processing":
return job
time.sleep(interval)
interval = min(interval * 2, max_interval)

Per-document errors

A file that cannot be processed does not fail the whole job: the job ends completed_with_errors (or failed when no document succeeded), the document's status becomes failed, and the reason appears both in the job's errors[] and in the document's error field. The reason is one of:

ErrorMeaning
No readable text was found in this document. Scanned or image-only files must be run through OCR before upload.The file parsed, but contained no text.
The document contained no indexable text.Text was found, but nothing remained to index after chunking.
The document could not be read. It may be corrupt, password-protected, or in an unsupported format.The parser rejected the file.
Processing failed due to an internal error. Please retry; contact support if the problem persists.Automatic retries were exhausted.
Document was deleted before it was processed.The document was deleted while the job was running. It is counted in failed_documents and listed in errors[], but no document record remains.

A failed document keeps its record (status: failed, chunk_count: 0) so you can see what went wrong. Delete it with DELETE /v1/documents/{documentId} and upload a corrected file.

Scanned PDFs and OCR

The API extracts the text layer of a PDF; it does not run optical character recognition. A scanned or image-only PDF passes upload validation (it is a valid PDF) and then fails processing with the No readable text was found error above. Run such files through OCR to produce a searchable PDF, or export them as .txt or .docx, before uploading.

Duplicates and updates

Uploads are not deduplicated. The same bytes uploaded twice, in one request or across requests, create two distinct documents, and both are indexed, so a question answered from that content may cite both copies. To replace a document with a newer version:

  1. Upload the new file and wait for its job to complete.
  2. Delete the old document with DELETE /v1/documents/{documentId}.

Deleting first and uploading second leaves a window where neither version can be cited. Using a different namespace per version also keeps them apart, since a bot reads one namespace.

Do not delete a document while its job is still processing: the delete succeeds, but the job then records that document in errors[] with Document was deleted before it was processed. and counts it as failed. Wait for a terminal job status first.

Namespaces

A namespace is a named partition of your knowledge base. Each bot configured for your account retrieves from one namespace, so upload into the namespace of the bot that should answer from the material. general is the default for uploads. Namespace names are case-sensitive and must match the bot's namespace exactly.

Use separate namespaces when different bots should answer from different material (for example a public-facing bot over court locations and fee schedules, and an internal bot over clerk procedures). Use the namespace filter on GET /v1/documents to review what each one holds.

Errors

StatusCodeWhen
400nullA file or form part failed validation; param names the part (file, file[i], or namespace). See Validation is atomic. Also returned when no file part is present.
401invalid_api_keyMissing or invalid key.
403insufficient_scopeThe key lacks the ingest scope.
413request_too_largeThe request body exceeds 525,336,576 bytes.
429rate_limit_exceeded, insufficient_quotaPer-minute request limit or daily token quota reached; honor Retry-After.

All errors use the standard envelope described in Errors.