Update API
Update custom metadata or the source filename for an existing document without re-uploading it.
Updates are asynchronous. The endpoint validates the request, binds a job to the calling
client, and returns 202 Accepted with a job ID. The worker merges the patch, republishes
canonical metadata and its Metadata Leaves as a new immutable generation, and activates it
through the publication authority. Poll GET /status/{job_id} for progress and completion.
If a publication is already FENCED, acceptance still means queued rather than applied; the
worker retries until the authority can publish or reports the bounded failed state. See the
publication lifecycle comparison for how acceptance
differs from search and synchronous deletion.
This endpoint requires the publication authority (PUBLICATION_ENABLED=true). A deployment with
publication disabled is rejected at startup because search has no synchronous fallback path.
Updates are scoped to the caller's client. The endpoint accepts before resolving the document, so a
document that belongs to a different client is simply invisible to the worker: the job fails, and
GET /status/{job_id} reports the same bounded {"status": "failed", "error": "Metadata update failed"} it reports for a document that does not exist. That masking is deliberate — the status
response never distinguishes an unknown document from another client's document, so no cross-client
information is leaked. The job itself stays client-bound: GET /status/{job_id} returns 404 for a
job owned by a different client.
Endpoint
PATCH /update
Request
Headers
| Header | Required | Description |
|---|---|---|
X-API-Key | Yes | Valid API key from API_KEY_CLIENTS env var |
Content-Type | Yes | application/json |
Body Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
document_id | string | Yes | The document ID to update |
version_id | string | No | Target one document version. Omit to target every version of the document under the calling client |
metadata | object | No* | JSON Merge Patch applied to Custom Metadata: objects merge recursively, null deletes a member, omitted members stay unchanged. An empty object is accepted and completes as a no-op |
filename | string | No* | Non-empty display name written to metadata.source.filename in the new published generation |
*At least one of metadata or filename is required. A filename is trimmed and cannot
contain control characters. It does not change source.mime_type or source.extension.
filename is a request field, not a custom metadata key; putting filename inside metadata
still stores metadata.custom.filename.
Response
{
"job_id": "6f1c8f0a2b7d4e6b8c1d2e3f4a5b6c7d",
"status": "pending",
"document_id": "doc-123",
"version_id": null,
"updated_fields": ["metadata.custom.title", "metadata.custom.author"]
}
| Field | Description |
|---|---|
job_id | Client-bound job ID to poll on GET /status/{job_id} |
status | Always "pending" — the update has been accepted, not applied |
document_id | The document ID targeted by the update |
version_id | The targeted version, or null when every version is targeted |
updated_fields | Canonical stored paths carried by the patch. Custom keys are listed under metadata.custom; a filename rename is listed as metadata.source.filename |
Canonical labels quote a literal custom key when needed. For example, the raw key foo.bar
remains one custom dictionary key and is reported as metadata.custom."foo.bar", rather than
as a nested path.
A filename update is reported as metadata.source.filename in updated_fields.
Examples
Update Metadata
Update user metadata for an existing document:
curl -X PATCH http://localhost:8000/update \
-H "X-API-Key: super-secret-key" \
-H "Content-Type: application/json" \
-d '{
"document_id": "doc-123",
"metadata": {
"title": "Updated Report",
"author": "Jane Smith",
"department": "Engineering"
}
}'
Updated fields are stored under metadata.custom.* and become searchable using the explicit metadata.custom. prefix (e.g. metadata.custom.author). For example, author here is user metadata; system-derived source properties remain under metadata.source.*.
The worker validates the complete merged Custom Metadata object against the Metadata Discovery V1 contract before staging anything; a violation fails the job and leaves the previous generation authoritative. See the Metadata and Entities Reference for eligible values, exact limits, path semantics, and the value-free diagnostic shape, and the Status API for the job response fields.
# Search for documents updated with a specific department
curl -X POST http://localhost:8000/search \
-H "X-API-Key: super-secret-key" \
-H "Content-Type: application/json" \
-d '{
"query": "quarterly report",
"scope": "Content",
"filters": {
"metadata.custom.department": "Engineering"
}
}'
Rename the source filename
PATCH /update can set metadata.source.filename on every live chunk of the document without re-uploading. Soft-deleted points keep the previous name. Later /search and /documents reads show the new name.
curl -X PATCH http://localhost:8000/update \
-H "X-API-Key: super-secret-key" \
-H "Content-Type: application/json" \
-d '{"document_id":"doc-123","filename":"renamed-report.pdf"}'
The worker republishes the selected document version or versions with the new
metadata.source.filename. Custom metadata and the remaining system metadata stay unchanged.
Lifecycle visibility is preserved as well: updating a soft-deleted version carries its deleted,
deleted_at, and deleted_by values into the new generation and does not restore it.
Update from Python
import requests
url = "http://localhost:8000/update"
headers = {
"X-API-Key": "super-secret-key",
"Content-Type": "application/json"
}
data = {
"document_id": "doc-123",
"filename": "FY2026-board-pack.pdf",
"metadata": {"title": "Updated Report"}
}
response = requests.patch(url, headers=headers, json=data)
job_id = response.json()["job_id"]
status = requests.get(f"http://localhost:8000/status/{job_id}", headers=headers)
print(status.json()["operation"], status.json()["status"])
# metadata_update completed
Response Codes
| Code | Description |
|---|---|
| 202 | Accepted - Update job queued and bound to the calling client |
| 400 | Bad Request - Invalid document_id/version_id, reserved metadata, or filename control characters |
| 401 | Unauthorized - Invalid API key |
| 500 | Internal Server Error - Job binding or enqueue failed; nothing was queued |
| 503 | Service Unavailable - Publication authority is disabled or unavailable |
| 422 | Validation Error - Missing update fields, invalid request shape, or a Metadata Discovery contract violation |
Publication-disabled deployments fail this path closed with 503. Enable the publication
authority to accept metadata updates; do not use direct Qdrant writes as a workaround.
A document that does not exist — or belongs to a different client — is only detected by the worker,
so the job fails rather than returning 404 at accept time. Poll GET /status/{job_id}: the job
reports status: "failed" with the bounded error "Metadata update failed". Merged-metadata
contract violations are the one failure that carries structured detail, in error_details
(code, path, observed, limit, unit); no other failure reason is disclosed, and no Custom
Metadata value ever appears there. GET /status/{job_id} still returns 404 for a job bound to a
different client.
Error Responses
{
"detail": "Invalid API key"
}
{
"detail": "Reserved metadata keys are not allowed: classification, metadata.created_at, processing"
}
A failed update job reports its outcome on GET /status/{job_id}, not on PATCH /update:
{
"status": "failed",
"operation": "metadata_update",
"error": "Metadata update validation failed",
"error_details": {
"code": "metadata_canonical_json_bytes_limit",
"path": [],
"observed": 65537,
"limit": 65536,
"unit": "utf8_bytes"
}
}
System and legacy metadata paths inside metadata remain read-only and are
rejected here. The dedicated filename field is the supported way to rename
metadata.source.filename. See the
Metadata and Entities Reference for the
authoritative group and reserved-key contract.
Best Practices
- Use for user metadata corrections - Fix user-facing titles, authors, or custom fields without reprocessing the entire document
- Partial updates - Only include the fields you want to change; omitted members are preserved and
nulldeletes a member - Custom metadata - All ordinary update keys are stored under
metadata.custom.*and become filterable in search using the explicitmetadata.custom.prefix - Verify first - Use
GET /documentsto confirm the document exists before updating - Poll the job - Treat
202as queued; confirmstatus: "completed"andsearch_ready: trueonGET /status/{job_id}before relying on the change in search