Skip to content

Compare

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

View as Markdown

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

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

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

FieldRequiredMeaning
originalYesThe base document — the earlier version
modifiedYesThe revised document — the later version
authorNoName 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.

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

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

StatusCodeCause
400INVALID_REQUESTA file field is missing, empty, or has no filename
400UNSUPPORTED_FILE_TYPEExtension is not .docx, .doc, or .pdf
400DOCUMENT_UNREADABLEA .docx source is not a readable Word file — corrupt, or encrypted
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

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

GET /v1/compare/{comparison_id}

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.

ParameterInRequiredDefaultMeaning
comparison_idpathYesThe id from POST /v1/compare
formatqueryNodocxWhich 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.

formatArtifact
docxWord document with real tracked changes — the default
pdfPDF rendering of the redline
pdf_changed_pages_onlyPDF containing only the pages that changed
mdMarkdown rendering of the redline — plain text, useful for reading or passing to a model
jsonStructured JSON — the redline as blocks and change segments, for programmatic use. See JSON format
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.

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

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.

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

{
  "comparison_id": "cmp_MTp2cy0xOnZtLTE",
  "status": "failed",
  "error": {
    "code": "DOCUMENT_PROTECTED",
    "upstream_code": "ONE_OR_MORE_DOCUMENTS_ARE_PROTECTED"
  }
}
error.codeMeaning
DOCUMENT_PROTECTEDA source document is password-protected or permission-restricted
DOCUMENT_UNREADABLEA source document could not be opened — corrupt or malformed
DOCUMENT_CONVERSION_FAILEDA .doc, .pdf, or email source could not be converted to .docx
SOURCE_UPLOAD_FAILEDA source document did not reach storage intact
PDF_RENDER_FAILEDThe redline generated, but the PDF rendering of it did not
COMPARISON_FAILEDCatch-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

StatusCodeCause
400INVALID_COMPARISON_IDThe id is malformed
400UNSUPPORTED_FORMATA requested format is unknown
404COMPARISON_NOT_FOUNDNo 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.

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");
}