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 URL | https://api.versionstory.com |
| Auth | Authorization: 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:
- Upload it.
POST /v1/fileswith the.docxreturns afile_id, which every later call uses to name the document. - Read it.
GET /v1/files/{file_id}?format=mdserves 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. - Edit it.
POST /v1/editwith thefile_idand a list ofeditsthat use those addresses. Returns anedit_idand, withwait, the finished result. - 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. - Edit again. For further changes, read the edited document's listing,
files.clean.file_idin the edit response, and send the nextPOST /v1/editwith thatfile_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 therevisionindex 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 anid, which they keep when content moves. - Parts. Each header and footer prints its
partlocator (kind,typeand the firstsectionthat shows it) andsections, every section that shows it. Each note prints apart(kindandindex) and alabel, the mark the reader sees. - Counting from 1. Only what a reader counts: a list paragraph's
list_level(1 to 9), a selector'soccurrence, and table-of-contents levels. Everyindexcounts from 0. - Revisions. Existing tracked changes are listed one entry per accept or reject decision, each with
an
index, atype(insertion,deletion,moveorproperty),author,date,onandtext. A replacement is two entries: a deletion and an insertion. - Comments. Listed as threads with their
replies, each with itsindexin 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.
| Field | Required | Meaning |
|---|---|---|
file_id | Yes | The 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_version | Yes | The 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 |
edits | Yes | 1 to 200 edit objects, applied in order. See Edit operations |
author | No | Name on every tracked change and comment the request makes. Default Version Story AI |
date | No | ISO 8601 date and time for those changes. Default: the time of the request |
wait | No | true 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 |
outputs | No | With wait: the formats of both result files, as GET /v1/edit/{edit_id}'s format takes them. Default ["docx"] |
inline | No | With wait: true adds each file's bytes as content_base64 to each ready download |
description | No | What 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_version | No | true makes the edit a new version even where it would amend your working draft. See Versions |
amend | No | true 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_name | No | The version's name, the user's name for its file. At most 255 characters |
style_template | No | Multipart only: a .docx, .dotx or .dotm whose styles and numbering are imported before the first edit |
pictures | No | Multipart 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:
| Form | Names |
|---|---|
{"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" } }matchis 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.prefixandsuffixare optional context that must sit immediately before and after the match. They narrow the candidates; the edit never acts on them.occurrencepicks among several matches, counted across every paragraph the edit names:Nfor the Nth, or"all"for every one. Omitted, the match must be unique.atis"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:
- 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. - 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.
- 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.
| Entry | Contents |
|---|---|
files.clean | The edited document, clean: the edits are accepted. To make further changes, send its file_id as the next edit's file_id |
files.comparison | The 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.
| Parameter | In | Required | Default | Meaning |
|---|---|---|---|---|
edit_id | path | Yes | — | The id from POST /v1/edit |
format | query | No | docx | The formats of both files: pdf, html, docx, md, json, as GET /v1/files/{file_id} serves them. Repeatable or comma-separated |
inline | query | No | false | true 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.code | Meaning |
|---|---|
INVALID_EDIT | The 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_CONFLICT | An amend was not made because a later version changed the same text. error.message quotes each version's conflicting passage. No version changed |
EDIT_FAILED | Anything 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": truefor 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_versionis alsotrue;- 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
| Status | Code | Cause |
|---|---|---|
| 400 | INVALID_REQUEST | The 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 |
| 400 | INVALID_EDIT | An 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): ... |
| 400 | UNSUPPORTED_FILE_TYPE | The file is a type that is never prepared for editing, such as a workbook |
| 400 | UNSUPPORTED_FORMAT | A format is unknown |
| 400 | INVALID_EDIT_ID | The edit_id is malformed |
| 401 | INVALID_API_KEY | Missing, malformed, unknown, revoked, or expired key |
| 404 | EDIT_NOT_FOUND | No such edit or file, or it belongs to another organization |
| 409 | CONFLICT | The document is still being prepared after the wait; retry after Retry-After. For an amend, a later version's redline is still being made |
| 410 | CONTENT_DELETED | The 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()Related
- Edit operations: every edit type, in full
- Files: uploads, downloads and clean metadata
- Errors: every error code and what to do about it