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:

EndpointMethodDescription
/api/documents/{document_id}/contentGETRead the extracted content as TEI/XML or Markdown
/api/documents/{document_id}/contentPATCHReplace the content or apply targeted string edits
/api/documents/{document_id}PATCHUpdate metadata; applies immediately
/api/documents/{document_id}/publishPOSTPublish or republish: split into parts, index, and embed
/api/documents/{document_id}/unpublishPOSTTake a published document out of the library, keeping its ingest

The review loop

  1. Ingest — upload the file and start a job with POST /api/jobs. The document is created as a draft.
  2. Spot-check — poll GET /api/jobs until the job is processed, then read a slice of the content with GET /api/documents/{document_id}/content.
  3. Edit — correct what is wrong with PATCH /api/documents/{document_id}/content, and fix metadata with PATCH /api/documents/{document_id}.
  4. Publish — call POST /api/documents/{document_id}/publish once 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

ParameterTypeDescription
document_idstringThe document’s UUID.

Query parameters

ParameterTypeDefaultDescription
formatstring"tei""tei" for the structured TEI/XML, or "markdown" for a plain-text rendering that is cheaper to read and easier to scan.
pagesstring—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
}
FieldTypeDescription
document_idstringThe document’s UUID.
job_idstringThe ingest job that produced this content.
formatstringThe format the content was returned in.
contentstringThe content itself, sliced to pages if you asked for a slice.
pages.totalnumberPages in the whole document.
pages.returnedstringPages included in this response, or "all" when no page slice was requested.
document_statusstringpending while the document is still a draft, published once it is live.
has_unpublished_editsbooleantrue when the stored content has changed since the last publish.
content_versionstringTimestamp that changes on every accepted edit.
lintobjectStructural 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
    }
  ]
}
FieldTypeDefaultDescription
contentstringConditional*The complete replacement content as TEI/XML.
operationsarrayConditional*Ordered list of edit operations.
operations[].typestringRequiredCurrently "replace".
operations[].old_stringstringRequiredThe exact text to find.
operations[].new_stringstringRequiredThe text to put in its place.
operations[].expected_occurrencesnumber1How many times old_string must occur. A mismatch fails the whole request with 409.
allow_new_lint_errorsbooleanfalseAccept 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 in error.issues. Pre-existing errors never block an edit. Set allow_new_lint_errors: true to accept the edit anyway.
  • Payloads over 20 MB are rejected with 413.
  • If the content changed since the ETag you sent as If-Match, the request fails with 412 and the response reports the current content_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 200 with already_applied: false;
  • the original landed and is recognized, so nothing is written and you get 200 with already_applied: true;
  • the original landed but is not recognized, so the If-Match you are holding is now stale, the precondition fails with 412, 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:

FieldTypeDescription
ignored_fieldsstring[]Keys you sent that the endpoint does not recognize. Check it to catch typos — unknown keys are dropped, not applied.
document_statusstringThe document’s current status.
needs_republishbooleanAlways 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 409 if the document’s job is still processing. Wait for processed first.
  • Returns 409 if 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, or GET /api/documents/{document_id}/embedding/status for 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 409 if the document is not currently published.
  • Returns 409 if the document has no current processing job. Publishing is the only way back, and it resolves current_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 500 and 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

OperationLimit
PATCH /api/documents/{document_id}/content20 requests per minute, per API key
POST /api/documents/{document_id}/publish5 requests per minute, per API key
POST /api/documents/{document_id}/unpublish5 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

StatusMeaning
400Invalid request, or content that is not well-formed XML/TEI.
403Forbidden: insufficient role, frozen team, or a document belonging to another team.
404Document not found, or no content yet (still processing).
409Occurrence 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.
412Stale If-Match: the content changed since you read it. Re-read and retry.
413Payload larger than 20 MB.
422The edit introduces new lint errors; see error.issues.
429Rate limit exceeded; see Retry-After.
500On 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.