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:
| Part | Type | Description |
|---|---|---|
file | binary | Required. 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). |
namespace | string | The 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
| Field | Type | Description |
|---|---|---|
job_id | string | UUID of the ingest job that processes this request. Poll it at GET /v1/ingest/jobs/{jobId}. |
document_ids | string[] | UUID of the document created for each file, in the order the file parts were sent. |
status | string | Always 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.
| Extension | Content check |
|---|---|
.pdf | Must begin with the PDF signature (%PDF-). Scanned or image-only PDFs upload successfully but fail during processing; see OCR. |
.docx | Must begin with the Office Open XML (zip) signature. |
.xlsx | Must begin with the Office Open XML (zip) signature. |
.txt, .md | Must decode as UTF-8. |
.html, .htm | Must decode as UTF-8. |
.csv | Must decode as UTF-8. |
.eml | Must 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
.txtis too long). Longer names are rejected withFilename 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 withFilename contains invalid characters.. Send the bare file name, notreports/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
| Limit | Value | On violation |
|---|---|---|
| Files per request | 20 | 400, param: file |
| Size per file | 25 MiB (26,214,400 bytes), inclusive | 400, param names the part |
| Request body | 525,336,576 bytes (20 x 25 MiB plus 1 MiB of multipart overhead) | 413 request_too_large |
| Filename | 255 UTF-8 bytes including the extension; no path separators, control characters, or other non-printable characters | 400, 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:
| Message | Cause |
|---|---|
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:
| Error | Meaning |
|---|---|
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:
- Upload the new file and wait for its job to complete.
- 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
| Status | Code | When |
|---|---|---|
400 | null | A 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. |
401 | invalid_api_key | Missing or invalid key. |
403 | insufficient_scope | The key lacks the ingest scope. |
413 | request_too_large | The request body exceeds 525,336,576 bytes. |
429 | rate_limit_exceeded, insufficient_quota | Per-minute request limit or daily token quota reached; honor Retry-After. |
All errors use the standard envelope described in Errors.