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 |
| OpenAPI | GET /openapi.json, with interactive docs at /docs |
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
POST /v1/mergewith the base and its revisions. Returns202and amerge_id.GET /v1/merge/{merge_id}untilstatusisready, then fetch eachdownloads[].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
multipart/form-data. The revisions field is repeated, once per revision.
| Field | Required | Meaning |
|---|---|---|
original | Yes | The base document every revision was edited from |
revisions | Yes, at least twice | A revised copy of the base — repeat the field per revision |
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"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. Merging unrelated documents is a different operation, available over MCP as combine.
- Two revisions is the minimum. 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. - Revisions routinely share a filename — three copies of
nda.docxis the normal case. Duplicates are disambiguated automatically.
Errors
| Status | Code | Cause |
|---|---|---|
| 400 | INVALID_REQUEST | Fewer than two revisions, or a file field is missing or empty |
| 400 | UNSUPPORTED_FILE_TYPE | An extension is not .docx, .doc, or .pdf |
| 401 | INVALID_API_KEY | Missing, malformed, unknown, inactive, or expired key |
| 402 | USAGE_LIMIT_REACHED | The organization's monthly upload limit is exhausted |
| 403 | API_KEY_ORG_MISMATCH | The key's service account no longer belongs to the key's organization |
| 413 | FILE_TOO_LARGE | A file exceeds the 100 MB limit |
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 |
Formats
As with compare, format is repeatable and also accepts a comma-separated list.
format | Artifact |
|---|---|
docx | The merged Word document, with every revision's changes tracked and labeled — the default |
md | Markdown rendering of the merged document |
curl -sS "https://api.versionstory.com/v1/merge/mrg_MTp2cy0xOnZ2LTE?format=docx,md" \
-H "Authorization: Bearer vs_live_..."Response — ready
{
"merge_id": "mrg_MTp2cy0xOnZ2LTE",
"status": "ready",
"downloads": [
{
"format": "docx",
"url": "https://documents.versionstory.com/...",
"file_name": "Merged nda.docx",
"expires_at": "2026-08-11T22:00:00+00:00"
},
{
"format": "md",
"url": "https://documents.versionstory.com/...",
"file_name": "Merged nda.md",
"expires_at": "2026-08-11T22:00:00+00:00"
}
]
}Each url is signed and expires one hour after it was issued, needs no Authorization
header, and can be reissued by polling again.
Response — still generating
Returned with Retry-After: 5. Formats that are ready are returned immediately; the rest
are named in pending_formats. status is ready only once every requested format exists.
{
"merge_id": "mrg_MTp2cy0xOnZ2LTE",
"status": "processing",
"downloads": [],
"pending_formats": ["docx", "md"]
}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.
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 or md |
| 404 | MERGE_NOT_FOUND | No such merge, or the key's user cannot access it |
MERGE_NOT_FOUND covers both "doesn't exist" and "not yours" on purpose, so that ids cannot
be probed for existence.
The whole loop, in Python
import time
import requests
BASE = "https://api.versionstory.com"
headers = {"Authorization": "Bearer vs_live_..."}
files = [
("original", open("nda.docx", "rb")),
("revisions", open("nda_from_counsel.docx", "rb")),
("revisions", open("nda_from_finance.docx", "rb")),
]
created = requests.post(f"{BASE}/v1/merge", headers=headers, files=files)
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": "docx,md"}
)
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", 5)))
for download in body["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
- Merge & combine over MCP — the same capability for agents, plus combine for documents with no shared original
- Redline format — how the tracked changes are structured and attributed