# 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](/developers/rest/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](/developers/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

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.

| 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 |

```bash
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

```json
{ "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](/developers/mcp/merge) 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`](/developers/rest/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

| 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/&#123;merge_id&#125;

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 |
| `json` | Structured JSON — blocks, change segments, and merge conflicts. See [JSON format](/developers/reference/json-format) |

```bash
curl -sS "https://api.versionstory.com/v1/merge/mrg_MTp2cy0xOnZ2LTE?format=docx,md" \
  -H "Authorization: Bearer vs_live_..."
```

### Response — ready

```json
{
  "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: 1`. Formats that are ready are returned immediately; the rest
are named in `pending_formats`. `status` is `ready` only once every requested format exists.

```json
{
  "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.

```json
{
  "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](/developers/rest/compare#response--failed).

### 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

```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", 1)))

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](/developers/rest/compare) — two documents, one redline
- [Merge & combine over MCP](/developers/mcp/merge) — the same capability for agents, plus
  combine for documents with no shared original
- [Redline format](/developers/reference/redline-format) — how the tracked changes are
  structured and attributed
