Skip to content
Documentation menu

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 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 exactly two file fields.

FieldRequiredMeaning
originalYesThe base document — the earlier version
modifiedYesThe revised document — the later version

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"

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
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
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: 5. 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") ?? 5);
    await new Promise((resolve) => setTimeout(resolve, retryAfter * 1000));
  }

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