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 |
| 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.
{ "error": { "code": "INVALID_API_KEY", "message": "...", "request_id": "..." } }The flow
POST /v1/comparewith both files. Returns202and acomparison_idimmediately.GET /v1/compare/{comparison_id}untilstatusisready, then fetch eachdownloads[].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.
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-.docxinputs are converted server-side first, so expect.docand.pdfcomparisons to spend longer inprocessing. - Both files may share a name — two revisions of
contract.docxis 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/{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.
| 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 |
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.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.
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");
}