Two endpoints, the same shape as Compare: one submits a base document and its revisions, the other polls for the merged draft.
| Base URL | https://api.versionstory.com |
| Auth | Authorization: Bearer vs_.... See Authentication |
What a merge is for
Three people take the same contract and each edits their own copy. You now have one original and three revisions, and no single document holding everybody's work.
A merge produces that document: one Word draft in which every change from every revision is a tracked change, labeled with the revision it came from. Reviewing it is the ordinary Word workflow — accept and reject, with attribution intact.
This is a different job from comparing. A comparison answers what changed between these two. A merge answers what does everyone's work look like together.
The flow
- Upload the base and each revision with
POST /v1/files. Each upload returns afile_id. POST /v1/mergewith a JSON body naming the base and its revisions by those ids, each revision with its author. Returns202and amerge_id.GET /v1/merge/{merge_id}?format=pdfuntilstatusisready, then fetch eachfile.downloads[].url.
A merge takes longer than a comparison, and for a structural reason worth knowing: each
revision is first compared against the base, and the merge itself begins only once all of
those redlines exist. Expect it to sit in processing for longer than a single comparison
would, with the gap widening as you add revisions.
POST /v1/merge
A JSON body (Content-Type: application/json) naming documents already in Version Story.
| Field | Required | Meaning |
|---|---|---|
original | Yes | The base document every revision was edited from |
revisions | Yes, two to ten | The revised copies of the base, in any order |
revisions[].author | No | The name that revision's tracked changes are credited to. Default: the revision's file name |
include_cover_sheet | No | Whether the merged document opens with the cover sheet explaining how to review it. Default true |
include_changes_summary | No | Whether the merged document ends with the summary of each revision's changes. Default true |
original and each revision name one document, in one of two forms:
| Form | Names |
|---|---|
{"file_id": "..."} | A file you uploaded with POST /v1/files, or an edit's files.clean |
{"document_id": "...", "version_id": "..."} | A version of one of your Documents. version_id may be "latest" |
Upload each document once, then merge:
curl -sS https://api.versionstory.com/v1/files \
-H "Authorization: Bearer vs_live_..." \
-F "document=@nda.docx"
# {"file_id": "3f2a9c1e-...", "status": "processing"}
curl -sS https://api.versionstory.com/v1/files \
-H "Authorization: Bearer vs_live_..." \
-F "document=@nda_from_counsel.docx"
# {"file_id": "8b41d7a0-...", "status": "processing"}
curl -sS https://api.versionstory.com/v1/files \
-H "Authorization: Bearer vs_live_..." \
-F "document=@nda_from_finance.docx"
# {"file_id": "d90c5e12-...", "status": "processing"}
curl -sS https://api.versionstory.com/v1/merge \
-H "Authorization: Bearer vs_live_..." \
-H "Content-Type: application/json" \
-d '{
"original": {"file_id": "3f2a9c1e-..."},
"revisions": [
{"file_id": "8b41d7a0-...", "author": "Dana Reyes"},
{"file_id": "d90c5e12-...", "author": "Sam Patel"}
]
}'Attributing the changes
Each revision's author labels the tracked changes from that revision. In the merged document,
they read Dana Reyes — nda_from_counsel.docx: the name, then the revision's file name, so
reviewers can tell whose edit each one is. Without author, a revision's changes are labeled with
its file name alone, so pass the names your reviewers should see.
How conflicts show
Each revision is compared against the same base, so the result does not depend on their order. When two revisions change the same words differently, the merged draft deletes the original words once, credited to both revisions, and inserts each revision's replacement beside it under its own author. The reviewer keeps one replacement and rejects the other.
The PDF marks each conflict in the margin. The JSON
wraps it in a conflict segment, so code can list the conflicts.
Response — 202 Accepted
{ "merge_id": "mrg_MTp2cy0xOnZ2LTE", "status": "processing" }merge_id is opaque and stable, and is not interchangeable with a comparison_id — each
endpoint rejects the other's ids.
Notes on inputs
- Every revision must be a revision of the base. Folding together drafts that share no base is a different operation, combine, which the REST API does not offer.
- A merge takes from two to ten revisions. With a single revision there is nothing to
reconcile: the comparison redline already is the combined document, so use
POST /v1/compare. - Sources may be
.docx,.doc, or.pdf, up to 100 MB each. Non-.docxinputs are converted first, so they spend longer inprocessing. Presentations and spreadsheets cannot be merged. - Revisions routinely share a filename — three copies of
nda.docxis the normal case. Duplicates are disambiguated automatically. - A redline PDF is read as its upload chose: send
track_redline_changeswithPOST /v1/files. See Redline PDFs.
Errors
| Status | Code | Cause |
|---|---|---|
| 400 | INVALID_REQUEST | Fewer than two or more than ten revisions, a document named in neither form or in both, or an unknown field |
| 400 | INVALID_FILE_ID, INVALID_DOCUMENT_ID, INVALID_VERSION_ID | An id is malformed |
| 400 | UNSUPPORTED_FILE_TYPE | A document is not a .docx, .doc, or .pdf |
| 400 | DOCUMENT_UNREADABLE | A .docx source is not a readable Word file: corrupt, or encrypted |
| 400 | TRIAL_BATCH_TOO_LARGE | An API trial organization sent more revisions than its per-minute allowance |
| 401 | INVALID_API_KEY | Missing, malformed, unknown, revoked, or expired key |
| 402 | USAGE_LIMIT_REACHED | The organization's monthly upload allowance is exhausted |
| 403 | API_KEY_ORG_MISMATCH | The key's service account no longer belongs to the key's organization |
| 403 | TRIAL_COMPARISON_LIMIT_REACHED | The organization's API trial allowance is used up |
| 403 | ZERO_DAY_RETENTION_UNSUPPORTED | A Document's version was named by an organization with zero-day retention, which keeps no versions |
| 404 | FILE_NOT_FOUND | No such file, it belongs to another organization, or it is a redline or a merged draft rather than a document |
| 404 | DOCUMENT_NOT_FOUND, VERSION_NOT_FOUND | No such Document or version, or it belongs to another organization |
| 413 | FILE_TOO_LARGE | A file exceeds the 100 MB limit |
| 429 | TRIAL_RATE_LIMITED | The trial's per-minute rate is exceeded; wait for Retry-After seconds |
Each revision is compared against the base on the way to the merge, so on an API trial a merge uses one comparison of the allowance per revision.
Multipart upload (deprecated)
POST /v1/merge also takes the documents' bytes as multipart/form-data, with the authors in a
separate positional list. It is deprecated: new code uploads each document with
POST /v1/files and sends the JSON body, where each revision carries its own
author. Existing clients keep working.
| Field | Required | Meaning |
|---|---|---|
original | Yes | The base document every revision was edited from |
revisions | Yes, two to ten times | A revised copy of the base — repeat the field per revision |
revision_authors | No | Name to attribute a revision's tracked changes to — repeat once per revision, in the same order as revisions |
include_cover_sheet | No | As in the JSON body. Default true |
include_changes_summary | No | As in the JSON body. Default true |
track_redline_changes | No | true turns a redline PDF's insertions and deletions into tracked changes, so the merge reads it as its revised text; see Redline PDFs. Default true |
curl -sS https://api.versionstory.com/v1/merge \
-H "Authorization: Bearer vs_live_..." \
-F "original=@nda.docx" \
-F "revisions=@nda_from_counsel.docx" \
-F "revisions=@nda_from_finance.docx" \
-F "revision_authors=Dana Reyes" \
-F "revision_authors=Sam Patel"Each revision_authors value labels the revision in the same position: the first name goes with
the first revisions file, and so on. Send exactly one value per revision; a different count is
400 INVALID_REQUEST. Values are not comma-split: Dana Reyes,Sam Patel is one name. An empty or
whitespace-only value keeps that revision's default label, its file name.
The response and errors are the JSON body's, with INVALID_REQUEST also covering a file field
that is missing or empty.
GET /v1/merge/{merge_id}
Reports whether the merged document exists yet, and returns signed download URLs once it does. Safe to call as often as you like — it creates nothing and has no side effects.
| Parameter | In | Required | Default | Meaning |
|---|---|---|---|---|
merge_id | path | Yes | — | The id from POST /v1/merge |
format | query | No | docx | Which renderings to return. Name pdf to show the merged document |
Formats
As with compare, format is repeatable and also accepts a comma-separated list. A request that
names no format gets the Word file.
format | Artifact | Use it for |
|---|---|---|
pdf | PDF rendering of the merged document, with conflicts drawn in the margin | Showing a person the merged document. Start here |
docx | The merged Word document, with every revision's changes tracked and labeled (the default) | A lawyer to resolve the changes in Word and send on |
md | The merged document as Markdown, with every change marked inline and its revision named. See Redline format | A model to read |
json | The merged document as structured data, conflicts included. See Document JSON | Code that acts on the changes, such as listing the conflicts. Not for display |
curl -sS "https://api.versionstory.com/v1/merge/mrg_MTp2cy0xOnZ2LTE?format=pdf,docx" \
-H "Authorization: Bearer vs_live_..."Response: ready
file is the merged document as a file, with one download per requested
format.
{
"merge_id": "mrg_MTp2cy0xOnZ2LTE",
"status": "ready",
"file": {
"file_id": "7d1a...",
"file_name": "Merged nda.docx",
"status": "ready",
"downloads": [
{
"format": "pdf",
"url": "https://documents.versionstory.com/...",
"file_name": "Merged nda.pdf",
"expires_at": "2026-10-22T22:00:00Z"
},
{
"format": "docx",
"url": "https://documents.versionstory.com/...",
"file_name": "Merged nda.docx",
"expires_at": "2026-10-22T22:00:00Z"
}
]
}
}Each url is signed and expires one hour after it was issued, needs no Authorization header, and
can be reissued by polling again. status is the merge's, and file.status the merged file's.
Response: still generating
Returned with Retry-After: 1. The PDF and Markdown trail the Word document, so formats that are
ready are returned at once and the rest are named in file.pending_formats. status is ready
only once every requested format exists.
{
"merge_id": "mrg_MTp2cy0xOnZ2LTE",
"status": "processing",
"file": {
"file_id": "7d1a...",
"file_name": "Merged nda.docx",
"status": "processing",
"downloads": [],
"pending_formats": ["pdf", "docx"]
}
}Response: failed
Terminal, and reported as 200 OK because the request itself succeeded: it correctly told you the
merge is not going to happen. Submit a new merge to retry.
{
"merge_id": "mrg_MTp2cy0xOnZ2LTE",
"status": "failed",
"error": {
"code": "DOCUMENT_CONVERSION_FAILED",
"upstream_code": "ONE_OR_MORE_DOCUMENTS_FAILED_TO_CONVERT_TO_DOCX"
}
}A merge fails if any of its underlying comparisons fails, since the merge is assembled from
them. The error.code values are the same set as
Compare, plus MERGE_UNUSABLE when the merge's
records can no longer produce a document. A failed merge has no file, except when only its
PDF failed: then it reports failed with an error, and file carries the other requested
formats with failed_formats: ["pdf"].
Request errors
| Status | Code | Cause |
|---|---|---|
| 400 | INVALID_MERGE_ID | The id is malformed, or is a cmp_ comparison id |
| 400 | UNSUPPORTED_FORMAT | A requested format is not docx, pdf, md, or json |
| 403 | TRIAL_WORD_DOWNLOADS_EXCLUDED | docx was requested, and the organization's API trial excludes Word downloads |
| 404 | MERGE_NOT_FOUND | No such merge, or it belongs to another organization |
MERGE_NOT_FOUND covers both "doesn't exist" and "not yours" on purpose, so that ids cannot
be probed for existence.
POST /v1/merge/{merge_id}/acknowledge
For an organization with zero-day retention, deletes the merge as soon as you have saved what you need, instead of an hour after it finished: the base and revisions, each revision's redline against the base, and the merged document. No body.
curl -sS -X POST https://api.versionstory.com/v1/merge/mrg_MTp2cy0xOnZ2LTE/acknowledge \
-H "Authorization: Bearer vs_live_..."{ "merge_id": "mrg_MTp2cy0xOnZ2LTE", "status": "deletion_queued" }A merge that is still processing, or that has no zero-day retention, is 409 CONFLICT.
The whole loop, in Python
import time
import requests
BASE = "https://api.versionstory.com"
headers = {"Authorization": "Bearer vs_live_..."}
def upload(path):
"""Store one document and return its file_id."""
with open(path, "rb") as document:
response = requests.post(f"{BASE}/v1/files", headers=headers, files={"document": document})
response.raise_for_status()
return response.json()["file_id"]
merge = {
"original": {"file_id": upload("nda.docx")},
"revisions": [
{"file_id": upload("nda_from_counsel.docx"), "author": "Dana Reyes"},
{"file_id": upload("nda_from_finance.docx"), "author": "Sam Patel"},
],
}
created = requests.post(f"{BASE}/v1/merge", headers=headers, json=merge)
created.raise_for_status()
merge_id = created.json()["merge_id"]
while True:
status = requests.get(
f"{BASE}/v1/merge/{merge_id}", headers=headers, params={"format": "pdf,docx"}
)
status.raise_for_status()
body = status.json()
if body["status"] == "ready":
break
if body["status"] == "failed":
raise RuntimeError(f"Merge failed: {body['error']['code']}")
time.sleep(int(status.headers.get("Retry-After", 1)))
for download in body["file"]["downloads"]:
content = requests.get(download["url"])
content.raise_for_status()
with open(download["file_name"], "wb") as out:
out.write(content.content)Related
- Compare: two documents, one redline
- Files: uploading the documents a merge names
- Documents: versions a merge can name by
document_idandversion_id - Redline format: how the Markdown marks and attributes each change