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 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 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:
- 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}/contentreturns 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. - Edit it.
POST /v1/editwith thefile_idand a list ofeditsthat use those numbers. Alternatively, send aninstructionin plain English and skip reading the numbers yourself. Returns anedit_idand, withwait, the finished result. - 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.
| Parameter | In | Required | Default | Meaning |
|---|---|---|---|---|
file_id | path | Yes | — | The id from POST /v1/files |
format | query | No | md | json 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 anindexcounted from 0 within that part. Table cells' paragraphs are included in the count. - Parts. Each header and footer prints a
partlocator (kind,typeand the firstsectionthat shows it) andsections, every section that shows it. Each note prints apart(kindandnumber) and alabel, the mark the reader sees. - Counting from 1. Sections, list levels, tables, rows, columns, comment threads, notes
and revisions all count from 1.
indexis the only value counted from 0. - Revisions. Existing tracked changes are listed one entry per accept/reject decision, each with a
number, akind(insertion,deletion,moveorproperty),author,dateandtext. 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.
| Field | Required | Meaning |
|---|---|---|
file_id | Yes | The upload to edit |
schema_version | Yes | The 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 |
edits | One of edits or instruction | 1 to 200 edit objects, applied in order |
instruction | One of edits or instruction | A plain-English request, up to 8,000 characters. See Editing with an instruction |
replacement_text | No | With instruction only: exact replacement or new paragraph text |
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 answers with the finished result instead of an edit_id to poll |
outputs | No | With wait: which files to return, ["docx"] (the default), ["redline"], or both |
inline | No | With wait: true adds the file's bytes as content_base64 to each ready download |
style_template | No | Multipart 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:
| Form | Names |
|---|---|
{"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" } }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.
| Run | Inserts |
|---|---|
{"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:
- 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. - 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.
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.
| Operation | Does |
|---|---|
insert | Insert text, breaks, fields, cross-references, notes or links at a point |
replace | Replace selected text |
delete | Delete selected text, or a page break |
format | Change character formatting of selected text or whole paragraphs |
insert_paragraphs | Add new paragraphs at a destination, with styles and lists |
delete_paragraphs | Delete whole paragraphs, and tables a range covers |
move_paragraphs | Move paragraphs and tables as a tracked move |
split_paragraph | Split one paragraph in two at a point |
join_paragraphs | Merge a range of paragraphs into one |
set_paragraph_style | Apply a paragraph style, optionally clearing direct formatting |
set_paragraph_format | Set alignment, indents, spacing and pagination |
set_numbering | Apply, restart, continue, remove or reformat list numbering |
insert_section_break, remove_section_break | Add or remove a section break |
set_page_setup | Set orientation, paper, margins, first page and page numbering |
add_header_footer | Give a section a header or footer of its own |
insert_table, delete_table | Add or remove a table |
insert_table_row, delete_table_row | Add or remove a table row |
insert_table_column, delete_table_column | Add or remove a table column |
add_hyperlink, set_hyperlink, remove_hyperlink | Link existing text, change a link's address, or unlink |
insert_table_of_contents | Insert a table of contents |
refresh_fields | Mark every field for Word to update on open |
delete_note | Delete a footnote or endnote |
add_comment | Add a comment on text, a paragraph or a range |
reply_comment, resolve_comment | Reply to a comment thread, or resolve it |
accept_revision, reject_revision | Decide existing tracked changes |
set_document_settings | Set 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.
| Entry | Contents |
|---|---|
files.original | The document you uploaded |
files.edited | The edited document, with every edit as a tracked change |
files.redline | A 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.
| Parameter | In | Required | Default | Meaning |
|---|---|---|---|---|
edit_id | path | Yes | — | The id from POST /v1/edit |
format | query | No | docx | docx 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.code | Meaning |
|---|---|
INVALID_EDIT | The 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_FAILED | Anything 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
| 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, or an author or date that does not meet the rules above |
| 400 | INVALID_EDIT | An 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): ... |
| 400 | UNSUPPORTED_FILE_TYPE | The upload to POST /v1/files is not a .docx, .doc or .pdf |
| 400 | DOCUMENT_UNREADABLE | The upload is not a readable Word file: corrupt, or encrypted |
| 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. GET /v1/files/{file_id} answers FILE_NOT_FOUND |
| 409 | CONFLICT | The document is still processing after the wait; retry after Retry-After |
| 410 | CONTENT_DELETED | The file's content was removed by the organization's retention setting |
| 503 | EDIT_MODEL_UNAVAILABLE | An 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"]))Related
- Compare: two documents, one redline
- JSON format: the redline JSON behind
format=json - Errors: every error code and what to do about it