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

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.
pagesstringSlice 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.

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:

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
}

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

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, or nothing new to publish.
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.

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.