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 |
/api/documents/{document_id}/unpublish | POST | Take a published document out of the library, keeping its ingest |
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.
Retrying safely
A 412 is only ever returned for a write that did not happen: the precondition is
part of the write itself, so a request cannot both edit the document and report a
failed precondition.
So when a request dies without a response — a dropped connection, a timeout — send
it again with the same If-Match. It comes back one of three ways, and none of
them edits the document twice:
- the original never landed, so the edit applies normally and returns
200withalready_applied: false; - the original landed and is recognized, so nothing is written and you get
200withalready_applied: true; - the original landed but is not recognized, so the
If-Matchyou are holding is now stale, the precondition fails with412, and nothing is written.
Recognition works by replaying your edit against the revision the last write replaced, so it holds for a retry that directly follows the lost attempt. Revision history is best effort — a snapshot can fail to record, or age out of the retained window — and an edit by someone else in between also breaks the match.
When recognition fails, which of 409 and 412 you get depends on the edit. An
operation whose own result consumed its old_string reports 409 with
actual_occurrences: 0; one that still matches its own result — word → word,
finds word again afterwards — gets as far as the write and is stopped there by
the stale If-Match, with 412. Read either as “re-read the content and see”,
not as proof that nothing was written.
Without If-Match there is no such guarantee: a retry that is not recognized
applies the edit a second time. This is the main reason to always send 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 },
"already_applied": false
},
"error": null
}
already_applied is true when this exact edit was already in the stored content
and nothing was written — see Retrying safely.
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
}
Unpublishing
Take a published document out of the library: its embeddings are deleted, it disappears from search and from the library listing, and its status becomes unpublished.
POST /api/documents/{document_id}/unpublish
Nothing produced by the pipeline is destroyed — the job, the TEI content, the extracted parts and the source files all survive — so this is the reversible alternative to DELETE, which removes the ingest permanently. To bring the document back, call POST /api/documents/{document_id}/publish again; it is not treated as a no-op republish while the document is unpublished.
The body is ignored. To change a document’s visibility within the organization, use scope on PATCH /api/documents/{document_id} instead; status is not writable there, because leaving the library means deleting embeddings and busting caches rather than editing a field.
Behavior and limits
- Returns
409if the document is not currentlypublished. - Returns
409if the document has no current processing job. Publishing is the only way back, and it resolvescurrent_job_id, so a document without one could not be restored — the endpoint refuses rather than becoming a one-way door. Bulk/catalogue imports are the common case. - If the embeddings cannot be removed, the request fails with
500and the document stays published. It is never left listed-but-hidden or hidden-but-searchable. - Unpublishing safely supersedes an indexing run that is still in progress. The embedding workflow verifies its run token and the document’s published status immediately before and after each vector write, so an older run cannot repopulate vectors after they are removed.
- Requires the same role as deleting the document: the uploader for private and shared documents, an organization manager for organization-scope documents.
Response
{
"data": {
"document_id": "doc-xyz789",
"job_id": "job-abc123",
"status": "unpublished"
},
"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 |
POST /api/documents/{document_id}/unpublish | 5 requests per minute, per API key (shared with publish) |
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, nothing new to publish, or — on unpublish — the document is not published or has no current processing job. |
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. |
500 | On unpublish, the embeddings could not be removed; the document stays published. |
End-to-end example
Step 1 — Ingest an already-uploaded file:
curl -X POST "https://vulgate.ai/api/jobs" \
-H "Authorization: Bearer $VULGATE_TEAM_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_TEAM_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_TEAM_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_TEAM_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_TEAM_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_TEAM_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.