Skip to content
Documentation menu

Merge

Combine several separately edited revisions of one document into a single draft whose tracked changes are labeled by their source.

Two endpoints, the same shape as Compare: one submits a base document and its revisions, the other polls for the merged draft.

Base URLhttps://api.versionstory.com
AuthAuthorization: Bearer vs_... — see Authentication
OpenAPIGET /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

  1. POST /v1/merge with the base and its revisions. Returns 202 and a merge_id.
  2. GET /v1/merge/{merge_id} until status is ready, then fetch each 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

multipart/form-data. The revisions field is repeated, once per revision.

FieldRequiredMeaning
originalYesThe base document every revision was edited from
revisionsYes, at least twiceA 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-.docx inputs are converted first, so they spend longer in processing.
  • Revisions routinely share a filename — three copies of nda.docx is the normal case. Duplicates are disambiguated automatically.

Errors

StatusCodeCause
400INVALID_REQUESTFewer than two revisions, or a file field is missing or empty
400UNSUPPORTED_FILE_TYPEAn extension is not .docx, .doc, or .pdf
401INVALID_API_KEYMissing, malformed, unknown, inactive, or expired key
402USAGE_LIMIT_REACHEDThe organization's monthly upload limit is exhausted
403API_KEY_ORG_MISMATCHThe key's service account no longer belongs to the key's organization
413FILE_TOO_LARGEA 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.

ParameterInRequiredDefaultMeaning
merge_idpathYesThe id from POST /v1/merge
formatqueryNodocxWhich renderings to return

Formats

As with compare, format is repeatable and also accepts a comma-separated list.

formatArtifact
docxThe merged Word document, with every revision's changes tracked and labeled — the default
mdMarkdown 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

StatusCodeCause
400INVALID_MERGE_IDThe id is malformed, or is a cmp_ comparison id
400UNSUPPORTED_FORMATA requested format is not docx or md
404MERGE_NOT_FOUNDNo 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)
  • 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