Skip to content

Edit

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

View as Markdown

Upload a .docx, read its listing, a list of its paragraphs with the address of each, then send edits that name the paragraphs to change by those addresses. The result is two files: the edited document, clean, with the edits accepted, and a redline in which every edit is a real Word tracked change that a reviewer can accept or reject.

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 address, such as "replace [Company Name] in paragraph 7". So before you can edit, you need the document stored with us and its listing:

  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}?format=md serves the document's listing as a download: its text, with every paragraph's address and the addresses of its headers, footers, notes, comments, tables and tracked changes. Find what you want to change and copy its address.
  3. Edit it. POST /v1/edit with the file_id and a list of edits that use those addresses. Returns an edit_id and, with wait, the finished result.
  4. Get the result. GET /v1/edit/{edit_id} until it is ready, if you did not wait. Download the edited document and the redline, as a PDF to show and as Word to send on.
  5. Edit again. For further changes, read the edited document's listing, files.clean.file_id in the edit response, and send the next POST /v1/edit with that file_id.

A file_id names one immutable document, so its listing never changes. An edit produces a new file with its own file_id; a user's updated copy is a new upload.

The listing

GET /v1/files/{file_id} serves a document's listing in two formats:

  • md, the listing to write edits from. A header explains each tag it prints; then each paragraph is one line, in document order: <p index="n" id="…"/> and its text. Tables, tracked changes (<ins>, <del>, <moved-from>, <moved-to>, each with the revision index a decision names), fields, links, pictures, equations, note references and comment anchors print where they are. After the body come the headers and footers, notes, comment threads, every revision, and the styles.
  • json, the same listing as structured data, for code. See Document JSON.
curl -sS "https://api.versionstory.com/v1/files/3f2a9c1e-...?format=md" \
  -H "Authorization: Bearer vs_live_..."
{ "file_id": "3f2a9c1e-...", "file_name": "nda.docx", "status": "ready",
  "downloads": [{ "format": "md", "url": "https://documents.versionstory.com/...", "file_name": "nda.md", "expires_at": "..." }] }

The listing is made once, when the file is prepared, and stored with it. While it is being made the format is in pending_formats; poll after Retry-After, then fetch its url. A long document's listing can be searched, or read in parts, without downloading all of it: see Reading the listing.

Copy addresses from the listing rather than counting them yourself:

  • Index and id. Everything an edit names has an index, where it is now, counted from 0 within its container: a body paragraph among the body's paragraphs, table cells' paragraphs included; a header's, footer's or note's paragraphs within that part; tables within their part, rows within their table and cells within their row; and sections, notes of a kind, comment threads, pictures and revisions within the document. Paragraphs, tables, rows and cells also have an id, which they keep when content moves.
  • Parts. Each header and footer prints its 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 index) and a label, the mark the reader sees.
  • Counting from 1. Only what a reader counts: a list paragraph's list_level (1 to 9), a selector's occurrence, and table-of-contents levels. Every index counts from 0.
  • Revisions. Existing tracked changes are listed one entry per accept or reject decision, each with an index, a type (insertion, deletion, move or property), author, date, on and text. A replacement is two entries: a deletion and an insertion.
  • Comments. Listed as threads with their replies, each with its index in document order.

The listing is for writing edits and for code, not for showing a person the document. Show them the PDF, or embed the HTML where the view must be interactive.

POST /v1/edit

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

FieldRequiredMeaning
file_idYesThe document to edit: an upload, an earlier edit's files.clean.file_id, or a Word comparison's file.file_id (see Commenting on the redline's changes)
schema_versionYesThe listing's schema_version, 1. A mismatch is 400 INVALID_EDIT, so a change to how the listing counts paragraphs cannot silently shift your addresses
editsYes1 to 200 edit objects, applied in order. See Edit operations
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 holds the response up to 5 seconds and answers with the finished result; an edit still running after that answers 202 with its edit_id to poll
outputsNoWith wait: the formats of both result files, as GET /v1/edit/{edit_id}'s format takes them. Default ["docx"]
inlineNoWith wait: true adds each file's bytes as content_base64 to each ready download
descriptionNoWhat the edits change, in one plain line, such as "Extend the termination notice to 60 days". Kept with the version the edit becomes and shown in the Version Story app
new_versionNotrue makes the edit a new version even where it would amend your working draft. See Versions
amendNotrue puts the change in the version this file_id is and in every version after it. See Amending a version. At most one of new_version and amend
version_nameNoThe version's name, the user's name for its file. At most 255 characters
style_templateNoMultipart only: a .docx, .dotx or .dotm whose styles and numbering are imported before the first edit
picturesNoMultipart only, repeated: PNG, JPEG or GIF files that picture runs name by file name. See Pictures

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": ["pdf", "docx"],
    "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 redline, 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 request waits up to 5 seconds and 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 listing 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
{"id": "8532bf246ab64e8788c26d27cfd105b4"}The paragraph with this id, in any part
{"part": {"kind": "footnote", "index": 0}, "index": 0}The first paragraph of the first footnote
{"part": {"kind": "footer", "type": "default", "section": {"index": 0}}, "index": 0}The first paragraph of the first section'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 {"index": 1}, a table is {"index": 1} or {"id": "…"}, a row is {"table": {"index": 1}, "index": 3} or {"id": "…"}, a column is {"table": {"index": 1}, "index": 2}, a cell is {"row": {...}, "index": 0} or {"id": "…"}, a comment thread is {"index": 2}, a note is {"kind": "footnote", "index": 2}, and an existing tracked change is {"index": 4}, "all", or a filter by author and type.

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: tabs, breaks, fields, cross-references, notes, links and pictures. See Content.

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 the listing gave index 10.
  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.

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.

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: read the listing of the first result's files.clean.file_id, and send the second against that file_id.

Every edit type, with its fields, examples and refusals, is in Edit operations. The machine-readable definition of each is in the API's OpenAPI specification under components.schemas.EditRequest.

Examples

Indexes in these examples are illustrative. Use the ones your document's listing prints.

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": "Dana Reyes" },
  { "type": "insert", "paragraphs": { "index": 305 }, "at": { "after": { "match": "Title:\t" } }, "content": "General Counsel" }
]

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" }
]

Move a clause and change its text in the same request. Index 14 still names the clause after the move, and the two edits could be listed in either order:

[
  { "type": "move_paragraphs", "paragraphs": { "index": 14 }, "destination": { "paragraph": { "index": 22 }, "position": "after" } },
  { "type": "replace", "paragraphs": { "index": 14 }, "text": { "match": "thirty (30) days" }, "content": "sixty (60) days" }
]

Response

POST /v1/edit and GET /v1/edit/{edit_id} return the same shape. files holds the edit's two files, each a File object, the same one GET /v1/files/{file_id} answers with. Their file_ids are allocated when the edit is created, so they are present while processing and after a failure.

EntryContents
files.cleanThe edited document, clean: the edits are accepted. To make further changes, send its file_id as the next edit's file_id
files.comparisonThe redline: the document you edited compared with the edited one, every edit a Word tracked change attributed to author and date

GET /v1/edit/{edit_id}

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

ParameterInRequiredDefaultMeaning
edit_idpathYes—The id from POST /v1/edit
formatqueryNodocxThe formats of both files: pdf, html, docx, md, json, as GET /v1/files/{file_id} serves them. Repeatable or comma-separated
inlinequeryNofalsetrue adds each file's bytes as content_base64 to each ready download
curl -sS "https://api.versionstory.com/v1/edit/edt_...?format=pdf,docx" \
  -H "Authorization: Bearer vs_live_..."

Response: ready

{
  "edit_id": "edt_MTp2cy0xOnZlLTE",
  "status": "ready",
  "files": {
    "clean": {
      "file_id": "8b41d7a0-...",
      "file_name": "nda (edited).docx",
      "status": "ready",
      "downloads": [
        { "format": "pdf", "url": "https://documents.versionstory.com/...", "file_name": "nda (edited).pdf", "expires_at": "2026-10-22T22:00:00Z" },
        { "format": "docx", "url": "https://documents.versionstory.com/...", "file_name": "nda (edited).docx", "expires_at": "2026-10-22T22:00:00Z" }
      ]
    },
    "comparison": {
      "file_id": "d90c5e12-...",
      "file_name": "Redline.docx",
      "status": "ready",
      "downloads": [
        { "format": "pdf", "url": "https://documents.versionstory.com/...", "file_name": "Redline.pdf", "expires_at": "2026-10-22T22:00:00Z" },
        { "format": "docx", "url": "https://documents.versionstory.com/...", "file_name": "Redline.docx", "expires_at": "2026-10-22T22:00:00Z" }
      ]
    }
  }
}

status is the edit's own, and each file's status is that file's: a ready edit with a PDF still converting has that file processing, with Retry-After: 5. 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.

Response: failed

Terminal: "status": "failed" with an error. Submit a corrected request to retry. With "wait": true, an edit that fails while being applied answers HTTP 200 with this body, so check status, not only the HTTP code.

error.codeMeaning
INVALID_EDITThe edit could not be applied 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
CARRY_CONFLICTAn amend was not made because a later version changed the same text. error.message quotes each version's conflicting passage. No version changed
EDIT_FAILEDAnything else. Also returned if the edited document is missing after the edit otherwise succeeded

Nothing is applied when an edit fails.

Versions

Through this API only POST /v1/documents creates a Document; an edit never does. An edit of a file that is a version of one of your Documents (a version's own file, or an earlier kept edit's files.clean.file_id) is kept as that Document's next version, where your account keeps version history (an organization with zero-day retention never does). The response then carries version, a reference to it, and app_url:

"version": { "document_id": "...", "version_id": "...", "index": 1, "label": "v2" },
"app_url": "https://app.versionstory.com/documents/.../versions/..."

index is the version's position among the Document's versions, counted from 0, and label is the label the app shows. A request names a version by {"version_id": "..."}. app_url opens the version at its redline in the Version Story app. A response without version is a one-off edit: its file is not a version of one of your Documents, such as an upload, or history is not kept. To keep an upload's history, make it a Document's first version with POST /v1/documents and its file_id, then edit that file.

  • A version's file. The edit is that version's next version. A further edit of the version your own edit made, while it is still the Document's latest version, amends that version instead, so one version holds all of your changes for the same piece of work. Send "new_version": true for a separate version, such as a draft for someone else.
  • "amend": true. The change goes into the named version wherever it is in the history. See Amending a version.

version_name names the version and its file; without it, a new version keeps the name of the version it was made from, and an amended version keeps its own. A one-off edit's version_name names only its file, and its description is ignored.

Amending a version

"amend": true puts the change in the version whose file file_id names, wherever it is in the history, and carries it into every version after it: each later version gets a new file with the change and a redline redone against the version before it. Nothing changes until every later version's new file is made without a conflict; the edit stays processing until then. A ready response lists a reference to each of those versions in carried_versions; GET /v1/documents/{document_id} reads each with its new file.

An amend is refused before anything is created with 400 INVALID_REQUEST when:

  • new_version is also true;
  • the account does not keep version history;
  • no version holds the file;
  • more than 15 versions follow it, a later version merges others, or a later version has no redline against the version before it.

It answers 409 CONFLICT while a later version's redline is still being made; send it again shortly. If a later version changed the same text, the edit fails with CARRY_CONFLICT and no version is changed.

POST /v1/edit/{edit_id}/acknowledge

For an organization with zero-day retention, deletes the edit's two files as soon as you have saved what you need, instead of an hour after the edit finished. No body; the response is 202 with "status": "deletion_queued". The file you edited is kept until its own deadline, so you can edit it again; acknowledge it with POST /v1/files/{file_id}/acknowledge once no further edit needs it.

Errors

StatusCodeCause
400INVALID_REQUESTThe request itself is malformed: a missing file_id, a field of the wrong type, outputs or inline without wait, both new_version and amend, an author or date that does not meet the rules above, or an amend refused as above
400INVALID_EDITAn edit was refused before anything was created: a stale schema_version, a selector that matched nothing or too much, or a combination that is undefined. Every refusal in the request is reported together, each as edit at index N (type): ...
400UNSUPPORTED_FILE_TYPEThe file is a type that is never prepared for editing, such as a workbook
400UNSUPPORTED_FORMATA format is unknown
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
409CONFLICTThe document is still being prepared after the wait; retry after Retry-After. For an amend, a later version's redline is still being made
410CONTENT_DELETEDThe file's content was removed by the organization's retention setting

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

The whole loop, in Python

import time
import requests

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


def download(file_id, file_format):
    """One format of a file, once it is ready."""
    while True:
        response = requests.get(f"{BASE}/v1/files/{file_id}", headers=headers, params={"format": file_format})
        response.raise_for_status()
        body = response.json()
        if body["status"] == "failed":
            raise RuntimeError(f"{file_format} failed: {body.get('error') or body.get('failed_formats')}")
        if body["status"] == "ready":
            content = requests.get(body["downloads"][0]["url"])
            content.raise_for_status()
            return content.content
        time.sleep(int(response.headers.get("Retry-After", "1")))


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"]

listing = download(file_id, "md").decode()
# Find the line holding "[Company Name]" and copy its <p index="n"/>.

edit = requests.post(
    f"{BASE}/v1/edit",
    headers=headers,
    json={
        "file_id": file_id,
        "schema_version": 1,
        "author": "Dana Reyes",
        "edits": [
            {
                "type": "replace",
                "paragraphs": {"index": 7},
                "text": {"match": "[Company Name]"},
                "content": "Acme, Inc.",
            }
        ],
    },
)
edit.raise_for_status()
edit_id = edit.json()["edit_id"]

while True:
    status = requests.get(f"{BASE}/v1/edit/{edit_id}", headers=headers, params={"format": "pdf,docx"})
    status.raise_for_status()
    body = status.json()
    if body["status"] == "failed":
        raise RuntimeError(f"Edit failed: {body['error']['code']} {body['error'].get('message', '')}")
    files = body["files"]
    if body["status"] == "ready" and all(f["status"] == "ready" for f in files.values()):
        break
    time.sleep(int(status.headers.get("Retry-After", "1")))

for name in ("clean", "comparison"):
    for item in files[name]["downloads"]:
        content = requests.get(item["url"])
        content.raise_for_status()
        with open(item["file_name"], "wb") as out:
            out.write(content.content)

# To edit again, read the edited document's listing and send the next request with its file_id.
edited_listing = download(files["clean"]["file_id"], "md").decode()
  • Edit operations: every edit type, in full
  • Files: uploads, downloads and clean metadata
  • Errors: every error code and what to do about it