Reviewing and Editing Documents
Ingestion is draft-by-default. A document created with POST /api/jobs starts with status pending, has no parts, and is invisible to the library and to search until it is published. That gap is deliberate: it lets an automated client read what was extracted, correct it, and only then make it live.
This page covers the endpoints that make that loop possible:
| Endpoint | Method | Description |
|---|---|---|
/api/documents/{document_id}/content | GET | Read the extracted content as TEI/XML or Markdown |
/api/documents/{document_id}/content | PATCH | Replace the content or apply targeted string edits |
/api/documents/{document_id} | PATCH | Update metadata; applies immediately |
/api/documents/{document_id}/publish | POST | Publish or republish: split into parts, index, and embed |
The review loop
- Ingest — upload the file and start a job with
POST /api/jobs. The document is created as a draft. - Spot-check — poll
GET /api/jobsuntil the job isprocessed, then read a slice of the content withGET /api/documents/{document_id}/content. - Edit — correct what is wrong with
PATCH /api/documents/{document_id}/content, and fix metadata withPATCH /api/documents/{document_id}. - Publish — call
POST /api/documents/{document_id}/publishonce you are satisfied. Only now does the document become searchable.
Steps 2 and 3 can repeat as many times as needed. Nothing an editing client does becomes visible to readers until step 4 runs.
Authentication
All endpoints require a Bearer token in the Authorization header:
Authorization: Bearer <your-api-key>
Use a team API key (vu_...), which you can generate in your team dashboard under Settings > API. Access is scoped to that team: a document belonging to another team responds with 404.
Every response uses the standard envelope, where error is null on success:
{ "data": { }, "error": { "status": 422, "message": "...", "issues": [] } }
Spot-checking content
Read the content that ingestion produced, before or after publishing.
GET /api/documents/{document_id}/content
Path parameters
| Parameter | Type | Description |
|---|---|---|
document_id | string | The document’s UUID. |
Query parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
format | string | "tei" | "tei" for the structured TEI/XML, or "markdown" for a plain-text rendering that is cheaper to read and easier to scan. |
pages | string | — | Slice the document: a single page (3) or a range (3-7). Recommended for spot checks — full books produce very large responses. |
Response
{
"data": {
"document_id": "doc-xyz789",
"job_id": "job-abc123",
"format": "markdown",
"content": "# Chapter I\n\nGallia est omnis divisa in partes tres...",
"pages": { "total": 412, "returned": "5" },
"document_status": "pending",
"has_unpublished_edits": false,
"content_version": "2026-08-10T12:00:00.000Z",
"lint": { "errors": 0, "warnings": 0 }
},
"error": null
}
| Field | Type | Description |
|---|---|---|
document_id | string | The document’s UUID. |
job_id | string | The ingest job that produced this content. |
format | string | The format the content was returned in. |
content | string | The content itself, sliced to pages if you asked for a slice. |
pages.total | number | Pages in the whole document. |
pages.returned | string | Pages included in this response, or "all" when no page slice was requested. |
document_status | string | pending while the document is still a draft, published once it is live. |
has_unpublished_edits | boolean | true when the stored content has changed since the last publish. |
content_version | string | Timestamp that changes on every accepted edit. |
lint | object | Structural TEI checks: numeric errors and warnings counts. |
The response carries an ETag header. Save it and send it back as If-Match when you edit, so a concurrent change cannot be overwritten silently.
Returns 404 when the document has no job or no content yet — typically because it is still processing.
Editing content
Rewrite the document’s content, or patch parts of it.
PATCH /api/documents/{document_id}/content
Send the If-Match header with the ETag you received from the GET. It is strongly recommended: without it, an edit computed from stale content can silently clobber someone else’s work.
Request body
The body must be exactly one of two shapes.
Full replacement — replaces the entire document content:
{ "content": "<TEI xmlns=\"http://www.tei-c.org/ns/1.0\">...</TEI>" }
Targeted edits — a list of string replacements applied in order. Recommended for large documents, since you never have to send the whole book back:
{
"operations": [
{
"type": "replace",
"old_string": "Gallia est omnis divisa in partes tres",
"new_string": "Gallia est omnis divisa in partes tres.",
"expected_occurrences": 1
}
]
}
| Field | Type | Default | Description |
|---|---|---|---|
content | string | Conditional* | The complete replacement content as TEI/XML. |
operations | array | Conditional* | Ordered list of edit operations. |
operations[].type | string | Required | Currently "replace". |
operations[].old_string | string | Required | The exact text to find. |
operations[].new_string | string | Required | The text to put in its place. |
operations[].expected_occurrences | number | 1 | How many times old_string must occur. A mismatch fails the whole request with 409. |
allow_new_lint_errors | boolean | false | Accept the edit even if it introduces new structural TEI errors. |
* Send either content or operations, but not both.
Because expected_occurrences is checked before anything is written, an operation that matches the wrong number of times aborts the request and leaves the document untouched. Widen old_string with surrounding context until it is unique, or raise expected_occurrences deliberately.
Validation
- Content that is not well-formed XML, or is not TEI, is rejected with
400. - Edits that introduce new lint errors — structural TEI problems that did not exist before your edit — are rejected with
422, and the offending issues are listed inerror.issues. Pre-existing errors never block an edit. Setallow_new_lint_errors: trueto accept the edit anyway. - Payloads over 20 MB are rejected with
413. - If the content changed since the
ETagyou sent asIf-Match, the request fails with412and the response reports the currentcontent_version. Re-read the content and retry your edit against it.
Response
{
"data": {
"job_id": "job-abc123",
"document_id": "doc-xyz789",
"document_status": "published",
"has_unpublished_edits": true,
"needs_republish": true,
"content_version": "2026-08-10T12:05:00.000Z",
"lint": { "errors": 0, "warnings": 1 }
},
"error": null
}
Edits never go live by themselves. needs_republish: true means the document is already published and readers still see the previous content; publish again to roll your changes out. On a draft, needs_republish is false — there is nothing live to update yet — and the first publish will include your edits.
Updating metadata
Metadata lives outside the content and is edited with the existing endpoint, documented in full under Documents API:
PATCH /api/documents/{document_id}
Unlike content edits, metadata edits apply immediately — no republish is needed. The response also includes:
| Field | Type | Description |
|---|---|---|
ignored_fields | string[] | Keys you sent that the endpoint does not recognize. Check it to catch typos — unknown keys are dropped, not applied. |
document_status | string | The document’s current status. |
needs_republish | boolean | Always false for metadata edits. |
Publishing and republishing
Publish the document’s current content: it is split into parts, indexed, and embedded for search, and the status becomes published.
POST /api/documents/{document_id}/publish
This is the same engine as POST /api/jobs/{job_id}/complete, addressed by document ID instead of job ID — convenient when you have been working with the document endpoints and no longer track the job.
Request body
The body is optional. When present, it is the same metadata-update object accepted by the complete endpoint, and it is applied atomically with the publish:
{
"document_name": "Commentarii de Bello Gallico",
"language": "la",
"license": "CC-BY-4.0",
"scope": "private"
}
Behavior and limits
- Returns
409if the document’s job is still processing. Wait forprocessedfirst. - Returns
409if there is nothing new to publish — the document is already published and has no unpublished edits. Publishing re-embeds the whole document and is expensive, so no-op republishes are rejected rather than silently accepted. - Embedding and search indexing continue asynchronously after the response. Poll
GET /api/documents/{document_id}for the status, orGET /api/documents/{document_id}/embedding/statusfor indexing progress. - Publishing requires a team API key, created in your team dashboard under Settings > API. The platform master (service) key cannot publish.
Response
{
"data": {
"document_id": "doc-xyz789",
"job_id": "job-abc123",
"status": "published",
"parts_count": 412
},
"error": null
}
Rate limits
| Operation | Limit |
|---|---|
PATCH /api/documents/{document_id}/content | 20 requests per minute, per API key |
POST /api/documents/{document_id}/publish | 5 requests per minute, per API key |
Exceeding a limit returns 429 with a Retry-After header giving the number of seconds to wait.
Error reference
| Status | Meaning |
|---|---|
400 | Invalid request, or content that is not well-formed XML/TEI. |
403 | Forbidden: insufficient role, frozen team, or a document belonging to another team. |
404 | Document not found, or no content yet (still processing). |
409 | Occurrence mismatch on an operation, job still processing, or nothing new to publish. |
412 | Stale If-Match: the content changed since you read it. Re-read and retry. |
413 | Payload larger than 20 MB. |
422 | The edit introduces new lint errors; see error.issues. |
429 | Rate limit exceeded; see Retry-After. |
End-to-end example
Step 1 — Ingest an already-uploaded file:
curl -X POST "https://vulgate.ai/api/jobs" \
-H "Authorization: Bearer $VULGATE_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "files": [{ "id": "<file-id-from-upload>" }] }'
The response returns document_id and job_id. The document is a draft: unpublished and not searchable.
Step 2 — Poll until the job is processed:
curl -G "https://vulgate.ai/api/jobs" \
-H "Authorization: Bearer $VULGATE_API_KEY" \
--data-urlencode "job_id=<job-id>"
Step 3 — Spot-check a few pages as Markdown; keep the ETag:
curl -G "https://vulgate.ai/api/documents/<document-id>/content" \
-H "Authorization: Bearer $VULGATE_API_KEY" \
--data-urlencode "format=markdown" \
--data-urlencode "pages=1-5" \
-D -
Step 4 — Correct what you found, guarded by the ETag:
curl -X PATCH "https://vulgate.ai/api/documents/<document-id>/content" \
-H "Authorization: Bearer $VULGATE_API_KEY" \
-H "Content-Type: application/json" \
-H 'If-Match: "<etag-from-step-3>"' \
-d '{
"operations": [
{
"type": "replace",
"old_string": "Gallia est omnis divisa in partes tres",
"new_string": "Gallia est omnis divisa in partes tres.",
"expected_occurrences": 1
}
]
}'
Step 5 — Fix the metadata (applies immediately):
curl -X PATCH "https://vulgate.ai/api/documents/<document-id>" \
-H "Authorization: Bearer $VULGATE_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "title": "Commentarii de Bello Gallico", "language": "la" }'
Step 6 — Publish:
curl -X POST "https://vulgate.ai/api/documents/<document-id>/publish" \
-H "Authorization: Bearer $VULGATE_API_KEY" \
-H "Content-Type: application/json" \
-d '{}'
The document is now published. Embedding runs in the background; poll GET /api/documents/<document-id>/embedding/status until indexing finishes, then the document is returned by search.