Skip to content

Edit

Upload a Word document, read its text, send edits, and download the edited document and a redline of what changed.

View as Markdown

Upload a .docx, read its paragraphs and their numbers, then send edits that name the paragraphs to change by those numbers. A fourth call fetches the result when you don't wait for it. Every edit is a real Word tracked change, so the output is a document a reviewer can accept or reject, plus a redline of exactly what the request changed.

Base URLhttps://api.versionstory.com
AuthAuthorization: Bearer vs_.... See Authentication

Non-2xx responses use the same envelope as Compare, and X-Request-Id works the same way.

{ "error": { "code": "INVALID_EDIT", "message": "...", "request_id": "..." } }

The flow

An edit says which paragraph to change by its number, such as "replace [Company Name] in paragraph 7". So before you can edit, you need the document stored with us and its paragraph numbers:

  1. Upload it. POST /v1/files with the .docx returns a file_id, which every later call uses to name the document.
  2. Read it. GET /v1/files/{file_id}/content returns the document's text with each paragraph's number, and the numbers of its headers, footers, notes, comments and tables. Find what you want to change and note its number.
  3. Edit it. POST /v1/edit with the file_id and a list of edits that use those numbers. Alternatively, send an instruction in plain English and skip reading the numbers yourself. Returns an edit_id and, with wait, the finished result.
  4. Get the result. GET /v1/edit/{edit_id} until the result is ready, if you did not wait. Download the edited document and the redline.

POST /v1/files

multipart/form-data with one file field, document.

curl -sS https://api.versionstory.com/v1/files \
  -H "Authorization: Bearer vs_live_..." \
  -F "document=@nda.docx"
{ "file_id": "3f2a9c1e-...", "status": "processing" }

A .docx answers at once while processing finishes in the background. processing here needs no polling, because reading the document and editing it both wait for it. A .doc or .pdf is converted first: the request waits up to 20 seconds and answers ready, failed with an error, or processing if conversion is still running.

A file_id names one immutable upload, so its content never changes. An edit produces a new file with its own file_id; to edit the result, read that file's content. A user's updated copy is a new upload.

GET /v1/files/{file_id}/content

Returns the document's text and structure.

ParameterInRequiredDefaultMeaning
file_idpathYes—The id from POST /v1/files
formatqueryNomdjson for the full structure with every address an edit uses, md for the same text as Markdown. Use json for editing
curl -sS "https://api.versionstory.com/v1/files/3f2a9c1e-.../content?format=json" \
  -H "Authorization: Bearer vs_live_..."

The call waits up to 5 seconds for processing to finish and answers as soon as it finishes. While the document is still processing, the response carries Retry-After: 1; call again.

The JSON carries document.schema_version (currently 1), then each body paragraph with its index, style, list numbering, tables, sections, fields, bookmarks and inline formatting. Beside the body it lists the document's headers, footers, notes, comments, revisions, list_styles and settings. Tabs and line breaks appear as segments, which selectors match as \t and \n. The document is read with content controls and smart tags unwrapped.

This is different from the redline JSON described on JSON format, which describes the changes between two documents. The two have separate schema_versions.

What the JSON contains, and how to read it:

  • Indexes. A body paragraph has an index, counted from 0. The paragraphs of a header, footer or note carry an index counted from 0 within that part. Table cells' paragraphs are included in the count.
  • Parts. Each header and footer prints a part locator (kind, type and the first section that shows it) and sections, every section that shows it. Each note prints a part (kind and number) and a label, the mark the reader sees.
  • Counting from 1. Sections, list levels, tables, rows, columns, comment threads, notes and revisions all count from 1. index is the only value counted from 0.
  • Revisions. Existing tracked changes are listed one entry per accept/reject decision, each with a number, a kind (insertion, deletion, move or property), author, date and text. A replacement is two units: a deletion and an insertion.
  • Comments. Listed as threads with their replies, numbered in document order.

POST /v1/edit

A JSON body. A request that uploads a style template is multipart/form-data instead, with the same fields as form fields and edits and outputs as JSON strings.

FieldRequiredMeaning
file_idYesThe upload to edit
schema_versionYesThe document.schema_version from GET /v1/files/{file_id}/content, 1. A mismatch is 400 INVALID_EDIT, so a change to how that content counts paragraphs cannot silently shift your addresses
editsOne of edits or instruction1 to 200 edit objects, applied in order
instructionOne of edits or instructionA plain-English request, up to 8,000 characters. See Editing with an instruction
replacement_textNoWith instruction only: exact replacement or new paragraph text
authorNoName on every tracked change and comment the request makes. Default Version Story AI
dateNoISO 8601 date and time for those changes. Default: the time of the request
waitNotrue answers with the finished result instead of an edit_id to poll
outputsNoWith wait: which files to return, ["docx"] (the default), ["redline"], or both
inlineNoWith wait: true adds the file's bytes as content_base64 to each ready download
style_templateNoMultipart only: a .docx, .dotx or .dotm whose styles and numbering are imported before the first edit

The body is at most 208 KB, 200 KB of it edits. Unknown fields are refused. That includes document: a document is staged with POST /v1/files, never sent with the edit.

curl -sS https://api.versionstory.com/v1/edit \
  -H "Authorization: Bearer vs_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "file_id": "3f2a9c1e-...",
    "schema_version": 1,
    "author": "Dana Reyes",
    "wait": true,
    "outputs": ["docx", "redline"],
    "edits": [
      {
        "type": "replace",
        "paragraphs": { "index": 7 },
        "text": { "match": "[Company Name]" },
        "content": "Acme, Inc."
      }
    ]
  }'

Attributing the changes

author is the name Word shows on every tracked change in the result, and on any comment that does not name its own author. It is one line of at most 255 characters with no control characters; surrounding spaces are trimmed. A comment edit can carry its own author, which takes precedence for that comment.

date takes an ISO 8601 timestamp such as 2026-10-05T14:30:00Z. Send the author's UTC offset (2026-10-05T10:30:00-04:00) if you want Word versions that read only the local clock time to show local time; without an offset, the value is UTC. It is kept to the second.

Waiting for the result

By default, POST /v1/edit answers 202 with the edit's ids and you poll. With "wait": true, the response is the same body GET /v1/edit/{edit_id} returns: 200 when the edit finished, 202 if it is still processing, in which case you continue by polling. outputs and inline apply only with wait.

How edits address things

This is the short version; Edit operations has every form.

An edit never uses a bare number. It names what it acts on with a locator object, an address such as {"index": 7}, and the document's content prints each locator so you can copy it.

Locating paragraphs

Every operation that acts on paragraphs names them in one required field, paragraphs:

FormNames
{"index": 12}Body paragraph 12
{"part": {"kind": "footnote", "number": 3}, "index": 0}The first paragraph of footnote 3
{"part": {"kind": "footer", "type": "default", "section": {"number": 1}}, "index": 0}The first paragraph of section 1's default footer
{"from": {"index": 9}, "through": {"index": 11}}The paragraphs between two locators, inclusive, within one part
[{"index": 3}, {"from": ..., "through": ...}]An array of locators and ranges
"all"Every paragraph of every part
{"ref": "p1"}A paragraph this same request created

There is no implicit scope: a whole-document replacement says "paragraphs": "all". Other things have locators of the same style: a section is {"number": 2}, a table is {"number": 2}, a row or column is {"table": {"number": 2}, "number": 4}, a comment thread is {"number": 3}, a note is {"kind": "footnote", "number": 3}, and an existing tracked change is {"number": 5}, "all", or a filter by author and kind.

Selecting text

text selects text inside the named paragraphs. at names an insertion point.

"text": { "match": "Series A Preferred Stock", "prefix": "the ", "suffix": " issued", "occurrence": 2 }
"at": "start"
"at": { "after": { "match": "Name:\t" } }
  • match is the text to find. It is case-sensitive, and a match never crosses a paragraph boundary. Curly and straight quotes, en and em dashes and hyphens, and non-breaking and ordinary spaces are read alike.
  • prefix and suffix are optional context that must sit immediately before and after the match. They narrow the candidates; the edit never acts on them.
  • occurrence picks among several matches, counted across every paragraph the edit names: N for the Nth, or "all" for every one. Omitted, the match must be unique.
  • at is "start", "end", or {"after": selector} or {"before": selector}.

A selector that matches nothing, or more than once without occurrence, is refused, naming the count and the paragraphs that hold the text:

edit at index 0 (replace): "Preferred" matches 3 times in paragraph 8; give prefix or suffix
so it matches once, or occurrence (1 to 3, or "all")

Writing content

New text is always content: a string, or an array of runs. In a string, \t becomes a tab and \n a line break. Runs add formatting and richer content.

RunInserts
{"text": "..."}Text
{"type": "tab"}A tab
{"type": "break", "kind": "line" | "page" | "column"}A break
{"type": "field", "code": "PAGE"}A Word field
{"type": "cross_reference", "to": locator, "show": "number"}A reference to another paragraph
{"type": "footnote" | "endnote", "content": ...}A note
{"type": "hyperlink", "url": "...", "content": ...}Linked text

Any run can carry format, for example {"text": "Name and title", "format": {"bold": true}}. Plain new text takes the formatting of the text before it, as typing does in Word.

For property objects such as format, a value sets the property, false turns it off even where the style turns it on, null removes the direct setting so the style decides, and an omitted key leaves it alone. To remove bold everywhere, send false; null would leave a heading that is bold through its style bold.

How a request is applied

Three rules govern the list of edits in one request:

  1. Addresses mean the uploaded document. Every locator and selector refers to the document as you read it, whatever earlier edits in the request have done. If edit 1 inserts a paragraph after paragraph 5, edit 2's {"index": 10} still means the paragraph that was number 10 when you read it.
  2. Edits apply in request order. When two edits set the same property of the same text, the later one wins. Content inserted at the same point appears in request order.
  3. An edit whose target an earlier edit removed or rewrote is refused, and so is the whole request. For example, replacing text that an earlier edit already deleted.

The result is the same as sending each edit as its own request in turn, except that every address comes from the one read of the document. Three consequences:

  • All or nothing. If any edit is refused, or fails while being applied, nothing is applied and no file is produced. Every refusal in the request is reported together.
  • Undefined means refused. A combination whose result the reference does not define is refused with an error that names it. Nothing is guessed.
  • Repeating an edit repeats its effect. Sending an insertion twice inserts the content twice.

To refer to something an earlier edit created, give that edit a ref and use {"ref": "name"} where a locator is expected. A ref must be defined before it is used. The text of a paragraph the request creates cannot be selected, because it was not in the content you read; give it its formatting in the edit that creates it.

Decisions on existing tracked changes (accept_revision, reject_revision) go alone. A request that contains one contains only decisions. To accept changes and then edit, send two requests, the second against the first's result.

Operations

Every edit has a type and the fields that type defines. Unknown fields are refused. Each operation's fields, accepted values, examples and refusals are in the Edit operations reference, along with the full rules for locators, text selectors, content runs and property values. The machine-readable definition of each is in the API's OpenAPI specification under components.schemas.EditRequest.

OperationDoes
insertInsert text, breaks, fields, cross-references, notes or links at a point
replaceReplace selected text
deleteDelete selected text, or a page break
formatChange character formatting of selected text or whole paragraphs
insert_paragraphsAdd new paragraphs at a destination, with styles and lists
delete_paragraphsDelete whole paragraphs, and tables a range covers
move_paragraphsMove paragraphs and tables as a tracked move
split_paragraphSplit one paragraph in two at a point
join_paragraphsMerge a range of paragraphs into one
set_paragraph_styleApply a paragraph style, optionally clearing direct formatting
set_paragraph_formatSet alignment, indents, spacing and pagination
set_numberingApply, restart, continue, remove or reformat list numbering
insert_section_break, remove_section_breakAdd or remove a section break
set_page_setupSet orientation, paper, margins, first page and page numbering
add_header_footerGive a section a header or footer of its own
insert_table, delete_tableAdd or remove a table
insert_table_row, delete_table_rowAdd or remove a table row
insert_table_column, delete_table_columnAdd or remove a table column
add_hyperlink, set_hyperlink, remove_hyperlinkLink existing text, change a link's address, or unlink
insert_table_of_contentsInsert a table of contents
refresh_fieldsMark every field for Word to update on open
delete_noteDelete a footnote or endnote
add_commentAdd a comment on text, a paragraph or a range
reply_comment, resolve_commentReply to a comment thread, or resolve it
accept_revision, reject_revisionDecide existing tracked changes
set_document_settingsSet Track Changes, editing restriction and personal-information removal

Every change to document content is a tracked change carrying the request's author and date. The things Word cannot track are applied directly and stay when revisions are accepted or rejected: comments and replies, document settings, field update flags, and supporting parts such as new list definitions, a notes part or a header part.

Examples

Fill in a signature block. Each value goes after its label's tab, so the underlined tab that draws the line is untouched:

[
  { "type": "replace", "paragraphs": { "index": 302 }, "text": { "match": "[Insert Company Name]" }, "content": "Version Story" },
  { "type": "insert", "paragraphs": { "index": 304 }, "at": { "after": { "match": "Name:\t" } }, "content": "Jordan Bryan" },
  { "type": "insert", "paragraphs": { "index": 305 }, "at": { "after": { "match": "Title:\t" } }, "content": "CTO" }
]

Replace a defined term everywhere, and un-bold a whole document:

[
  { "type": "replace", "paragraphs": "all", "text": { "match": "Acme Ltd", "occurrence": "all" }, "content": "Acme Holdings Ltd" },
  { "type": "format", "paragraphs": "all", "format": { "bold": false } }
]

Add a comment, then reply to it. The ref names the new thread:

[
  { "type": "add_comment", "paragraphs": { "index": 25 }, "content": "double check on this", "ref": "c1" },
  { "type": "reply_comment", "comment": { "ref": "c1" }, "content": "will do" }
]

Add a row to a table after an existing row, with one value per column:

{
  "type": "insert_table_row",
  "destination": { "row": { "index": 311 }, "position": "after" },
  "cells": ["Google LLC", "Google Cloud Support", "AI-Enhanced PDF Conversion"]
}

Turn a page break into a section break that restarts page numbering. Three edits, with the last addressing the section the second one creates:

[
  { "type": "delete", "paragraphs": { "index": 129 }, "text": { "match": [{ "type": "break", "kind": "page" }] } },
  { "type": "insert_section_break", "paragraphs": { "index": 129 }, "section_type": "next_page", "ref": "schedule" },
  { "type": "set_page_setup", "section": { "ref": "schedule" }, "page_setup": { "page_numbering": { "start": 1 } } }
]

Move a block of paragraphs to a new place. Accepting the change leaves them at the destination only; rejecting restores them:

{
  "type": "move_paragraphs",
  "paragraphs": { "from": { "index": 9 }, "through": { "index": 11 } },
  "destination": { "paragraph": { "index": 19 }, "position": "after" }
}

Editing with an instruction

instruction takes the place of edits. Send the file_id, the schema_version and a sentence; the server reads the document and builds the edits, so you supply no locators.

curl -sS https://api.versionstory.com/v1/edit \
  -H "Authorization: Bearer vs_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "file_id": "3f2a9c1e-...",
    "schema_version": 1,
    "instruction": "Replace every \"Acme Ltd\" with \"Acme Holdings Ltd\"."
  }'

The edits made from your instruction are checked against the document exactly as written edits are, and the request is applied the same way, with the same response. replacement_text supplies exact text separately, for example newline-separated paragraphs to insert.

The instruction path covers existing paragraph styles and direct-format cleanup, decisions on tracked changes, deleting existing text, literal replacements, inserting supplied paragraphs, and turning one explicit page break into a section break with a page-number restart. Drafting prose, defining styles, setting fonts, adding table rows and removing paragraph elements are outside it; use edits for those.

An instruction that is unclear, unsupported, conflicting or incomplete is refused with 400 INVALID_EDIT before anything is created, as is one that selects no changes. A temporarily unavailable model is 503 EDIT_MODEL_UNAVAILABLE.

Response

POST /v1/edit and GET /v1/edit/{edit_id} return the same shape. files holds three entries, and their file_ids are allocated when the edit is created, so they are present while processing and after a failure.

EntryContents
files.originalThe document you uploaded
files.editedThe edited document, with every edit as a tracked change
files.redlineA redline: the uploaded document compared with the edited one

comparison_id opens the same redline through GET /v1/compare/{comparison_id}, if you want it as pdf, md or json as well.

GET /v1/edit/{edit_id}

Safe to call as often as you like: it creates nothing. It waits up to 5 seconds for the apply job and answers as soon as it finishes.

ParameterInRequiredDefaultMeaning
edit_idpathYes—The id from POST /v1/edit
formatqueryNodocxdocx selects downloads for files.edited, redline for files.redline. Repeatable or comma-separated: docx,redline
curl -sS "https://api.versionstory.com/v1/edit/edt_...?format=docx,redline" \
  -H "Authorization: Bearer vs_live_..."

Response — ready

Downloads are nested under the file they belong to. Both report format: "docx".

{
  "edit_id": "edt_MTp2cy0xOnZlLTE",
  "status": "ready",
  "comparison_id": "cmp_MTp2cy0xOnZtLTE",
  "files": {
    "original": { "file_id": "3f2a9c1e-...", "file_name": "nda.docx" },
    "edited": {
      "file_id": "8b41d7a0-...",
      "file_name": "nda (edited).docx",
      "downloads": [
        {
          "format": "docx",
          "url": "https://documents.versionstory.com/...",
          "file_name": "nda (edited).docx",
          "expires_at": "2026-10-05T22:00:00+00:00"
        }
      ]
    },
    "redline": {
      "file_id": "d90c5e12-...",
      "file_name": "Redline.docx",
      "downloads": [
        {
          "format": "docx",
          "url": "https://documents.versionstory.com/...",
          "file_name": "Redline.docx",
          "expires_at": "2026-10-05T22:00:00+00:00"
        }
      ]
    }
  }
}

Each url is signed, needs no Authorization header, and stops working at expires_at, one hour after it was issued. Poll again for fresh ones. With "inline": true on a waiting request, each ready download also carries content_base64, the file's bytes, so no second request is needed.

curl -sS --fail -o nda-edited.docx "<files.edited.downloads[0].url>"

Response — still processing

Returned with Retry-After: 1. A selected file that is not ready yet carries pending_formats: ["docx"] in place of downloads. There are no top-level downloads or pending_formats fields on an edit.

Response — failed

Terminal: "status": "failed" with an error. Submit a corrected request to retry. There are two codes.

error.codeMeaning
INVALID_EDITThe worker could not apply an edit as written, for instance one whose target an earlier edit removed. error.message names it as edit at index N (type): ..., so you can correct it and send the request again
EDIT_FAILEDAnything else, without a message. Also returned if a requested output is missing after the worker succeeded; any downloads that did get produced stay in the response

Nothing is applied when an edit fails.

Editing the result again

Any file_id in the response can be downloaded as docx or pdf through GET /v1/files/{file_id}. To edit further, read the edited file's content and send a new request naming its file_id. The earlier result stays untouched.

Errors

StatusCodeCause
400INVALID_REQUESTThe request itself is malformed: a missing file_id, a field of the wrong type, outputs or inline without wait, or an author or date that does not meet the rules above
400INVALID_EDITAn edit was refused before anything was created: a stale schema_version, a selector that matched nothing or too much, a combination that is undefined, or an instruction that could not be turned into edits. Every refusal in the request is reported together, each as edit at index N (type): ...
400UNSUPPORTED_FILE_TYPEThe upload to POST /v1/files is not a .docx, .doc or .pdf
400DOCUMENT_UNREADABLEThe upload is not a readable Word file: corrupt, or encrypted
400INVALID_EDIT_IDThe edit_id is malformed
401INVALID_API_KEYMissing, malformed, unknown, revoked, or expired key
404EDIT_NOT_FOUNDNo such edit or file, or it belongs to another organization. GET /v1/files/{file_id} answers FILE_NOT_FOUND
409CONFLICTThe document is still processing after the wait; retry after Retry-After
410CONTENT_DELETEDThe file's content was removed by the organization's retention setting
503EDIT_MODEL_UNAVAILABLEAn instruction request could not reach the model; try again later

Validation runs before anything is created, so a refused request costs nothing.

The whole loop, in Python

import base64
import requests

BASE = "https://api.versionstory.com"
headers = {"Authorization": "Bearer vs_live_..."}

with open("nda.docx", "rb") as document:
    uploaded = requests.post(f"{BASE}/v1/files", headers=headers, files={"document": document})
uploaded.raise_for_status()
file_id = uploaded.json()["file_id"]

content = requests.get(f"{BASE}/v1/files/{file_id}/content", headers=headers, params={"format": "json"})
content.raise_for_status()
# Read content.json() to find the paragraph index for "[Company Name]".

edit = requests.post(
    f"{BASE}/v1/edit",
    headers=headers,
    json={
        "file_id": file_id,
        "schema_version": content.json()["document"]["schema_version"],
        "author": "Dana Reyes",
        "wait": True,
        "inline": True,
        "edits": [
            {
                "type": "replace",
                "paragraphs": {"index": 7},
                "text": {"match": "[Company Name]"},
                "content": "Acme, Inc.",
            }
        ],
    },
)
edit.raise_for_status()
body = edit.json()
if body["status"] == "failed":
    raise RuntimeError(f"Edit failed: {body['error']['code']} {body['error'].get('message', '')}")
if body["status"] != "ready":
    raise RuntimeError("Still processing: poll GET /v1/edit/{edit_id}")

download = body["files"]["edited"]["downloads"][0]
with open(download["file_name"], "wb") as out:
    out.write(base64.b64decode(download["content_base64"]))
  • Compare: two documents, one redline
  • JSON format: the redline JSON behind format=json
  • Errors: every error code and what to do about it