# Compare

> Submit two documents, poll for the redline, download it. The REST comparison endpoints in full.

Two endpoints: one submits a pair of documents, the other polls for the finished redline
and hands back a signed download URL.

| | |
| --- | --- |
| Base URL | `https://api.versionstory.com` |
| Auth | `Authorization: Bearer vs_...` — see [Authentication](/developers/authentication) |
| OpenAPI | `GET /openapi.json`, with interactive docs at `/docs` |

Every non-2xx response uses the same envelope. Send an `X-Request-Id` header to correlate
with our logs; if you don't, one is minted and echoed back.

```json
{ "error": { "code": "INVALID_API_KEY", "message": "...", "request_id": "..." } }
```

## The flow

1. `POST /v1/compare` with both files. Returns `202` and a `comparison_id` immediately.
2. `GET /v1/compare/{comparison_id}` until `status` is `ready`, then fetch each
   `downloads[].url`.

Generation is asynchronous. The first call does not block, and the second call does not
block either — it is a single-shot readiness check you repeat.

## POST /v1/compare

Submits two documents for comparison. `multipart/form-data` with two file fields and an
optional text field.

| Field | Required | Meaning |
| --- | --- | --- |
| `original` | Yes | The base document — the earlier version |
| `modified` | Yes | The revised document — the later version |
| `author` | No | Name to attribute the redline's tracked changes to |

The direction matters: the redline describes what changed in going *from* `original` *to*
`modified`. Swapping them gives you insertions where you expected deletions.

```bash
curl -sS https://api.versionstory.com/v1/compare \
  -H "Authorization: Bearer vs_live_..." \
  -F "original=@nda_v1.docx" \
  -F "modified=@nda_v2.docx" \
  -F "author=Dana Reyes"
```

### Attributing the changes

`author` becomes the author on every insertion and deletion in the Word redline — what Word
shows in the reviewing pane and in each change's tooltip. Without it, changes are attributed
to the key's service account and the revised file's name, in the form
`api@yourfirm.com — nda_v2.docx`. Pass the name your reviewers should see instead.

The value is written verbatim, capped at 100 characters, with control characters stripped.
An empty or whitespace-only value is treated the same as omitting the field.

### Response — 202 Accepted

```json
{ "comparison_id": "cmp_MTp2cy0xOnZtLTE", "status": "processing" }
```

`comparison_id` is opaque and stable. Store it; it is the only handle to the result.

The comparison is created under the organization's API service account, and is retrieved
through `GET /v1/compare/{comparison_id}` below.

### Notes on inputs

- Sources may be `.docx`, `.doc`, or `.pdf`, up to **100 MB** each. Non-`.docx` inputs are
  converted server-side first, so expect `.doc` and `.pdf` comparisons to spend longer in
  `processing`.
- Both files may share a name — two revisions of `contract.docx` is a normal case. The
  revised side is disambiguated automatically.

### Errors

| Status | Code | Cause |
| --- | --- | --- |
| 400 | `INVALID_REQUEST` | A file field is missing, empty, or has no filename |
| 400 | `UNSUPPORTED_FILE_TYPE` | Extension is not `.docx`, `.doc`, or `.pdf` |
| 400 | `DOCUMENT_UNREADABLE` | A `.docx` source is not a readable Word file — corrupt, or encrypted |
| 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 |

The quota check runs before anything is created, so a `402` costs you nothing.

## GET /v1/compare/&#123;comparison_id&#125;

Reports whether the redline exists yet, and returns a signed download URL once it does.
Safe to call as often as you like — it creates nothing and has no side effects.

| Parameter | In | Required | Default | Meaning |
| --- | --- | --- | --- | --- |
| `comparison_id` | path | Yes | — | The id from `POST /v1/compare` |
| `format` | query | No | `docx` | Which renderings to return |

### Formats

`format` is repeatable, and also accepts a comma-separated list, so one call can ask for
several renderings: `?format=docx&format=md` and `?format=docx,md` are equivalent.

| `format` | Artifact |
| --- | --- |
| `docx` | Word document with real tracked changes — the default |
| `pdf` | PDF rendering of the redline |
| `pdf_changed_pages_only` | PDF containing only the pages that changed |
| `md` | Markdown rendering of the redline — plain text, useful for reading or passing to a model |
| `json` | Structured JSON — the redline as blocks and change segments, for programmatic use. See [JSON format](/developers/reference/json-format) |

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

### Response — ready

`downloads` carries one entry per requested format, in the order you asked for them.

```json
{
  "comparison_id": "cmp_MTp2cy0xOnZtLTE",
  "status": "ready",
  "downloads": [
    {
      "format": "docx",
      "url": "https://documents.versionstory.com/...",
      "file_name": "Redline nda_v1 to nda_v2.docx",
      "expires_at": "2026-08-10T22:00:00+00:00"
    },
    {
      "format": "md",
      "url": "https://documents.versionstory.com/...",
      "file_name": "Redline nda_v1 to nda_v2.md",
      "expires_at": "2026-08-10T22:00:00+00:00"
    }
  ]
}
```

Each `url` is signed and expires one hour after it was issued. It needs no `Authorization`
header — fetch it directly, saving the response as a binary file. Poll again at any time for
fresh URLs; the redline itself does not expire.

```bash
curl -sS --fail -o redline.docx "<downloads[0].url>"
```

### Response — still generating

Returned with `Retry-After: 1`. Honor it rather than polling tighter; a comparison is not
made faster by being asked about more often.

Renderings do not all finish together — the PDF and the Markdown trail the Word document.
Rather than withhold what already exists, the response returns every format that is ready
and names the rest in `pending_formats`. `status` is `ready` only once every format you
asked for is available.

```json
{
  "comparison_id": "cmp_MTp2cy0xOnZtLTE",
  "status": "processing",
  "downloads": [
    {
      "format": "docx",
      "url": "https://documents.versionstory.com/...",
      "file_name": "Redline nda_v1 to nda_v2.docx",
      "expires_at": "2026-08-10T22:00:00+00:00"
    }
  ],
  "pending_formats": ["md"]
}
```

A client that only wants the Word document should ask only for `docx`, and will see
`ready` as soon as that one exists.

### Response — failed

Terminal. The comparison will not succeed on its own; waiting longer changes nothing.
Submit a new comparison to retry.

```json
{
  "comparison_id": "cmp_MTp2cy0xOnZtLTE",
  "status": "failed",
  "error": {
    "code": "DOCUMENT_PROTECTED",
    "upstream_code": "ONE_OR_MORE_DOCUMENTS_ARE_PROTECTED"
  }
}
```

| `error.code` | Meaning |
| --- | --- |
| `DOCUMENT_PROTECTED` | A source document is password-protected or permission-restricted |
| `DOCUMENT_UNREADABLE` | A source document could not be opened — corrupt or malformed |
| `DOCUMENT_CONVERSION_FAILED` | A `.doc`, `.pdf`, or email source could not be converted to `.docx` |
| `SOURCE_UPLOAD_FAILED` | A source document did not reach storage intact |
| `PDF_RENDER_FAILED` | The redline generated, but the PDF rendering of it did not |
| `COMPARISON_FAILED` | Catch-all; `upstream_code` carries the specific pipeline code |

Note the shape here: a *failed comparison* is reported as `200 OK` with
`"status": "failed"`, because the request succeeded — it correctly told you the comparison
is not going to happen. Non-2xx status codes are reserved for problems with the request
itself.

### Request errors

| Status | Code | Cause |
| --- | --- | --- |
| 400 | `INVALID_COMPARISON_ID` | The id is malformed |
| 400 | `UNSUPPORTED_FORMAT` | A requested `format` is unknown |
| 404 | `COMPARISON_NOT_FOUND` | No such comparison, or the key's user cannot access it |

`COMPARISON_NOT_FOUND` covers both "doesn't exist" and "not yours" on purpose, so that ids
cannot be probed for existence.

## Polling in practice

A reasonable client submits, then polls on the interval the server suggests, with a ceiling
on total wait rather than on attempts. Most `.docx` comparisons finish in seconds; `.pdf`
and `.doc` sources take longer because they are converted first.

```javascript
const BASE = "https://api.versionstory.com";
const headers = { Authorization: `Bearer ${process.env.VERSION_STORY_API_KEY}` };

async function waitForRedline(comparisonId, { formats = ["docx"], timeoutMs = 10 * 60_000 } = {}) {
  const deadline = Date.now() + timeoutMs;
  const query = new URLSearchParams({ format: formats.join(",") });

  while (Date.now() < deadline) {
    const response = await fetch(`${BASE}/v1/compare/${comparisonId}?${query}`, { headers });
    if (!response.ok) {
      const { error } = await response.json();
      throw new Error(`${error.code}: ${error.message}`);
    }

    const body = await response.json();
    if (body.status === "ready") return body.downloads;
    if (body.status === "failed") throw new Error(`Comparison failed: ${body.error.code}`);

    const retryAfter = Number(response.headers.get("Retry-After") ?? 1);
    await new Promise((resolve) => setTimeout(resolve, retryAfter * 1000));
  }

  throw new Error("Timed out waiting for the redline");
}
```
