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 exactly two file fields.
| Field | Required | Meaning |
|---|---|---|
original | Yes | The base document — the earlier version |
modified | Yes | The 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-.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 |
| 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 |
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.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") ?? 5);
await new Promise((resolve) => setTimeout(resolve, retryAfter * 1000));
}
throw new Error("Timed out waiting for the redline");
}